記憶體洩漏-原因、避免和定位

高效能架構探索發表於2022-01-13

你好,我是雨樂!

作為C/C++開發人員,記憶體洩漏是最容易遇到的問題之一,這是由C/C++語言的特性引起的。C/C++語言與其他語言不同,需要開發者去申請和釋放記憶體,即需要開發者去管理記憶體,如果記憶體使用不當,就容易造成段錯誤(segment fault)或者記憶體洩漏(memory leak)

今天,藉助此文,分析下專案中經常遇到的導致記憶體洩漏的原因,以及如何避免和定位記憶體洩漏。

本文的主要內容如下:

背景

C/C++語言中,記憶體的分配與回收都是由開發人員在編寫程式碼時主動完成的,好處是記憶體管理的開銷較小,程式擁有更高的執行效率;弊端是依賴於開發者的水平,隨著程式碼規模的擴大,極容易遺漏釋放記憶體的步驟,或者一些不規範的程式設計可能會使程式具有安全隱患。如果對記憶體管理不當,可能導致程式中存在記憶體缺陷,甚至會在執行時產生記憶體故障錯誤。

記憶體洩漏是各類缺陷中十分棘手的一種,對系統的穩定執行威脅較大。當動態分配的記憶體在程式結束之前沒有被回收時,則發生了記憶體洩漏。由於系統軟體,如作業系統、編譯器、開發環境等都是由C/C++語言實現的,不可避免地存在記憶體洩漏缺陷,特別是一些在伺服器上長期執行的軟體,若存在記憶體洩漏則會造成嚴重後果,例如效能下降、程式終止、系統崩潰、無法提供服務等。

所以,本文從原因避免以及定位幾個方面去深入講解,希望能給大家帶來幫助。

概念

記憶體洩漏(Memory Leak)是指程式中己動態分配的堆記憶體由於某種原因程式未釋放或無法釋放,造成系統記憶體的浪費,導致程式執行速度減慢甚至系統崩潰等嚴重後果。

當我們在程式中對原始指標(raw pointer)使用new操作符或者free函式的時候,實際上是在堆上為其分配記憶體,這個記憶體指的是RAM,而不是硬碟等永久儲存。持續申請而不釋放(或者少量釋放)記憶體的應用程式,最終因記憶體耗盡導致OOM(out of memory)

方便大家理解記憶體洩漏的危害,我們舉個簡單的例子。有一個賓館,有100間房間,顧客每次都是在前臺進行登記,然後拿到房間鑰匙。如果有些顧客不需要該房間了,也不歸還鑰匙,久而久之,前臺處可用房間越來越少,收入也越來越少,瀕臨倒閉。當程式申請了記憶體,而不進行歸還,久而久之,可用記憶體越來越少,OS就會進行自我保護,殺掉該程式,這就是我們常說的OOM(out of memory)

分類

記憶體洩漏分為以下兩類:

  • 堆記憶體洩漏:我們經常說的記憶體洩漏就是堆記憶體洩漏,在堆上申請了資源,在結束使用的時候,沒有釋放歸還給OS,從而導致該塊記憶體永遠不會被再次使用
  • 資源洩漏:通常指的是系統資源,比如socket,檔案描述符等,因為這些在系統中都是有限制的,如果建立了而不歸還,久而久之,就會耗盡資源,導致其他程式不可用

本文主要分析堆記憶體洩漏,所以後面的記憶體洩漏均指的是堆記憶體洩漏

根源

記憶體洩漏,主要指的是在堆(heap)上申請的動態記憶體洩漏,或者說是指標指向的記憶體塊忘了被釋放,導致該塊記憶體不能再被申請重新使用。

之前在知乎上看了一句話,指標是C的精髓,也是初學者的一個坎。換句話說,記憶體管理是C的精髓,C/C++可以直接跟OS打交道,從效能角度出發,開發者可以根據自己的實際使用場景靈活進行記憶體分配和釋放。雖然在C++中自C++11引入了smart pointer,雖然很大程度上能夠避免使用裸指標,但仍然不能完全避免,最重要的一個原因是你不能保證組內其他人不適用指標,更不能保證合作部門不使用指標。

那麼為什麼C/C++中會存在指標呢?

這就得從程式的記憶體佈局說起。

程式記憶體佈局

