全面分析C++內存泄漏:你的程序正在悄悄“失血”
作為 C/C++ 開發人員,內存泄漏是最容易遇到的問題之一,這一現象的根源深深植根于 C/C++ 語言獨特的特性之中。與 Java、Python 等現代高級語言截然不同,C/C++ 語言采用了手動內存管理機制,要求開發者親自負責內存的申請與釋放。在 C/C++ 程序運行過程中,當開發者需要使用額外的內存空間來存儲數據,如創建動態數組、鏈表節點,或是為復雜結構體分配空間時,需要主動調用malloc、new等函數來申請內存。
然而,內存申請僅僅是開始,更為關鍵的是,在這些內存使用完畢后,開發者必須準確無誤地調用free、delete等對應的釋放函數,將內存歸還給操作系統,以便后續其他程序或模塊使用。
這種手動內存管理模式賦予了開發者極高的靈活性與性能掌控力,在對內存使用效率要求苛刻的場景下,如游戲引擎開發、嵌入式系統編程等領域,C/C++ 能夠充分發揮優勢,實現對硬件資源的高效利用。但與此同時,也帶來了巨大的挑戰。一旦開發者在復雜的程序邏輯中遺漏了內存釋放的操作,或者在條件判斷、循環語句中錯誤地控制內存釋放時機,就極易造成段錯誤(segment fault)或者內存泄漏(memory leak)。
一、內存泄漏是什么?
內存泄漏(Memory Leak),指的是由于疏忽或錯誤,造成程序未能釋放已經不再使用的內存的情況 。這里要明確的是,內存泄漏并非指內存在物理上消失不見了,而是應用程序在分配了某段內存后,由于設計方面的錯誤,在本應釋放該段內存之前就失去了對它的控制,進而造成了內存的浪費。
在程序的運行過程中,內存就像是一個臨時的 “儲物間”,程序會根據需要申請內存空間來存放各種數據和對象 。當這些數據和對象不再被使用時,程序理應將其所占用的內存釋放掉,以便其他數據和對象能夠使用這塊空間。但如果程序出現了內存泄漏,就好比一個人從儲物間里拿走了東西,卻忘記把儲物間的空間騰出來給別人使用,導致儲物間越來越擁擠,可用空間越來越少。
通常我們所說的內存泄漏,多指堆內存泄漏。在計算機的內存管理中,堆是用于動態分配內存的區域,這部分內存的分配和釋放由程序員或程序運行時進行控制 。例如,在 C、C++ 等語言中,使用malloc、calloc、realloc、new等函數來分配堆內存,使用完后則要調用相應的free或delete函數來釋放內存。倘若使用不當,比如分配了內存卻沒有釋放,就會導致內存泄漏。
方便大家理解內存泄漏的危害,我們舉個簡單的例子:想象一下你正在房間里工作,需要用到一些材料。你從倉庫中拿了一些材料出來,但是在工作結束后卻沒有把它們放回倉庫。隨著時間的推移,越來越多的材料堆積在你的房間里,最終導致無法正常使用房間空間。
類似地,在程序中,如果我們反復申請新的內存空間而沒有及時釋放它們,就會產生內存泄漏問題。這些未被釋放的內存塊會逐漸累積起來,并最終耗盡可用的系統資源。
二、內存泄漏的類型
2.1常發性內存泄漏
常發性內存泄漏,指的是每次執行相關代碼時,都會出現內存泄漏的情況 。比如,在一個循環中頻繁地分配內存,但卻沒有釋放。以 C 語言為例:
#include <stdio.h>
#include <stdlib.h>
int main() {
for (int i = 0; i < 10; i++) {
int *ptr = (int *)malloc(sizeof(int));
// 這里沒有釋放ptr所指向的內存
}
return 0;
}
在上述代碼中,每次循環都會分配一塊int類型大小的內存,但沒有調用free函數釋放內存,隨著循環次數的增加,內存泄漏會越來越嚴重 。這種類型的內存泄漏比較容易發現,因為只要執行相關代碼,就會出現內存泄漏,通過代碼審查或者簡單的測試就能察覺 。
2.2偶發性內存泄漏
偶發性內存泄漏,是指在某些特定條件或操作序列下,才會出現的內存泄漏 。這種內存泄漏的出現具有不確定性,難以通過常規測試發現,給調試工作帶來很大挑戰。例如,在一個多線程程序中,當多個線程按照特定順序訪問共享資源時,可能會出現內存泄漏。假設存在一個共享的內存池,多個線程可以從池中分配和釋放內存:
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
// 模擬內存池
void* memory_pool[100];
int pool_index = 0;
// 分配內存
void* my_malloc(size_t size) {
if (pool_index < 100) {
memory_pool[pool_index] = malloc(size);
return memory_pool[pool_index++];
}
return NULL;
}
// 釋放內存
void my_free(void* ptr) {
for (int i = 0; i < pool_index; i++) {
if (memory_pool[i] == ptr) {
free(ptr);
// 這里假設簡單地將后面的元素前移來填補空位,但多線程下可能有問題
for (int j = i; j < pool_index - 1; j++) {
memory_pool[j] = memory_pool[j + 1];
}
pool_index--;
return;
}
}
}
// 線程函數
void* thread_function(void* arg) {
void* ptr1 = my_malloc(100);
void* ptr2 = my_malloc(200);
// 假設這里有一些復雜的條件判斷
if (/* 某些特定條件 */) {
// 只釋放了ptr1,沒有釋放ptr2,在特定條件下導致內存泄漏
my_free(ptr1);
}
return NULL;
}
int main() {
pthread_t thread1, thread2;
pthread_create(&thread1, NULL, thread_function, NULL);
pthread_create(&thread2, NULL, thread_function, NULL);
pthread_join(thread1, NULL);
pthread_join(thread2, NULL);
return 0;
}
在這個例子中,由于線程執行的不確定性,以及if條件判斷中的邏輯,只有在特定條件滿足時才會出現內存泄漏 。要檢測這種類型的內存泄漏,需要全面地考慮各種可能的情況,進行大量的隨機測試和邊界條件測試 。
2.3一次性內存泄漏
一次性內存泄漏,是指只在某些特定情況下發生一次的內存泄漏 。例如,在程序啟動時初始化某些資源,由于代碼錯誤導致這些資源在程序運行期間一直占用內存,不會再被釋放,但也不會重復發生泄漏 。比如在一個 Java 程序中:
public class OneTimeMemoryLeak {
private static byte[] largeArray;
public static void main(String[] args) {
// 假設在程序啟動時分配了一個大數組,但沒有使用也沒有釋放
largeArray = new byte[1024 * 1024 * 10]; // 10MB
// 這里沒有對largeArray進行任何釋放操作,且后續不會再次分配導致泄漏
}
}
盡管這種內存泄漏只發生一次,但如果泄漏的內存量較大,仍然會對程序性能產生嚴重影響 ,尤其對于需要長期運行且對內存資源敏感的程序,可能會逐漸耗盡系統內存 。
2.4隱式內存泄漏
隱式內存泄漏,是一種不易被察覺的內存泄漏 。在這種情況下,程序雖然沒有直接丟失對已分配內存的引用,但內存卻沒有被有效地釋放,導致內存逐漸被消耗 。例如,在使用緩存機制時,如果沒有正確地管理緩存的生命周期,就可能出現隱式內存泄漏 。以 Python 的字典模擬緩存為例:
cache = {}
def get_data(key):
if key in cache:
return cache[key]
else:
data = expensive_operation_to_get_data(key)
cache[key] = data
return data
# 假設這里有大量不同的key被使用,且cache沒有清理機制
for i in range(10000):
get_data(i)
在這個例子中,隨著越來越多的數據被存入緩存,cache占用的內存會不斷增加 。如果沒有定期清理cache中不再使用的數據,就會導致內存泄漏 。由于程序并沒有直接丟棄對緩存數據的引用,這種內存泄漏不太容易被發現 。
三、內存泄漏產生的原因
在C語言中,從變量存在的時間生命周期角度上,把變量分為靜態存儲變量和動態存儲變量兩類。靜態存儲變量是指在程序運行期間分配了固定存儲空間的變量,而動態存儲變量是指在程序運行期間根據實際需要進行動態地分配存儲空間的變量。在內存中供用戶使用的內存空間分為三部分:
- 程序存儲區
- 靜態存儲區
- 動態存儲區
程序中所用的數據分別存放在靜態存儲區和動態存儲區中。靜態存儲區數據在程序的開始就分配好內存區,在整個程序執行過程中它們所占的存儲單元是固定的,在程序結束時就釋放,因此靜態存儲區數據一般為全局變量。動態存儲區數據則是在程序執行過程中根據需要動態分配和動態釋放的存儲單元,動態存儲區數據有三類函數形參變量、局部變量和函數調用時的現場保護與返回地址。由于動態存儲變量可以根據函數調用的需要,動態地分配和釋放存儲空間,大大提高了內存的使用效率,使得動態存儲變量在程序中被廣泛使用。
開發人員進行程序開發的過程使用動態存儲變量時,不可避免地面對內存管理的問題。程序中動態分配的存儲空間,在程序執行完畢后需要進行釋放。沒有釋放動態分配的存儲空間而造成內存泄漏,是使用動態存儲變量的主要問題。一般情況下,開發人員使用系統提供的內存管理基本函數,如malloc、realloc、calloc、free等,完成動態存儲變量存儲空間的分配和釋放。但是,當開發程序中使用動態存儲變量較多和頻繁使用函數調用時,就會經常發生內存管理錯誤,例如:
- 分配一個內存塊并使用其中未經初始化的內容;
- 釋放一個內存塊,但繼續引用其中的內容;
- 子函數中分配的內存空間在主函數出現異常中斷時、或主函數對子函數返回的信息使用結束時,沒有對分配的內存進行釋放;
- 程序實現過程中分配的臨時內存在程序結束時,沒有釋放臨時內存。內存錯誤一般是不可再現的,開發人員不易在程序調試和測試階段發現,即使花費了很多精力和時間,也無法徹底消除。
3.1忘記釋放內存
在使用 C、C++ 等手動管理內存的語言時,程序員需要負責分配和釋放內存 。如果在分配內存后,忘記調用相應的釋放函數,就會導致內存泄漏 。例如,在 C 語言中:
#include <stdio.h>
#include <stdlib.h>
int main() {
int *ptr = (int *)malloc(sizeof(int));
// 使用ptr
// 這里忘記釋放ptr所指向的內存
return 0;
}
在上述代碼中,通過malloc函數分配了一塊int類型大小的內存,并將指針ptr指向這塊內存 。然而,在程序結束時,沒有調用free(ptr)來釋放內存,導致這塊內存一直被占用,無法被其他程序使用,從而造成了內存泄漏 。隨著程序中這種情況的增多,內存會被逐漸耗盡,最終導致程序崩潰或系統性能下降 。
3.2指針操作不當
指針操作不當也是導致內存泄漏的常見原因之一 。這包括指針重新賦值導致原內存無法釋放、指針偏移操作越界以及對象內部動態分配的內存未正確釋放等情況 。
以 C 語言為例,假設我們有如下代碼:
#include <stdio.h>
#include <stdlib.h>
int main() {
int *ptr1 = (int *)malloc(sizeof(int));
int *ptr2 = (int *)malloc(sizeof(int));
ptr1 = ptr2; // 這里ptr1重新賦值,導致原來ptr1指向的內存無法釋放
free(ptr2);
return 0;
}
在這段代碼中,首先分配了兩塊內存,分別由ptr1和ptr2指向 。隨后,將ptr2賦值給ptr1,此時ptr1不再指向原來分配的內存,而原來ptr1指向的內存的指針丟失,無法再通過free函數釋放,從而造成內存泄漏 。
再比如,在進行指針偏移操作時,如果不小心越界,也可能導致內存訪問錯誤和內存泄漏 。假設我們有一個字符數組,通過指針來訪問數組元素:
#include <stdio.h>
int main() {
char str[] = "hello";
char *ptr = str;
// 假設這里錯誤地將指針偏移了10個位置,超出了數組范圍
ptr += 10;
// 對越界的指針進行操作,可能導致內存錯誤和泄漏
*ptr = 'a';
return 0;
}
在這個例子中,ptr指針被錯誤地偏移超出了數組str的范圍,對越界的指針進行寫操作會導致未定義行為,可能破壞其他內存區域的數據,甚至引發內存泄漏 。
此外,對于包含動態分配內存的對象,如果在對象銷毀時沒有正確釋放內部的動態內存,也會導致內存泄漏 。例如,在 C++ 中定義一個包含動態數組的類:
#include <iostream>
class MyClass {
public:
MyClass() {
data = new int[10];
}
~MyClass() {
// 這里沒有釋放data數組,導致內存泄漏
}
private:
int *data;
};
int main() {
MyClass obj;
return 0;
}
在上述代碼中,MyClass類的構造函數中分配了一個包含 10 個int類型元素的動態數組data 。然而,在析構函數中沒有釋放這個數組,當obj對象被銷毀時,data所占用的內存無法被釋放,從而造成內存泄漏 。
3.3循環引用
在面向對象編程中,當兩個或多個對象相互引用,形成一個閉環時,就會發生循環引用 。如果垃圾回收器無法正確處理循環引用,這些對象將無法被回收,導致內存泄漏 。以 Python 為例:
class Node:
def __init__(self):
self.next = None
a = Node()
b = Node()
a.next = b
b.next = a
在這段代碼中,a和b兩個對象相互引用,形成了一個循環 。如果 Python 的垃圾回收器沒有特殊的機制來處理這種循環引用,a和b對象將一直占用內存,即使它們在程序中不再被使用,也無法被回收,從而導致內存泄漏 。
在 Java 中,同樣可能出現循環引用導致的內存泄漏問題 。例如:
class A {
B b;
}
class B {
A a;
}
public class Main {
public static void main(String[] args) {
A a = new A();
B b = new B();
a.b = b;
b.a = a;
// 這里a和b形成循環引用,如果沒有特殊處理,可能導致內存泄漏
}
}
在這個 Java 示例中,A類的實例 a 持有 B類實例b的引用,而B類的實例b又持有A類實例a的引用,形成了循環引用 。在某些垃圾回收算法中,如果不能識別和處理這種循環引用,a和b所占用的內存將無法被回收,造成內存泄漏 。
3.4靜態集合使用不當
在程序中,靜態集合(如靜態的HashMap、ArrayList等)如果使用不當,也會導致內存泄漏 。由于靜態集合的生命周期與程序的生命周期相同,如果不斷向靜態集合中添加對象,而這些對象在后續不再被使用,但又沒有從集合中移除,那么這些對象將一直被靜態集合引用,無法被垃圾回收器回收,從而造成內存泄漏 。以 Java 為例:
import java.util.ArrayList;
import java.util.List;
public class MemoryLeakExample {
private static List<Object> list = new ArrayList<>();
public static void main(String[] args) {
while (true) {
Object obj = new Object();
list.add(obj);
// 沒有移除對象的操作,隨著循環進行,list會占用越來越多內存
}
}
}
在上述代碼中,list是一個靜態的ArrayList 。在while循環中,不斷創建新的Object對象并添加到list中,但沒有從list中移除這些對象的操作 。隨著循環的持續進行,list中存儲的對象越來越多,占用的內存也越來越大,而這些對象在程序中實際上已經不再被使用,卻無法被垃圾回收,最終導致內存泄漏 。
3.5外部類引用內部類
在 Java 等語言中,非靜態內部類會持有外部類的隱式引用 。如果內部類的實例生命周期比外部類長,并且在內部類中沒有正確處理對外部類的引用,就可能導致外部類無法被垃圾回收,從而造成內存泄漏 。例如:
public class OuterClass {
private int[] data = new int[100];
private class InnerClass {
public void doSomething() {
// 內部類方法可以訪問外部類的成員
}
}
public static void main(String[] args) {
OuterClass outer = new OuterClass();
OuterClass.InnerClass inner = outer.new InnerClass();
// 如果inner對象一直存在,outer對象也無法被回收,即使outer不再被使用
}
}
在這段代碼中,InnerClass是OuterClass的非靜態內部類 。當創建InnerClass的實例inner時,inner會持有對外部類OuterClass實例outer的隱式引用 。如果inner對象在程序中一直存在,例如被存儲在某個全局變量或靜態集合中,那么即使outer對象在其他地方不再被使用,由于inner對outer的引用,outer也無法被垃圾回收,從而導致內存泄漏 。
3.6前端閉包問題
在 JavaScript 中,閉包是一個強大的特性,但如果使用不當,也可能導致內存泄漏 。閉包是指函數可以訪問并操作其外部作用域的變量,即使在外部函數執行完畢后,這些變量仍然存在于內存中 。如果在閉包中引用了大量的外部變量,并且這些變量在閉包執行完畢后不再被使用,但由于閉包的存在,它們無法被垃圾回收,就會造成內存泄漏 。例如:
function outerFunction() {
let largeData = new Array(1000000).fill(1); // 假設這是一個占用大量內存的數據
return function innerFunction() {
// 閉包中使用了largeData
console.log(largeData.length);
};
}
let closure = outerFunction();
// 這里closure持有對largeData的引用,即使outerFunction執行完畢,largeData也不會被回收
在上述代碼中,outerFunction返回一個內部函數innerFunction,形成了閉包 。innerFunction可以訪問并操作outerFunction作用域內的largeData變量 。當outerFunction執行完畢后,largeData本應可以被垃圾回收,但由于innerFunction(閉包)仍然引用著largeData,導致largeData無法被回收,占用大量內存,從而可能引發內存泄漏 。
3.7未關閉連接
在進行數據庫操作、網絡通信等場景中,如果打開了連接(如數據庫連接、Socket 連接等),但在使用完畢后沒有及時關閉,這些連接對象將一直占用內存和系統資源,導致內存泄漏 。以 Java 的 JDBC(Java Database Connectivity)為例:
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;
public class DatabaseConnectionExample {
public static void main(String[] args) {
Connection conn = null;
try {
conn = DriverManager.getConnection("jdbc:mysql://localhost:3306/mydb", "username", "password");
// 進行數據庫操作
} catch (SQLException e) {
e.printStackTrace();
} finally {
// 這里沒有關閉連接,導致連接對象無法被回收
// 正確做法應該是在finally塊中調用conn.close()
}
}
}
在這段代碼中,通過DriverManager.getConnection方法獲取了一個數據庫連接conn 。在使用完連接后,應該在finally塊中調用conn.close()方法關閉連接,以釋放相關資源 。然而,上述代碼中沒有關閉連接,conn對象將一直占用內存和數據庫資源,無法被垃圾回收,隨著這種情況的增多,可能會導致內存泄漏和系統資源耗盡 。
四、泄漏根源詳解
內存泄漏,主要指的是在堆(heap)上申請的動態內存泄漏,或者說是指針指向的內存塊忘了被釋放,導致該塊內存不能再被申請重新使用。
之前在知乎上看了一句話,指針是C的精髓,也是初學者的一個坎。換句話說,內存管理是C的精髓,C/C++可以直接跟OS打交道,從性能角度出發,開發者可以根據自己的實際使用場景靈活進行內存分配和釋放。雖然在C++中自C++11引入了smart pointer,雖然很大程度上能夠避免使用裸指針,但仍然不能完全避免,最重要的一個原因是你不能保證組內其他人不適用指針,更不能保證合作部門不使用指針。
那么為什么C/C++中會存在指針呢?
這就得從進程的內存布局說起。
4.1進程內存布局
進程的內存布局:棧、堆、數據段、程序文本段
圖片
- 棧區: 由編譯器自動分配,主要保存進程使用的局部變量、函數的參數、返回值等,是一種由高地址向低地址擴展的數據結構,類似棧,是一塊連續的內存區域,大小一般為2M或者1M,如果申請的空間超過棧的剩余空間,將提示overflow。
- 堆區: 這段空間由程序員分配、釋放,例如:malloc,回收時free、delete等,如果程序員不釋放,則在程序結束后由操作系統釋放,是一種由低地址向高地址擴展的數據結構,類似鏈表,是一塊不連續的內存區域,鏈表存儲空閑地址,鏈表遍歷時由低地址向高地址。
- 數據段: bss存儲的是未初始化或者初始化為0的全局變量、靜態變量,表示一個占位符,并不給該段的數據分配空間,只是記錄數據所需空間的大小。數據段存儲的是已初始化的數據段和靜態變量
- 代碼段(程序文本段): 主要保存的是進程的二進制程序代碼、也可能有可讀的常量變量,例如字符串常量。
由于本文主要講內存分配相關,所以下面的內容僅涉及到棧(stack)和堆(heap)。
圖片
4.2棧stack
棧一塊連續的內存塊,棧上的內存分配就是在這一塊連續內存塊上進行操作的。編譯器在編譯的時候,就已經知道要分配的內存大小,當調用函數時候,其內部的遍歷都會在棧上分配內存;當結束函數調用時候,內部變量就會被釋放,進而將內存歸還給棧。
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的方式進棧和出棧
- 大小受限:棧的大小依賴于操作系統
- 訪問受限:只能在當前函數或者作用域內進行訪問
4.3堆heap
堆(heap)是一種內存管理方式。內存管理對操作系統來說是一件非常復雜的事情,因為首先內存容量很大,其次就是內存需求在時間和大小塊上沒有規律(操作系統上運行著幾十甚至幾百個進程,這些進程可能隨時都會申請或者是釋放內存,并且申請和釋放的內存塊大小是隨意的)。
堆這種內存管理方式的特點就是自由(隨時申請、隨時釋放、大小塊隨意)。堆內存是操作系統劃歸給堆管理器(操作系統中的一段代碼,屬于操作系統的內存管理單元)來管理的,堆管理器提供了對應的接口_sbrk、mmap_等,只是該接口往往由運行時庫進行調用,即也可以說由運行時庫進行堆內存管理,運行時庫提供了malloc/free函數由開發人員調用,進而使用堆內存。
分配方式
正如我們所理解的那樣,由于是在運行期進行內存分配,分配的大小也在運行期才會知道,所以堆只支持動態分配,內存申請和釋放的行為由開發者自行操作,這就很容易造成我們說的內存泄漏。
特點
- 變量可以在進程范圍內訪問,即進程內的所有線程都可以訪問該變量
- 沒有內存大小限制,這個其實是相對的,只是相對于棧大小來說沒有限制,其實最終還是受限于RAM
- 相對棧來說訪問比較慢
- 內存碎片
- 由開發者管理內存,即內存的申請和釋放都由開發人員來操作
4.4堆與棧區別
(1)在申請方式上
棧(stack): 現在很多人都稱之為堆棧,這個時候實際上還是指的棧。它由編譯器自動管理,無需我們手工控制。 例如,聲明函數中的一個局部變量 int b 系統自動在棧中為b開辟空間;在調用一個函數時,系統自動的給函數的形參變量在棧中開辟空間。
堆(heap): 申請和釋放由程序員控制,并指明大小。容易產生memory leak。
在C中使用malloc函數。
如:p1 = (char *)malloc(10);
在C++中用new運算符。
如:p2 = new char[20];//(char *)malloc(10);
但是注意p1本身在全局區,而p2本身是在棧中的,只是它們指向的空間是在堆中。
(2)申請后系統的響應上
棧(stack):只要棧的剩余空間大于所申請空間,系統將為程序提供內存,否則將報異常提示棧溢出。
堆(heap):首先應該知道操作系統有一個記錄空閑內存地址的鏈表,當系統收到程序的申請時,會遍歷該鏈表,尋找第一個空間大于所申請空間的堆結點,然后將該結點從空閑結點鏈表中刪除,并將該結點的空間分配給程序。另外,對于大多數系統,會在這塊內存空間中的首地址處記錄本次分配的大小,這樣,代碼中的delete或free語句才能正確的釋放本內存空間。另外,由于找到的堆結點的大小不一定正好等于申請的大小,系統會自動的將多余的那部分重新放入空閑鏈表中。
(3)申請大小的限制
棧(stack):在Windows下,棧是向低地址擴展的數據結構,是一塊連續的內存的區域。這句話的意思是棧頂的地址和棧的最大容量是系統預先規定好的,在WINDOWS下,棧的大小是2M(也有的說是1M,總之是一個編譯時就確定的常數),如果申請的空間超過棧的剩余空間時,將提示overflow。因此,能從棧獲得的空間較小。
例如,在VC6下面,默認的棧空間大小是1M(好像是,記不清楚了)。當然,我們可以修改:打開工程,依次操作菜單如下:Project->Setting->Link,在Category 中選中Output,然后在Reserve中設定堆棧的最大值和commit。
注意:reserve最小值為4Byte;commit是保留在虛擬內存的頁文件里面,它設置的較大會使棧開辟較大的值,可能增加內存的開銷和啟動時間。
堆(heap):堆是向高地址擴展的數據結構,是不連續的內存區域(空閑部分用鏈表串聯起來)。正是由于系統是用鏈表來存儲空閑內存,自然是不連續的,而鏈表的遍歷方向是由低地址向高地址。一般來講在32位系統下,堆內存可以達到4G的空間,從這個角度來看堆內存幾乎是沒有什么限制的。由此可見,堆獲得的空間比較靈活,也比較大。
(4)分配空間的效率上
棧(stack):棧是機器系統提供的數據結構,計算機會在底層對棧提供支持:分配專門的寄存器存放棧的地址,壓棧出棧都有專門的指令執行,這就決定了棧的效率比較高。但程序員無法對其進行控制。
堆(heap):是C/C++函數庫提供的,由new或malloc分配的內存,一般速度比較慢,而且容易產生內存碎片。它的機制是很復雜的,例如為了分配一塊內存,庫函數會按照一定的算法(具體的算法可以參考數據結構/操作系統)在堆內存中搜索可用的足夠大小的空間,如果沒有足夠大小的空間(可能是由于內存碎片太多),就有可能調用系統功能去增加程序數據段的內存空間,這樣就有機會分到足夠大小的內存,然后進行返回。這樣可能引發用戶態和核心態的切換,內存的申請,代價變得更加昂貴。顯然,堆的效率比棧要低得多。
(5)堆和棧中的存儲內容
棧(stack):在函數調用時,第一個進棧的是主函數中子函數調用后的下一條指令(子函數調用語句的下一條可執行語句)的地址,然后是子函數的各個形參。在大多數的C編譯器中,參數是由右往左入棧的,然后是子函數中的局部變量。注意:靜態變量是不入棧的。 當本次函數調用結束后,局部變量先出棧,然后是參數,最后棧頂指針指向最開始存的地址,也就是主函數中子函數調用完成的下一條指令,程序由該點繼續運行。
堆(heap):一般是在堆的頭部用一個字節存放堆的大小,堆中的具體內容有程序員安排。
(6)存取效率的比較
這個應該是顯而易見的。拿棧上的數組和堆上的數組來說:
void main()
{
int arr[5]={1,2,3,4,5};
int *arr1;
arr1=new int[5];
for (int j=0;j<=4;j++)
{
arr1[j]=j+6;
}
int a=arr[1];
int b=arr1[1];
}
上面代碼中,arr1(局部變量)是在棧中,但是指向的空間確在堆上,兩者的存取效率,當然是arr高。因為arr[1]可以直接訪問,但是訪問arr1[1],首先要訪問數組的起始地址arr1,然后才能訪問到arr1[1]。
五、內存泄漏的危害
內存泄漏就像程序中的一顆 “定時炸彈”,隨著時間的推移,會逐漸對程序乃至整個系統產生嚴重的負面影響。它不僅會導致程序性能下降,還可能引發程序崩潰,甚至帶來安全隱患。下面我們將詳細探討內存泄漏的危害。
5.1性能下降
內存泄漏最直接的影響就是導致系統內存逐漸減少 。隨著未釋放內存的不斷累積,可用內存空間越來越小 。當程序需要為新的對象或數據分配內存時,由于內存不足,系統可能會頻繁地進行內存交換操作,將內存中的數據交換到磁盤的虛擬內存中,然后再從虛擬內存中讀取數據 。這個過程涉及大量的磁盤 I/O 操作,而磁盤的讀寫速度遠遠低于內存,從而導致程序運行速度顯著變慢,響應時間大幅增加 。
例如,一個原本運行流暢的數據庫管理系統,如果存在內存泄漏問題,隨著運行時間的增長,可用內存逐漸減少,系統會頻繁地將數據庫數據在內存和磁盤之間交換 。這將使得數據庫的查詢、插入、更新等操作變得遲緩,用戶在執行這些操作時會明顯感覺到卡頓,嚴重影響系統的性能和用戶體驗 。
5.2程序崩潰
當內存泄漏達到一定程度,系統內存被耗盡時,程序將無法為新的對象或數據分配所需的內存空間 。在這種情況下,程序通常會拋出內存不足的異常 。如果程序沒有正確處理這些異常,就可能導致程序崩潰 。
以一個長時間運行的服務器程序為例,假設它在處理大量用戶請求的過程中存在內存泄漏 。隨著時間的推移,內存不斷被泄漏,最終服務器的內存被耗盡 。當有新的用戶請求到來時,程序無法為處理該請求分配內存,就會導致服務器程序崩潰 。這不僅會中斷正在進行的業務,還可能造成數據丟失,給用戶和企業帶來嚴重的損失 。
5.3安全問題
內存泄漏還可能引發安全問題 。在一些情況下,未釋放的內存中可能包含敏感信息,如用戶密碼、銀行賬戶信息等 。如果這些內存被其他程序或惡意攻擊者獲取,就可能導致敏感信息泄露,從而引發安全漏洞 。
比如,一個處理用戶登錄信息的程序,在驗證用戶身份后,將用戶的密碼存儲在內存中 。如果在后續的操作中出現內存泄漏,而這塊包含密碼的內存沒有被正確釋放,那么惡意攻擊者就有可能通過特定的手段獲取到這塊內存中的密碼信息,進而對用戶的賬戶安全構成威脅 。此外,內存泄漏還可能導致程序的內存布局發生變化,使得程序更容易受到緩沖區溢出等攻擊,進一步增加了系統的安全風險 。
六、內存泄漏的檢測方法
6.1動態檢測方法
動態檢測方法是在程序運行時,實時監測內存的分配和釋放情況,從而發現內存泄漏問題。這類方法通常借助專門的工具來實現,以下介紹幾種常見的動態檢測工具。
①Mtrace:Mtrace 是 GNU C 庫提供的一個內存泄漏檢測工具 。它的工作原理是通過在程序中插入一些特殊的代碼,來跟蹤內存的分配和釋放操作 。在使用 Mtrace 時,需要在程序中包含<mcheck.h>頭文件,并在程序開始和結束時分別調用mtrace()和muntrace()函數 。當程序運行時,Mtrace 會記錄下所有的內存分配和釋放信息,并將這些信息輸出到一個日志文件中 。通過分析這個日志文件,開發者可以找出內存泄漏的位置 。例如,假設有如下 C 語言代碼:
#include <stdio.h>
#include <stdlib.h>
#include <mcheck.h>
int main() {
mtrace();
int *ptr = (int *)malloc(sizeof(int));
// 這里沒有釋放ptr
muntrace();
return 0;
}
運行該程序后,會生成一個包含內存分配和釋放信息的日志文件,通過分析這個文件,就能發現ptr所指向的內存沒有被釋放,從而確定內存泄漏的位置 。
②Memwatch:Memwatch 是一個專門用于檢測 C 和 C++ 程序中內存泄漏的工具 。它通過重載內存分配和釋放函數(如malloc、free、new、delete等),來監控內存的使用情況 。當檢測到內存泄漏時,Memwatch 會輸出詳細的報告,包括泄漏的內存塊大小、分配該內存塊的代碼位置以及調用堆棧等信息 。這使得開發者能夠快速定位到內存泄漏的源頭 。例如,在一個 C++ 程序中使用 Memwatch:
#include "memwatch.h"
#include <iostream>
int main() {
int *arr = new int[10];
// 沒有釋放arr
return 0;
}
運行該程序后,Memwatch 會生成內存泄漏報告,指出arr所指向的內存沒有被釋放,以及具體的泄漏位置,方便開發者進行修復 。
③Purify:Purify 是 IBM Rational 公司開發的一款強大的內存分析工具,它可以檢測多種內存相關的問題,包括內存泄漏、內存越界訪問等 。Purify 采用了目標代碼插入技術(OCI:Object Code Insertion),在程序的目標代碼中插入特殊的指令,用來檢查內存的狀態和使用情況 。這樣做的好處是不需要修改源代碼,只需要重新編譯程序即可進行分析 。對于所有程序中使用的動態內存,Purify 會將它們按照狀態進行歸類,通過監測內存狀態的變化來檢測內存泄漏 。例如,在一個 C 程序中:
#include <stdio.h>
#include <stdlib.h>
int main() {
char *str = (char *)malloc(100);
// 沒有釋放str
return 0;
}
使用 Purify 分析該程序后,它會準確地報告出str所指向的內存發生了泄漏,并提供相關的詳細信息,幫助開發者解決問題 。
6.2靜態檢測方法
靜態檢測方法是在不運行程序的情況下,通過分析程序的源代碼或目標代碼,來查找潛在的內存泄漏隱患。這類方法主要依賴于靜態分析工具,以下是幾種常見的靜態檢測工具。
①BEAM:BEAM(Binary and Executable Analyzer for Memory leaks)是一款可以檢測內存泄漏等問題的工具 ,它支持多種平臺 。BEAM 能夠檢測四類問題,即沒有初始化的變量、廢棄的空指針、內存泄漏和冗余計算 。它通過對代碼進行詞法分析、語法分析和語義分析,來識別可能導致內存泄漏的代碼模式 。例如,對于如下 C++ 代碼:
#include <iostream>
void leakTest() {
int *ptr = new int;
// 這里沒有釋放ptr
}
int main() {
leakTest();
return 0;
}
BEAM 在分析這段代碼時,能夠檢測到ptr在分配內存后沒有被釋放,從而指出潛在的內存泄漏問題 。
②PC - Lint:PC - Lint 是一款功能強大的靜態代碼分析工具,它能夠幫助開發者在代碼編譯之前發現潛在的錯誤、漏洞和不良的編程實踐,其中就包括內存泄漏問題 。PC - Lint 通過檢查代碼中的語法、語義以及編程規范,來找出可能存在的內存泄漏隱患 。它支持多種編譯器和編程語言,并可以通過配置文件進行定制化設置,以適應不同項目的需求 。例如,在一段 C 代碼中,如果存在內存分配后未釋放的情況:
#include <stdio.h>
#include <stdlib.h>
void func() {
char *buf = (char *)malloc(100);
// 沒有釋放buf
}
int main() {
func();
return 0;
}
PC - Lint 在對這段代碼進行分析時,會檢測到buf的內存泄漏問題,并給出相應的提示和建議,幫助開發者改進代碼 。
七、如何避免內存泄漏
通常來說,一個線程的棧內存是有限的,通常來說是 8M 左右(取決于運行的環境)。棧上的內存通常是由編譯器來自動管理的。當在棧上分配一個新的變量時,或進入一個函數時,棧的指針會下移,相當于在棧上分配了一塊內存。我們把一個變量分配在棧上,也就是利用了棧上的內存空間。當這個變量的生命周期結束時,棧的指針會上移,相同于回收了內存。
由于棧上的內存的分配和回收都是由編譯器控制的,所以在棧上是不會發生內存泄露的,只會發生棧溢出(Stack Overflow),也就是分配的空間超過了規定的棧大小。而堆上的內存是由程序直接控制的,程序可以通過 malloc/free 或 new/delete 來分配和回收內存,如果程序中通過 malloc/new 分配了一塊內存,但忘記使用 free/delete 來回收內存,就發生了內存泄露。
7.1及時釋放內存
在使用動態分配內存的編程語言(如 C、C++)中,及時釋放不再使用的內存是避免內存泄漏的關鍵 。以 C 語言為例,當使用malloc、calloc或realloc分配內存后,一定要記得在合適的時機使用free函數釋放內存 。例如:
#include <stdio.h>
#include <stdlib.h>
int main() {
int *ptr = (int *)malloc(sizeof(int));
if (ptr != NULL) {
// 使用ptr
*ptr = 10;
printf("The value of ptr is: %d\n", *ptr);
// 使用完后釋放內存
free(ptr);
ptr = NULL; // 防止懸空指針
}
return 0;
}
在上述代碼中,通過malloc分配了一塊int類型大小的內存,并將指針ptr指向它 。在對ptr進行使用后,及時調用free(ptr)釋放了內存,并將ptr賦值為NULL,避免了懸空指針的問題 。這樣,這塊內存就可以被重新分配給其他需要的地方,從而避免了內存泄漏 。
7.2避免循環引用
在設計和實現代碼時,要特別注意避免對象之間的循環引用 。以 Python 為例,假設我們有兩個類A和B,如果它們之間形成循環引用,就可能導致內存泄漏 。如下代碼:
class A:
def __init__(self):
self.b = None
class B:
def __init__(self):
self.a = None
a = A()
b = B()
a.b = b
b.a = a
在這段代碼中,a對象持有b對象的引用,b對象又持有a對象的引用,形成了循環引用 。為了避免這種情況,可以重新設計類的結構,避免不必要的相互引用 。如果確實需要相互引用,可以考慮使用弱引用(如 Python 中的weakref模塊),弱引用不會增加對象的引用計數,當對象沒有其他強引用時,垃圾回收器可以正常回收它 。例如:
import weakref
class A:
def __init__(self):
self.b = None
class B:
def __init__(self):
self.a_ref = None
def set_a(self, a):
self.a_ref = weakref.ref(a)
a = A()
b = B()
a.b = b
b.set_a(a)
在這個改進后的代碼中,B類使用弱引用a_ref指向A類的實例a 。這樣,即使a和b之間存在引用關系,也不會形成循環引用導致內存泄漏 。當a沒有其他強引用時,垃圾回收器可以正常回收a,b.a_ref會變為None 。
7.3正確使用指針
在操作指針時,要防止指針丟失和偏移不當等問題 。以 C 語言為例,在進行指針賦值和偏移操作時,要確保不會導致原內存無法釋放或內存訪問越界 。例如,在鏈表操作中,當刪除一個節點時,要注意正確處理指針的指向,避免內存泄漏 。假設我們有一個簡單的單向鏈表:
#include <stdio.h>
#include <stdlib.h>
// 定義鏈表節點
struct Node {
int data;
struct Node *next;
};
// 刪除鏈表中的指定節點
void deleteNode(struct Node **head, int value) {
struct Node *current = *head;
struct Node *prev = NULL;
while (current != NULL && current->data != value) {
prev = current;
current = current->next;
}
if (current == NULL) {
return; // 未找到要刪除的節點
}
if (prev == NULL) {
*head = current->next; // 刪除的是頭節點
} else {
prev->next = current->next;
}
free(current); // 釋放被刪除節點的內存
}
int main() {
// 創建鏈表 1 -> 2 -> 3
struct Node *head = (struct Node *)malloc(sizeof(struct Node));
head->data = 1;
head->next = (struct Node *)malloc(sizeof(struct Node));
head->next->data = 2;
head->next->next = (struct Node *)malloc(sizeof(struct Node));
head->next->next->data = 3;
head->next->next->next = NULL;
// 刪除節點 2
deleteNode(&head, 2);
// 釋放剩余鏈表內存
struct Node *current = head;
struct Node *next;
while (current != NULL) {
next = current->next;
free(current);
current = next;
}
return 0;
}
在上述代碼中,deleteNode函數用于刪除鏈表中指定值的節點 。在刪除節點時,通過正確處理指針的指向,確保鏈表結構的完整性,并及時釋放被刪除節點的內存,避免了內存泄漏 。在main函數中,創建鏈表后進行節點刪除操作,最后釋放剩余鏈表的內存,保證了內存的正確管理 。
7.4管理靜態集合
對于靜態數據結構(如靜態的HashMap、ArrayList等),要定期清理其中不再使用的對象 。以 Java 為例,假設我們有一個靜態的HashMap用于緩存數據:
import java.util.HashMap;
import java.util.Map;
public class StaticCollectionExample {
private static Map<String, Object> cache = new HashMap<>();
public static void addToCache(String key, Object value) {
cache.put(key, value);
}
public static Object getFromCache(String key) {
return cache.get(key);
}
// 定期清理緩存的方法
public static void cleanCache() {
cache.clear();
}
public static void main(String[] args) {
addToCache("key1", new Object());
addToCache("key2", new Object());
// 假設某些操作后,不再需要緩存中的數據
cleanCache();
}
}
在這個例子中,cache是一個靜態的HashMap 。通過提供cleanCache方法,在適當的時候清空cache,可以避免由于不斷向cache中添加對象而不清理,導致對象無法被垃圾回收,從而引發內存泄漏的問題 。
7.5注意內部類和閉包
在處理非靜態內部類時,要注意其生命周期和對外部類的引用 。以 Java 為例,如果非靜態內部類的生命周期比外部類長,并且內部類持有外部類的引用,可能會導致外部類無法被垃圾回收 。可以使用靜態內部類,并且在靜態內部類需要持有外部類引用時,通過關聯外部類的弱引用去調用 。例如:
public class OuterClass {
private int[] data = new int[100];
// 靜態內部類
private static class StaticInnerClass {
private final WeakReference<OuterClass> outerRef;
public StaticInnerClass(OuterClass outer) {
this.outerRef = new WeakReference<>(outer);
}
public void doSomething() {
OuterClass outer = outerRef.get();
if (outer != null) {
// 操作外部類的成員
System.out.println(outer.data.length);
}
}
}
public static void main(String[] args) {
OuterClass outer = new OuterClass();
StaticInnerClass inner = new StaticInnerClass(outer);
// inner對象的生命周期可以比outer長,且不會阻止outer被回收
}
}
在 JavaScript 中,對于閉包,要確保在閉包執行完畢后,不再引用那些不再需要的外部變量 。例如:
function outerFunction() {
let largeData = new Array(1000000).fill(1); // 假設這是一個占用大量內存的數據
return function innerFunction() {
// 閉包中使用了largeData
console.log(largeData.length);
// 在閉包執行完畢后,將不再需要的外部變量設為null,以便垃圾回收
largeData = null;
};
}
let closure = outerFunction();
closure();
在上述代碼中,在閉包innerFunction執行完畢后,將不再需要的largeData設為null,這樣當垃圾回收器運行時,就可以回收largeData所占用的內存,避免了內存泄漏 。
7.6關閉連接
在進行數據庫操作、網絡通信等操作時,一定要及時關閉連接 。以 Java 的 JDBC 為例,在使用完數據庫連接后,應該在finally塊中關閉連接,確保無論是否發生異常,連接都能被正確關閉 。例如:
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;
public class DatabaseConnectionExample {
public static void main(String[] args) {
Connection conn = null;
try {
conn = DriverManager.getConnection("jdbc:mysql://localhost:3306/mydb", "username", "password");
// 進行數據庫操作
} catch (SQLException e) {
e.printStackTrace();
} finally {
if (conn != null) {
try {
conn.close();
} catch (SQLException e) {
e.printStackTrace();
}
}
}
}
}
在這段代碼中,finally塊中的conn.close()確保了數據庫連接在使用完畢后被關閉,避免了由于連接未關閉而導致的內存泄漏和資源浪費 。同樣,在進行網絡通信時,如使用Socket進行網絡連接,也應該在通信結束后及時關閉Socket連接 。例如:
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.Socket;
public class SocketExample {
public static void main(String[] args) {
Socket socket = null;
InputStream in = null;
OutputStream out = null;
try {
socket = new Socket("localhost", 8080);
in = socket.getInputStream();
out = socket.getOutputStream();
// 進行網絡通信操作
} catch (IOException e) {
e.printStackTrace();
} finally {
if (socket != null) {
try {
socket.close();
} catch (IOException e) {
e.printStackTrace();
}
}
if (in != null) {
try {
in.close();
} catch (IOException e) {
e.printStackTrace();
}
}
if (out != null) {
try {
out.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
}
在這個Socket示例中,finally塊中關閉了Socket、InputStream和OutputStream,保證了網絡連接和相關資源的正確釋放,避免了內存泄漏和資源占用問題 。