一区二区三区日韩精品-日韩经典一区二区三区-五月激情综合丁香婷婷-欧美精品中文字幕专区

分享

《源碼探秘 CPython》91. 可執(zhí)行文件的內(nèi)存模型,變量的值是放在棧上還是放在堆上

 古明地覺(jué)O_o 2022-12-08 發(fā)布于北京

楔子


前段時(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:

  • 1)預(yù)處理:gcc -E main.c -o main.i,根據(jù) C 源文件得到預(yù)處理之后的文件。這一步只是對(duì) main.c 進(jìn)行了預(yù)處理,比如宏定義展開(kāi)、頭文件展開(kāi)、條件編譯等等,同時(shí)將代碼中的注釋刪除,注意:這一步并不會(huì)檢查語(yǔ)法;

  • 2)編譯:gcc -S main.i -o main.s,將預(yù)處理后的文件進(jìn)行編譯、生成匯編文件,這一步會(huì)進(jìn)行語(yǔ)法檢測(cè)、變量的內(nèi)存分配等等;

  • 3)匯編:gcc -c main.s -o main.o,根據(jù)匯編文件生成目標(biāo)文件;

  • 4)鏈接:gcc main.o -o main.exe,程序是需要依賴(lài)各種庫(kù)的,可以是靜態(tài)庫(kù)也可以是動(dòng)態(tài)庫(kù),因此需要將目標(biāo)文件和其引用的庫(kù)鏈接在一起,最終才能構(gòu)成可執(zhí)行的二進(jìn)制文件;

所以從 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)和未初始化數(shù)據(jù)區(qū)(bss)整體被稱(chēng)為靜態(tài)區(qū)或全局區(qū)。


代碼區(qū)(text):負(fù)責(zé)存放 CPU 執(zhí)行的機(jī)器指令。通常代碼區(qū)是可共享的(即另外的執(zhí)行程序可以調(diào)用它),可共享的目的是對(duì)于頻繁被執(zhí)行的程序,只需要在內(nèi)存中存儲(chǔ)一份代碼區(qū)的內(nèi)容即可。并且代碼區(qū)通常是只讀的,只讀的原因是既然可以被其它程序讀取,那么就還要保證它不會(huì)被其它程序篡改。此外,代碼區(qū)還負(fù)責(zé)存放常量,常量在程序運(yùn)行期間也不能修改,例如: 字符串常量 "hello world"、數(shù)組的名字等。

初始化數(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)。


棧區(qū)(stack):棧是一種先入后出的數(shù)據(jù)結(jié)構(gòu)(非常高效),由操作系統(tǒng)自動(dòng)分配釋放,存放函數(shù)的參數(shù)值、返回值、局部變量等等。在程序運(yùn)行過(guò)程中實(shí)時(shí)加載和釋放,因此局部變量的生命周期為相應(yīng)的??臻g從申請(qǐng)到釋放的這段時(shí)間。


text、data、bss 解析


光用文字解釋的話還不夠直觀,下面我們就來(lái)舉幾個(gè)栗子,分析一下這幾個(gè)區(qū)。

text

首先是代碼區(qū):

#include <stdio.h>
void main() { //定義一個(gè) char * 類(lèi)型的指針 //存放字符串常量 "hello cruel world" 的首地址 //注意:此時(shí)的字符串是存在代碼區(qū)當(dāng)中的 //因?yàn)樗且粋€(gè)字符串常量 char *s = "hello cruel world"; printf("%p, %s\n", s, s);
//同樣,在代碼區(qū)創(chuàng)建一個(gè)新的字符串 //然后讓 s 指向新字符串的首元素 s = "hello beautiful world"; printf("%p, %s\n", s, s);}

我們編譯執(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)確定好了。

#include <stdio.h>
void main() { char *s = "hello cruel world..."; printf("%p, %s\n", s, s);
s = "hello beautiful world..."; printf("%p, %s\n", s, s);}

修改源文件,給每個(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í)行文件具有相同的生命周期。

#include <stdio.h> 
void main() { char *s = "hello cruel world"; s[0] = 'H'; printf("%p, %s\n", s, s); }

我們嘗試修改常量字符串的第一個(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ū)。

#include <stdio.h> 
void main() { //這里將 char *s 改成了 char s[] //那么這兩者有什么區(qū)別呢? /*char *s = "abc" 相當(dāng)于聲明了一個(gè)字符串常量 "abc" 它是放在常量區(qū)當(dāng)中的,然后讓指針 s 指向這個(gè)常量 char s[] = "abc" 等價(jià)于 char s[] = {'a', 'b', 'c', '\0'} 此時(shí)的 "abc" 是放在棧區(qū)的,因?yàn)榇藭r(shí)的 s 是一個(gè)局部數(shù)組 */ //所以要注意聲明數(shù)組和聲明指針的區(qū)別 char s[] = "hello cruel world"; printf("%p, %s\n", s, s); // 將 s[0] 進(jìn)行修改 s[0] = 'H'; printf("%p, %s\n", s, s); }

我們?cè)賮?lái)測(cè)試一下:

