關於C++內存管理這話題,永遠都不過時。在我剛出道的時候,就已經在考慮怎么檢測內存泄漏(https://www.cnblogs.com/coding-my-life/p/3985164.html)。想用一份簡單的代碼,並且不太影響執行效率去實現內存泄漏檢測,是不太現實的。當時覺得重寫new和delete是沒有太大價值的,不過后來在自己的項目中還是重寫了,加了個計數。在程序退出時檢測下計數new的次數和delete次數是否對得上,對不上就是有問題了,再用valgrind之類的工具去檢測。這種排除不了所有情況,但確實也解決了一些問題。畢竟每次寫新功能時發現問題立馬去解決,比你寫了成千上萬個功能,上線后出問題再查找容易得多。
在windows下則有另一種方案, C Run-time Library (CRT) debug,_CrtDumpMemoryLeaks()函數,這也僅僅是發現泄漏,定位還得用另一個工具visual leak detector。最近在解決公司程序內存泄漏過程中,發現其實並沒有內存泄漏,而是程序是在atexit里調用_CrtDumpMemoryLeaks()函數的,而static變量申請的內存,可能要在atexit回調之后釋放。由此,我忽然想到我以前重寫new和delete,有些地方寫得並不對,在這里重新整理一下。
new、delete並不是一個函數,它在編譯的時候會被解析成三個步驟:1.調用operator new分配內存;2.調用構造函數;3.把指針轉換成對應的類型返回。能夠重寫的,是operator new函數。
#include <cstdlib> #include <iostream> int g_counter = 0; void *operator new(size_t size) { ++g_counter; std::cout << "new mem:" << g_counter << std::endl; return ::malloc(size); } void operator delete(void* ptr) { --g_counter; std::cout << "delete mem:" << g_counter << std::endl; ::free(ptr); } void on_exit() { std::cout << "exit,mem counter = " << g_counter << std::endl; } int main() { atexit(on_exit); char *ptr = new char[8]; return 0; }
$ g++ main.cpp$ ./a.out new mem:1 exit,mem counter = 1
上面簡單地重寫了operator new和operator delete,在程序退出時可以檢測到還有一次內存沒釋放掉。但上面的代碼存在很多問題。
1. 盡量重寫所有函數
C++的operator new和operator delete函數通常比你想像中的多。而且不同的版本會帶來不同的函數,17、20版本都相應的增加了一些函數,參考https://en.cppreference.com/w/cpp/memory/new/operator_new。 如果你沒有重寫完,雖然能編譯通過,但可能並不是你想要的結果。比如上面的代碼,new char[8]本應該調用operator new[]函數的,由於沒有重寫operator new[],默認調用了libstdcxx中的operator new[],默認函數又調用了operator new。雖然這不一定有什么問題,但在某些項目中,對內存分配做了特殊處理,或者一些特殊操作(比如一個內存池重寫了operator new[]但沒重寫operator delete[],而他們的內存是回收到不同地方),這就會出問題。
2. 利用atexit統計內存並不准確
atexit是在程序退出時調用,對絕大多數變量來說都是OK的,但對static和global變量則不一定了。根據C++標准:https://isocpp.org/files/papers/N3690.pdf 3.6.3 Termination,atexit注冊之前就已經創建的變量,則在atexit之后釋放,這意味着你的static和global變量如果new了內存必須在atexit之后創建。但這又引出C++的另一個問題:static initialization order fiasco。當然我們有很多方法去處理它,比如把所有static和global放到一個cpp文件里,或者在程序退出時手動釋放new的內存。另外,gcc鏈接的時候,放在最后的object文件里的global變量會優先初始化,或者用gcc的__attribute__ ((init_priority (N)))屬性來指定初始化優先級,但這不是標准,不過這畢竟是值得注意的地方。
3. 線程安全
上面的代碼沒有加鎖,所以是不能用在多線程中的。但現在有幾個程序不用多線程的,所以還是得把鎖加上,加鎖的代碼很簡單。
static pthread_mutex_t *counter_mutex() { static pthread_mutex_t _mutex; assert( 0 == pthread_mutex_init( &_mutex,NULL ) ); return &_mutex; } static pthread_mutex_t *_mem_mutex_ = counter_mutex(); int g_counter = 0; void *operator new(size_t size) { assert( _mem_mutex_ ); pthread_mutex_lock( _mem_mutex_ ); ++g_counter; pthread_mutex_unlock( _mem_mutex_ ); std::cout << "new mem:" << g_counter << std::endl; return ::malloc(size); }
4. 線程安全帶來初始化問題
在上面說atexit統計內存不准確的時候提到static initialization order fiasco的問題,在這里變得更嚴重了。因為線程安全是用一個static pthread_mutex_t指針來實現的,那么在其他global變量創建時如果調用了new,那么它可能是沒有被初始化的。當然如果你已按上面的方法解決了,那就不會有這個問題了。或者,根據C++標准,Static initialization初始化必須在所有Dynamic initialization之前,我們可以這樣寫:
/* Static initialization */ static pthread_mutex_t *_mem_mutex_ = NULL; class global_static { public: global_static() { assert( !_mem_mutex_ ); _mem_mutex_ = counter_mutex(); } ~global_static() {_mem_mutex_ = NULL;} }; /* Dynamic initialization */ const static global_static gs;
這樣雖然不能解決問題,但是由於我們在new里校驗了_mem_mutex_是否為NULL,至少能發現問題。
既然是C++,那么還可以Construct On First Use Idiom:在使用_mem_mutex_時去檢測是否已初始化,未初始化就初始化。而不是像上面那樣全局一次初始化,以后都不用檢測。
5. 能否統計到STL、BOOST、so、.a等外部代碼中的new、delete是否會被重寫
STL和BOOST這種很多時候是是模板,也就是源碼,和你項目中的代碼一樣,當然也會被重寫。對於so動態鏈接庫,他和程序是分離的。當你的程序加載這個so文件時,它會優先在你的程序里查找他需要的符號,如果找到了,就會優先使用。這和LD_PRELOAD的機制是一樣的,因此也是會被重寫的。而.a這種靜態鏈接庫,在gcc鏈接時會按你傳入的庫順序查找符號,一般來說你項目中的符號都是優先於libgcc這種標准庫的,因此也是會被重寫的。
要明白這些,要懂得gcc是如何編譯、鏈接一個程序的,尤其是對符號的管理。https://akkadia.org/drepper/dsohowto.pdf
6. 可以用nm來判斷是否重寫
xzc@xzc-HP-ProBook-4446s:~/Documents/code/test$ nm -C a.out | grep new 0000000000400f60 T test_static_new() U operator new[](unsigned long)@@GLIBCXX_3.4 0000000000400d34 T operator new(unsigned long) 0000000000401120 r operator new(unsigned long)::__PRETTY_FUNCTION__
T表示text,說明你已經重寫了。U表示undefine,表示沒有重寫,程序運行時,要去庫里查找這個符號。
大部分人重寫operator new和operator delete的初衷,無非就是檢測內存泄漏,或者實現自己的內存管理。對於內存泄漏,通過重寫operator new來實現的,可以看http://wyw.dcweb.cn/leakage.htm這里,現在還在維護的項目是https://github.com/adah1972/nvwa,我沒用過,但看下邏輯應該還是不錯的。而對於在代碼中重寫operator new來實現內存管理,我倒沒見過。畢竟想寫一個通用的內存管理不容易,寫出來也是一個庫了,比如jemalloc這種。