楔子 前段時(shí)間有個(gè)小伙伴問(wèn)我,有沒(méi)有棧和堆相關(guān)的內(nèi)容,由于我們后面要分析Python的垃圾回收,所以這部分內(nèi)容值得說(shuō)一說(shuō)。那么下面就以 C 語(yǔ)言的可執(zhí)行文件為例,來(lái)探討一下內(nèi)存模型,以及變量的值究竟是放在棧上還是放在堆上。 可執(zhí)行文件的內(nèi)存模型 首先 C 源文件被編譯成可執(zhí)行程序總共需要四步,假設(shè)源文件叫 main.c:
所以從 C 源文件到可執(zhí)行文件會(huì)經(jīng)歷以上幾步,不過(guò)我們一般都會(huì)將這幾步組合起來(lái),整體稱(chēng)之為編譯。比如我們常說(shuō),將某個(gè)源文件編譯成可執(zhí)行程序。 而對(duì)于一個(gè)可執(zhí)行程序而言,還沒(méi)有運(yùn)行之前,也就是程序還沒(méi)有加載到內(nèi)存之前,可執(zhí)行程序內(nèi)部就已經(jīng)分好了三個(gè)區(qū)域,分別是:代碼區(qū)(text)、初始化數(shù)據(jù)區(qū)(data)、未初始化數(shù)據(jù)區(qū)(bss)。
初始化數(shù)據(jù)區(qū)(data):該區(qū)域包含了在程序中明確被初始化的全局變量,以及被初始化的靜態(tài)變量(包括全局靜態(tài)變量和局部靜態(tài)變量)。 未初始化數(shù)據(jù)區(qū)(bss):該區(qū)域存儲(chǔ)的是未初始化的全局變量和未初始化的靜態(tài)變量,而未初始化數(shù)據(jù)區(qū)的數(shù)據(jù)在程序開(kāi)始執(zhí)行前會(huì)被內(nèi)核初始化為 0 或者 nil。 初始化數(shù)據(jù)區(qū)和未初始化數(shù)據(jù)區(qū)統(tǒng)稱(chēng)為靜態(tài)區(qū),而靜態(tài)區(qū)的內(nèi)存和整個(gè)程序具有相同的生命周期。也就是說(shuō),靜態(tài)區(qū)的內(nèi)存會(huì)等到程序全部結(jié)束之后才釋放。 以上這幾個(gè)區(qū)域是固定的,程序還沒(méi)運(yùn)行的時(shí)候就已經(jīng)劃分好了。但是當(dāng)運(yùn)行可執(zhí)行程序的時(shí)候,系統(tǒng)會(huì)把程序加載到內(nèi)存中,然后除了上面說(shuō)的幾個(gè)區(qū)域之外,還會(huì)額外增加兩個(gè)區(qū)域:堆區(qū)、棧區(qū)。 堆區(qū)(heap):堆是一個(gè)大容器,它的容量要遠(yuǎn)大于棧,但沒(méi)有棧那樣先進(jìn)后出的順序。堆在內(nèi)存中位于未初始化數(shù)據(jù)區(qū)和棧區(qū)之間,主要用于動(dòng)態(tài)內(nèi)存分配。根據(jù)語(yǔ)言的不同,如果是 C 、C艸 等語(yǔ)言,堆區(qū)內(nèi)存由程序猿手動(dòng)釋放;如果程序猿不釋放,那么程序結(jié)束時(shí)會(huì)由操作系統(tǒng)回收。但是這并不代表使用 C、C++ 語(yǔ)言編程就可以不管堆區(qū)內(nèi)存了,如果你的程序占用內(nèi)存過(guò)大并且不及時(shí)釋放的話,很有可能造成內(nèi)存溢出。而像 Go、Python、Java 等語(yǔ)言都帶有垃圾回收機(jī)制,你盡管使用,內(nèi)存管理由對(duì)應(yīng)的編譯器或解釋器來(lái)做即可。雖然垃圾回收機(jī)制會(huì)占用額外的資源,但也將程序猿從內(nèi)存管理的繁忙工作中解放了出來(lái)。
text、data、bss 解析 光用文字解釋的話還不夠直觀,下面我們就來(lái)舉幾個(gè)栗子,分析一下這幾個(gè)區(qū)。 text 首先是代碼區(qū):
我們編譯執(zhí)行一下: 打印的結(jié)果是沒(méi)有問(wèn)題的,而且編譯之后這些字符串就是可執(zhí)行文件的一部分了,在執(zhí)行的時(shí)候直接拿來(lái)用即可,沒(méi)有動(dòng)態(tài)申請(qǐng)這一步。我們可以看一下它內(nèi)部區(qū)域的大小信息: 我們看到結(jié)果顯示了代碼區(qū)(text)、初始化數(shù)據(jù)區(qū)(data)、未初始化數(shù)據(jù)區(qū)(bss)的大小信息,說(shuō)明這些區(qū)域在生成可執(zhí)行文件的時(shí)候就已經(jīng)確定好了。
修改源文件,給每個(gè)常量字符串各自增加三個(gè)字符,然后重新生成可執(zhí)行文件并查看大小信息。 可以看到代碼區(qū)(text)的大小變成了 1286,因?yàn)閮蓚€(gè)常量字符串加起來(lái)相比之前多了 6 個(gè)字符,所以這完全符合我們的預(yù)期。 然后我們?cè)倏匆幌碌刂?,之前第一個(gè)常量字符串的地址是 0x400600,現(xiàn)在也是,說(shuō)明分配的地址是相同的。但是之前第二個(gè)常量字符串的地址是 0x40061a,現(xiàn)在變成了 0x40061d,如果將兩者轉(zhuǎn)成 10 進(jìn)制相減的話,結(jié)果相差 3。 相信原因很好想,因?yàn)橄啾戎?,我們的第一個(gè)常量字符串多了 3 字節(jié),那么第二個(gè)常量字符串的地址相比之前自然就要往后移 3 個(gè)字節(jié),這一切都是符合預(yù)期的。 因?yàn)槲覀冎恍薷牧俗址A浚灾粫?huì)影響 text,而 data 和 bss 則不受影響,整體還是很好理解的。然后我們還說(shuō)代碼區(qū)中的常量是不能修改的,因?yàn)樵诰幾g之后它們就已經(jīng)是可執(zhí)行文件的一部分了,然后在讀取的時(shí)候只需一條指令即可,效率非常高。并且和靜態(tài)區(qū)(data、bss)一樣,這些常量也和執(zhí)行時(shí)的可執(zhí)行文件具有相同的生命周期。
我們嘗試修改常量字符串的第一個(gè)元素,將其改為 'H',看看會(huì)有什么后果。 我們看到直接段錯(cuò)誤了,因?yàn)槌A坎辉试S修改。另外,根據(jù) size 命令我們知道常量是存在代碼區(qū)當(dāng)中的,不過(guò)有時(shí)我們更喜歡將存放常量的區(qū)域單獨(dú)稱(chēng)為常量區(qū)。但很明顯,常量區(qū)也是代碼區(qū)的一部分。 但如果我們就是想修改字符串該怎么辦呢?答案是如果想修改,就不要將其放在常量區(qū)(代碼區(qū)),而是將其放在棧區(qū)。
我們?cè)賮?lái)測(cè)試一下: 顯然數(shù)組聲明完之后,地址是固定的,同時(shí)內(nèi)部元素是可修改的,因此我們成功將首字母 'h' 改成了 'H'。此外 char *s 和 char s[] 還有一個(gè)顯著的區(qū)別,就是當(dāng)作為返回值的時(shí)候。
對(duì)于 test1 而言,代碼是沒(méi)有任何問(wèn)題的,但是 test2 就不行了。原因是 test2 里面的字符串是存放在棧區(qū)的,函數(shù)結(jié)束之后就被銷(xiāo)毀了,所以我們直接返回一個(gè)局部數(shù)組是不行的;而 test1 里面的字符串存放在常量區(qū)當(dāng)中,在整個(gè)可執(zhí)行文件執(zhí)行結(jié)束之前都可以使用,所以返回它的指針沒(méi)有任何問(wèn)題。 打印結(jié)果告訴我們不應(yīng)該返回局部變量的地址,那么如何解決呢?很簡(jiǎn)單,將數(shù)組變成靜態(tài)數(shù)組即可。 data 初始化數(shù)據(jù)區(qū)(data)負(fù)責(zé)存儲(chǔ)已初始化的全局變量和靜態(tài)變量,它也是和可執(zhí)行文件具有相同的生命周期,在程序執(zhí)行結(jié)束之前我們都可以使用它。
此時(shí)再來(lái)執(zhí)行,看看有沒(méi)有問(wèn)題: 正常輸出,沒(méi)有任何警告??。除了靜態(tài)局部變量,還有靜態(tài)全局變量、全局變量,它們都是放在初始化數(shù)據(jù)區(qū)當(dāng)中的。
注意:靜態(tài)變量只會(huì)初始化一次,所以我們調(diào)用了 5 次 test,但是變量 c 只會(huì)初始化一次,因?yàn)樗庆o態(tài)的。 那么問(wèn)題來(lái)了,如果我們想要獲取一個(gè)局部變量的地址,那么除了將變量聲明為靜態(tài)變量之外,還有沒(méi)有其它的方法呢?因?yàn)殪o態(tài)變量會(huì)伴隨著程序一直存在,但有時(shí)我們希望用完之后就將它回收掉,避免內(nèi)存占用,這個(gè)時(shí)候就可以在堆區(qū)為變量申請(qǐng)內(nèi)存。 因?yàn)闂I系膬?nèi)存會(huì)隨著函數(shù)的調(diào)用完畢而被釋放,但堆則不會(huì),它需要程序猿手動(dòng)釋放,我們以 Go 為例:
類(lèi)似的邏輯如果放在 C 里面顯然是有問(wèn)題的,因?yàn)樵?test 里面返回了局部變量的地址,但在 Go 里面為什么是正常的呢?原因就是 Go 編譯器會(huì)進(jìn)行逃逸分析,如果返回了變量的地址,就意味著該變量對(duì)應(yīng)的值要被外界訪問(wèn),那么 Go 編譯器會(huì)在堆區(qū)為該變量分配內(nèi)存。但是在 C 里面,我們需要手動(dòng)實(shí)現(xiàn)這一點(diǎn)。
我們看到此時(shí)也是可以正常運(yùn)行的,因此當(dāng)返回一個(gè)局部變量的地址的時(shí)候,除了將其聲明為靜態(tài)變量之外,還可以通過(guò) malloc 在堆區(qū)為其分配內(nèi)存。 只不過(guò)和棧不同,堆區(qū)的內(nèi)存不會(huì)自動(dòng)回收,需要我們調(diào)用 free 手動(dòng)回收。但在 Go 里面我們貌似沒(méi)有進(jìn)行 free 之類(lèi)的操作,原因是 Go 是一門(mén)帶有 GC 的語(yǔ)言,它會(huì)自動(dòng)進(jìn)行垃圾回收,找出堆上不會(huì)被使用的內(nèi)存,然后將其釋放掉。 因此雖然 Go 也是靜態(tài)編譯型語(yǔ)言,但因?yàn)閹в?GC,導(dǎo)致它的性能不如 C/C++/Rust 這類(lèi)語(yǔ)言。但 Go 語(yǔ)法簡(jiǎn)單、天生擅長(zhǎng)并發(fā)編程,仍然是值得我們學(xué)習(xí)的。
bss 最后是未初始化數(shù)據(jù)區(qū) bss,它是負(fù)責(zé)存儲(chǔ)未初始化全局變量、未初始化靜態(tài)變量。
變量 a、c 存儲(chǔ)在 bss 中,變量 b、d 存儲(chǔ)在 data 中,并且 bss 中的變量會(huì)被初始化為 0 或 nil。 觀察可執(zhí)行文件,發(fā)現(xiàn) data 的大小為 548、bss 的大小為 12。然后我們修改代碼,將變量 b、d 的值給刪掉,也就是只聲明、不賦初始值,那么此時(shí) a、b、c、d 就都會(huì)存在 bss 中。 而一個(gè) int 占 4 字節(jié),那么將變量 b、d 修改之后重新編譯,新生成的可執(zhí)行文件的 data 區(qū)的大小相比之前就會(huì)少 8 個(gè)字節(jié),bss 區(qū)的大小會(huì)多 8 個(gè)字節(jié),我們看看是不是這樣。 和我們分析的是一樣的。 以上就是可執(zhí)行文件中,代碼區(qū)(text)、初始化數(shù)據(jù)區(qū)(data)、未初始化數(shù)據(jù)區(qū)(bss)的區(qū)別與作用,但顯然還沒(méi)有完。因?yàn)槲覀兊闹仡^戲還沒(méi)有說(shuō)(也不知道是誰(shuí)的頭這么重哈),就是棧區(qū)和堆區(qū)。 棧區(qū) 棧是程序運(yùn)行的基礎(chǔ),每當(dāng)一個(gè)函數(shù)被調(diào)用時(shí),一塊連續(xù)的內(nèi)存就會(huì)在棧頂被分配出來(lái),供函數(shù)執(zhí)行使用,這塊內(nèi)存被稱(chēng)為棧幀(stack frame),或者簡(jiǎn)稱(chēng)為幀。我們以一個(gè)簡(jiǎn)單的函數(shù)調(diào)用為例:
這段程序非常簡(jiǎn)單,但是這背后的函數(shù)調(diào)用棧是怎么一回事呢?我們來(lái)解釋一下。 首先棧是自頂向下增長(zhǎng)的,也就是從棧頂?shù)綏5椎牡刂肥侵饾u增大的,一個(gè)程序的調(diào)用棧的最底部,除去入口對(duì)應(yīng)的棧幀之外,就是 main 函數(shù)對(duì)應(yīng)的棧幀。 而隨著函數(shù)一層層調(diào)用,棧幀也會(huì)一層層地被創(chuàng)建,比如 main 函數(shù)里面調(diào)用了 hello 函數(shù),那么就會(huì)在 main 函數(shù)對(duì)應(yīng)的棧幀之上為 hello 函數(shù)創(chuàng)建棧幀(并保存當(dāng)前 main 函數(shù)執(zhí)行的上下文),然后將執(zhí)行的控制權(quán)交給 hello 函數(shù)的棧幀。 然后在 hello 函數(shù)內(nèi)部又調(diào)用了 world 函數(shù),那么就又會(huì)在 hello 函數(shù)的棧幀之上為 world 函數(shù)創(chuàng)建棧幀(并保存當(dāng)前 hello 函數(shù)執(zhí)行的上下文),然后將執(zhí)行的控制權(quán)交給 world 函數(shù)對(duì)應(yīng)的棧幀。 而調(diào)用結(jié)束之后,棧幀又會(huì)一層層地被銷(xiāo)毀,并釋放對(duì)應(yīng)的內(nèi)存。比如 world 函數(shù)調(diào)用完畢之后,就會(huì)將 world 函數(shù)對(duì)應(yīng)的棧幀銷(xiāo)毀,然后回到上一個(gè)調(diào)用者(hello 函數(shù))對(duì)應(yīng)的棧幀中,恢復(fù)其之前執(zhí)行的上下文,并賦予執(zhí)行的控制權(quán)。 所以整個(gè)過(guò)程就像遞歸一樣,一層層創(chuàng)建,一層層返回,但如果棧幀創(chuàng)建的過(guò)多,那么有可能會(huì)造成棧溢出。因?yàn)檎{(diào)用棧的大小是有閾值的,一旦當(dāng)前程序的調(diào)用棧超出了系統(tǒng)允許的最大棧空間,就會(huì)無(wú)法創(chuàng)建新的幀來(lái)運(yùn)行下一個(gè)要執(zhí)行的函數(shù),從而發(fā)生棧溢出,這時(shí)程序會(huì)被系統(tǒng)終止,產(chǎn)生崩潰信息。比如遞歸函數(shù)沒(méi)有妥善終止,那么一個(gè)遞歸函數(shù)會(huì)不斷調(diào)用自己,而每次調(diào)用都會(huì)形成一個(gè)新的幀,最終導(dǎo)致棧溢出。 而在調(diào)用的過(guò)程中,一個(gè)新的幀會(huì)分配足夠的空間來(lái)存儲(chǔ)寄存器的上下文。在函數(shù)里使用到的通用寄存器會(huì)在棧上保存一個(gè)副本,當(dāng)這個(gè)函數(shù)調(diào)用結(jié)束,通過(guò)副本,可以恢復(fù)出原本的寄存器的上下文,就像什么都沒(méi)有經(jīng)歷一樣。此外,函數(shù)所需要使用到的局部變量,也都會(huì)在幀分配的時(shí)候被預(yù)留出來(lái)。 那么問(wèn)題來(lái)了,當(dāng)一個(gè)函數(shù)運(yùn)行時(shí),怎么確定究竟需要多大的幀呢? 這要?dú)w功于編譯器,在編譯并優(yōu)化代碼的時(shí)候,一個(gè)函數(shù)就是一個(gè)最小的編譯單元。在這個(gè)函數(shù)里,編譯器得知道要用到哪些寄存器、棧上要放哪些局部變量,而這些都要在編譯時(shí)確定。所以編譯器就需要明確每個(gè)局部變量的大小,以便于預(yù)留空間。
數(shù)據(jù)放在棧上的問(wèn)題 首先棧上的數(shù)據(jù)在傳遞時(shí)永遠(yuǎn)都是拷貝一份,但棧上的內(nèi)存分配是非常高效的,和堆是天壤之別。只需要改動(dòng)棧指針(stack pointer),就可以預(yù)留相應(yīng)的空間;把棧指針改動(dòng)回來(lái),預(yù)留的空間又會(huì)被釋放掉。空間的申請(qǐng)和釋放只是動(dòng)動(dòng)寄存器,不涉及額外計(jì)算、不涉及系統(tǒng)調(diào)用,因而效率很高。 并且棧還是由操作系統(tǒng)自動(dòng)維護(hù)的,根本不需要我們關(guān)心。 而在 Go 里面,很多朋友喜歡返回指針,即使數(shù)據(jù)不需要被共享。這么做的原因是認(rèn)為拷貝指針比拷貝值更有效率,因?yàn)橹羔樀拇笮∠鄬?duì)較小一些。但事實(shí)真的如此嗎?答案是不一定,因?yàn)槿绻截愔档脑?,那么?fù)制是在棧上完成的,而我們說(shuō)棧的效率極高。要是返回指針的話,那么當(dāng)發(fā)生內(nèi)存逃逸時(shí),就會(huì)將變量的值從棧上分配改為堆上分配,這個(gè)過(guò)程反而會(huì)消耗更多的資源。 所以理論上說(shuō),我們應(yīng)該把變量的值分配到棧上,這樣可以達(dá)到更好的運(yùn)行速度。但是實(shí)際工作中,我們卻又避免這么做,這又是為什么呢?原因就是??臻g是有限的,分配過(guò)大的棧內(nèi)存容易導(dǎo)致棧溢出。
因此變量的值究竟分配在棧上還是分配是在堆上,結(jié)論如下:
堆區(qū) 棧的效率雖然很高,不用我們維護(hù),但它的局限性也顯而易見(jiàn),就是它要求變量的大小必須明確、固定。 而當(dāng)我們需要?jiǎng)討B(tài)大小的內(nèi)存時(shí),只能使用堆,比如我們要實(shí)現(xiàn)可變長(zhǎng)度的數(shù)組,那么必須分配在堆上,否則無(wú)法擴(kuò)容。而堆上分配內(nèi)存時(shí),一般都會(huì)預(yù)留一些空間,這是最佳實(shí)踐。 在堆上分配內(nèi)存除了可以讓大小動(dòng)態(tài)化,還可以讓生命周期動(dòng)態(tài)化。我們說(shuō)過(guò),函數(shù)調(diào)用結(jié)束之后,那么函數(shù)對(duì)應(yīng)的棧幀會(huì)被回收,同時(shí)相關(guān)變量對(duì)應(yīng)的內(nèi)存也會(huì)被回收。所以棧上內(nèi)存的生命周期是不受開(kāi)發(fā)者控制的,并且局限在當(dāng)前調(diào)用棧。 而堆則不同,堆上分配出來(lái)的每一塊內(nèi)存都需要顯式地釋放,這就使得堆內(nèi)存有更加靈活的生命周期,可以在不同的調(diào)用棧之間共享數(shù)據(jù)。因?yàn)閿?shù)據(jù)只要我們不回收,那么就始終就駐留在堆上,并且何時(shí)回收也是由我們來(lái)決定的。 因此當(dāng)內(nèi)存動(dòng)態(tài)可變的時(shí)候,我們會(huì)在堆上分配。當(dāng)然啦,堆內(nèi)存是負(fù)責(zé)具體存儲(chǔ)數(shù)據(jù)的,然后還要在棧上分配一個(gè)指針,它引用堆區(qū)的內(nèi)存。我們以 Rust 的 String 為例:
此時(shí) s1 內(nèi)存布局如下: String 實(shí)際上由 3 部分組成:指向字符串的指針(ptr)、長(zhǎng)度(len)、容量(capacity),這部分的數(shù)據(jù)存儲(chǔ)在了棧中,即圖中的左半部分。然后 ptr 指向了字符串存儲(chǔ)在堆上的文本內(nèi)容,也就是圖中的右半部分。 但下面又把 s1 賦值給了 s2,于是會(huì)把 s1 拷貝一份給 s2,因?yàn)?s1 和 s2 都是棧上的數(shù)據(jù),所以會(huì)直接拷貝一份。因?yàn)闂I系臄?shù)據(jù)拷貝的效率非常高,和堆根本不在一個(gè)層次,并且也不需要我們來(lái)維護(hù)。只不過(guò)大小固定,不能動(dòng)態(tài)變化,畢竟速度擺在那里。 但需要注意的是,這里的拷貝僅僅是針對(duì)棧上的數(shù)據(jù),字符串里面的 ptr 指向的存儲(chǔ)在堆區(qū)的文本并沒(méi)有拷貝。 這么做完全可以理解,因?yàn)樵诙焉峡截悢?shù)據(jù)的效率遠(yuǎn)不如棧,所以不能像棧那樣直接將數(shù)據(jù)拷貝一份。而且存在堆上的數(shù)據(jù)也可能會(huì)比較大,這樣的話拷貝就更加消耗資源了。 對(duì)于任何一門(mén)語(yǔ)言來(lái)說(shuō),默認(rèn)情況下,堆區(qū)的數(shù)據(jù)都不會(huì)自動(dòng)拷貝。如果非要拷貝,只能手動(dòng)拷貝。 但是這里就產(chǎn)生了一個(gè)問(wèn)題,上面的 s1 和 s2 都引用了同一份堆內(nèi)存,那這份堆內(nèi)存要何時(shí)回收呢? 對(duì)于 Python 而言,申請(qǐng)?jiān)诙焉系膬?nèi)存都有一個(gè)引用計(jì)數(shù),每當(dāng)有一個(gè)指針引用它,引用計(jì)數(shù)就會(huì)加 1。當(dāng)引用計(jì)數(shù)為 0 時(shí),堆內(nèi)存就被回收。 而對(duì)于 Rust 而言則不是這樣,首先 Rust 是一個(gè)沒(méi)有 GC 的語(yǔ)言,并且還能保證內(nèi)存安全,那么對(duì)于當(dāng)前這個(gè)例子它是怎么做的呢?很簡(jiǎn)單,Rust 內(nèi)部提出了一個(gè)所有權(quán)的概念,在這里你可以把所有權(quán)簡(jiǎn)單理解為操作堆內(nèi)存的權(quán)限。一開(kāi)始 s1 是持有所有權(quán)的,但是在 s1 賦值給 s2 之后,所有權(quán)就發(fā)生了轉(zhuǎn)移。也就是說(shuō) s1 不再具有操作堆內(nèi)存的權(quán)利,所有權(quán)被轉(zhuǎn)移到了 s2 上面,如果使用 s1 就會(huì)報(bào)錯(cuò)。 所以 Rust 是保證每塊堆內(nèi)存同時(shí)只能被一個(gè)變量所持有,因此堆內(nèi)存是否釋放,就看持有所有權(quán)的變量是否還存活。 數(shù)據(jù)放在堆上的問(wèn)題 堆是非常靈活的,然而堆內(nèi)存的這種靈活性也給內(nèi)存管理帶來(lái)很多挑戰(zhàn)。如果手工管理堆內(nèi)存的話,那么堆內(nèi)存分配后忘記釋放,就會(huì)造成內(nèi)存泄漏。一旦有內(nèi)存泄漏,程序運(yùn)行得越久,就越吃?xún)?nèi)存,最終會(huì)因?yàn)檎紳M內(nèi)存而被操作系統(tǒng)終止運(yùn)行。 如果堆上內(nèi)存被多個(gè)線程的調(diào)用棧引用,該內(nèi)存的改動(dòng)要特別小心,需要加鎖以獨(dú)占訪問(wèn),來(lái)避免潛在的問(wèn)題。比如一個(gè)線程在訪問(wèn)某一個(gè)指針,但另一個(gè)線程將該指針指向的內(nèi)存釋放了,此時(shí)就可能出現(xiàn)訪問(wèn)懸空指針的情況,輕則程序崩潰,重則隱含安全隱患。根據(jù)微軟安全反應(yīng)中心(MSRC)的研究,這是第二大內(nèi)存安全問(wèn)題。 除此之外還有堆越界(heap out of bounds),比如程序在堆區(qū)申請(qǐng)的內(nèi)存只能容納 5 個(gè) int,但是我們嘗試操作第 6 個(gè) int,此時(shí)就會(huì)引發(fā)堆越界,而堆越界是第一大內(nèi)存安全問(wèn)題。 垃圾回收機(jī)制是如何解決的? 很多語(yǔ)言帶有垃圾回收機(jī)制,它們是如何解決堆內(nèi)存回收的問(wèn)題呢?
首先無(wú)論何種垃圾回收機(jī)制,一般都分為兩個(gè)階段:垃圾檢測(cè)和垃圾回收。 垃圾檢測(cè)是從已經(jīng)分配的堆內(nèi)存中區(qū)別出可回收和不可回收的內(nèi)存,而垃圾回收則是使操作系統(tǒng)重新掌握垃圾檢測(cè)階段所標(biāo)識(shí)出來(lái)的可回收內(nèi)存塊。所以垃圾回收,并不是說(shuō)直接把這塊內(nèi)存的數(shù)據(jù)清空了,而是將使用權(quán)重新交給了操作系統(tǒng),不會(huì)自己霸占了。 那么,常見(jiàn)的垃圾回收算法都有哪些呢?
以 Java 為首的一系列編程語(yǔ)言,采用了標(biāo)記-清除法。這種方式通過(guò)定期標(biāo)記(mark)找出不再被引用的對(duì)象,然后將其清除(sweep)掉,來(lái)自動(dòng)管理內(nèi)存,減輕開(kāi)發(fā)者的負(fù)擔(dān),因此該方法也被稱(chēng)為追蹤式垃圾回收(Tracing GC)。 而 Objective-C 和 Swift 則走了另一條路,也就是引用計(jì)數(shù)(reference count)法。在編譯時(shí),它為每個(gè)函數(shù)插入 retain/release 語(yǔ)句來(lái)自動(dòng)維護(hù)堆上對(duì)象的引用計(jì)數(shù),當(dāng)引用計(jì)數(shù)為零的時(shí)候,就通過(guò) release 語(yǔ)句釋放對(duì)象。 從效率上來(lái)講,標(biāo)記清除在內(nèi)存分配和釋放上無(wú)需額外操作,而引用計(jì)數(shù)法則添加了額外的代碼來(lái)處理引用計(jì)數(shù),所以標(biāo)記-清除法的效率更高,吞吐量(throughout)更大。 但標(biāo)記-清除法釋放內(nèi)存的時(shí)機(jī)是不確定的,并且是定期批量操作,因此在釋放內(nèi)存時(shí)會(huì)引發(fā) STW(Stop The World),從而導(dǎo)致某些時(shí)刻延遲(latency)較高。我們使用 Android 手機(jī)偶爾感覺(jué)卡頓,就是這個(gè)原因,出現(xiàn)卡頓說(shuō)明此時(shí)內(nèi)部正在進(jìn)行垃圾回收。 但引用計(jì)數(shù)法是當(dāng)對(duì)象的引用計(jì)數(shù)為 0 時(shí)就立即回收,所以相當(dāng)于將垃圾回收的開(kāi)銷(xiāo)分?jǐn)傇诹苏麄€(gè)運(yùn)行時(shí),因此使用 IOS 手機(jī)時(shí)始終會(huì)感覺(jué)很絲滑,不會(huì)出現(xiàn)卡頓。 所以盡管標(biāo)記清除法在分配和釋放內(nèi)存的效率和吞吐量上比引用計(jì)數(shù)法要高,但因?yàn)榕紶柕母哐舆t,導(dǎo)致被感知的性能較差。 棧與堆總結(jié) 我們上面已經(jīng)介紹了棧和堆,這里再總結(jié)一下。 棧和堆都是代碼在運(yùn)行時(shí)可以使用的內(nèi)存空間,不過(guò)它們通常以不同的結(jié)構(gòu)組織而成。棧會(huì)以我們放入值時(shí)的順序來(lái)存儲(chǔ)它們,并以相反的順序?qū)⒅等〕觯@也就是所謂的后進(jìn)先出(Last In First Out,LIFO)策略。 你可以把棧上的操作想象成堆放盤(pán)子:當(dāng)你需要放置盤(pán)子時(shí),你只能將它們放置在最上面,而當(dāng)你需要取出盤(pán)子時(shí),你也只能從最上面取出。換句話說(shuō),你沒(méi)有辦法從中間或底部插入、移除盤(pán)子。用術(shù)語(yǔ)來(lái)講,添加數(shù)據(jù)這一操作被稱(chēng)作入棧,移除數(shù)據(jù)則被稱(chēng)作出棧。 所有存儲(chǔ)在棧中的數(shù)據(jù)都必須擁有一個(gè)已知且固定的大小,對(duì)于那些在編譯期無(wú)法確定大小的數(shù)據(jù),只能將它們存儲(chǔ)在堆中(在棧上是不安全的)。 而堆空間的管理較為松散:當(dāng)你希望將數(shù)據(jù)放入堆中時(shí),你就可以請(qǐng)求特定大小的空間。操作系統(tǒng)會(huì)根據(jù)你的請(qǐng)求在堆中找到一塊足夠大的可用空間,將它標(biāo)記為已使用,并把指向這片空間的指針?lè)祷亍_@一過(guò)程就是所謂的堆分配,它也常常被簡(jiǎn)稱(chēng)為分配,至于將值壓入棧中則不叫分配。 由于指針的大小是固定的,且可以在編譯期確定(64位系統(tǒng)固定 8 字節(jié)),所以會(huì)將指針存儲(chǔ)在棧中,也就是棧區(qū)的指針指向堆區(qū)的數(shù)據(jù)。 可以想象一下到餐廳聚餐,當(dāng)你到達(dá)餐廳表明自己需要的座位數(shù)后,服務(wù)員會(huì)找到一張足夠大的空桌子,并將你們領(lǐng)過(guò)去入座。即便這時(shí)有小伙伴來(lái)遲了,他們也可以通過(guò)詢(xún)問(wèn)你們就座的位置來(lái)找到你們。 向棧上壓入數(shù)據(jù)要遠(yuǎn)比在堆上進(jìn)行分配更有效率,因?yàn)槿绻嵌训脑挘僮飨到y(tǒng)還要搜索新數(shù)據(jù)的存儲(chǔ)位置,需要額外開(kāi)銷(xiāo);但棧不用,對(duì)于棧而言這個(gè)位置永遠(yuǎn)處于棧的頂端。除此之外,操作系統(tǒng)在堆上分配空間時(shí)還必須首先找到足夠放下對(duì)應(yīng)數(shù)據(jù)的空間,并進(jìn)行某些記錄,來(lái)協(xié)調(diào)隨后的其余分配操作。 訪問(wèn)數(shù)據(jù)也是同理,由于指針存在棧上,數(shù)據(jù)存在堆上,所以要通過(guò)指針存儲(chǔ)的地址來(lái)訪問(wèn)數(shù)據(jù)。而這會(huì)多一步指針跳轉(zhuǎn)的環(huán)節(jié),因此訪問(wèn)堆上的數(shù)據(jù)要慢于訪問(wèn)棧上的數(shù)據(jù)。一般來(lái)說(shuō),現(xiàn)代處理器在進(jìn)行計(jì)算的過(guò)程中,由于緩存的緣故,指令在內(nèi)存中跳轉(zhuǎn)的次數(shù)越多,性能就越差。 繼續(xù)使用上面的餐廳來(lái)作類(lèi)比,假設(shè)現(xiàn)在同時(shí)有許多桌的顧客正在等待服務(wù)員的處理。那么最高效的處理方式自然是報(bào)完一張桌子所有的訂單之后再接著服務(wù)下一張桌子的顧客。而一旦服務(wù)員每次在單個(gè)桌子前只處理單個(gè)訂單,那么他就不得不浪費(fèi)較多的時(shí)間往返于不同的桌子之間。 出于同樣的原因,處理器操作排布緊密的數(shù)據(jù)(比如在棧上)要比操作排布稀疏的數(shù)據(jù)(比如在堆上)有效率得多。另外,分配命令本身也可能消耗不少時(shí)鐘周期。 所以 Python 為什么這么慢,就是因?yàn)樗械膶?duì)象都分配在堆上,即使是棧幀,也是分配在堆上的,雖然名字里面帶了一個(gè)棧字。這也是 Python 效率低下的原因之一,至于另一個(gè)原因則是無(wú)法在編譯期間確定類(lèi)型,因?yàn)镻ython的變量只是一個(gè)指針,執(zhí)行任何操作,都需要先通過(guò) ob_type 判斷指向的對(duì)象的類(lèi)型是什么。 為此,Python 不得不大量使用緩存技術(shù),在對(duì)象被銷(xiāo)毀時(shí)不釋放內(nèi)存,而是緩存起來(lái)留著下次備用。但即便如此,依舊架不住效率低。 小結(jié) 以上我們就分析了可執(zhí)行文件的內(nèi)存模型,以及棧和堆的特點(diǎn)。 對(duì)于存入棧上的值,它的大小不能變化、以及在編譯期就需要確定。并且棧上存儲(chǔ)的變量的生命周期局限在當(dāng)前調(diào)用棧的作用域內(nèi),無(wú)法跨調(diào)用棧引用。 堆可以存入大小未知或者動(dòng)態(tài)伸縮的數(shù)據(jù)類(lèi)型,堆上數(shù)據(jù)的生命周期從分配后開(kāi)始,一直到釋放時(shí)才結(jié)束,因此堆上的變量允許在多個(gè)調(diào)用棧之間引用。但也導(dǎo)致堆變量的管理非常復(fù)雜,手工管理會(huì)引發(fā)很多內(nèi)存安全性問(wèn)題,而自動(dòng)管理,無(wú)論垃圾回收算法采用的是哪一種,都有性能損耗和其它問(wèn)題。
一句話對(duì)比總結(jié)就是:棧上存放的數(shù)據(jù)是靜態(tài)的,固定大小,固定生命周期;堆上存放的數(shù)據(jù)是動(dòng)態(tài)的,不固定大小,不固定生命周期。 思考題 1)如果有一個(gè)數(shù)據(jù)結(jié)構(gòu)需要在多個(gè)線程中訪問(wèn),可以把它放在棧上嗎?為什么? 在多線程場(chǎng)景下,每個(gè)線程的生命周期是不固定的,無(wú)法在編譯期得知誰(shuí)先結(jié)束誰(shuí)后結(jié)束,所以不能把屬于線程 A 調(diào)用棧上的內(nèi)存共享給線程 B,因?yàn)?A 可能先于 B 結(jié)束,因此這時(shí)應(yīng)該使用堆內(nèi)存。 但是有個(gè)例外,如果我們能保證結(jié)束的順序是確定的,那么可以共享,比如 scoped thread。 2)可以使用指針引用棧上的某個(gè)變量嗎?如果可以,是在什么情況下呢? 顯然是可以的,比如在函數(shù)中創(chuàng)建一個(gè)局部變量,然后再用一個(gè)指針指向它。
只要指針的生命周期小于等于引用源就行,但如果指針的生命周期大于引用源,那么就不行了。 比如我們上面的代碼不能將 p 返回,否則的話,函數(shù)結(jié)束后 a 會(huì)被銷(xiāo)毀,但 p 還在,所以會(huì)出現(xiàn)懸空指針的情況,因?yàn)榇藭r(shí)指針 p 的生命周期超過(guò)了引用源 a。 本文參考自以下文章:
|
|
來(lái)自: 古明地覺(jué)O_o > 《待分類(lèi)》