1. 程式人生 > 其它 >c++記憶體洩露的坑

c++記憶體洩露的坑

Modern C++之前,C++無疑是個更容易寫出坑的語言,無論從開發效率,和易坑性,讓很多新手望而卻步。比如記憶體洩露問題,就是經常會被寫出來的坑,本文就讓我們一起來看看,這些讓現在或者曾經的C++程式設計師淚流滿面的記憶體洩露場景吧。你是否有踩過?

1. 函式內或者類成員記憶體未釋放

這類問題可以稱之為out of scope的時候,並沒有釋放相應物件的堆上記憶體。有時候最簡單的場景,反而是最容易犯錯的。這個我想主要是因為經常寫,哪有不出錯。下面場景一看就知道了,當你在寫XXX_Class * pObj = new XXX_Class();這一行的時候,腦子裡面還在默唸記得要釋放pObj ,記得要釋放pObj

, 可能因為重要的事情要說三遍,而你只喊了兩遍,最終還是忘記了寫delete pObj;這樣去釋放物件。

voidMemoryLeakFunction()
{
XXX_Class*pObj=newXXX_Class();
pObj->DoSomething();
return;
}

下面這個場景,就是解構函式中並沒有釋放成員所指向的記憶體。這個我們就要注意了,一般當你構建一個類的時候,寫解構函式一定要切記釋放類成員關聯的資源。

classMemoryLeakClass
{
public:
MemoryLeakClass()
{
m_pObj=newXXX_ResourceClass;
}
voidDoSomething()
{
m_pObj->DoSomething();
}
~MemoryLeakClass()
{
;
}
private:
XXX_ResourceClass*m_pObj;
};

上述這兩種程式碼例子,是不是讓一個C++工程師如履薄冰,完全看自己的大腦在不在狀態。在boost或者C++ 11後,通過智慧指標去進行包裹這個原始指標,這是一種RAII的思想(可以參閱本文末尾的關聯閱讀), 在out of scope的時候,釋放自己所包裹的原始指標指向的資源。將上述例子用unique_ptr改寫一下。

voidMemoryLeakFunction()
{
std::unique_ptr<XXX_Class>pObj=make_unique<XXX_Class>();
pObj->DoSomething();
return;
}

2. delete []

大家知道C++

中這樣一個語句XXX_Class * pObj = new XXX_Class();中的new我們一般稱其為C++關鍵字(keyword), 就以這個語句為例做了兩個操作:

  1. 呼叫了operator new從堆上申請所需的空間

  2. 呼叫XXX_Class的建構函式

那麼當你呼叫delete pObj;的時候,道理同new,剛好相反:

  1. 呼叫了XXX_Class的解構函式

  2. 通過operator delete釋放了記憶體

一切似乎都沒有什麼問題,然後又一個坑來了。但如果申請的是一個數組呢,入下述例子:

classMemoryLeakClass
{
public:
MemoryLeakClass()
{
m_pStr=newchar[100];
}
voidDoSomething()
{
strcpy_s(m_pStr,100,"HelloMemoryLeak!");
std::cout<<m_pStr<<std::endl;
}
~MemoryLeakClass()
{
deletem_pStr;
}
private:
char*m_pStr;
};

voidMemoryLeakFunction()
{
constintiSize=5;
MemoryLeakClass*pArrayObjs=newMemoryLeakClass[iSize];
for(inti=;i<iSize;i++)
{
(pArrayObjs+i)->DoSomething();
}
deletepArrayObjs;
}

上述例子通過MemoryLeakClass* pArrayObjs = new MemoryLeakClass [iSize];申請了一個MemoryLeakClass陣列,那麼呼叫不匹配的delete pArrayObjs;, 會產生記憶體洩露。先看看下圖, 然後結合剛講的delete的行為:
那麼其實呼叫delete pArrayObjs;的時候,釋放了整個pArrayObjs的記憶體,但是隻呼叫了pArrayObjs[0]解構函式並釋放中的m_pStr指向的記憶體。pArrayObjs 1~4並沒有呼叫解構函式,從而導致其中的m_pStr指向的記憶體沒有釋放。所以我們要注意newdelete要匹配使用,當使用的new []申請的記憶體最好要用delete[]。那麼留一個問題給讀者, 上面程式碼delete m_pStr;會導致同樣的問題嗎?如果總是要讓我們自己去保證,newdelete的配對,顯然還是難以避免錯誤的發生的。這個時候也可以使用unique_ptr, 修改如下:

voidMemoryLeakFunction()
{
constintiSize=5;
std::unique_ptr<MemoryLeakClass[]>pArrayObjs=std::make_unique<MemoryLeakClass[]>(iSize);
for(inti=;i<iSize;i++)
{
(pArrayObjs.get()+i)->DoSomething();
}
}

3. delete (void*)

