­

內存管理之堆、棧、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 這樣的關鍵字,有些語言則是在對象的構造時隱式分配,不需要特殊關鍵字。不管哪種情況,程序通常需要牽涉到三個可能的內存管理器的操作:

  1. 讓內存管理器分配一個某個大小的內存塊
  2. 讓內存管理器釋放一個之前分配的內存塊
  3. 讓內存管理器進行垃圾收集操作,尋找不再使用的內存塊並予以釋放

例如: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直接操作。
  • 釋放其他重要的系統資源