上圖為32位程式的記憶體佈局,從上圖中主要包含以下幾個塊:

  • 核心空間:供核心使用,存放的是核心程式碼和資料
  • stack:這就是我們經常所說的棧,用來儲存自動變數(automatic variable)
  • mmap:也成為記憶體對映,用來在程式虛擬記憶體地址空間中分配地址空間,建立和實體記憶體的對映關係
  • heap:就是我們常說的堆,動態記憶體的分配都是在堆上
  • bss:包含所有未初始化的全域性和靜態變數,此段中的所有變數都由0或者空指標初始化,程式載入器在載入程式時為BSS段分配記憶體
  • ds:初始化的資料塊
    • 包含顯式初始化的全域性變數和靜態變數
    • 此段的大小由程式原始碼中值的大小決定,在執行時不會更改
    • 它具有讀寫許可權,因此可以在執行時更改此段的變數值
    • 該段可進一步分為初始化只讀區和初始化讀寫區
  • text:也稱為文字段
    • 該段包含已編譯程式的二進位制檔案。
    • 該段是一個只讀段,用於防止程式被意外修改
    • 該段是可共享的,因此對於文字編輯器等頻繁執行的程式,記憶體中只需要一個副本

由於本文主要講記憶體分配相關,所以下面的內容僅涉及到棧(stack)和堆(heap)。

棧一塊連續的記憶體塊,棧上的記憶體分配就是在這一塊連續記憶體塊上進行操作的。編譯器在編譯的時候,就已經知道要分配的記憶體大小,當呼叫函式時候,其內部的遍歷都會在棧上分配記憶體;當結束函式呼叫時候,內部變數就會被釋放,進而將記憶體歸還給棧。

class Object {
  public:
    Object() = default;
    // ....
};

void fun() {
  Object obj;
  
  // do sth
}

在上述程式碼中,obj就是在棧上進行分配,當出了fun作用域的時候,會自動呼叫Object的解構函式對其進行釋放。

前面有提到,區域性變數會在作用域(如函式作用域、塊作用域等)結束後析構、釋放記憶體。因為分配和釋放的次序是剛好完全相反的,所以可用到堆疊先進後出(first-in-last-out, FILO)的特性,而 C++ 語言的實現一般也會使用到呼叫堆疊(call stack)來分配區域性變數(但非標準的要求)。

因為棧上記憶體分配和釋放,是一個進棧和出棧的過程(對於編譯器只是一個移動指標的過程),所以相比於堆上的記憶體分配,棧要快的多。

雖然棧的訪問速度要快於堆,每個執行緒都有一個自己的棧,棧上的物件是不能跨執行緒訪問的,這就決定了棧空間大小是有限制的,如果棧空間過大,那麼在大型程式中幾十乃至上百個執行緒,光棧空間就消耗了RAM,這就導致heap的可用空間變小,影響程式正常執行。

設定

在Linux系統上,可用通過如下命令來檢視棧大小:

ulimit -s
10240

在筆者的機器上,執行上述命令輸出結果是10240(KB)即10m,可以通過shell命令修改棧大小。

ulimit -s 102400

通過如上命令,可以將棧空間臨時修改為100m,可以通過下面的命令:

/etc/security/limits.conf

分配方式

靜態分配

靜態分配由編譯器完成,假如區域性變數以及函式引數等,都在編譯期就分配好了。

void fun() {
  int a[10];
}

上述程式碼中,a佔10 * sizeof(int)個位元組,在編譯的時候直接計算好了,執行的時候,直接進棧出棧。

動態分配

可能很多人認為只有堆上才會存在動態分配,在棧上只可能是靜態分配。其實,這個觀點是錯的,棧上也支援動態分配,該動態分配由alloca()函式進行分配。棧的動態分配和堆是不同的,通過alloca()函式分配的記憶體由編譯器進行釋放,無序手動操作。

特點

  • 分配速度快:分配大小由編譯器在編譯器完成
  • 不會產生記憶體碎片:棧記憶體分配是連續的,以FIFO的方式進棧和出棧
  • 大小受限:棧的大小依賴於作業系統
  • 訪問受限:只能在當前函式或者作用域內進行訪問

堆(heap)是一種記憶體管理方式。記憶體管理對作業系統來說是一件非常複雜的事情,因為首先記憶體容量很大,其次就是記憶體需求在時間和大小塊上沒有規律(作業系統上執行著幾十甚至幾百個程式,這些程式可能隨時都會申請或者是釋放記憶體,並且申請和釋放的記憶體塊大小是隨意的)。

堆這種記憶體管理方式的特點就是自由(隨時申請、隨時釋放、大小塊隨意)。堆記憶體是作業系統劃歸給堆管理器(作業系統中的一段程式碼,屬於作業系統的記憶體管理單元)來管理的,堆管理器提供了對應的介面_sbrk、mmap_等,只是該介面往往由執行時庫進行呼叫,即也可以說由執行時庫進行堆記憶體管理,執行時庫提供了malloc/free函式由開發人員呼叫,進而使用堆記憶體。

分配方式

正如我們所理解的那樣,由於是在執行期進行記憶體分配,分配的大小也在執行期才會知道,所以堆只支援動態分配,記憶體申請和釋放的行為由開發者自行操作,這就很容易造成我們說的記憶體洩漏。

