C++開發必知的記憶體問題及常用的解決方法

1. 記憶體管理功能問題

由於C 語言對記憶體有主動控制權,記憶體使用靈活和效率高,但代價是不小心使用就會導致以下記憶體錯誤:

• memory overrun:寫記憶體越界 • double free:同一塊記憶體釋放兩次 • use after free:記憶體釋放後使用 • wild free:釋放記憶體的引數為非法值 • access uninitialized memory:訪問未初始化記憶體 • read invalid memory:讀取非法記憶體,本質上也屬於記憶體越界 • memory leak:記憶體洩露 • use after return:caller訪問一個指標,該指標指向callee的棧內記憶體 • stack overflow:棧溢位

常用的解決記憶體錯誤的方法

  • 程式碼靜態檢測

靜態程式碼檢測是指無需執行被測程式碼,通過詞法分析、語法分析、控制流、資料流分析等技術對程式程式碼進行掃描,找出程式碼隱藏的錯誤和缺陷,如引數不匹配,有歧義的巢狀語句,錯誤的遞迴,非法計算,可能出現的空指標引用等等。統計證明,在整個軟體開發生命週期中,30%至70%的程式碼邏輯設計和編碼缺陷是可以通過靜態程式碼分析來發現和修復的。在C 專案開發過程中,因為其為編譯執行語言,語言規則要求較高,開發團隊往往要花費大量的時間和精力發現並修改程式碼缺陷。所以C 靜態程式碼分析工具能夠幫助開發人員快速、有效的定位程式碼缺陷並及時糾正這些問題,從而極大地提高軟體可靠性並節省開發成本。

靜態程式碼分析工具的優勢:

1、自動執行靜態程式碼分析,快速定位程式碼隱藏錯誤和缺陷。

2、幫助程式碼設計人員更專注於分析和解決程式碼設計缺陷。

3、減少在程式碼人工檢查上花費的時間,提高軟體可靠性並節省開發成本。

一些主流的靜態程式碼檢測工具,免費的cppcheck,clang static analyzer;

商用的coverity,pclint等

各個工具效能對比:

  • 程式碼動態檢測

所謂的程式碼動態檢測,就是需要再程式執行情況下,通過插入特殊指令,進行動態檢測和收集執行資料資訊,然後分析給出報告。

1.為了檢測記憶體非法使用,需要hook記憶體分配和操作函式。hook的方法可以是用C-preprocessor,也可以是在連結庫中直接定義(因為Glibc中的malloc/free等函式都是weak symbol),或是用LD_PRELOAD。另外,通過hook strcpy(),memmove()等函式可以檢測它們是否引起buffer overflow。

  1. 為了檢查記憶體的非法訪問,需要對程式的記憶體進行bookkeeping,然後截獲每次訪存操作並檢測是否合法。bookkeeping的方法大同小異,主要思想是用shadow memory來驗證某塊記憶體的合法性。至於instrumentation的方法各種各樣。有run-time的,比如通過把程式執行在虛擬機器中或是通過binary translator來執行;或是compile-time的,在編譯時就在訪存指令時就加入檢查操作。另外也可以通過在分配記憶體前後加設為不可訪問的guard page,這樣可以利用硬體(MMU)來觸發SIGSEGV,從而提高速度。

3.為了檢測棧的問題,一般在stack上設定canary,即在函式呼叫時在棧上寫magic number或是隨機值,然後在函式返回時檢查是否被改寫。另外可以通過mprotect()在stack的頂端設定guard page,這樣棧溢位會導致SIGSEGV而不至於破壞資料。

工具總結對比,常用valgrind(檢測記憶體洩露),gperftools(統計記憶體消耗)等:

DBI:動態二進位制工具 CTI:編譯時工具 UMR:未初始化的儲存器讀取 UAF:釋放後使用(又名懸掛指標) UAR:返回後使用 OOB:越界 x86:包括32和64-少量。在GCC 4.9中已刪除了 Mudflap,因為它已被AddressSanitizer取代。 Guard Page:一系列記憶體錯誤檢測器(Linux上為電子圍欄或DUMA,Windows上為Page Heap,OS X上為 libgmallocgperftools:與TCMalloc捆綁在一起的各種效能工具/錯誤檢測器。堆檢查器(檢漏器)僅在Linux上可用。除錯分配器同時提供了保護頁和Canary值,以更精確地檢測OOB寫入,因此它比僅保護頁的檢測器要好。

2. C 記憶體管理效率問題

1、記憶體管理可以分為三個層次

自底向上分別是:

  • 第一層:作業系統核心的記憶體管理-虛擬記憶體管理
  • 第二層:glibc層維護的記憶體管理演算法
  • 第三層:應用程式從glibc動態分配記憶體後,根據應用程式本身的程式特性進行優化, 比如SGI STL allocator,使用引用計數std::shared_ptr,RAII,實現應用的記憶體池等等。

當然應用程式也可以直接使用系統呼叫從核心分配記憶體,自己根據程式特性來維護記憶體,但是會大大增加開發成本。

2、C 記憶體管理問題

  • 頻繁的new/delete勢必會造成記憶體碎片化,使記憶體再分配和回收的效率下降;
  • new/delete分配記憶體在linux下預設是通過呼叫glibc的api-malloc/free來實現的,而這些api是通過呼叫到linux的系統呼叫:

brk()/sbrk() // 通過移動Heap堆頂指標brk,達到增加記憶體目的 mmap()/munmap() // 通過檔案影射的方式,把檔案對映到mmap區

分配記憶體 < DEFAULT_MMAP_THRESHOLD,走brk,從記憶體池獲取,失敗的話走brk系統呼叫

分配記憶體 > DEFAULT_MMAP_THRESHOLD,走mmap,直接呼叫mmap系統呼叫

其中,DEFAULT_MMAP_THRESHOLD預設為128k,可通過mallopt進行設定。

sbrk/brk系統呼叫的實現:分配記憶體是通過調節堆頂的位置來實現, 堆頂的位置是通過函式 brk 和 sbrk 進行動態調整,參考例子:

(1) 初始狀態:如圖 (1) 所示,系統已分配 ABCD 四塊記憶體,其中 ABD 在堆內分配, C 使用 mmap 分配。為簡單起見,圖中忽略瞭如共享庫等檔案對映區域的地址空間。

(2) E=malloc(100k) :分配 100k 記憶體,小於 128k ,從堆內分配,堆內剩餘空間不足,擴充套件堆頂 (brk) 指標。

(3) free(A) :釋放 A 的記憶體,在 glibc 中,僅僅是標記為可用,形成一個記憶體空洞 ( 碎片 ),並沒有真正釋放。如果此時需要分配 40k 以內的空間,可重用此空間,剩餘空間形成新的小碎片。

(4) free(C) :C 空間大於 128K ,使用 mmap 分配,如果釋放 C ,會呼叫 munmap 系統呼叫來釋放,並會真正釋放該空間,還給 OS ,如圖 (4) 所示。

所以free的記憶體不一定真正的歸還給OS,隨著系統頻繁地 malloc 和 free ,尤其對於小塊記憶體,堆內將產生越來越多不可用的碎片,導致“記憶體洩露”。而這種“洩露”現象使用 valgrind 是無法檢測出來的。

  • 綜上,頻繁記憶體分配釋放還會導致大量系統呼叫開銷,影響效率,降低整體效能;

相關視訊推薦

linux記憶體管理-龐雜的記憶體問題,如何理出自己的思路出來

記憶體洩漏的3個解決方案與原理實現,知道一個可以輕鬆應對開發

學習地址:C/C Linux伺服器開發/後臺架構師【零聲教育】-學習視訊教程-騰訊課堂

需要C/C Linux伺服器架構師學習資料加qun812855908獲取(資料包括C/C ,Linux,golang技術,Nginx,ZeroMQ,MySQL,Redis,fastdfs,MongoDB,ZK,流媒體,CDN,P2P,K8S,Docker,TCP/IP,協程,DPDK,ffmpeg等),免費分享

3. 常用解決上述問題的方案

記憶體池技術

記憶體池方案通常一次從系統申請一大塊記憶體塊,然後基於在這塊記憶體塊可以進行不同記憶體策略實現,可以比較好得解決上面提到的問題,一般採用記憶體池有以下好處:

1.少量系統申請次數,非常少(幾沒有) 堆碎片。 2.由於沒有系統呼叫等,比通常的記憶體申請/釋放(比如通過malloc, new等)的方式快。 3.可以檢查應用的任何一塊記憶體是否在記憶體池裡。 4.寫一個”堆轉儲(Heap-Dump)”到你的硬碟(對事後的除錯非常有用)。 5.可以更方便實現某種記憶體洩漏檢測(memory-leak detection)。

6.減少額外系統記憶體管理開銷,可以節約記憶體;

記憶體管理方案實現的指標:

  • 額外的空間損耗盡量少
  • 分配速度儘可能快
  • 儘量避免記憶體碎片
  • 多執行緒效能好
  • 快取本地化友好
  • 通用性,相容性,可移植性,易除錯等

各個記憶體分配器的實現都是在以上的各種指標中進行權衡選擇.

4. 一些業界主流的記憶體管理方案

SGI STL allocator

是比較優秀的 C 庫記憶體分配器(細節參考上面描述)

ptmalloc

是glibc的記憶體分配管理模組, 主要核心技術點:

  1. Arena-main /thread;支援多執行緒
  2. Heap segments;for thread arena via by mmap call ;提高管理
  3. chunk/Top chunk/Last Remainder chunk;提高記憶體分配的區域性性
  4. bins/fast bin/unsorted bin/small bin/large bin;提高分配效率

ptmalloc的缺陷

  • 後分配的記憶體先釋放,因為 ptmalloc 收縮記憶體是從 top chunk 開始,如果與 top chunk 相鄰的 chunk 不能釋放, top chunk 以下的 chunk 都無法釋放。
  • 多執行緒鎖開銷大, 需要避免多執行緒頻繁分配釋放。
  • 記憶體從thread的areana中分配, 記憶體不能從一個arena移動到另一個arena, 就是說如果多執行緒使用記憶體不均衡,容易導致記憶體的浪費。比如說執行緒1使用了300M記憶體,完成任務後glibc沒有釋放給作業系統,執行緒2開始建立了一個新的arena, 但是執行緒1的300M卻不能用了。
  • 每個chunk至少8位元組的開銷很大
  • 不定期分配長生命週期的記憶體容易造成記憶體碎片,不利於回收。64位系統最好分配32M以上記憶體,這是使用mmap的閾值。

tcmalloc

google的gperftools記憶體分配管理模組, 主要核心技術點:

  1. thread-localcache/periodic garbagecollections/CentralFreeList;提高多執行緒效能,提高cache利用率

TCMalloc給每個執行緒分配了一個執行緒區域性快取。小分配可以直接由執行緒區域性快取來滿足。需要的話,會將物件從中央資料結構移動到執行緒區域性快取中,同時定期的垃圾收集將用於把記憶體從執行緒區域性快取遷移回中央資料結構中:

2. Thread Specific Free List/size-classes [8,16,32,…32k]: 更好小物件記憶體分配;

每個小物件的大小都會被對映到170個可分配的尺寸類別中的一個。例如,在分配961到1024位元組時,都會歸整為1024位元組。尺寸類別這樣隔開:較小的尺寸相差8位元組,較大的尺寸相差16位元組,再大一點的尺寸差32位元組,如此類推。最大的間隔(對於尺寸 >= ~2K的)是256位元組。一個執行緒快取對每個尺寸類都包含了一個自由物件的單向連結串列

3. The central page heap:更好的大物件記憶體分配,一個大物件的尺寸(> 32K)會被除以一個頁面尺寸(4K)並取整(大於結果的最小整數),同時是由中央頁面堆來處理 的。中央頁面堆又是一個自由列表的陣列。對於i < 256而言,第k個條目是一個由k個頁面組成的自由列表。第256個條目則是一個包含了長度>= 256個頁面的自由列表:

4. Spans:

TCMalloc管理的堆由一系列頁面組成。連續的頁面由一個“跨度”(Span)物件來表示。一個跨度可以是_已被分配_或者是_自由_的。如果是自由的,跨度則會是一個頁面堆連結串列中的一個條目。如果已被分配,它會是一個已經被傳遞給應用程式的大物件,或者是一個已經被分割成一系列小物件的一個頁面。如果是被分割成小物件的,物件的尺寸類別會被記錄在跨度中。

由頁面號索引的中央陣列可以用於找到某個頁面所屬的跨度。例如,下面的跨度_a_佔據了2個頁面,跨度_b_佔據了1個頁面,跨度_c_佔據了5個頁面最後跨度_d_佔據了3個頁面。

tcmalloc的改進

  • ThreadCache會階段性的回收記憶體到CentralCache裡。解決了ptmalloc2中arena之間不能遷移的問題。
  • Tcmalloc佔用更少的額外空間。例如,分配N個8位元組物件可能要使用大約8N * 1.01位元組的空間。即,多用百分之一的空間。Ptmalloc2使用最少8位元組描述一個chunk。
  • 更快。小物件幾乎無鎖, >32KB的物件從CentralCache中分配使用自旋鎖。並且>32KB物件都是頁面對齊分配,多執行緒的時候應儘量避免頻繁分配,否則也會造成自旋鎖的競爭和頁面對齊造成的浪費。

jemalloc

FreeBSD的提供的記憶體分配管理模組, 主要核心技術點:

1. 與tcmalloc類似,每個執行緒同樣在<32kb的時候無鎖使用執行緒本地cache;< p="">

  1. Jemalloc在64bits系統上使用下面的size-class分類:

Small: [8], [16, 32, 48, …, 128], [192, 256, 320, …, 512], [768, 1024, 1280, …, 3840] Large: [4 KiB, 8 KiB, 12 KiB, …, 4072 KiB] Huge: [4 MiB, 8 MiB, 12 MiB, …]

  1. small/large物件查詢metadata需要常量時間, huge物件通過全域性紅黑樹在對數時間內查詢
  2. 虛擬記憶體被邏輯上分割成chunks(預設是4MB,1024個4k頁),應用執行緒通過round-robin演算法在第一次malloc的時候分配arena, 每個arena都是相互獨立的,維護自己的chunks, chunk切割pages到small/large物件。free()的記憶體總是返回到所屬的arena中,而不管是哪個執行緒呼叫free().

上圖可以看到每個arena管理的arena chunk結構, 開始的header主要是維護了一個page map(1024個頁面關聯的物件狀態), header下方就是它的頁面空間。Small物件被分到一起, metadata資訊存放在起始位置。large chunk相互獨立,它的metadata資訊存放在chunk header map中。

  1. 通過arena分配的時候需要對arena bin(每個small size-class一個,細粒度)加鎖,或arena本身加鎖。並且執行緒cache物件也會通過垃圾回收指數退讓演算法返回到arena中。

jemalloc的優化

  • Jmalloc小物件也根據size-class,但是它使用了低地址優先的策略,來降低記憶體碎片化。
  • Jemalloc大概需要2%的額外開銷。(tcmalloc 1%, ptmalloc最少8B).
  • Jemalloc和tcmalloc類似的執行緒本地快取,避免鎖的競爭 .
  • 相對未使用的頁面,優先使用dirty page,提升快取命中。

效能比較

測試環境:2x Intel E5/2.2Ghz with 8 real cores per socket,16 real cores, 開啟hyper-threading, 總共32個vcpu。16個table,每個5M row。OLTP_RO測試包含5個select查詢:select_ranges, select_order_ranges, select_distinct_ranges, select_sum_ranges:

facebook的測試結果:

伺服器吞吐量分別用6個malloc實現的對比資料,可以看到tcmalloc和jemalloc最好(tcmalloc這裡版本較舊)。

總結

可以看出tcmalloc和jemalloc效能接近,比ptmalloc效能要好,在多執行緒環境使用tcmalloc和jemalloc效果非常明顯。一般支援多核多執行緒擴充套件情況下可以使用jemalloc;反之使用tcmalloc可能是更好的選擇。

思考問題:

1 jemalloc和tcmalloc最佳實踐是什麼?

2 記憶體池的設計有哪些套路?為什麼?