你好,我是雨樂!
作為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操作符來進行內存分配,其內部主要做了兩件事:
- 通過operator new從堆上申請內存(glibc下,operator new底層調用的是malloc)
- 調用構造函數(如果操作對象是一個class的話)
對應的,使用delete操作符來釋放內存,其順序正好與new相反:
- 調用對象的析構函數(如果操作對象是一個class的話)
- 通過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類指針
- 通過operator new申請內存(底層malloc實現)
- 通過placement new在上述申請的內存塊上調用構造函數
- 調用ptr->~Test()釋放Test對象的成員變量
- 調用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
借助此文,我們再次總結下存在繼承情況下,構造函數和析構函數的調用順序。
派生類對象在創建時構造函數調用順序:
- 調用父類的構造函數
- 調用父類成員變量的構造函數
- 調用派生類本身的構造函數
派生類對象在析構時的析構函數調用順序:
- 執行派生類自身的析構函數
- 執行派生類成員變量的析構函數
- 執行父類的析構函數
為了避免存在繼承關系時候的內存泄漏,請遵守一條規則:無論派生類有沒有申請堆上的資源,請將父類的析構函數聲明為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
RAII
是Resource 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】免費獲取計算機必備經典書籍