特點

  • 變數可以在程式範圍內訪問,即程式內的所有執行緒都可以訪問該變數
  • 沒有記憶體大小限制,這個其實是相對的,只是相對於棧大小來說沒有限制,其實最終還是受限於RAM
  • 相對棧來說訪問比較慢
  • 記憶體碎片
  • 由開發者管理記憶體,即記憶體的申請和釋放都由開發人員來操作

堆與棧區別

理解堆和棧的區別,對我們開發過程中會非常有用,結合上面的內容,總結下二者的區別。

對於棧來講,是由編譯器自動管理,無需我們手工控制;對於堆來說,釋放工作由程式設計師控制,容易產生memory leak

  • 空間大小不同
    • 一般來講在 32 位系統下,堆記憶體可以達到4G的空間,從這個角度來看堆記憶體幾乎是沒有什麼限制的。
    • 對於棧來講,一般都是有一定的空間大小的,一般依賴於作業系統(也可以人工設定)
  • 能否產生碎片不同
    • 對於堆來講,頻繁的記憶體分配和釋放勢必會造成記憶體空間的不連續,從而造成大量的碎片,使程式效率降低。
    • 對於棧來講,記憶體都是連續的,申請和釋放都是指令移動,類似於資料結構中的進棧和出棧
  • 增長方向不同
    • 對於堆來講,生長方向是向上的,也就是向著記憶體地址增加的方向
    • 對於棧來講,它的生長方向是向下的,是向著記憶體地址減小的方向增長
  • 分配方式不同
    • 堆都是動態分配的,比如我們常見的malloc/new;而棧則有靜態分配和動態分配兩種。
    • 靜態分配是編譯器完成的,比如區域性變數的分配,而棧的動態分配則通過alloca()函式完成
    • 二者動態分配是不同的,棧的動態分配的記憶體由編譯器進行釋放,而堆上的動態分配的記憶體則必須由開發人自行釋放
  • 分配效率不同
    • 棧有作業系統分配專門的暫存器存放棧的地址,壓棧出棧都有專門的指令執行,這就決定了棧的效率比較高
    • 堆記憶體的申請和釋放專門有執行時庫提供的函式,裡面涉及複雜的邏輯,申請和釋放效率低於棧

截止到這裡,棧和堆的基本特性以及各自的優缺點、使用場景已經分析完成,在這裡給開發者一個建議,能使用棧的時候,就儘量使用棧,一方面是因為效率高於堆,另一方面記憶體的申請和釋放由編譯器完成,這樣就避免了很多問題。

擴充套件

終於到了這一小節,其實,上面講的那麼多,都是為這一小節做鋪墊。

在前面的內容中,我們對比了棧和堆,雖然棧效率比較高,且不存在記憶體洩漏、記憶體碎片等,但是由於其本身的侷限性(不能多執行緒、大小受限),所以在很多時候,還是需要在堆上進行記憶體。

我們先看一段程式碼:

#include <stdio.h>
#include <stdlib.h>

int main() {
  int a;
  int *p;
  p = (int *)malloc(sizeof(int));
  free(p);

  return 0;
}

上述程式碼很簡單,有兩個變數a和p,型別分別為int和int *,其中,a和p儲存在棧上,p的值為在堆上的某塊地址(在上述程式碼中,p的值為0x1c66010),上述程式碼佈局如下圖所示:

產生方式

以產生的方式來分類,記憶體洩漏可以分為四類:

  • 常發性記憶體洩漏
  • 偶發性記憶體洩漏
  • 一次性記憶體洩漏
  • 隱式記憶體洩漏

常發性記憶體洩漏

產生記憶體洩漏的程式碼或者函式會被多次執行到,在每次執行的時候,都會產生記憶體洩漏。

偶發性記憶體洩漏

常發性記憶體洩漏不同的是,偶發性記憶體洩漏函式只在特定的場景下才會被執行。

筆者在19年的時候,曾經遇到一個這種記憶體洩漏。有一個函式專門進行價格加密,每次洩漏3個位元組,且只有在競價成功的時候,才會呼叫此函式進行價格加密,因此洩漏的非常不明顯。當時發現這個問題,是上線後的第二天,幫忙排查線上問題,發現記憶體較上線前上漲了點(大概幾百兆的樣子),瞭解glibc記憶體分配原理的都清楚,呼叫delete後,記憶體不一定會歸還給OS,但是本著寧可信其有,不可信其無的心態,決定來分析是否真的存在記憶體洩漏。

當時用了個比較傻瓜式的方法,通過top命令,將該程式所佔的記憶體輸出到本地檔案,大概幾個小時後,將這些資料匯入Excel中,記憶體佔用基本呈一條斜線,所以基本能夠確定程式碼存在記憶體洩漏,所以就對新上線的這部分程式碼進行重新review ,定位到洩漏點,然後修復,重新上線。

一次性記憶體洩漏

這種記憶體洩漏在程式的生命週期內只會洩漏一次,或者說造成洩漏的程式碼只會被執行一次。