如果上一個章節已經有理解,那麼對於這個例子,就很容易明白了。正因為C++的靈活性,有時候會將一個物件指標轉換為void *,隱藏其型別。這種情況SDK比較常用,實際上返回的並不是SDK用的實際型別,而是一個沒有型別的地址,當然有時候我們會為其親切的取一個名字,比如叫做XXX_HANDLE。那麼繼續用上述為例MemoryLeakClass, SDK假設提供了下面三個介面:

  1. InitObj建立一個物件,並且返回一個PROGRAMER_HANDLE(即void *),對應用程式遮蔽其實際型別

  2. DoSomething提供了一個功能去做一些事情,輸入的引數,即為通過InitObj申請的物件

  3. 應用程式使用完畢後,一般需要釋放SDK申請的物件,提供了FreeObj

typedefvoid*PROGRAMER_HANDLE;

PROGRAMER_HANDLEInitObj()
{
MemoryLeakClass*pObj=newMemoryLeakClass();
return(PROGRAMER_HANDLE)pObj;
}

voidDoSomething(PROGRAMER_HANDLEpHandle)
{
((MemoryLeakClass*)pHandle)->DoSomething();
}

voidFreeObj(void*pObj)
{
deletepObj;
}

看到這裡,也許有讀者已經發現問題所在了。上述程式碼在呼叫FreeObj的時候,delete看到的是一個void *, 只會釋放物件所佔用的記憶體,但是並不會呼叫物件的解構函式,那麼物件內部的m_pStr所指向的記憶體並沒有被釋放,從而會導致記憶體洩露。修改也是自然比較簡單的:

voidFreeObj(void*pObj)
{
delete((MemoryLeakClass*)pObj);
}

那麼一般來說,最好由相對資深的程式設計師去進行SDK的開發,無論從設計和實現上面,都儘量避免了各種讓人淚流滿滿的坑。

4. Virtual destructor

現在大家來看看這個很容易犯錯的場景, 一個很常用的多型場景。那麼在呼叫delete pObj;會出現記憶體洩露嗎?

classFather
{
public:
virtualvoidDoSomething()
{
std::cout<<"FatherDoSomething()"<<std::endl;
}
};

classChild:publicFather
{
public:
Child()
{
std::cout<<"Child()"<<std::endl;
m_pStr=newchar[100];
}

~Child()
{
std::cout<<"~Child()"<<std::endl;
delete[]m_pStr;
}

voidDoSomething()
{
std::cout<<"ChildDoSomething()"<<std::endl;
}
protected:
char*m_pStr;
};

voidMemoryLeakVirualDestructor()
{
Father*pObj=newChild;
pObj->DoSomething();
deletepObj;
}

會的,因為Father沒有設定Virtual 解構函式,那麼在呼叫delete pObj;的時候會直接呼叫Father的解構函式,而不會呼叫Child的解構函式,這就導致了Child中的m_pStr所指向的記憶體,並沒有被釋放,從而導致了記憶體洩露。並不是絕對,當有這種使用場景的時候,最好是設定基類的解構函式為虛解構函式。修改如下:

classFather
{
public:
virtualvoidDoSomething()
{
std::cout<<"FatherDoSomething()"<<std::endl;
}
virtual~Father(){;}
};

classChild:publicFather
{
public:
Child()
{
std::cout<<"Child()"<<std::endl;
m_pStr=newchar[100];
}

virtual~Child()
{
std::cout<<"~Child()"<<std::endl;
delete[]m_pStr;
}

voidDoSomething()
{
std::cout<<"ChildDoSomething()"<<std::endl;
}
protected:
char*m_pStr;
};

5. 物件迴圈引用

看下面例子,既然為了防止記憶體洩露,於是使用了智慧指標shared_ptr;並且這個例子就是建立了一個雙向連結串列,為了簡單演示,只有兩個節點作為演示,建立了連結串列後,對連結串列進行遍歷。
那麼這個例子會導致記憶體洩露嗎?

structNode
{
Node(intiVal)
{
m_iVal=iVal;
}
~Node()
{
std::cout<<"~Node():"<<"NodeValue:"<<m_iVal<<std::endl;
}
voidPrintNode()
{
std::cout<<"NodeValue:"<<m_iVal<<std::endl;
}

std::shared_ptr<Node>m_pPreNode;
std::shared_ptr<Node>m_pNextNode;
intm_iVal;
};

voidMemoryLeakLoopReference()
{
std::shared_ptr<Node>pFirstNode=std::make_shared<Node>(100);
std::shared_ptr<Node>pSecondNode=std::make_shared<Node>(200);
pFirstNode->m_pNextNode=pSecondNode;
pSecondNode->m_pPreNode=pFirstNode;

//Iteratenodes
autopNode=pFirstNode;
while(pNode)
{
pNode->PrintNode();
pNode=pNode->m_pNextNode;
}
}

先來看看下圖,是連結串列建立完成後的示意圖。有點暈乎了,怎麼一個雙向連結串列畫的這麼複雜,黃色背景的均為智慧指標或者智慧指標的組成部分。其實根據雙向連結串列的簡單性和下圖的複雜性,可以想到,智慧指標的引入雖然提高了安全性,但是損失的是效能。所以往往安全性和效能是需要互相權衡的。我們繼續往下看,哪裡記憶體洩露了呢?