顯然數(shù)組聲明完之后,地址是固定的,同時(shí)內(nèi)部元素是可修改的,因此我們成功將首字母 'h' 改成了 'H'。此外 char *s 和 char s[] 還有一個(gè)顯著的區(qū)別,就是當(dāng)作為返回值的時(shí)候。

#include <stdio.h> 
char *test1() { char *s = "hello cruel world"; return s; }
char *test2() { char s[] = "hello cruel world"; return s; }
void main() { printf("%s\n", test1()); printf("%s\n", test2()); }

對(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é)束之前我們都可以使用它。

#include <stdio.h> 
char *test1() { char *s = "hello cruel world"; return s; }
char *test2() { //此時(shí)的 s 就是一個(gè)靜態(tài)數(shù)組 //里面的元素會(huì)放在初始化數(shù)據(jù)區(qū)當(dāng)中,而不會(huì)放在棧區(qū) static char s[] = "hello cruel world"; return s; }
void main() { printf("%s\n", test1()); printf("%s\n", test2()); }

此時(shí)再來(lái)執(zhí)行,看看有沒(méi)有問(wèn)題:

正常輸出,沒(méi)有任何警告??。除了靜態(tài)局部變量,還有靜態(tài)全局變量、全局變量,它們都是放在初始化數(shù)據(jù)區(qū)當(dāng)中的。

#include <stdio.h> 
// 靜態(tài)全局變量 static int a = 123; // 全局變量 int b = 234;
int *test() { // 靜態(tài)局部變量 static int c = 345; c++; //返回靜態(tài)局部變量的地址沒(méi)有任何問(wèn)題 //因?yàn)楹瘮?shù)結(jié)束之后不會(huì)被銷(xiāo)毀 return &c; }
void main() { printf("%d %d\n", a, b);
for (int i=0; i < 5; i++) { int *p = test(); printf("%p %d\n", p, *p); }}

注意:靜態(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 為例:

package main 
import "fmt"
func test() *int { var a int = 123 return &a }
func main() { var a *int = test() fmt.Println("*a =", *a) // *a = 123 }

類(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)。

#include <stdio.h> #include <stdlib.h> 
int *test() { // 在堆區(qū)分配 int 大小的內(nèi)存,然后返回它的指針 int *a = (int *)malloc(sizeof(int)); *a = 123; return a; }
void main() { int *a = test(); printf("%d\n", *a); // 用完之后釋放掉,否則可能造成內(nèi)存泄漏 if (a != NULL) free(a); }

我們看到此時(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í)的。

這里我們就得出了一個(gè)結(jié)論:如果一個(gè)局部變量的地址要返回給外界,那么它的值要申請(qǐng)?jiān)诙焉稀?/p>

bss

最后是未初始化數(shù)據(jù)區(qū) bss,它是負(fù)責(zé)存儲(chǔ)未初始化全局變量、未初始化靜態(tài)變量。

#include <stdio.h>
static int a;static int b = 123;
void main() { static int c; static int d = 345; printf("%d %d %d %d\n", a, b, c, d);}

變量 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)用為例:

#include <stdio.h> 
int hello() { return world(); }
int world() { return 666; }
void main() { printf("%d\n", hello()); // 666 }

這段程序非常簡(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ù)留空間。

這里我們就又得出了一個(gè)結(jié)論:在編譯時(shí),如果局部變量的大小不確定、或者大小可以改變,那么它的值就無(wú)法安全地放在棧上,應(yīng)該要放在堆上。也就是在堆上為變量分配內(nèi)存,并且還要在棧上分配一個(gè)指針,引用堆上的內(nèi)存。

數(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)致棧溢出。

所以我們又得到了一個(gè)結(jié)論:當(dāng)變量的值占用內(nèi)存過(guò)大時(shí),那么優(yōu)先在堆上分配。

因此變量的值究竟分配在棧上還是分配是在堆上,結(jié)論如下:

  • 1)如果一個(gè)函數(shù)返回了局部變量的指針,那么要在堆上為其分配內(nèi)存;

  • 2)如果在編譯時(shí),局部變量的大小不確定、或者大小可以改變,那么它的值就無(wú)法安全地放在棧上,所以此時(shí)也要在堆上為其分配內(nèi)存;

  • 3)如果變量的值過(guò)大,那么優(yōu)先在堆上為其分配內(nèi)存;


堆區(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 為例:

fn main() {      let s1 = String::from("hello");     let s2 = s1; }

此時(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)題呢?

?垃圾回收針對(duì)的是堆內(nèi)存,因?yàn)闂?nèi)存由操作系統(tǒng)維護(hù),不需要我們關(guān)心。

包括 Rust 也是如此,Rust 在轉(zhuǎn)移所有權(quán)的時(shí)候,針對(duì)的也是堆內(nèi)存。如果數(shù)據(jù)都在棧上,不涉及到堆,那么由于棧上數(shù)據(jù)在傳遞的時(shí)候會(huì)拷貝一份,所以傳遞之后你是你的,我是我的,兩者互不影響。

所以垃圾回收、以及 Rust 的所有權(quán)轉(zhuǎn)移,針對(duì)的一定是堆內(nèi)存。如果數(shù)據(jù)都在棧上,那么操作系統(tǒng)會(huì)為我們處理好一切。

首先無(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)的垃圾回收算法都有哪些呢?

  • 引用計(jì)數(shù)法(reference count):記錄對(duì)象的被引用次數(shù),引用計(jì)數(shù)降為 0 時(shí)回收;

  • 標(biāo)記-清除法(mark-sweep):從根集合觸發(fā),遍歷所有能訪問(wèn)到的對(duì)象并對(duì)其進(jìn)行標(biāo)記,然后將未被標(biāo)記的對(duì)象清除;

  • 停止-復(fù)制法(stop-copy):將內(nèi)存劃分為大小相同的內(nèi)存塊,一塊用完后啟用另一塊、并將存活的對(duì)象拷貝過(guò)去,原來(lái)那塊則整體被回收;

  • 分代回收法(generational-collection):根據(jù)對(duì)象的存活時(shí)間將對(duì)象分為若干代,并按照不同代的特征采用最合適的回收策略;

以 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)題。