有的時候,這種可能不算記憶體洩漏,或者說設計如此。就以筆者現線上上的服務來說,類似於如下這種:

int main() {
  auto *service = new Service;
  // do sth
  service->Run();// 服務啟動
  service->Loop(); // 可以理解為一個sleep,目的是使得程式不退出
  return 0;
}

這種嚴格意義上,並不算記憶體洩漏,因為程式是這麼設計的,即使程式異常退出,那麼整個服務程式也就退出了,當然,在Loop()後面加個delete更好。

隱式記憶體洩漏

程式在執行過程中不停的分配記憶體,但是直到結束的時候才釋放記憶體。嚴格的說這裡並沒有發生記憶體洩漏,因為最終程式釋放了所有申請的記憶體。但是對於一個伺服器程式,需要執行幾天,幾周甚至幾個月,不及時釋放記憶體也可能導致最終耗盡系統的所有記憶體。所以,我們稱這類記憶體洩漏為隱式記憶體洩漏。

比較常見的隱式記憶體洩漏有以下三種:

  • 記憶體碎片:還記得我們之前的那篇文章深入理解glibc記憶體管理精髓,程式跑了幾天之後,程式就因為OOM導致了退出,就是因為記憶體碎片導致剩下的記憶體不能被重新分配導致
  • 即使我們呼叫了free/delete,執行時庫不一定會將記憶體歸還OS,具體深入理解glibc記憶體管理精髓
  • 用過STL的知道,STL內部有一個自己的allocator,我們可以當做一個memory poll,當呼叫vector.clear()時候,記憶體並不會歸還OS,而是放回allocator,其內部根據一定的策略,在特定的時候將記憶體歸還OS,是不是跟glibc原理很像?

分類

未釋放

這種是很常見的,比如下面的程式碼:

int fun() {
    char * pBuffer = malloc(sizeof(char));
    
    /* Do some work */
    return 0;
}

上面程式碼是非常常見的記憶體洩漏場景(也可以使用new來進行分配),我們申請了一塊記憶體,但是在fun函式結束時候沒有呼叫free函式進行記憶體釋放。

在C++開發中,還有一種記憶體洩漏,如下:

class Obj {
 public:
   Obj(int size) {
     buffer_ = new char;
   }
   ~Obj(){}
  private:
   char *buffer_;
};

int fun() {
  Object obj;
  // do sth
  return 0;
}

上面這段程式碼中,解構函式沒有釋放成員變數buffer_指向的記憶體,所以在編寫解構函式的時候,一定要仔細分析成員變數有沒有申請動態記憶體,如果有,則需要手動釋放,我們重新編寫了解構函式,如下:

~Object() {
  delete buffer_;
}

在C/C++中,對於普通函式,如果申請了堆資源,請跟進程式碼的具體場景呼叫free/delete進行資源釋放;對於class,如果申請了堆資源,則需要在對應的解構函式中呼叫free/delete進行資源釋放。

未匹配

在C++中,我們經常使用new操作符來進行記憶體分配,其內部主要做了兩件事:

  1. 通過operator new從堆上申請記憶體(glibc下,operator new底層呼叫的是malloc)
  2. 呼叫建構函式(如果操作物件是一個class的話)

對應的,使用delete操作符來釋放記憶體,其順序正好與new相反:

  1. 呼叫物件的解構函式(如果操作物件是一個class的話)
  2. 通過operator delete釋放記憶體
void* operator new(std::size_t size) {
    void* p = malloc(size);
    if (p == nullptr) {
        throw("new failed to allocate %zu bytes", size);
    }
    return p;
}
void* operator new[](std::size_t size) {
    void* p = malloc(size);
    if (p == nullptr) {
        throw("new[] failed to allocate %zu bytes", size);
    }
    return p;
}

void  operator delete(void* ptr) throw() {
    free(ptr);
}
void  operator delete[](void* ptr) throw() {
    free(ptr);
}

為了加深多這塊的理解,我們舉個例子:

class Test {
 public:
   Test() {
     std::cout << "in Test" << std::endl;
   }
   // other
   ~Test() {
     std::cout << "in ~Test" << std::endl;
   }
};

int main() {
  Test *t = new Test;
  // do sth
  delete t;
  return 0;
}

在上述main函式中,我們使用new 操作符建立一個Test類指標

  1. 通過operator new申請記憶體(底層malloc實現)
  2. 通過placement new在上述申請的記憶體塊上呼叫建構函式
  3. 呼叫ptr->~Test()釋放Test物件的成員變數
  4. 呼叫operator delete釋放記憶體

上述過程,可以理解為如下:

// new
void *ptr = malloc(sizeof(Test));
t = new(ptr)Test
  
// delete
ptr->~Test();
free(ptr);

好了,上述內容,我們簡單的講解了C++中new和delete操作符的基本實現以及邏輯,那麼,我們就簡單總結下下產生記憶體洩漏的幾種型別。