如果函式退出,那麼m_pFirstNodem_pNextNode作為棧上區域性變數,智慧指標本身呼叫自己的解構函式,給引用的物件引用計數減去1(shared_ptr本質採用引用計數,當引用計數為0的時候,才會刪除物件)。此時如下圖所示,可以看到智慧指標的引用計數仍然為1, 這也就導致了這兩個節點的實際記憶體,並沒有被釋放掉, 從而導致記憶體洩露。

你可以在函式返回前手動呼叫pFirstNode->m_pNextNode.reset();強制讓引用計數減去1, 打破這個迴圈引用。
還是之前那句話,如果通過手動去控制難免會出現遺漏的情況, C++提供了weak_ptr

structNode
{
Node(intiVal)
{
m_iVal=iVal;
}
~Node()
{
std::cout<<"~Node():"<<"NodeValue:"<<m_iVal<<std::endl;
}
voidPrintNode()
{
std::cout<<"NodeValue:"<<m_iVal<<std::endl;
}

std::shared_ptr<Node>m_pPreNode;
std::weak_ptr<Node>m_pNextNode;
intm_iVal;
};

voidMemoryLeakLoopRefference()
{
std::shared_ptr<Node>pFirstNode=std::make_shared<Node>(100);
std::shared_ptr<Node>pSecondNode=std::make_shared<Node>(200);
pFirstNode->m_pNextNode=pSecondNode;
pSecondNode->m_pPreNode=pFirstNode;

//Iteratenodes
autopNode=pFirstNode;
while(pNode)
{
pNode->PrintNode();
pNode=pNode->m_pNextNode.lock();
}
}

看看使用了weak_ptr之後的連結串列結構如下圖所示,weak_ptr只是對管理的物件做了一個弱引用,其並不會實際支配物件的釋放與否,物件在引用計數為0的時候就進行了釋放,而無需關心weak_ptrweak計數。注意shared_ptr本身也會對weak計數加1.
那麼在函式退出後,當pSecondNode呼叫解構函式的時候,物件的引用計數減一,引用計數為0,釋放第二個Node,在釋放第二個Node的過程中又呼叫了m_pPreNode的解構函式,第一個Node物件的引用計數減1,再加上pFirstNode解構函式對第一個Node物件的引用計數也減去1,那麼第一個Node物件的引用計數也為0,第一個Node物件也進行了釋放。

如果將上述程式碼改為雙向迴圈連結串列,去除那個迴圈遍歷Node的程式碼,那麼最後Node的記憶體會被釋放嗎?這個問題留給讀者。

6. 資源洩露

如果說些作文的話,這一章節,可能有點偏題了。本章要講的是廣義上的資源洩露,比如控制代碼或者fd洩露。這些也算是記憶體洩露的一點點擴充套件,寫作文的一點點延伸吧。
看看下述例子, 其在操作完檔案後,忘記呼叫CloseHandle(hFile);了,從而導致記憶體洩露。

voidMemroyLeakFileHandle()
{
HANDLEhFile=CreateFile(LR"(C:\test\doc.txt)",
GENERIC_READ,
FILE_SHARE_READ,
NULL,
OPEN_EXISTING,
FILE_ATTRIBUTE_NORMAL,
NULL);

if(INVALID_HANDLE_VALUE==hFile)
{
std::cerr<<"OpenFileerror!"<<std::endl;
return;
}

constintBUFFER_SIZE=100;
charpDataBuffer[BUFFER_SIZE];
DWORDdwBufferSize;
if(ReadFile(hFile,
pDataBuffer,
BUFFER_SIZE,
&dwBufferSize,
NULL))
{
std::cout<<dwBufferSize<<std::endl;
}
}

上述你可以用RAII機制去封裝hFile從而讓其在函式退出後,直接呼叫CloseHandle(hFile);。C++智慧指標提供了自定義deleter的功能,這就可以讓我們使用這個deleter的功能,改寫程式碼如下。不過本人更傾向於使用類似於golang defer的實現方式,讀者可以參閱本文相關閱讀部分。

voidMemroyLeakFileHandle()
{
HANDLEhFile=CreateFile(LR"(C:\test\doc.txt)",
GENERIC_READ,
FILE_SHARE_READ,
NULL,
OPEN_EXISTING,
FILE_ATTRIBUTE_NORMAL,
NULL);
std::unique_ptr<HANDLE,std::function<void(HANDLE*)>>phFile(
&hFile,
[](HANDLE*pHandle){
if(nullptr!=pHandle)
{
std::cout<<"CloseHandle"<<std::endl;
CloseHandle(*pHandle);
}
});

if(INVALID_HANDLE_VALUE==*phFile)
{
std::cerr<<"OpenFileerror!"<<std::endl;
return;
}

constintBUFFER_SIZE=100;
charpDataBuffer[BUFFER_SIZE];
DWORDdwBufferSize;
if(ReadFile(*phFile,
pDataBuffer,
BUFFER_SIZE,
&dwBufferSize,
NULL))
{
std::cout<<dwBufferSize<<std::endl;
}
}

- EOF -

有時候,不小心知道了一些事,才發現自己所在乎的事是那麼可笑。