內存管理之堆、棧、RAII
- 2019 年 12 月 16 日
- 筆記
內存管理之堆、棧、RAII
0.導語
半個月沒有敲代碼了,終於復活了!
最近在極客時間上看到吳老師的《現代C++實戰30講》,覺得很是不錯,於是學習一下,本文中的一些文字概念引用自這裡。同時,對於這個課的代碼我放在了我的《C++那些事》倉庫裏面,點擊閱讀原文,或者下面鏈接,即可獲取。歡迎star!
https://github.com/Light-City/CPlusPlusThings

1.基本概念
- 堆
C++裏面的堆,英文是 heap,在內存管理的語境下,指的是動態分配內存的區域。這個堆跟數據結構 里的堆不是一回事。這裡的內存,被分配之後需要手工釋放,否則,就會造成內存泄漏。
C++ 標準里一個相關概念是自由存儲區,英文是 free store,特指使用 new 和 delete 來分配和釋放內存的區域。一般而言,這是堆的一個子集:
(1)new 和 delete 操作的區域是 free store
(2)malloc 和 free 操作的區域是 heap
但 new 和 delete 通常底層使用 malloc 和 free 來實現,所以 free store 也是 heap。
- 棧
英文:stack,同數據結構中的stack,滿足後進先出。
- RAII
英文是 Resource Acquisition Is Initialization,是 C++ 所特有的資源管理方式。有少量其他語言,如 D、Ada 和 Rust 也採納了 RAII,但主流的編程語言中, C++是唯一一個依賴 RAII 來做資源管理的。
原理:RAII 依託棧和析構函數,來對所有的資源——包括堆內存在內——進行管理。 對 RAII 的 使用,使得 C++ 不需要類似於 Java 那樣的垃圾收集方法,也能有效地對內存進行管理。RAII 的存在,也是垃圾收集雖然理論上可以在 C++ 使用,但從來沒有真正流行過的主要原因。
2.深入學習
2.1 堆
堆牽扯的通常是動態分配內存,在堆上分配內存,有些語言可能使用 new 這樣的關鍵字,有些語言則是在對象的構造時隱式分配,不需要特殊關鍵字。不管哪種情況,程序通常需要牽涉到三個可能的內存管理器的操作:
- 讓內存管理器分配一個某個大小的內存塊
- 讓內存管理器釋放一個之前分配的內存塊
- 讓內存管理器進行垃圾收集操作,尋找不再使用的內存塊並予以釋放
例如:C++ 通常會做上面的操作 1 和 2。Java 會做上面的操作 1 和 3。而 Python 會做上面的操 作 1、2、3。這是語言的特性和實現方式決定的。
下面詳細闡述上述三個步驟:
第一,分配內存要考慮程序當前已經有多少未分配的內存。內存不足時要從操作系統申請新 的內存。內存充足時,要從可用的內存里取出一塊合適大小的內存,做簿記工作將其標記為 已用,然後將其返回給要求內存的代碼。
注意:代碼只被允許使用其被分配的內存區域,剩餘的內存區域屬於未分配狀態。如果內存管理器支持垃圾收集的話,分配內存的操作可能會出觸發垃圾收集。
第二,釋放內存不只是簡單地把內存標記為未使用。對於連續未使用的內存塊,通常內存管 理器需要將其合併成一塊,以便可以滿足後續的較大內存分配要求。畢竟,目前的編程模式 都要求申請的內存塊是連續的。
第三:垃圾收集操作有很多不同的策略和實現方式,以實現性能、實時性、額外開銷等各方 面的平衡。C++中這個不是重點。
在作者文檔中,提到一個new與delete例子,非常有意思,這裡引用過來。
void foo() { bar* ptr = new bar(); … delete ptr; }
這裡存在兩個問題:
- 中間省略部分若拋出異常,則導致delete ptr得不到執行。
- 更重要的,這個代碼不符合 C++ 的慣用法。在 C++ 里,這種情況下有 99% 的可能性不應該使用堆內存分配,而應使用棧內存分配。
第二點非常重要,於是作者給出了一個更常見、也更合理的作法,分配和釋放不在一個函數里:
bar *make_bar() { bar *ptr = nullptr; try { ptr = new bar(); } catch (...) { delete ptr; throw; } return ptr; } // 獨立出函數 分配和釋放不在一個函數里 void foo1() { cout << "method 2" << endl; bar *ptr = make_bar(); delete ptr; }
2.2 棧
函數調用、本地變量入棧出棧會取決於計算機的試劑架構,原理都是後進先出。棧是向上增長,在包括 x86 在內的大部分計算機體系架構中,棧的增長方向是低地址,因而上方意味着低地址
本地變量所需的內存就在棧上,跟函數執行所需的其他數據在一起。當函數執行完成之後,這些內存也就自然而然釋放掉了。因此得出棧的分配與釋放:
- 分配
移動一下棧指針
- 釋放
函數執行結束時移動一下棧指針
POD類型:本地變量是簡單類型,C++ 里稱之為 POD 類型(Plain Old Data)。
對於有構造和析構函數的非 POD 類型,棧上的內存分配也同樣有效,只不過 C++ 編譯器會在生 棧上的分配極為簡單,移動一下棧指針而已。棧上的釋放也極為簡單,函數執行結束時移動一下棧指針即可。由於後進先出的執行過程,不可能出現內存碎片。成代碼的合適位置,插入對構造和析構函數的調用。
棧展開:編譯器會自動調用析構函數,包括在函數執行發生異常的情況。在發生異常時對析構函數的調用,還有一個專門的術語,叫棧展開(stack unwinding)。
在 C++ 里,所有的變量缺省都是值語義——如果不使用 * 和 & 的話,變量不會像 Java 或Python 一樣引用一個堆上的對象。對於像智能指針這樣的類型,你寫 ptr->call() 和ptr.get(),語法上都是對的,並且 -> 和 . 有着不同的語法作用。而在大部分其他語言里,訪問成員只用 .,但在作用上實際等價於 C++ 的 ->。這種值語義和引用語義的區別,是 C++ 的特點,也是它的複雜性的一個來源。
2.3 RAII
C++ 支持將對象存儲在棧上面。但是,在很多情況下,對象不能,或不應該,存儲在棧 上。比如:
- 對象很大;
- 對象的大小在編譯時不能確定;
- 對象是函數的返回值,但由於特殊的原因,不應使用對象的值返回。
實際例子如下:
enum class shape_type { circle, triangle, rectangle, }; class shape { public: shape() { cout << "shape" << endl; } virtual void print() { cout << "I am shape" << endl; } virtual ~shape() {} }; class circle : public shape { public: circle() { cout << "circle" << endl; } void print() { cout << "I am circle" << endl; } }; class triangle : public shape { public: triangle() { cout << "triangle" << endl; } void print() { cout << "I am triangle" << endl; } }; class rectangle : public shape { public: rectangle() { cout << "rectangle" << endl; } void print() { cout << "I am rectangle" << endl; } }; // 利用多態 上轉 如果返回值為shape,會存在對象切片問題。 shape *create_shape(shape_type type) { switch (type) { case shape_type::circle: return new circle(); case shape_type::triangle: return new triangle(); case shape_type::rectangle: return new rectangle(); } } int main() { shape *sp = create_shape(shape_type::circle); sp->print(); delete sp; return 0; }
函數返回值在這裡需要注意,只能為指針,而不能是值類型,當把shape* 改為shape的時候,會引發對象切片(object slicing)。
例如:
class shape { int foo; }; class circle : public shape { int bar; };
因此,B類型的對象有兩個數據成員:foo和bar。
調用如下:
circle b; shape a = b;
編譯器不會報錯,但結果多半是錯的。然後,circle中關於成員bar的信息在shape中丟失。
那麼,我們怎樣才能確保,在使用 create_shape 的返回值時不會發生內存泄漏呢?
答案就在析構函數和它的棧展開行為上。我們只需要把這個返回值放到一個本地變量里,並確保其析構函數會刪除該對象即可。一個簡單的實現如下所示:
class shape_wrapper { public: explicit shape_wrapper(shape *ptr = nullptr) : ptr_(ptr) {} ~shape_wrapper() { delete ptr_; } shape *get() const { return ptr_; } private: shape *ptr_; }; void foo() { shape_wrapper ptr(create_shape(shape_type::circle)); ptr.get()->print(); }
對於上述代碼,當ptr_為空指針的時候,delete是合法的。
在析構函數里做必要的清理工作,這就是 RAII 的基本用法。這種清理並不限於釋放內存,也可以是:
- 關閉文件 fstream 的析構就會這麼做
- 釋放同步鎖, 例如:使用lock_guard代替mutex直接操作。
- 釋放其他重要的系統資源