new 和 free

仍然以上面的Test物件為例,程式碼如下:

Test *t = new Test;
free(t)

此處會產生記憶體洩漏,在上面,我們已經分析過,new操作符會先通過operator new分配一塊記憶體,然後在該塊記憶體上呼叫placement new即呼叫Test的建構函式。而在上述程式碼中,只是通過free函式釋放了記憶體,但是沒有呼叫Test的解構函式以釋放Test的成員變數,從而引起記憶體洩漏

new[] 和 delete

int main() {
  Test *t = new Test [10];
  // do sth
  delete t;
  return 0;
}

在上述程式碼中,我們通過new建立了一個Test型別的陣列,然後通delete操作符刪除該陣列,編譯並執行,輸出如下:

in Test
in Test
in Test
in Test
in Test
in Test
in Test
in Test
in Test
in Test
in ~Test

從上面輸出結果可以看出,呼叫了10次建構函式,但是隻呼叫了一次解構函式,所以引起了記憶體洩漏。這是因為呼叫delete t釋放了通過operator new[]申請的記憶體,即malloc申請的記憶體塊,且只呼叫了t[0]物件的解構函式,t[1..9]物件的解構函式並沒有被呼叫。

虛析構

記得08年面谷歌的時候,有一道題,面試官問,std::string能否被繼承,為什麼?

當時沒回答上來,後來過了沒多久,進行面試覆盤的時候,偶然看到繼承需要父類解構函式為virtual,才恍然大悟,原來考察點在這塊。

下面我們看下std::string的解構函式定義:

~basic_string() { 
  _M_rep()->_M_dispose(this->get_allocator()); 
}

這塊需要特別說明下,std::basic_string是一個模板,而std::string是該模板的一個特化,即std::basic_string

typedef std::basic_string<char> string;

現在我們可以給出這個問題的答案:不能,因為std::string的解構函式不為virtual,這樣會引起記憶體洩漏

仍然以一個例子來進行證明。

class Base {
 public:
  Base(){
    buffer_ = new char[10];
  }

  ~Base() {
    std::cout << "in Base::~Base" << std::endl;
    delete []buffer_;
  }
private:
  char *buffer_;

};

class Derived : public Base {
 public:
  Derived(){}

  ~Derived() {
    std::cout << "int Derived::~Derived" << std::endl;
  }
};

int main() {
  Base *base = new Derived;
  delete base;
  return 0;
}

上面程式碼輸出如下:

in Base::~Base

可見,上述程式碼並沒有呼叫派生類Derived的解構函式,如果派生類中在堆上申請了資源,那麼就會產生記憶體洩漏

為了避免因為繼承導致的記憶體洩漏,我們需要將父類的解構函式宣告為virtual,程式碼如下(只列了部分修改程式碼,其他不變):

~Base() {
    std::cout << "in Base::~Base" << std::endl;
    delete []buffer_;
  }

然後重新執行程式碼,輸出結果如下:

int Derived::~Derived
in Base::~Base

藉助此文,我們再次總結下存在繼承情況下,建構函式和解構函式的呼叫順序。

派生類物件在建立時建構函式呼叫順序:

  1. 呼叫父類的建構函式
  2. 呼叫父類成員變數的建構函式
  3. 呼叫派生類本身的建構函式

派生類物件在析構時的解構函式呼叫順序:

  1. 執行派生類自身的解構函式
  2. 執行派生類成員變數的解構函式
  3. 執行父類的解構函式

為了避免存在繼承關係時候的記憶體洩漏,請遵守一條規則:無論派生類有沒有申請堆上的資源,請將父類的解構函式宣告為virtual

迴圈引用

在C++開發中,為了儘可能的避免記憶體洩漏,自C++11起引入了smart pointer,常見的有shared_ptr、weak_ptr以及unique_ptr等(auto_ptr已經被廢棄),其中weak_ptr是為了解決迴圈引用而存在,其往往與shared_ptr結合使用。

下面,我們看一段程式碼:

class Controller {
 public:
  Controller() = default;

  ~Controller() {
    std::cout << "in ~Controller" << std::endl;
  }

  class SubController {
   public:
    SubController() = default;

    ~SubController() {
      std::cout << "in ~SubController" << std::endl;
    }

    std::shared_ptr<Controller> controller_;
  };

  std::shared_ptr<SubController> sub_controller_;
};

int main() {
  auto controller = std::make_shared<Controller>();
  auto sub_controller = std::make_shared<Controller::SubController>();

  controller->sub_controller_ = sub_controller;
  sub_controller->controller_ = controller;
  return 0;
}

編譯並執行上述程式碼,發現並沒有呼叫Controller和SubController的解構函式,我們嘗試著列印下引用計數,程式碼如下:

int main() {
  auto controller = std::make_shared<Controller>();
  auto sub_controller = std::make_shared<Controller::SubController>();

  controller->sub_controller_ = sub_controller;
  sub_controller->controller_ = controller;

  std::cout << "controller use_count: " << controller.use_count() << std::endl;
  std::cout << "sub_controller use_count: " << sub_controller.use_count() << std::endl;
  return 0;
}

編譯並執行之後,輸出如下:

controller use_count: 2
sub_controller use_count: 2

通過上面輸出可以發現,因為引用計數都是2,所以在main函式結束的時候,不會呼叫controller和sub_controller的解構函式,所以就出現了記憶體洩漏

上面產生記憶體洩漏的原因,就是我們常說的迴圈引用

為了解決std::shared_ptr迴圈引用導致的記憶體洩漏,我們可以使用std::weak_ptr來單面去除上圖中的迴圈。

class Controller {
 public:
  Controller() = default;

  ~Controller() {
    std::cout << "in ~Controller" << std::endl;
  }

  class SubController {
   public:
    SubController() = default;

    ~SubController() {
      std::cout << "in ~SubController" << std::endl;
    }

    std::weak_ptr<Controller> controller_;
  };

  std::shared_ptr<SubController> sub_controller_;
};

在上述程式碼中,我們將SubController類中controller_的型別從std::shared_ptr變成std::weak_ptr,重新編譯執行,結果如下:

controller use_count: 1
sub_controller use_count: 2
in ~Controller
in ~SubController

從上面結果可以看出,controller和sub_controller均以釋放,所以迴圈引用引起的記憶體洩漏問題,也得以解決。

可能有人會問,使用std::shared_ptr可以直接訪問對應的成員函式,如果是std::weak_ptr的話,怎麼訪問呢?我們可以使用下面的方式:

std::shared_ptr controller = controller_.lock();

即在子類SubController中,如果要使用controller呼叫其對應的函式,就可以使用上面的方式。

避免

避免在堆上分配

眾所周知,大部分的記憶體洩漏都是因為在堆上分配引起的,如果我們不在堆上進行分配,就不會存在記憶體洩漏了(這不廢話嘛),我們可以根據具體的使用場景,如果物件可以在棧上進行分配,就在棧上進行分配,一方面棧的效率遠高於堆,另一方面,還能避免記憶體洩漏,我們何樂而不為呢。

手動釋放

  • 對於malloc函式分配的記憶體,在結束使用的時候,使用free函式進行釋放
  • 對於new操作符建立的物件,切記使用delete來進行釋放
  • 對於new []建立的物件,使用delete[]來進行釋放(使用free或者delete均會造成記憶體洩漏)

避免使用裸指標

儘可能避免使用裸指標,除非所呼叫的lib庫或者合作部門的介面是裸指標。

int fun(int *ptr) {// fun 是一個介面或lib函式
  // do sth
  
  return 0;
}

int main() {}
  int a = 1000;
  int *ptr = &a;
  // ...
  fun(ptr);
  
  return 0;
}

在上面的fun函式中,有一個引數ptr,為int *,我們需要根據上下文來分析這個指標是否需要釋放,這是一種很不好的設計

使用STL中或者自己實現物件

在C++中,提供了相對完善且可靠的STL供我們使用,所以能用STL的儘可能的避免使用C中的程式設計方式,比如:

  • 使用std::string 替代char *, string類自己會進行記憶體管理,而且優化的相當不錯
  • 使用std::vector或者std::array來替代傳統的陣列
  • 其它

智慧指標

自C++11開始,STL中引入了智慧指標(smart pointer)來動態管理資源,針對使用場景的不同,提供了以下三種智慧指標。

unique_ptr

unique_ptr是限制最嚴格的一種智慧指標,用來替代之前的auto_ptr,獨享被管理物件指標所有權。當unique_ptr物件被銷燬時,會在其解構函式內刪除關聯的原始指標。

unique_ptr物件分為以下兩類:

  • unique_ptr 該型別的物件關聯了單個Type型別的指標

    std::unique_ptr<Type>   p1(new Type); // c++11
    auto p1 = std::make_unique<Type>(); // c++14
    
  • unique_ptr<Type[]> 該型別的物件關聯了多個Type型別指標,即一個物件陣列

    std::unique_ptr<Type[]> p2(new Type[n]()); // c++11
    auto p2 = std::make_unique<Type[]>(n); // c++14
    
  • 不可用被複制

    unique_ptr<int> a(new int(0));
    unique_ptr<int> b = a;  // 編譯錯誤
    unique_ptr<int> b = std::move(a); // 可以通過move語義進行所有權轉移
    

根據使用場景,可以使用std::unique_ptr來避免記憶體洩漏,如下:

void fun() {
  unique_ptr<int> a(new int(0));
  // use a
}

在上述fun函式結束的時候,會自動呼叫a的解構函式,從而釋放其關聯的指標。

shared_ptr