所以才有了 Rust

一句話對(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è)指針指向它。

int test() {    int a = 123;    // 引用棧上的變量    int *p = &a;      return 0;}

只要指針的生命周期小于等于引用源就行,但如果指針的生命周期大于引用源,那么就不行了。

比如我們上面的代碼不能將 p 返回,否則的話,函數(shù)結(jié)束后 a 會(huì)被銷(xiāo)毀,但 p 還在,所以會(huì)出現(xiàn)懸空指針的情況,因?yàn)榇藭r(shí)指針 p 的生命周期超過(guò)了引用源 a。


本文參考自以下文章:

  • 極客時(shí)間,陳天《Rust 編程第一課》

  • 《Rust 權(quán)威指南》

    轉(zhuǎn)藏 分享 獻(xiàn)花(0

    0條評(píng)論

    發(fā)表

    請(qǐng)遵守用戶 評(píng)論公約

    類(lèi)似文章 更多

    日韩亚洲精品国产第二页| 日本视频在线观看不卡| 日本av一区二区不卡| 国产成人精品国内自产拍| 亚洲国产精品一区二区毛片| 日韩欧美黄色一级视频| 亚洲欧美黑人一区二区| 亚洲中文字幕亲近伦片| 色综合久久中文综合网| 日系韩系还是欧美久久| 绝望的校花花间淫事2| 大尺度剧情国产在线视频| 爱草草在线观看免费视频| 精品亚洲香蕉久久综合网| 99久久精品午夜一区二区| 国产精品久久三级精品| 日韩一区二区三区在线日| 超薄丝袜足一区二区三区| 一区二区三区精品人妻| 亚洲男人天堂成人在线视频| 国产一级精品色特级色国产| 亚洲中文字幕视频在线播放 | 国内尹人香蕉综合在线| 亚洲熟女少妇精品一区二区三区| 欧美在线观看视频三区| 欧美性猛交内射老熟妇| 青青操在线视频精品视频| 日韩欧美三级中文字幕| 亚洲精品av少妇在线观看| 在线欧洲免费无线码二区免费| 最新69国产精品视频| 精品少妇人妻av免费看| 色丁香之五月婷婷开心| 观看日韩精品在线视频| 激情少妇一区二区三区| 日韩黄片大全免费在线看| 欧美丰满大屁股一区二区三区| 大香蕉大香蕉手机在线视频| 在线观看国产成人av天堂野外| 国内尹人香蕉综合在线| 久久99热成人网不卡|