本文探討程式設計領域的一個重要內容:記憶體管理。你可能對記憶體管理的概念比較模糊,或者經常忽略它。我們將重點介紹高階記憶體管理抽象,如果您希望從更廣泛的角度瞭解記憶體管理,尤其是作為 Web 開發人員,這可能會很有用。
問題
我先問你一個問題:
什麼資料放在棧上,什麼資料放在堆上?
如果你精通 JavaScript/Python/Java 等具有自動記憶體管理功能的語言,你可能會說出以下答案:
基礎型別(Primitive types)儲存在棧中,物件(objects)儲存在堆中,閉包變數(closure variables)儲存在堆中,等等。
這個答案正確嗎?沒問題,但這只是表象,不是本質。那麼本質是什麼呢?
讓我們先分析一下程式中的棧和堆,然後給出答案。
棧
棧數據結構的特點是先進後出(first-in, last-out)。正因為這個特點,它非常適合記錄程式的函式呼叫,也被稱為函式呼叫棧。那麼請看下面的簡單程式碼示例:
fn test() {} fn main() { test() }
讓我們來分析一下。首先,我們編寫的程式碼將作為 "entry "執行。請看下圖:
函式呼叫棧從上到下遞增(grows from top to bottom)。在這個簡單的程式碼示例中,呼叫流程是 entry -> main() -> test()
。
接下來,每當執行一個函式時,堆疊頂部都會分配一塊連續的記憶體,這塊記憶體被稱為頁幀(frame) 。
在頁式儲存管理當中,它把物理的地址空間分成的基本單位叫頁幀,或者叫幀(frame)。這個大小是2的n次冪,那麼為什麼會是2的n次冪呢?原因在於我們要在地址轉換的過程當中讓地址轉換比較方便,那在計算機裡二進制的移位是做乘法的一個非常重要的快速的一個因素,所以這一定要求它是2的整次冪。比如說現在在32位機器裡頭,4K(4096)是常見的頁幀大小(64位一般為8k)。
該 "frame "儲存了當前函式通用暫存器和當前函式區域性變數的上下文資訊。
在本例中,當 main()
呼叫 test()
時,會暫時中斷當前的 CPU 執行程序,並在堆疊中儲存 main()
通用暫存器的副本。執行完 test()
後,將根據之前的副本恢復原來的暫存器上下文,就像什麼都沒發生過一樣。
酷,這就是通用暫存器的神奇之處!
然後,隨著函式被逐層呼叫,堆疊會一層層擴充套件,呼叫結束後,堆疊會逐層回溯,每幀佔用的記憶體會被逐一釋放。
但等等,我們似乎遺漏了什麼。在通常情況下,它需要連續的記憶體空間,這意味著程式在呼叫下一個函式之前,必須知道下一個函式需要多少記憶體空間。
那麼,程式是如何知道的呢?
答案是編譯器為我們完成了這一切。
編譯程式碼時,函式是最小的編譯單元。每當編譯器遇到一個函式,它就會知道當前函式使用暫存器和區域性變數所需的空間。
因此,在編譯時無法確定大小或大小可以改變的資料不能安全地放在堆疊中。
堆
如上所述,這些資料不能安全地放在堆疊中,因此最好放在堆上,例如下面的變長陣列:
fn main() { let mut arr = Vec::new(); arr.push(1); println("length: {}, capacity: {}", arr.len(), arr.capacity()); }
建立陣列而不指定其長度時,程式需要動態分配記憶體。例如,在 C 語言中,通常使用 malloc()
函式進行分配。最初,程式會預留一定的空間(例如,在 Rust 中可能會預留 4 個元素的空間)。如果陣列的實際使用量超過了這個容量,程式會分配一個更大的記憶體塊,將現有元素複製到其中,新增新元素,然後釋放舊記憶體。這個過程允許陣列根據需要動態調整大小。
請求系統呼叫並找到新的記憶體,然後一個個複製的過程是非常低效的。
因此,最好的做法是提前預留陣列真正需要的空間。
此外,需要跨棧引用的記憶體也需要放在堆上,這一點很好理解,因為一旦棧幀被回收,其內部區域性變數也會被回收,所以在不同的呼叫棧中共享資料只能使用堆。
但這又帶來了一個新問題,堆上佔用的記憶體何時釋放?
垃圾回收
主流程式語言都給出了自己的答案:
早期的 C 語言將所有這些都留給開發人員手動管理,這對於經驗豐富的程式設計師來說是一個優勢,因為他們可以更精細地控制程式的記憶體。但對於初學者來說,必須牢記那些記憶體管理的最佳實踐。但與機器不同的是,總有一些疏忽會導致記憶體安全問題,導致程式執行緩慢或直接崩潰。
以 Java 為代表的一系列程式語言都使用追蹤式垃圾收集(Tracing GC,Tracing Garbage Collection)來自動管理堆記憶體。這種方法透過定期標記不再被引用的物件,然後將其清理掉,從而自動管理記憶體,減輕了開發人員的負擔。但它在標記和釋放記憶體時需要執行額外的邏輯,這會導致 STW(Stop The World),比如程式卡住,而且這些時間也是不確定的。因此,如果要開發一些實時性要求較高的系統,一般不會使用類似 GC 的語言。
Apple 公司的 Objective-C 和 Swift 使用 ARC(Automatic Reference Counting,自動引用計數),在編譯時為每個函式插入 retain/release
語句,以自動維護堆上物件的引用計數。當物件的引用計數為 0 時,release 語句就可以釋放物件。但它增加了大量額外程式碼來處理引用計數,因此效率和吞吐量都不如 GC。
Rust 使用所有權 ownership 機制,預設情況下將堆上資料的生命週期與棧幀的生命週期繫結在一起。一旦棧幀被銷燬,堆上的資料也將被丟棄,佔用的記憶體也將被釋放。Rust 還提供了 API,供開發人員更改預設行為或自定義釋放時的行為。
總結
棧中儲存的資料是靜態的,大小固定,生命週期固定,不能跨棧引用。
堆上儲存的資料是動態的,大小不固定,生命週期也不固定,可以跨堆引用。
文章來源:Memory Management Every Developer Should Know