與unique_ptr不同的是,unique_ptr是獨佔管理權,而shared_ptr則是共享管理權,即多個shared_ptr可以共用同一塊關聯物件,其內部採用的是引用計數,在拷貝的時候,引用計數+1,而在某個物件退出作用域或者釋放的時候,引用計數-1,當引用計數為0的時候,會自動釋放其管理的物件。

void fun() {
  std::shared_ptr<Type> a; // a是一個空物件
  {
    std::shared_ptr<Type> b = std::make_shared<Type>(); // 分配資源
    a = b; // 此時引用計數為2
    {
      std::shared_ptr<Type> c = a; // 此時引用計數為3
    } // c退出作用域,此時引用計數為2
  } // b 退出作用域,此時引用計數為1
} // a 退出作用域,引用計數為0,釋放物件

weak_ptr

weak_ptr的出現,主要是為了解決shared_ptr的迴圈引用,其主要是與shared_ptr一起來私用。和shared_ptr不同的地方在於,其並不會擁有資源,也就是說不能訪問物件所提供的成員函式,不過,可以通過weak_ptr.lock()來產生一個擁有訪問許可權的shared_ptr。

std::weak_ptr<Type> a;
{
  std::shared_ptr<Type> b = std::make_shared<Type>();
  a = b
} // b所對應的資源釋放

RAII

RAIIResource Acquisition is Initialization(資源獲取即初始化)的縮寫,是C++語言的一種管理資源,避免洩漏的用法。

利用的就是C++構造的物件最終會被銷燬的原則。利用C++物件生命週期的概念來控制程式的資源,比如記憶體,檔案控制程式碼,網路連線等。

RAII的做法是使用一個物件,在其構造時獲取對應的資源,在物件生命週期內控制對資源的訪問,使之始終保持有效,最後在物件析構的時候,釋放構造時獲取的資源。

簡單地說,就是把資源的使用限制在物件的生命週期之中,自動釋放。

舉個簡單的例子,通常在多執行緒程式設計的時候,都會用到std::mutex,以下為例

std::mutex mutex_;

void fun() {
  mutex_.lock();
  
  if (...) {
    mutex_.unlock();
    return;
  }
  
  mutex_.unlock()
}

在上述程式碼中,如果if分支多的話,每個if分支裡面都要釋放鎖,如果一不小心忘記釋放,那麼就會造成故障,為了解決這個問題,我們使用RAII技術,程式碼如下:

std::mutex mutex_;

void fun() {
  std::lock_guard<std::mutex> guard(mutex_);

  if (...) {
    return;
  }
}

在guard出了fun作用域的時候,會自動呼叫mutex_.lock()進行釋放,避免了很多不必要的問題。

定位

在發現程式存在記憶體洩漏後,往往需要定位洩漏點,而定位這一步往往是最困難的,所以經常為了定位洩漏點,採取各種各樣的方案,甭管方案優雅與否,畢竟管他白貓黑貓,抓住老鼠才是好貓,所以在本節,簡單說下筆者這麼多年定位洩漏點的方案,有些比較邪門歪道,您就隨便看看就行?。

日誌

這種方案的核心思想,就是在每次分配記憶體的時候,列印指標地址,在釋放記憶體的時候,列印記憶體地址,這樣在程式結束的時候,通過分配和釋放的差,如果分配的條數大於釋放的條數,那麼基本就能確定程式存在記憶體洩漏,然後根據日誌進行詳細分析和定位。

char * fun() {
  char *p = (char*)malloc(20);
  printf("%s, %d, address is: %p", __FILE__, __LINE__, p);
  // do sth
  return p;
}

int main() {
  fun();
  
  return 0;
}

統計

統計方案可以理解為日誌方案的一種特殊實現,其主要原理是在分配的時候,統計分配次數,在釋放的時候,則是統計釋放的次數,這樣在程式結束前判斷這倆值是否一致,就能判斷出是否存在記憶體洩漏。

此方法可幫助跟蹤已分配記憶體的狀態。為了實現這個方案,需要建立三個自定義函式,一個用於記憶體分配,第二個用於記憶體釋放,最後一個用於檢查記憶體洩漏。程式碼如下:

static unsigned int allocated  = 0;
static unsigned int deallocated  = 0;
void *Memory_Allocate (size_t size)
{
    void *ptr = NULL;
    ptr = malloc(size);
    if (NULL != ptr) {
        ++allocated;
    } else {
        //Log error
    }
    return ptr;
}
void Memory_Deallocate (void *ptr) {
    if(pvHandle != NULL) {
        free(ptr);
        ++deallocated;
    }
}
int Check_Memory_Leak(void) {
    int ret = 0;
    if (allocated != deallocated) {
        //Log error
        ret = MEMORY_LEAK;
    } else {
        ret = OK;
    }
    return ret;
}

工具

在Linux上比較常用的記憶體洩漏檢測工具是valgrind,所以我們們就以valgrind為工具,進行檢測。

