Rust如何使用堆和棧

有些數據適合存放於堆,有些數據適合存放於棧。

(1).棧適合存放存活時間短的數據

比如函數內部的局部變量適合存放在棧中,因為函數返回後,該函數中聲明的局部變量就沒有意義了,隨著函數棧幀的釋放,該棧中的所有數據也隨之消失。

與之對應的,存活時間長的數據通常應該存放在堆空間中。比如多個函數(有不同棧幀)共用的數據應該存放在堆中,這樣即使一個函數返回也不會銷燬這份數據。

(2).數據要存放於棧中,要求數據所屬數據類型的大小是已知的。因為只有這樣,Rust編譯器才知道在棧中為該數據分配多少內存。

與之對應的,如果無法在編譯期間得知數據類型的大小,該數據將不允許存放在棧中,只能存放在堆中。

例如,i32類型的數據存放在棧中,因為i32類型的大小是固定的,無論對它做什麼操作,只要它仍然是i32類型,那麼它的大小就一定是4字節。而String類型的數據是存放在堆中的,因為String類型的字符串是可變而非固定大小的,最初初始化的時候可能是空字符串,但可以在後期向此空字符串中加入任意長度的字符串,編譯器顯然無法在編譯期間就得知字符串的長度。

(3).使用棧的效率要高於使用堆

將數據存放於棧中時,因為編譯器已經知道將要存放於棧中數據的大小,所以編譯器總是在棧幀中分配合適大小的內存來存放數據。另一方面,棧中數據的存放方式是後進先出。這相當於編譯器總是找好各種大小合適的盒子來存放數據並將盒子放在棧的頂部,而釋放棧中數據的方式則是從棧頂拿走盒子

與之對應的是將數據存放於堆中時,當程序運行時會向操作系統申請一片空閒的堆內存空間,然後將數據存放進去。但是堆內存空間是無人管理的自由內存區,操作系統想要從堆中找到空閒空間需要做一些額外操作。更嚴重的是堆中有大量碎片內存的情況,操作系統可能會將多份小的碎片空閒內存通過鏈表的方式連接起來組成一個大的空閒空間分配給程序,這樣的效率是非常低的。

對比堆和棧的使用方式,顯然以【盒子】為操作單位且總是跟蹤棧頂的棧內存管理方式的效率要遠高於堆

其實,可以將棧理解為將物品放進大小合適的紙箱並將紙箱按規律放進儲物間,將堆理解為在儲物間隨便找一個空位置來放置物品。顯然,以紙箱為單位來存取物品的效率要高的多,而直接將物品放進凌亂的儲物間的效率要低的多,而且儲物間隨意堆放的東西越多,空閒位置就越零碎,存取物品的效率就越低,且空間利用率就越低。

用一張圖來描述它們:

(4).Rust將哪些數據存放於棧中

Rust中各種類型的值默認都存儲在棧中,除非顯式地使用Box::new()將它們存放在堆上。

但數據要存放在棧中,要求其數據類型的大小已知。對於靜態大小的類型,可直接存儲在棧上。

例如如下類型的數據存放在棧中:

  • 裸指針(一個機器字長)、普通引用(一個機器字長)、胖指針(除了指針外還包含其他元數據信息,智能指針也是一種帶有額外功能的胖指針,而胖指針實際上又是Struct結構)
  • 布爾值
  • char
  • 各種整數、浮點數
  • 數組(Rust數組的元素數據類型和數組長度都是固定不變的)
  • 元組

對於動態大小的類型(如Vec、String),則數據部分分佈在堆中(被稱為allocate buffer),並在棧中留下胖指針(Struct方式實現)指向實際的數據,棧中的那個胖指針結構是靜態大小的(換句話說,動態類型以Vec為例,Vec類型的值理應是那些連續的元素,但因為這樣的連續內存的大小是不確定的,所以改變了它的行為,它的值是那個棧中的胖指針,而不是存儲在allocatge buffer中的實際數據)。

以上分類需要注意幾點:

  • 將棧中數據賦值給變量時,數據直接存放在棧中。比如i32類型的33,33直接存放在棧內,而不是在堆中存放33並在棧中存放指向33的指針
  • 因為類型的值默認都分佈在棧中(即便是動態類型的數據,但也通過胖指針改變了該類型的值的表現形式),所以創建某個變量的引用時,引用的是棧中的那個值
  • 有些數據是0字節的,不需要佔用空間,比如()
  • 儘管【容器】結構中(如數組、元組、Struct)可以存放任意數據,但保存在容器中的要麼是原始類型的棧中值,要麼是指向堆中數據的引用,所以這些容器類型的值也在棧中。例如,對於struct User {name: String},name字段存儲的是String類型的胖指針,String類型實際的數據則在堆中
  • 儘管Box::new(T)可以將類型T的數據放入堆中,但Box類型本身是一個struct,它是一個胖指針(更嚴格地說是智能指針),它在棧中

實際上,對於理解來說,只有Box才能讓數據存放到堆中,但對於實現上,只有調用alloc才能申請堆內存並將數據存放在堆中。比如,自己想實現一個類型,將某些數據明確存放在堆中,那麼必須要在實現代碼中調用alloc來分配堆內存,但同時,要實現的這個類型本身,它的值是在棧中的。

(5).Rust除了使用堆棧,還使用全局內存區(靜態變量區和字面量區)

Rust編譯器會將全局內存區的數據直接嵌入在二進制程序文件中,當啟動並加載程序時,嵌入在全局內存區的數據被放入內存的某個位置。

全局內存區的數據是編譯期間就可確定的,且存活於整個程序運行期間。

字符串字面量、static定義的靜態變量(相當於全局變量)都會硬編碼嵌入到二進制程序的全局內存區

例如:

fn main(){
  let _s = "hello";     // (1)
  let _ss = String::from("hello"); // (2)
  let _arr = ["hello";3];    // (3)
  let _tuple = ("hello",);   // (4)
  // ...
}

上面代碼中的幾個變量都使用了字符串字面量,且使用的都是相同的字面量"hello",在編譯期間,它們會共用同一個"hello",該"hello"會硬編碼到二進制程序文件中。當程序被加載到內存時,該被放入到全局內存區,它在全局內存區有自己的內存地址,當運行到以上各行代碼時:

  • 代碼(1)、(3)、(4),將根據地址取得其引用,並分別保存到變量_s_arr各元素、_tuple元素中
  • 代碼(2),將根據地址取得數據並將其拷貝到堆中(轉換為Vec<u8>的方式存儲,它是String類型的底層存儲方式)

(6).Rust中允許使用const定義常量。常量將在編譯期間直接以硬編碼的方式內聯(inline)插入到使用常量的地方

所謂內聯,即將它代表的值直接替換到使用它的地方。

比如,定義了常量ABC=33,在第100行和第300行處都使用了常量ABC,那麼在編譯期間,會將33硬編碼到第100行和第300行處。

Rust中,除了const定義的常量會被內聯,某些函數也可以被內聯。將函數進行內聯,表示將該函數對應的代碼體直接展開並插入到調用該函數的地方,這樣就沒有函數調用的開銷(比如沒有調用函數時申請棧幀、在寄存器保存某些變量等的行為),效率會更高一些。但只有那些頻繁調用的短函數才適合被內聯,並且內聯會導致程序的代碼膨脹。