第四章 內存系統
不同的編程語言對內存有着不同的管理方式。
按照內存的管理方式可將編程語言大致分為兩類:
- 手動管理類
- 手動內存管理類需要開發者使用malloc和free等函數顯式管理內存。
- 自動內存管理類
- 自動內存管理類GC(Gargage Collection,垃圾回收)來對內存進行自動化管理,而無須開發者手動開辟和釋放內存,Java, C#, Ruby, Python
手動內存管理的另一個常見問題就是懸垂指針 Dangling Pointer。 如果某個指針引用的內存被非法釋放掉了, 而該指針卻依舊指向被釋放的內存,這種情況下的指針就叫作懸垂指針。如果將懸垂指針分配給某個其他的對象,將會產生無法預料的后果。
GC最大的問題是會引起“世界暫停”,GC 在工作的時候必須保證程序不會引入新的“垃圾”, 所以要使運行中程序暫停,這就造就了性能問題。
Rust既能無GC又可以安全地進行手動內存管理,還不缺乏更高地抽象,可以像其他高級語言一樣進行快速地開發。
Rust允許開發者直接操作內存,所以了解內存如何工作對於編寫出高效地Rust代碼至關重要。
通用概念
現代操作系統在保護模式下都采用虛擬內存管理技術。虛擬內存是一種對物理存儲設備的統一抽象,其中物理存儲設備包括物理內存、磁盤、寄存器、高速緩存等。
這樣統一的好處是,方便同時運行多道程序,使得每個進程都有各自獨立的進程地址空間,並且可以通過操作系統調度將外存當作內存來使用。
這就引出了一個新的概念:虛擬地址空間。
虛擬地址空間是線性空間,用戶所接觸到的地址都是虛擬地址,而不是真實的物理地址。利用這種虛擬地址不但能保護操作系統,讓進程在各自的地址空間內操作內存,更重要的是,用戶程序可以使用比物理內存更大的地址空間。
虛擬地址空間被人為分為兩部分:
- 用戶空間內核空間
- 用戶空間中有的棧和堆
- 用戶空間中有的棧和堆
以linux為例,32位計算機的地址空間大小是4GB, 尋址范圍是0x00000000~0XFFFFFFF。然后通過內存分頁等底層復雜的機制來把虛擬地址翻譯為物理地址。
棧向下(由高地址向低地址)增長,堆向上(由低地址向高地址)增長,這樣是為來更加有效利用內存。
棧
棧,也被稱為堆棧。棧一般有兩種定義,一種是數據結構,一種是棧內存。
在數據結構中,棧是一種特殊的線性表,其特殊在於限定了插入和刪除數據只能在線性表固定的一端進行。
操作棧的一端稱為棧頂,相反的一端稱為棧底。
從棧頂壓入數據叫作入棧,從棧頂彈出數據叫作出棧。
最后一個入棧的數據會第一個出棧,所以棧被稱為后進先出線性表。
物理內存本身不區分堆和棧,但是虛擬內存空間需要一部分內存,用於支持CPU入棧和出棧的指令操作,這部分內存空間就是棧內存。
棧內存擁有和棧數據結構相同的特性,支持入棧和出棧操作,數據壓入的操作使棧頂的地址減少,數據彈出的操作使棧頂的地址增多。
棧頂由棧寄存器ESP保存,起初棧頂指向棧底的位置,當有數據入棧時,棧頂地址向下增長,地址由高地址變成低地址;當有數據被彈出時,棧頂地址向上增長,地址由低地址變成高地址。
降低ESP的地址等價於開辟空間,增加ESP的地址等價於回收棧空間。
棧內存最重要的作用是在程序運行過程中保存函數調用所維護的信息。
存儲每次函數調用所需信息的記錄單元被稱為棧幀,有時也被稱為活動記錄,因為棧內存被棧幀分割成了N個記錄塊,而且這些記錄塊都是大小不一。
棧幀一般包括三方面的內容:
- 函數的返回地址和參數
- 臨時變量。包括函數內部的非靜態局部變量和編譯器產生的臨時變量。
- 保存的上下文
EBP指針是幀指針,它指向當前棧幀的一個固定的位置,而ESP始終指向棧頂,EBP指向的值是調用該函數之前的舊的ESP值,這樣在函數返回時,就可以通過該值恢復到調用前的值。由EBP和ESP指針構成的區域就是一個棧幀,一般是指當前棧幀。
棧幀的分配非常快,其中的局部變量都預分配內存,在棧上分配的值都是可以預先確定大小的類型。當函數結束調用的時候,棧幀會被自動釋放,所以棧上數據的生命周期都是在一個函數調用周期內的。
1 fn foo(x: u32) { 2 let y = x; 3 let z = 100; 4 } 5 fn main() { 6 let x = 42; 7 foo(x); 8 }
main函數為入口函數,所以首先被調用。main函數中聲明了變量x,在調用foo函數前,main函數先在棧里開辟了空間,壓入了x變量。棧幀里EBP指向起始位置,變量x保存在幀指針EBP-4偏移處。
在調用foo函數時,將返回地址壓入棧中,然后由PC指針(程序計數器)引導執行函數調用指令,進入foo函數棧幀中。此時同樣在棧中開辟空間,依次將main函數的EBP地址,參數x以及局部變量y和z壓入棧中。EBP指針依舊指向地址為0的固定位置,表明當前是在foo函數棧幀中,通過EBP-4, EBP-8, EBP-12就可以訪問參數和變量。當foo函數執行完畢時,其參數或局部變量會依次彈出,直到得到main函數的EBP地址,就可以跳回main函數棧幀中,然后通過返回地址就可以繼續執行main函數中其余的代碼。
調用main和foo函數時,棧幀ESP地址降低,因為要分配棧內存,棧向下增長,當foo函數執行完畢時,ESP地址會增長,因為棧內存會被釋放。
隨着棧內存的釋放,函數中的局部變量也會被釋放。
所以,全局變量不會被存儲到棧中,該過程說來簡單,但其實底層涉及尋址、寄存器、匯編指令等比較復雜的協作過程,這些都是由編譯器或解析器自動完全的,對於上層開發者來說,只需要了解棧內存的工作機制即可。
堆
堆也是有兩種定義,一種是指數據結構,另一種指堆內存。
在數據結構中,堆表示一種特殊的樹形數據結構,特殊之處在於此樹是一棵完全二叉樹,它的特點是父節點的值要么大於兩個字節點的值,稱為大頂堆。
要么都小於兩個子節點的值,稱為小頂堆。 一般用於實現堆排序或優先隊列。
棧數據結構和棧內存在特性上還有所關聯,但堆數據結構和堆內存並無直接的聯系。
程序不可以主動申請棧內存,但可以主動申請堆內存。
在堆內存中存放的數據會在程序運行過程中一直存在,除非該內存被主動釋放掉。
在實際工作中,對於事先直到大小的類型,可以分配到棧中,比如固定大小的數組。但是如果需要動態大小的數組,則需要使用堆內存。開發者只能通過指針來掌握已分配的堆內存,這本身就帶來來安全隱患,如果指針指向的堆內存被釋放掉但指針沒有被正確處理,或者該指針指向一個不合法的內存,就會帶來內存不安全問題。
面向對象大師Bertand Meyer: 要么保證軟件質量,要么使用指針,兩者不可兼得。
堆是一大塊內存空間,程序通過malloc申請到內存空間是大小不一,不連續且無序的,所以如何管理堆內存是一個問題。
堆分配算法,分為兩大類: 空閑鏈表和位圖標記。
空閑鏈表實際上就是把堆中空閑的內存地址記錄為鏈表,當系統收到程序申請時,會遍歷鏈表;當找到適合的空間堆節點時,會將此節點從鏈表中刪除;當空間被回收以后,再將其加到空閑鏈表中。空閑鏈表的優勢是實現簡單,但如果鏈表遭到破壞,整個堆就無法正常工作。
位圖的核心思想是將整個堆划分為大量大小相等的塊。當程序申請內存時,總是分配整個塊的空間。每塊內存都用一個二進制位來表示其狀態,如果該內存被占用,則相應位圖中的位置置為1; 如果該內存空閑,則相應位圖中的位置置為0,位圖的優勢是速度快,如果單個內存塊數據遭到破壞,也不會整個堆,但缺點是容易產生內存碎片。
不管是什么算法,分配的都是虛擬地址空間,所以當堆空間被釋放時,並不代表指物理空間也馬上被釋放。
堆內存分配函數malloc和回收函數free背后是內存分配器,比如glibc的內存分配器ptmallac2,或者FreeBSD平台的jemalloc。 這些內存分配器負責管理申請和回收堆內存,當堆內存釋放時,內存被歸還給內存分配器。內存分配器會對空閑的內存進行統一整理,在適合的時候,才會把內存歸還給系統,也就是指釋放物理空間。
Rust編譯器目前自帶兩個默認分配器: alloc_system,alloc_jemalloc。
在Rust 2015版本下,編譯器產生的二進制文件默認使用alloc_jemalloc, 而對於靜態或動態鏈接庫,默認使用alloc_system. 在Rust 2018版本下,默認使用alloc_sysem,並且可以由開發者自己指派Jemalloc或其他第三方分配器。
Jemalloc的優勢有以下幾點:
- 分配或回收內存更快速
- 內存碎片更少
- 多核友好
- 良好的可伸縮性
Jemalloc 是現代的業界流行的內存分配解決方案,它整塊批發內存(chunk)以供程序員使用,而非頻繁地使用系統調用brk, mmap來向操作系統申請內存。其內存管理采用層級架構,分別是線程緩存tcache,分配區arena,和系統內存,system memory, 不同大小的內存塊對應不同的分配區,每個線程對應一個tcache,tcache負責當前線程所使用內存塊的申請和釋放,避免線程間鎖的競爭和同步。tcache是對arena中內存塊的緩存,當沒有tcachea時則使用arena分配內存。
arena采用內存池思想對內存區域進行合理划分和管理,在有效保證低碎片的前提下實現了不同大小內存塊的高效管理。當arena中有不能分配的超大內存時,再直接使用mmap從系統內存中申請,並使用紅黑樹進行管理。
即使堆分配算法再好,也只是解決了堆內存合理分配和回收地問題,其訪問性能不如棧內存。存放在堆上地數據要通過其存放於棧上地指針進行訪問,這就至少多了一層內存中地跳轉。所以能放在棧上地數據最好不要放到堆上,因此,Rust的類型默認都是放在棧上的。
內存布局
內存中數據的排列方式稱為內存布局。不同的排列方式,占用的內存不同,也會間接影響CPU訪問內存的效率,為了權衡空間占用情況和訪問效率,引入了內存對齊規則。
CPU在單位時間內能處理的一組二進制數稱為字。這組二進制數的位數稱為字長。如果是32位CPU,其字長為32位,也就是4個字節。一般來說字長越大,計算機處理信息的速度就越快,例如,64位CPU就比32位CPU效率高。
以32位CPU為例,CPU每次只能從內存中讀取4個字節的數據,所以每次只能對4的倍數進行讀取。
假如現有一行數據類型的數據,首地址並不是4的倍數,不妨設0x3,則該類型存儲在地址范圍是0x3~0x7的存儲空間中,因此,CPU如果想讀取該數據,則需要分別在0x1和0x5處進行兩次讀取,而且還需要對讀取到的數據進行處理才能得到該數據,CPU的處理速度比從內存中讀取數據的速度要快的多,因此減少CPU對內存空間的訪問是提高程序性能的關鍵。
通過內存對齊策略是提高程序性能的關鍵。 因為是32位CPU,所以只需要按4字節對齊,CPU只需要讀取一次。
因為對齊的是字節,所以內存對齊也叫字節對齊,內存對齊是編譯器或虛擬機的工作,不需要人位指定,,但是作為開發者需要了解內存對齊的規則,這有助於編寫出合理利用內存的高性能程序。
內存對齊包括基本數據對齊和結構體(或聯合體)數據對齊,對於基本數據類型,默認對齊方式是按其大小進行對齊的,也被稱為自然對齊。比如Rust中32類型占4字節,則它默認對齊方式為4字節。對於內部含有多個基本類型的結構體來說,對齊規則稍微點復雜。
假設對齊字節數為N(N=1,2,4,8,16),每個成員內存長度為len, Max(len)為最大成員內存長度。
如果沒有外部明確的規定,N默認按Max(len)對齊。字節對齊規則:
- 結構體的起始地址能夠被Max(len)整除
- 結構體中每個成員相對於結構體起始地址的偏移量,即對齊值,應該是Min(N, Len)的倍數,若不滿足對齊值的要求,編譯器會在成員之間填充若干字節。
- 結構體的總長度應該是Min(N, Max(len))的倍數,若不滿足總長度的要求,則編譯器會在為最后一個成員分配空間后,在其后面填充若干個字節。
1 struct A { 2 a: u8, //1 補齊值Min(4, 1)
3 b: u32, //4 b已經是對齊的
4 c: u16, //2 c是結構體中最后一個成員,
5 }//當前結構體A的總長度為a,b,c之和。占8個字節。正好是Min(4, 4),也就是4的倍數,所以成員c不需要再補齊, 6 //因此結構體A實際占用也是8個字節 7 //A沒有明確指定字節對齊值,所以默認按其最長成員值來對齊,結構體A中最長的成員是b,占4個字節。
8 fn main() { 9 println!("{:?}", std::mem::size_of::<A>()); // 8
10 }
聯合體和結構體不同地方在於,聯合體中的所有成員都共享一端內存,所以聯合體以最長成員為對齊數。
union U { f1: u32, f2: f32, f3: f64, } fn main() { println!("{:?}", std::mem::size_of::<U>());//8
}
Rust 中的資源管理
Rust可以靜態地在編譯時確定何時需要釋放內存,而不需要在運行時去確定。Rust有一套完整的內存管理機制保證資源合理利用和良好的性能。
變量和函數
變量有兩種:全局變量和局部變量。 全局變量分為常量變量和靜態變量。局部變量是指在函數中定義的變量。
常量使用const關鍵字定義,並且需要顯式指定類型,只能進行簡單賦值,只能使用支持CTFE的表達式。常量沒有固定的內存地址,因為其生命周期是全局的,隨着程序消亡,並且會被編譯器有效地內聯到每個使用到它的地方。
靜態變量使用static 關鍵字定義,跟常量一樣需要顯式指明類型,進行簡單賦值,而不能使用任何表達式。靜態變量的生命周期也是全局的,但它並不會被內聯,每個靜態變量都有一個固定的內存地址。
靜態變量並非分配到棧中,也不是堆中,而是和程序代碼一起被存儲於靜態存儲區中,靜態存儲區是伴隨着程序的二進制文件的生成(編譯時)被分配的,並且在程序的整個運行期都會存在,Rust中的字符串字面量同樣是存儲於靜態內存中的。
檢測是否聲明未初始化變量
在函數中定義的局部變量都會被默認存儲到棧中。不同的是Rust編譯器可以檢查未初始化的變量,以保證內存安全。
1 fn main() { 2 let x: i32; 3 pritln!("{}", x); // use of possibly uninitialized variable 'x'
4 }
Rust編譯器會對代碼做基本的靜態分支流程分析,x在整個main函數中並沒有綁定任何值,這樣的代碼會引起很多內存不安全的問題,比如計算結果非預期,程序崩潰,所以Rust編譯器必須報錯。
檢測分支流程是否產生未初始化變量
Rust編譯器的靜態分支流程比較嚴格。
1 fn main() { 2 let x: i32; 3 if true { 4 x = 1; 5 } else { 6 x = 2; 7 } 8 println!("{}", x); 9 }
if分支的所有情況都給變量x綁定了值,所以他可以正確的運行。
但是如果去掉else分支,編譯器就會報錯:
error: use of possibly uninitialized variable 'x'
這說明編譯器已經檢查出變量x並未正確初始化,去掉else分支之后,編譯器的靜態分支流程分析判斷出if表達式之外的println!也用到了變量x,但並未有任何值綁定行為。編譯器的靜態分支流程分析並不能識別if表達式中的條件true, 所以他要檢查所有的分支情況。
如果把代碼中的else分支和println!語句都去掉,則可以正常編譯運行。因為在if表達式之外再沒有使用到x的地方,在唯一使用到x的if表達式中已經綁定了值,所以編譯正常。
檢查循環中是否產生為初始化變量
當在循環中使用break關鍵字的時候,break會將分支中的變量值返回。
1 fn main() { 2 let x: i32; 3 loop { 4 if true { 5 x = 2; 6 break; 7 } 8 } 9 println!("{}", x); //2
10 }
從Rust編譯器的分支流程分析可以直到,break會將x的值返回,所以在loop循環之外的println!語句可以正常打印x的值。
空數組或向量可以初始化變量
當變量綁定空的數組或向量時,需要顯式指定類型,否則編譯器無法推斷期類型。
1 fn main() { 2 let a: Vec<i32> = vec![]; 3 let b: [i32; 0] = []; 4 }// 如果不加顯式類型標注,編譯器會報錯; error : type annotations needed
空數組或變量可以用來初始化變量,當目前暫時無法用於初始化常量或靜態變量。
轉移所有權產生了為初始化變量
當將一個已初始化的變量y綁定給另一個變量y2時,Rust會把變量y看作邏輯上的未初始化變量。
fn main() { let x = 42; // x原生類型,實現了Copy trait,所以這里變量x並未發生任何變化。
let y = Box::new(5); println!("{:p}", y); let x2 = x; let y2 = y; //會將y的值移動給y2,而變量y會被編譯器看作一個未初始化的變量,當再使用時就會報錯。 //此時如果再重新綁定一個新值,y依然可用。這個過程稱為重新初始化。 // println!("{:?}", y);
}
當main函數調用完畢時,棧幀會被釋放,變量x和y也被清空。Box<T>類型的指針會在變量y被清空之時,自動清空其指向的已分配堆內存。
Box<T>這樣的指針稱為智能指針,使用智能指針,可以讓Rust利用棧來隱式自動釋放堆內存,從而避免顯示調用free之類的函數去釋放內存。這樣更加符合開發者的直覺。
智能指針與RAII
Rust中的指針大致可以分為三種: 引用,原生指針(裸指針)和智能指針。
引用就是Rust提供的普通指針,用&和&mut操作符來創建,形如&T和&mut T, 原生指針是指形如const T和 mut T這樣的指針。
引用和原生指針類型之間的異同:
- 可以通過as操作符隨意轉換,& T as * const T, &mut T as *mut T.
- 原生指針可以在unsafe塊下任意使用,不受Rust的安全檢查規則的限制,而引用則必須收到編譯器安全檢查規則的限制。
智能指針
智能指針實際上是一個結構體,行為類似指針。智能指針是對指針的一層封裝,提供了一些額外的功能,比如自動釋放內存。
智能指針區別於常規結構體的特性在於,它實現了Deref和Drop這兩個trait. Deref提供了解引用能力,Drop提供了自動析構的能力,正是這兩個trait讓智能指針擁有了類似指針的行為。 類似決定行為,同時類型也取決於行為,不是指針勝似指針,所以稱其為智能指針。
智能指針結構體中實現了Deref,重載了解引用運算符的行為,String和Vec也是一種智能指針。都實現了Deref和Drop
1 fn main() { 2 let s = String::from("hello"); 3 // let deref_s : str = *s; // str是大小不確定的類型,所以編譯器會報錯
4 let v = vec![1, 2, 3]; 5 // let deref_v: [u32] = *v; //[u32]也是大小不確定的類型
6 }
String類型和Vec類型的值都是被分配到堆內存並返回指針的,通過將返回的指針封裝來實現Deref和Drop,以自動化管理解引用和釋放堆內存。
當main函數執行完畢后,棧幀釋放,變量s和v被清空之后,其對應的已分配堆內存會被自動釋放,這是因為它們實現了Drop
Drop對於智能指針來說非常重要,因為它可以幫助智能指針在丟棄時自動執行一些重要的清理工作,比如釋放堆內存。除了釋放內存,Drop還可以做很多其他的工作,比如釋放文件和網絡🔗。
確定性析構
這種資源管理的方式有一個術語: RAII(Resource Acquisition is Initialization) 資源獲取及初始化,RAII和智能指針均起源於現代C++, 智能指針就是基於RAII機制實現的。
RAII將資源托管給創建堆內存的指針對象本身來管理,並保證資源在其生命周期始終有效,一旦聲明周期終止,資源馬上會被回收,
GC是由第三方只針對內存來統一回收垃圾的,這樣就會很被動。
Rust沒有現代C++所擁有的那種構造函數,而是直接對每個成員的初始化來完成構造,也可以直接通過封裝一個靜態函數來構造“構造函數“
Rust中的Drop就是析構函數
Drop被定義在std::ops模塊中,
1 #[lang = "drop"] 2 pub trait Drop { 3 fn drop(&mut self); 4 }
Drop已經被標記為語言項,這表明該trait為語言本身所用,比如智能指針被丟棄后自動觸發析構函數時,編譯器直到去哪里找Drop
1 use std::ops::Drop; 2 #[derive(Debug)] 3 struct S(i32); 4 impl Drop for S { 5 fn drop(&mut self ) { 6 println!("drop {}", self,0); 7 } 8 } 9
10 fn main() { 11 let x = S(1); 12 println!("crate x: {:?}", x); 13 { 14 let y = S(2); 15 println!("crate y: {:?}", y); 16 println!("exit inner scope"); 17 } 18 println!("exit main"); 19 }
RAII 也叫做作用域界定的資源管理(Scope -Bound Resource Management , SBRM)
Drop的特性,它允許在對象即將消亡之時,自行調用指定代碼(drop方法)
Rust中的一些常見類型都實現來Drop, Vec, String, File
drop-flag
編譯器使用drop-flag,在函數調用棧中為離開作用域的變量自動插入布爾標記,標記是否調用析構函數,這樣,在運行時就可以根據編譯期做的標記來調用析構函數。
對於結構體或枚舉體這種復合類型來說,並不存在隱式的drop-flag,只有在函數調用時,這些復合結構實例被初始化之后,編譯器才會加上drop-flag,如果復合結構體本身實現了Drop,則會調用它自己的析構函數函數;否則,會調用其成員的析構函數。
當變量被綁定給另一個變量,值發生移動時,也會被加上drop-flag, 在運行時會調用析構函數,加上drop-flag的變量意味着生命周期的結束,之后再也不能被訪問。
可以使用花括號構造顯示作用域來“主動析構“那些需要提前結束生命周期的變量。
1 fn main() { 2 let mut v = vec![1, 2, 3]; 3 { 4 v 5 }; 6
7 //v.push(4);
8 }
對於實現Copy 的類型,是沒有析構函數的,因為實現Copy的類型會復制,其生命周期不受析構函數的影響,所以也就沒必要存在析構函數。
變量遮蔽並不會導致其生命周期提前結束
1 use std::ops::Drop; 2 #[derive(Debug)] 3 struct S(i32); 4 impl Drop for S { 5 fn drop(&mut self) { 6 println!("drop for {}", self.0); 7 } 8 } 9
10 fn main(){ 11 let x = S(1); 12 println!("create x: {:?}", x); 13 let x = S(2); 14 println!("create shadowing x: {:?}", x); 15 }
內存泄漏於內存安全
制造內存泄漏
需要對同已堆內存快滿進行多次引用,
創建一個鏈表
1 struct Node<T> { 2 data: T, 3 next: NodePtr<T>, 4 } 5 type NodePtr<T> = Option<Box<Node<T>>>
6 //這里NodePtr<T>首先是一個Option<T>, 因為鏈表的結尾節點之后有可能不存在下一節點,所以需要Some<T>和None
還需要一個智能指針來保持節點之間的連接,
Box<T>指針對所管理的堆內存有唯一擁有權,所以並不共享。
1 type NodePtr<T> = Option<Box<Node<T>>>; 2 struct Node<T> { 3 data: T, 4 next: NodePtr<T>, 5 } 6 fn main() [ 7 let mut first = Box::new(Node { data: 1, next: None}); 8 let mut second = Box::new(Node { data: 2, next: Nonde}); 9 first.next = Some(seond);// value moved here 10 // 將second節點指定給了first, 因為seond 使用了Box<T>指針,此時second發生了值移動,變成了未初始化變量,
11 second.next = Some(first); 12 }
Rust提供了智能指針Rc<T>, 引用計數reference counting 智能指針,使用它可以共享同一塊堆內存,
Rc<T>有一個特性:它包含的數據T是不可變的,而second.next = Some(first)這種操作需要是可變的。
use std::rc::Rc; type NodePtr<T> = Option<Rc<Node<T>>>; struct Node<T> { data: T, next: NodePtr<T>, } fn main() [ let mut first = Box::new(Node { data: 1, next: None}); let mut second = Box::new(Node { data: 2, next: Nonde}); first.next = Some(seond.clone()); //cannot mutably borrow immutable field
second.next = Some(first.clone()); }
變量first和second使用了clone方法,但並不會真的復制,Rc<T>內部維護着一個引用計數器,每clone一次,計數器加1,當它們離開main函數作用域時計數器會被清零,對應的堆內存也會被自動釋放。
編譯器提示,不能對不可變字段進行修改,
Rust提供了另一種智能指針RefCell<T>, 它提供了一種內部可變性,這意味着,它對編譯器來說不可變的,但是在運行過程中,包含在其中內部數據是可變的
1 use std::rc::Rc; 2 use std::cell:RefCell; 3 type NodePtr<T> = Option<Rc<RefCell<Node<T>>>>; 4
5 struct Node<T> { 6 data: T, 7 next: NodePtr<T>, 8 } 9
10 impl<T> Drop for Node<T> { 11 fn drop(&mut self) { 12 println!("Dropping"); 13 } 14 } 15
16 fn main() [ 17 let mut first = Rc::new(RefCell::new(Node { data: 1, next: None})); 18 let mut second = Rc::new(RefCell::new(Node { data: 2, Some(first.clone() })); 19 first.borrow_mut().next = Some(seond.clone()); //cannot mutably borrow immutable field
20 second.borrow_mut().next = Some(first.clone()); 21 }
出現了一個循環引用,first和second節點互相指向對方,但是編譯運行之后沒有看到任何輸出。這說明析構函數並沒有執行,這里存在內存泄漏。
內存安全的含義
內存泄漏 memoty leak並不再內存安全概念范圍內。
只要不會出現以下內存問題即內存安全:
- 使用未定義內存
- 空指針
- 懸垂指針
- 緩沖區溢出
- 非法釋放未分配的指針或已經釋放過的指針
Rust中的變量必須初始化以后才可以使用,否則無法通過編譯器檢查。所以Rust不會允許開發式使用未定義內存
空指針就是指java中的null, C++中的nullptr, 在Rust中,開發者沒有任何辦法創建一個空指針,因為Rust不支持將整數轉換為指針,也不支持初始化變量。
Rust中使用option類型來代替指針,Option實際是枚舉體,包含兩個值,Some(T)和None, 分別表示兩種情況,有和無,迫使開發者必須對這兩種情況都做處理,以保證內存安全。
懸垂指針 dangling pointer,是指堆內存已經被釋放,但其本身還沒做任何處理,依舊指向已回收地址的指針。如果懸垂指針被程序使用,則會出現無法預期的后果。
1 fn foo<'a>() -> &'a str { 2 let a = "hello".to_stirng(); 3 &a // 局部變量a在離開foo函數之后會被銷毀 4 // ‘a' does not live long enough
5 } 6 fn main() { 7 let x = foo(); 8 }
緩沖區是指一塊連續的內存區域,可保存相同類型的多個實例,緩沖區可以是堆內存,棧內存。 Rust編譯器在編譯期就能檢查除數組越界問題,從而完美地避免緩沖區溢出
Rust不會出現未分配的指針,所以不存在非法釋放的情況,Rust的所有權機制嚴格地保證了析構函數只會調用一次,所以不會出現非法釋放已經釋放的情況
內存泄漏的原因
在Rust中可以導致內存泄漏的情況
- 線程崩潰,析構函數函數無法執行
- 使用引用計數時造成循環引用
- 調用Rust標准庫中的forget函數主動泄漏
對於線程崩潰沒有什么好的辦法阻止
析構函數會做很多事,除了釋放內存,還可以釋放其他資源,如果析構函數不能執行,不僅僅會導致內存泄漏,從更廣的角度來看,還會導致其他資源泄漏。
內存泄漏是指沒有對應該釋放的內存進行釋放,屬於沒有對合法的數據進行操作。
內存不安全操作是對不合法的數據進行了操作,兩者性質不同,造成的后果也不同。
主動泄漏,通過FFI與外部函數打交道,把值交由C代碼去處理,在Rust這邊要使用forget來主動泄漏,防止rust調用析構函數引起問題。
復合類型的內存分配和布局
1 use std::mem; 2 struct A { 3 a: u32, //基本數字類型
4 b: Box<u64>, 5 } 6 struct B(i32, f64, char); 7 struct N; // 0
8 enum E { 9 H(u32), 10 M(Box<u32>), 11 } 12 union U { 13 u: u32, 14 v: u64, 15 } 16
17 println!("Box<u32> : {:?}", std::mem::size_of::<Box<u32>>()); 18 println!("A: {:?}", std::mem::size_of::<A>()); 19 println!("B: {:?}", std::mem::size_of::<B>()); 20 println!("N: {:?}", std::mem::size_of::<N>()); 21 println!("E: {:?}", std::mem::size_of::<E>()); 22 println!("U: {:?}", std::mem::size_of::<U>());
當結構體A在函數中有實例被初始化時,該結構體會被放到棧中,首地址為第一個成員變量a的地址,長度為16個字節,其中成員b是Box<u32>類型,會在堆內存上開辟空間存放數據,但是其指針會返回給成員b,並存放在棧中,一共占8個字節。
枚舉體實際上是一種標簽聯合體,和普通聯合體的共同點在於,其成員變量也公用一塊內存,所以聯合體也稱為共用體。不同點在於,標簽聯合體中每個成員都有一個標簽,用於顯式地表明同一時刻哪一個成員在使用內存,而且標簽也需要占用內存,操作枚舉體的時候,需要匹配處理其所有成員,這也是稱為枚舉體的原因。
枚舉體和聯合體在函數中有實例被初始化,與結構體一樣,也會被分配到棧中,占相應的字節長度,如果成員的值存放與堆上,那么棧中就存放其指針。