我們首先看一段程式碼:

#include <stdlib.h>

void func (void){
    char *buff = (char*)malloc(10);
}

int main (void){
    func(); // 產生記憶體洩漏
    return 0;
}
  • 通過gcc -g leak.c -o leak命令進行編譯
  • 執行valgrind --leak-check=full ./leak

在上述的命令執行後,會輸出如下:

==9652== Memcheck, a memory error detector
==9652== Copyright (C) 2002-2017, and GNU GPL'd, by Julian Seward et al.
==9652== Using Valgrind-3.15.0 and LibVEX; rerun with -h for copyright info
==9652== Command: ./leak
==9652==
==9652==
==9652== HEAP SUMMARY:
==9652==     in use at exit: 10 bytes in 1 blocks
==9652==   total heap usage: 1 allocs, 0 frees, 10 bytes allocated
==9652==
==9652== 10 bytes in 1 blocks are definitely lost in loss record 1 of 1
==9652==    at 0x4C29F73: malloc (vg_replace_malloc.c:309)
==9652==    by 0x40052E: func (leak.c:4)
==9652==    by 0x40053D: main (leak.c:8)
==9652==
==9652== LEAK SUMMARY:
==9652==    definitely lost: 10 bytes in 1 blocks
==9652==    indirectly lost: 0 bytes in 0 blocks
==9652==      possibly lost: 0 bytes in 0 blocks
==9652==    still reachable: 0 bytes in 0 blocks
==9652==         suppressed: 0 bytes in 0 blocks
==9652==
==9652== For lists of detected and suppressed errors, rerun with: -s
==9652== ERROR SUMMARY: 1 errors from 1 contexts (suppressed: 0 from 0)

valgrind的檢測資訊將記憶體洩漏分為如下幾類:

  • definitely lost:確定產生記憶體洩漏
  • indirectly lost:間接產生記憶體洩漏
  • possibly lost:可能存在記憶體洩漏
  • still reachable:即使在程式結束時候,仍然有指標在指向該塊記憶體,常見於全域性變數

主要上面輸出的下面幾句:

==9652==    by 0x40052E: func (leak.c:4)
==9652==    by 0x40053D: main (leak.c:8)

提示在main函式(leak.c的第8行)fun函式(leak.c的第四行)產生了記憶體洩漏,通過分析程式碼,原因定位,問題解決。

valgrind不僅可以檢測記憶體洩漏,還有其他很強大的功能,由於本文以記憶體洩漏為主,所以其他的功能就不在此贅述了,有興趣的可以通過valgrind --help來進行檢視

對於Windows下的記憶體洩漏檢測工具,筆者推薦一款輕量級功能卻非常強大的工具UMDH,筆者在十二年前,曾經在某外企負責記憶體洩漏,程式碼量幾百萬行,光編譯就需要兩個小時,嘗試了各種工具(免費的和收費的),最終發現了UMDH,如果你在Windows上進行開發,強烈推薦。

經驗之談

在C/C++開發過程中,記憶體洩漏是一個非常常見的問題,其影響相對來說遠低於coredump等,所以遇到記憶體洩漏的時候,不用過於著急,大不了重啟嘛?。

在開發過程中遵守下面的規則,基本能90+%避免記憶體洩漏:

  • 良好的程式設計習慣,只有有malloc/new,就得有free/delete
  • 儘可能的使用智慧指標,智慧指標就是為了解決記憶體洩漏而產生
  • 使用log進行記錄
  • 也是最重要的一點,誰申請,誰釋放

對於malloc分配記憶體,分配失敗的時候返回值為NULL,此時程式可以直接退出了,而對於new進行記憶體分配,其分配失敗的時候,是丟擲std::bad_alloc,所以為了第一時間發現問題,不要對new異常進行catch,畢竟記憶體都分配失敗了,程式也沒有執行的必要了。

如果我們上線後,發現程式存在記憶體洩漏,如果不嚴重的話,可以先暫時不管線上,同時進行排查定位;如果線上洩漏比較嚴重,那麼第一時間根據實際情況來決定是否回滾。在定位問題點的時候,可以採用縮小範圍法,著重分析這次新增的程式碼,這樣能夠有效縮短問題解決的時間。

結語

C/C++之所以複雜、效率高,是因為其靈活性,可用直接訪問作業系統API,而正因為其靈活性,就很容易出問題,團隊成員必須願意按照一定的規則來進行開發,有完整的review機制,將問題暴露在上線之前。這樣才可以把經歷放在業務本身,而不是查詢這些問題上,有時候往往一個小問題就能消耗很久的時間去定位解決,所以,一定要有一個良好的開發習慣

好了,本期的文章就到這,我們下期見。

作者:高效能架構探索
掃描下方二維碼關注公眾號【高效能架構探索】,回覆【pdf】免費獲取計算機必備經典書籍

相關文章