C语言运行时库详解
網(wǎng)址:http://blog.csdn.net/jxth152913/archive/2010/07/02/5708369.aspx
運行時庫是程序在運行時所需要的庫文件,通常運行時庫是以LIB或DLL形式提供的。C運行時庫誕生于20世紀70年代,當時的程序世界還很單純,應用程序都是單線程的,多任務或多線程機制在此時還屬于新觀念。所以這個時期的C運行時庫都是單線程的。
隨著操作系統(tǒng)?多線程技術(shù)的發(fā)展?,最初的C運行時庫無法滿足程序的需求,出現(xiàn)了嚴重的問題?。C運行時庫使用了多個全局變量(例如errno)和靜態(tài)變量,這可能在多線程程序中引起沖突。假設兩個線程都同時設置errno,其結(jié)果是后設置的errno會將先前的覆蓋,用戶得不到正確的錯誤信息。
因此,Visual C++提供了兩種版本的C運行時庫。一個版本供單線程應用程序調(diào)用,另一個版本供多線程應用程序調(diào)用。多線程運行時庫與單線程運行時庫有兩個重大差別:
(1)類似errno的全局變量,每個線程單獨設置一個;
這樣從每個線程中可以獲取正確的錯誤信息。
(2)多線程庫中的數(shù)據(jù)結(jié)構(gòu)以同步機制加以保護。
這樣可以避免訪問時候的沖突。
Visual C++提供的多線程運行時庫又分為靜態(tài)鏈接庫和動態(tài)鏈接庫兩類,而每一類運行時庫又可再分為debug版和release版,因此Visual C++共提供了6個運行時庫。如下表:
C運行時庫 庫文件
Single thread(static link) ?libc.lib
Debug single thread(static link) ?libcd.lib
MultiThread(static link) ?libcmt.lib
Debug multiThread(static link) libcmtd.lib
MultiThread(dynamic link) msvert.lib
Debug multiThread(dynamic link) msvertd.lib?
2.C運行時庫的作用
C運行時庫除了給我們提供必要的庫函數(shù)調(diào)用(如memcpy、printf、malloc等)之外,它提供的另一個最重要的功能是為應用程序添加啟動函數(shù)。
C運行時庫啟動函數(shù)的主要功能為進行程序的初始化,對全局變量進行賦初值,加載用戶程序的入口函數(shù)。
不采用寬字符集的控制臺程序的入口點為mainCRTStartup(void)。下面我們以該函數(shù)為例來分析運行時庫究竟為我們添加了怎樣的入口程序。這個函數(shù)在crt0.c中被定義,下列的代碼經(jīng)過了筆者的整理和簡化:
從以上代碼可知,運行庫在調(diào)用用戶程序的main或WinMain函數(shù)之前,進行了一些初始化工作。初始化完成后,接著才調(diào)用了我們編寫的main或WinMain函數(shù)。只有這樣,我們的C語言運行時庫和應用程序才能正常地工作起來。
除了crt0.c外,C運行時庫中還包含wcrt0.c、 wincrt0.c、wwincrt0.c三個文件用來提供初始化函數(shù)。wcrt0.c是crt0.c的寬字符集版,wincrt0.c中包含 windows應用程序的入口函數(shù),而wwincrt0.c則是wincrt0.c的寬字符集版。
Visual C++的運行時庫源代碼缺省情況下不被安裝。如果您想查看其源代碼,則需要重裝Visual C++,并在重裝在時選中安裝運行庫源代碼選項。
3.各種C運行時庫的區(qū)別
(1)靜態(tài)鏈接的單線程庫
靜態(tài)鏈接的單線程庫只能用于單線程的應用程序,C運行時庫的目標代碼最終被編譯在應用程序的二進制文件中。通過/ML編譯選項可以設置Visual C++使用靜態(tài)鏈接的單線程庫。
(2)靜態(tài)鏈接的多線程庫
靜態(tài)鏈接的多線程庫的目標代碼也最終被編譯在應用程序的二進制文件中,但是它可以在多線程程序中使用。通過/MD編譯選項可以設置Visual C++使用靜態(tài)鏈接的單線程庫。
(3)動態(tài)鏈接的運行時庫
動態(tài)鏈接的運行時庫將所有的C庫函數(shù)保存在一個單獨的動態(tài)鏈接庫MSVCRTxx.DLL中,MSVCRTxx.DLL處理了多線程問題。使用/ML編譯選項可以設置Visual C++使用動態(tài)鏈接的運行時庫。
/MDd、 /MLd 或 /MTd 選項使用 Debug runtime library(調(diào)試版本的運行時刻函數(shù)庫),與/MD、 /ML 或 /MT分別對應。Debug版本的 Runtime Library 包含了調(diào)試信息,并采用了一些保護機制以幫助發(fā)現(xiàn)錯誤,加強了對錯誤的檢測,因此在運行性能方面比不上Release版本。
下面看一個未正確使用C運行時庫的控制臺程序:
我們在"rebuild all"的時候發(fā)生了link錯誤:
發(fā) 生錯誤的原因在于Visual C++對控制臺程序默認使用單線程的靜態(tài)鏈接庫,而MFC中的CFile類已暗藏了多線程。我們只需要在Visual C++6.0中依次點選Project->Settings->C/C++菜單和選項,在Project Options里修改編譯選項即可。
C 運行時庫是微軟?對 標準C庫函數(shù)的實現(xiàn),因為當時考慮到許多程序都使用C編寫,而這些程序都要使用標準的C庫,按照以前的方式每一個程序最終都要拷貝一份標準庫的實現(xiàn)到程序 中,這樣同一時刻內(nèi)存中可能有許多份標準庫的代碼(一個程序一份),所以微軟出于效率的考慮把 ?標準C庫做為動態(tài)鏈接來實現(xiàn),這樣多個程序使用C標準庫時內(nèi)存中就只有一份拷貝了。(對每一個程序來說,它相當于自己擁有一份,? ?對于標準庫中的全局變量也做了處理的,不會因為共享同一份代碼而出現(xiàn)沖突)。? ?這也算是對C標準庫的一個擴展吧,至于說靜態(tài)鏈接的時候仍然把它叫做運行時庫那只能說這是個習慣問題而已了。??
?
?運行時庫和普通的? ?dll? ?一樣,如果有程序用到了才會加載,沒有程序使用的時候不會駐留內(nèi)存的。話雖如此,但有多少系統(tǒng)的東西說不定也是用C寫的,這些東西的存在就使C運行時庫存在于內(nèi)存中了
從字面上看,運行庫是程序在運行時所需要的庫文件。通常運行庫是以DLL形式提供的。 Delphi和C++ Builder的運行庫為.bpl文件,實際還是一個DLL。運行庫中一般包括編程時常用的函數(shù),如字符串操作、文件操作、界面等內(nèi)容。不同的語言所支持 的函數(shù)通常是不同的,所以使用的庫也是完全不同的,這就是為什么有VB運行庫、C運行庫、Delphi運行庫之分的原因。即使都是C++語言,也可能因為 提供的函數(shù)不同,而使用不同的庫。如VC++使用的運行庫和C++ Builder就完全不同。
如果不使用運行庫,每個程序中都會包括很多重復的代碼,而使用運行庫,可以大大縮小編譯后的程序的大小。但另一方面,由于使用了運行庫,所以在分發(fā)程序時就必須帶有這些庫,比較麻煩。如果在操作系統(tǒng)中找不到相應的運行庫程序就無法運行。為了解決這個矛盾,Windows?總 是會帶上它自己開發(fā)的軟件的最新的運行庫。象Windows 2000以后的版本都包括Visual Basic 5.0/6.0的庫。Internet Explorer總是帶有最新的Visual C++ 6.0的庫。Windows XP帶有Microsoft .NET 1.0(用于VB.NET和C#)的庫。Visual C++、Delphi和C++ Builder允許用戶選擇所編譯得到的程序是否依賴于運行庫。而VB、FoxPro、PowerBuilder、LabWindows/CVI和 Matlab就不允許用戶進行這種選擇,必須依賴于運行庫。
2 內(nèi)存泄漏
2.1 C++中動態(tài)內(nèi)存分配引發(fā)問題的解決方案
假設我們要開發(fā)一個String類,它可以方便地處理字符串數(shù)據(jù)。我們可以在類中聲明一個數(shù)組,考慮到有時候字符串極長,我們可以把數(shù)組大小設為200, 但一般的情況下又不需要這么多的空間,這樣是浪費了內(nèi)存。對了,我們可以使用new操作符,這樣是十分靈活的,但在類中就會出現(xiàn)許多意想不到的問題,本文 就是針對這一現(xiàn)象而寫的。現(xiàn)在,我們先來開發(fā)一個String類,但它是一個不完善的類。的確,我們要刻意地使它出現(xiàn)各種各樣的問題,這樣才好對癥下藥。 好了,我們開始吧!
運行結(jié)果:
大家網(wǎng)
請按任意鍵繼續(xù). . .
大家可以看到,以上程序十分正確,而且也是十分有用的。可是,我們不能被表面現(xiàn)象所迷惑!下面,請大家用test_String.cpp文件替換test_right.cpp文件進行編譯,看看結(jié)果。有的編譯器可能就是根本不能進行編譯!
運行結(jié)果:
下面分別輸入三個范例:
第一個范例。
第二個范例。
第三個范例。
第一個范例。
這個字符串將被刪除:第一個范例。
使用正確的函數(shù):
第二個范例。
第二個范例。
使用錯誤的函數(shù):
第二個范例。
這個字符串將被刪除:第二個范例。
這個字符串將被刪除:?=
?=
String2: 第三個范例。
String3: 第四個范例。
下面,程序結(jié)束,析構(gòu)函數(shù)將被調(diào)用。
這個字符串將被刪除:第四個范例。
這個字符串將被刪除:第三個范例。
這個字符串將被刪除:?=
這個字符串將被刪除:x =
這個字符串將被刪除:?=
這個字符串將被刪除:
現(xiàn)在,請大家自己試試運行結(jié)果,或許會更加慘不忍睹呢!下面,我為大家一一分析原因。
首先,大家要知道,C++類有以下這些極為重要的函數(shù):
一:復制構(gòu)造函數(shù)。
二:賦值函數(shù)。
我們先來講復制構(gòu)造函數(shù)。什么是復制構(gòu)造函數(shù)呢?比如,我們可以寫下這樣的代碼:String test1(test2);這是進行初始化。我們知道,初始化對象要用構(gòu)造函數(shù)。可這兒呢?按理說,應該有聲明為這樣的構(gòu)造函 數(shù):String(const String &);可是,我們并沒有定義這個構(gòu)造函數(shù)呀?答案是,C++提供了默認的復制構(gòu)造函數(shù),問題也就出在這兒。
(1):什么時候會調(diào)用復制構(gòu)造函數(shù)呢?(以String類為例。)
在我們提供這樣的代碼:String test1(test2)時,它會被調(diào)用;當函數(shù)的參數(shù)列表為按值傳遞,也就是沒有用引用和指針作為類型時,如:void show_String(const String),它會被調(diào)用。其實,還有一些情況,但在這兒就不列舉了。
(2):它是什么樣的函數(shù)。
它的作用就是把兩個類進行復制。拿String類為例,C++提供的默認復制構(gòu)造函數(shù)是這樣的:
在 平時,這樣并不會有任何的問題出現(xiàn),但我們用了new操作符,涉及到了動態(tài)內(nèi)存分配,我們就不得不談談淺復制和深復制了。以上的函數(shù)就是實行的淺復制,它 只是復制了指針,而并沒有復制指針指向的數(shù)據(jù),可謂一點兒用也沒有。打個比方吧!就像一個朋友讓你把一個程序通過網(wǎng)絡發(fā)給他,而你大大咧咧地把快捷方式發(fā) 給了他,有什么用處呢?我們來具體談談:
假如,A對象中存儲了這樣的字符串:“C++”。它的地址為2000。現(xiàn)在,我們把A對象賦給B對象:String B=A。現(xiàn)在,A和B對象的str指針均指向2000地址。看似可以使用,但如果B對象的析構(gòu)函數(shù)被調(diào)用時,則地址2000處的字符串“C++”已經(jīng)被從 內(nèi)存中抹去,而A對象仍然指向地址2000。這時,如果我們寫下這樣的代碼:cout<<A<<endl;或是等待程序結(jié)束,A 對象的析構(gòu)函數(shù)被調(diào)用時,A對象的數(shù)據(jù)能否顯示出來呢?只會是亂碼。而且,程序還會這樣做:連續(xù)對地址2000處使用兩次delete操作符,這樣的后果 是十分嚴重的!
本例中,有這樣的代碼:
假 設test1中str指向的地址為2000,而String中str指針同樣指向地址2000,我們刪除了2000處的數(shù)據(jù),而test1對象呢?已經(jīng)被 破壞了。大家從運行結(jié)果上可以看到,我們使用cout<<test1時,一點反應也沒有。而在test1的析構(gòu)函數(shù)被調(diào)用時,顯示是這樣: “這個字符串將被刪除:”。
再看看這段代碼:
show_String 函數(shù)的參數(shù)列表void show_String(const String a)是按值傳遞的,所以,我們相當于執(zhí)行了這樣的代碼:String a=test2;函數(shù)執(zhí)行完畢,由于生存周期的緣故,對象a被析構(gòu)函數(shù)刪除,我們馬上就可以看到錯誤的顯示結(jié)果了:這個字符串將被刪除:?=。當 然,test2也被破壞了。解決的辦法很簡單,當然是手工定義一個復制構(gòu)造函數(shù)嘍!人力可以勝天!
我 們執(zhí)行的是深復制。這個函數(shù)的功能是這樣的:假設對象A中的str指針指向地址2000,內(nèi)容為“I am a C++ Boy!”。我們執(zhí)行代碼String B=A時,我們先開辟出一塊內(nèi)存,假設為3000。我們用strcpy函數(shù)將地址2000的內(nèi)容拷貝到地址3000中,再將對象B的str指針指向地址 3000。這樣,就互不干擾了。
大家把這個函數(shù)加入程序中,問題就解決了大半,但還沒有完全解決,問題在賦值函數(shù)上。我們的程序中有這樣的段代碼:
經(jīng) 過我前面的講解,大家應該也會對這段代碼進行尋根摸底:憑什么可以這樣做:String3=test4???原因是,C++為了用戶的方便,提供的這樣的 一個操作符重載函數(shù):operator=。所以,我們可以這樣做。大家應該猜得到,它同樣是執(zhí)行了淺復制,出了同樣的毛病。比如,執(zhí)行了這段代碼后,析構(gòu) 函數(shù)開始大展神威^_^。由于這些變量是后進先出的,所以最后的String3變量先被刪除:這個字符串將被刪除:第四個范例。很正常。最后,刪除到 test4的時候,問題來了:這個字符串將被刪除:?=。原因我不用贅述了,只是這個賦值函數(shù)怎么寫,還有一點兒學問呢!大家請看:
平時,我們可以寫這樣的代碼:x=y=z。(均為整型變量。)而在類對象中,我們同樣要這樣,因為這很方便。而對象A=B=C就是 A.operator=(B.operator=(c))。而這個operator=函數(shù)的參數(shù)列表應該是:const String& a,所以,大家不難推出,要實現(xiàn)這樣的功能,返回值也要是String&,這樣才能實現(xiàn)A=B=C。我們先來寫寫看:
是 不是這樣就行了呢?我們假如寫出了這種代碼:A=A,那么大家看看,豈不是把A對象的數(shù)據(jù)給刪除了嗎?這樣可謂引發(fā)一系列的錯誤。所以,我們還要檢查是否 為自身賦值。只比較兩對象的數(shù)據(jù)是不行了,因為兩個對象的數(shù)據(jù)很有可能相同。我們應該比較地址。以下是完好的賦值函數(shù):
把這些代碼加入程序,問題就完全解決,下面是運行結(jié)果:
下面分別輸入三個范例:
第一個范例
第二個范例
第三個范例
第一個范例
這個字符串將被刪除:第一個范例。
第一個范例
使用正確的函數(shù):
第二個范例。
第二個范例。
使用錯誤的函數(shù):
第二個范例。
這個字符串將被刪除:第二個范例。
第二個范例。
String2: 第三個范例。
String3: 第四個范例。
下面,程序結(jié)束,析構(gòu)函數(shù)將被調(diào)用。
這個字符串將被刪除:第四個范例。
這個字符串將被刪除:第三個范例。
這個字符串將被刪除:第四個范例。
這個字符串將被刪除:第三個范例。
這個字符串將被刪除:第二個范例。
這個字符串將被刪除:第一個范例。
2.2 如何對付內(nèi)存泄漏?
寫出那些不會導致任何內(nèi)存泄漏的代碼。很明顯,當你的代碼中到處充滿了new 操作、delete操作和指針運算的話,你將會在某個地方搞暈了頭,導致內(nèi)存泄漏,指針引用錯誤,以及諸如此類的問題。這和你如何小心地對待內(nèi)存分配工作 其實完全沒有關(guān)系:代碼的復雜性最終總是會超過你能夠付出的時間和努力。于是隨后產(chǎn)生了一些成功的技巧,它們依賴于將內(nèi)存分配(allocations) 與重新分配(deallocation)工作隱藏在易于管理的類型之后。標準容器(standard containers)是一個優(yōu)秀的例子。它們不是通過你而是自己為元素管理內(nèi)存,從而避免了產(chǎn)生糟糕的結(jié)果。想象一下,沒有string和vector 的幫助,寫出這個:
你有多少機會在第一次就得到正確的結(jié)果?你又怎么知道你沒有導致內(nèi)存泄漏呢?
注意,沒有出現(xiàn)顯式的內(nèi)存管理,宏,造型,溢出檢查,顯式的長度限制,以及指針。通過使用函數(shù)對象和標準算法(standard algorithm),我可以避免使用指針——例如使用迭代子(iterator),不過對于一個這么小的程序來說有點小題大作了。
這些技巧并不完美,要系統(tǒng)化地使用它們也并不總是那么容易。但是,應用它們產(chǎn)生了驚人的差異,而且通過減少顯式的內(nèi)存分配與重新分配的次數(shù),你甚至可 以使余下的例子更加容易被跟蹤。早在1981年,我就指出,通過將我必須顯式地跟蹤的對象的數(shù)量從幾萬個減少到幾打,為了使程序正確運行而付出的努力從可 怕的苦工,變成了應付一些可管理的對象,甚至更加簡單了。
如果你的程序還沒有包含將顯式內(nèi)存管理減少到最小限度的庫,那么要讓你程序完成和正確運行的話,最快的途徑也許就是先建立一個這樣的庫。
模板和標準庫實現(xiàn)了容器、資源句柄以及諸如此類的東西,更早的使用甚至在多年以前。異常的使用使之更加完善。
如果你實在不能將內(nèi)存分配/重新分配的操作隱藏到你需要的對象中時,你可以使用資源句柄(resource handle),以將內(nèi)存泄漏的可能性降至最低。這里有個例子:我需要通過一個函數(shù),在空閑內(nèi)存中建立一個對象并返回它。這時候可能忘記釋放這個對象。畢 竟,我們不能說,僅僅關(guān)注當這個指針要被釋放的時候,誰將負責去做。使用資源句柄,這里用了標準庫中的auto_ptr,使需要為之負責的地方變得明確 了。
在更一般的意義上考慮資源,而不僅僅是內(nèi)存。
如果在你的環(huán)境中不能系統(tǒng)地應用這些技巧(例如,你必須使用別的地方的代碼,或者你的程序的另一部分簡直是原始人類(譯注:原文是 Neanderthals,尼安德特人,舊石器時代廣泛分布在歐洲的猿人)寫的,如此等等),那么注意使用一個內(nèi)存泄漏檢測器作為開發(fā)過程的一部分,或者 插入一個垃圾收集器(garbage collector)。
2.3淺談C/C++內(nèi)存泄漏及其檢測工具
對于一個c/c++程序員來說,內(nèi)存泄漏是一個常見的也是令人頭疼的問題。已經(jīng)有許多技術(shù)被研究出來以應對這個問題,比如Smart Pointer,Garbage Collection等。Smart Pointer技術(shù)比較成熟,STL中已經(jīng)包含支持Smart Pointer的class,但是它的使用似乎并不廣泛,而且它也不能解決所有的問題;Garbage Collection技術(shù)在Java中已經(jīng)比較成熟,但是在c/c++領(lǐng)域的發(fā)展并不順暢,雖然很早就有人思考在C++中也加入GC的支持。現(xiàn)實世界就是 這樣的,作為一個c/c++程序員,內(nèi)存泄漏是你心中永遠的痛。不過好在現(xiàn)在有許多工具能夠幫助我們驗證內(nèi)存泄漏的存在,找出發(fā)生問題的代碼。
2.3.1 內(nèi)存泄漏的定義
一般我們常說的內(nèi)存泄漏是指堆內(nèi)存的泄漏。堆內(nèi)存是指程序從堆中分配的,大小任意的(內(nèi)存塊的大小可以在程序運行期決定),使用完后必須顯示釋放的內(nèi)存。 應用程序一般使用malloc,realloc,new等函數(shù)從堆中分配到一塊內(nèi)存,使用完后,程序必須負責相應的調(diào)用free或delete釋放該內(nèi)存 塊,否則,這塊內(nèi)存就不能被再次使用,我們就說這塊內(nèi)存泄漏了。以下這段小程序演示了堆內(nèi)存發(fā)生泄漏的情形:
當函數(shù)GetStringFrom()返回零的時候,指針p指向的內(nèi)存就不會被釋放。這是一種常見的發(fā)生內(nèi)存泄漏的情形。程序在入口處分配內(nèi)存,在出口處釋放內(nèi)存,但是c函數(shù)可以在任何地方退出,所以一旦有某個出口處沒有釋放應該釋放的內(nèi)存,就會發(fā)生內(nèi)存泄漏。
廣義的說,內(nèi)存泄漏不僅僅包含堆內(nèi)存的泄漏,還包含系統(tǒng)資源的泄漏(resource leak),比如核心態(tài)HANDLE,GDI Object,SOCKET, Interface等,從根本上說這些由操作系統(tǒng)分配的對象也消耗內(nèi)存,如果這些對象發(fā)生泄漏最終也會導致內(nèi)存的泄漏。而且,某些對象消耗的是核心態(tài)內(nèi) 存,這些對象嚴重泄漏時會導致整個操作系統(tǒng)不穩(wěn)定。所以相比之下,系統(tǒng)資源的泄漏比堆內(nèi)存的泄漏更為嚴重。
GDI Object的泄漏是一種常見的資源泄漏:
當 函數(shù)Something()返回非零的時候,程序在退出前沒有把pOldBmp選回pDC中,這會導致pOldBmp指向的HBITMAP對象發(fā)生泄漏。 這個程序如果長時間的運行,可能會導致整個系統(tǒng)花屏。這種問題在Win9x下比較容易暴露出來,因為Win9x的GDI堆比Win2k或NT 的要小很多。
2.3.2 內(nèi)存泄漏的發(fā)生方式
以發(fā)生的方式來分類,內(nèi)存泄漏可以分為4類:
1. 常發(fā)性內(nèi)存泄漏。發(fā)生內(nèi)存泄漏的代碼會被多次執(zhí)行到,每次被執(zhí)行的時候都會導致一塊內(nèi)存泄漏。比如例二,如果Something()函數(shù)一直返回True,那么pOldBmp指向的HBITMAP對象總是發(fā)生泄漏。
2. 偶發(fā)性內(nèi)存泄漏。發(fā)生內(nèi)存泄漏的代碼只有在某些特定環(huán)境或操作過程下才會發(fā)生。比如例二,如果Something()函數(shù)只有在特定環(huán)境下才返回 True,那么pOldBmp指向的HBITMAP對象并不總是發(fā)生泄漏。常發(fā)性和偶發(fā)性是相對的。對于特定的環(huán)境,偶發(fā)性的也許就變成了常發(fā)性的。所以 測試環(huán)境和測試方法對檢測內(nèi)存泄漏至關(guān)重要。
3. 一次性內(nèi)存泄漏。發(fā)生內(nèi)存泄漏的代碼只會被執(zhí)行一次,或者由于算法上的缺陷,導致總會有一塊僅且一塊內(nèi)存發(fā)生泄漏。比如,在類的構(gòu)造函數(shù)中分配內(nèi)存,在析構(gòu)函數(shù)中卻沒有釋放該內(nèi)存,但是因為這個類是一個Singleton,所以內(nèi)存泄漏只會發(fā)生一次。另一個例子:
如果程序在結(jié)束的時候沒有釋放g_lpszFileName指向的字符串,那么,即使多次調(diào)用SetFileName(),總會有一塊內(nèi)存,而且僅有一塊內(nèi)存發(fā)生泄漏。
4. 隱式內(nèi)存泄漏。程序在運行過程中不停的分配內(nèi)存,但是直到結(jié)束的時候才釋放內(nèi)存。嚴格的說這里并沒有發(fā)生內(nèi)存泄漏,因為最終程序釋放了所有申請的內(nèi)存。但 是對于一個服務器程序,需要運行幾天,幾周甚至幾個月,不及時釋放內(nèi)存也可能導致最終耗盡系統(tǒng)的所有內(nèi)存。所以,我們稱這類內(nèi)存泄漏為隱式內(nèi)存泄漏。舉一 個例子:
假 設在Client從Server端斷開后,Server并沒有呼叫OnClientDisconnected()函數(shù),那么代表那次連接的 Connection對象就不會被及時的刪除(在Server程序退出的時候,所有Connection對象會在ConnectionManager的析 構(gòu)函數(shù)里被刪除)。當不斷的有連接建立、斷開時隱式內(nèi)存泄漏就發(fā)生了。
從用戶使用程序的角度來看,內(nèi)存泄漏本身不會產(chǎn)生什么危害,作為一般的用戶,根本感覺不到內(nèi)存泄漏的存在。真正有危害的是內(nèi)存泄漏的堆積,這會最終消耗盡 系統(tǒng)所有的內(nèi)存。從這個角度來說,一次性內(nèi)存泄漏并沒有什么危害,因為它不會堆積,而隱式內(nèi)存泄漏危害性則非常大,因為較之于常發(fā)性和偶發(fā)性內(nèi)存泄漏它更 難被檢測到。
2.3.3 檢測內(nèi)存泄漏
檢測內(nèi)存泄漏的關(guān)鍵是要能截獲住對分配內(nèi)存和釋放內(nèi)存的函數(shù)的調(diào)用。截獲住這兩個函數(shù),我們就能跟蹤每一塊內(nèi)存的生命周期,比如,每當成功的分配一塊 內(nèi)存后,就把它的指針加入一個全局的list中;每當釋放一塊內(nèi)存,再把它的指針從list中刪除。這樣,當程序結(jié)束的時候,list中剩余的指針就是指 向那些沒有被釋放的內(nèi)存。這里只是簡單的描述了檢測內(nèi)存泄漏的基本原理,詳細的算法可以參見Steve Maguire的<<Writing Solid Code>>。
如果要檢測堆內(nèi)存的泄漏,那么需要截獲住malloc/realloc/free和new/delete就可以了(其實new/delete最終也是 用malloc/free的,所以只要截獲前面一組即可)。對于其他的泄漏,可以采用類似的方法,截獲住相應的分配和釋放函數(shù)。比如,要檢測 BSTR的泄漏,就需要截獲SysAllocString/SysFreeString;要檢測HMENU的泄漏,就需要截獲CreateMenu/ DestroyMenu。(有的資源的分配函數(shù)有多個,釋放函數(shù)只有一個,比如,SysAllocStringLen也可以用來分配BSTR,這時就需要 截獲多個分配函數(shù))
在Windows平臺下,檢測內(nèi)存泄漏的工具常用的一般有三種,MS C-Runtime Library內(nèi)建的檢測功能;外掛式的檢測工具,諸如,Purify,BoundsChecker等;利用Windows NT自帶的Performance Monitor。這三種工具各有優(yōu)缺點,MS C-Runtime Library雖然功能上較之外掛式的工具要弱,但是它是免費的;Performance Monitor雖然無法標示出發(fā)生問題的代碼,但是它能檢測出隱式的內(nèi)存泄漏的存在,這是其他兩類工具無能為力的地方。
以下我們詳細討論這三種檢測工具:
2.3.3.1 VC下內(nèi)存泄漏的檢測方法
用MFC開發(fā)的應用程序,在DEBUG版模式下編譯后,都會自動加入內(nèi)存泄漏的檢測代碼。在程序結(jié)束后,如果發(fā)生了內(nèi)存泄漏,在Debug窗口中會顯示出所有發(fā)生泄漏的內(nèi)存塊的信息,以下兩行顯示了一塊被泄漏的內(nèi)存塊的信息:
E:"TestMemLeak"TestDlg.cpp(70) : {59} normal block at 0x00881710, 200 bytes long.
Data: <abcdefghijklmnop> 61 62 63 64 65 66 67 68 69 6A 6B 6C 6D 6E 6F 70
第一行顯示該內(nèi)存塊由TestDlg.cpp文件,第70行代碼分配,地址在0x00881710,大小為200字節(jié),{59}是指調(diào)用內(nèi)存分配函數(shù) 的Request Order,關(guān)于它的詳細信息可以參見MSDN中_CrtSetBreakAlloc()的幫助。第二行顯示該內(nèi)存塊前16個字節(jié)的內(nèi)容,尖括號內(nèi)是以 ASCII方式顯示,接著的是以16進制方式顯示。
一般大家都誤以為這些內(nèi)存泄漏的檢測功能是由MFC提供的,其實不然。MFC只是封裝和利用了MS C-Runtime Library的Debug Function。非MFC程序也可以利用MS C-Runtime Library的Debug Function加入內(nèi)存泄漏的檢測功能。MS C-Runtime Library在實現(xiàn)malloc/free,strdup等函數(shù)時已經(jīng)內(nèi)建了內(nèi)存泄漏的檢測功能。
注意觀察一下由MFC Application Wizard生成的項目,在每一個cpp文件的頭部都有這樣一段宏定義:
有了這樣的定義,在編譯DEBUG版時,出現(xiàn)在這個cpp文件中的所有new都被替換成DEBUG_NEW了。那么DEBUG_NEW是什么呢?DEBUG_NEW也是一個宏,以下摘自afx.h,1632行
所以如果有這樣一行代碼:
經(jīng)過宏替換就變成了:
根據(jù)C++的標準,對于以上的new的使用方法,編譯器會去找這樣定義的operator new:
我們在afxmem.cpp 63行找到了一個這樣的operator new 的實現(xiàn)
第 二個operator new函數(shù)比較長,為了簡單期間,我只摘錄了部分。很顯然最后的內(nèi)存分配還是通過_malloc_dbg函數(shù)實現(xiàn)的,這個函數(shù)屬于MS C-Runtime Library 的Debug Function。這個函數(shù)不但要求傳入內(nèi)存的大小,另外還有文件名和行號兩個參數(shù)。文件名和行號就是用來記錄此次分配是由哪一段代碼造成的。如果這塊內(nèi) 存在程序結(jié)束之前沒有被釋放,那么這些信息就會輸出到Debug窗口里。
這里順便提一下THIS_FILE,__FILE和__LINE__。__FILE__和__LINE__都是編譯器定義的宏。當碰到 __FILE__時,編譯器會把__FILE__替換成一個字符串,這個字符串就是當前在編譯的文件的路徑名。當碰到__LINE__時,編譯器會把 __LINE__替換成一個數(shù)字,這個數(shù)字就是當前這行代碼的行號。在DEBUG_NEW的定義中沒有直接使用__FILE__,而是用了 THIS_FILE,其目的是為了減小目標文件的大小。假設在某個cpp文件中有100處使用了new,如果直接使用__FILE__,那編譯器會產(chǎn)生 100個常量字符串,這100個字符串都是飧?/SPAN>cpp文件的路徑名,顯然十分冗余。如果使用THIS_FILE,編譯器只會產(chǎn)生一個常 量字符串,那100處new的調(diào)用使用的都是指向常量字符串的指針。
再次觀察一下由MFC Application Wizard生成的項目,我們會發(fā)現(xiàn)在cpp文件中只對new做了映射,如果你在程序中直接使用malloc函數(shù)分配內(nèi)存,調(diào)用malloc的文件名和行 號是不會被記錄下來的。如果這塊內(nèi)存發(fā)生了泄漏,MS C-Runtime Library仍然能檢測到,但是當輸出這塊內(nèi)存塊的信息,不會包含分配它的的文件名和行號。
要在非MFC程序中打開內(nèi)存泄漏的檢測功能非常容易,你只要在程序的入口處加入以下幾行代碼:
這樣,在程序結(jié)束的時候,也就是winmain,main或dllmain函數(shù)返回之后,如果還有內(nèi)存塊沒有釋放,它們的信息會被打印到Debug窗口里。
如果你試著創(chuàng)建了一個非MFC應用程序,而且在程序的入口處加入了以上代碼,并且故意在程序中不釋放某些內(nèi)存塊,你會在Debug窗口里看到以下的信息:
{47} normal block at 0x00C91C90, 200 bytes long.
Data: < > 00 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F
內(nèi)存泄漏的確檢測到了,但是和上面MFC程序的例子相比,缺少了文件名和行號。對于一個比較大的程序,沒有這些信息,解決問題將變得十分困難。
為了能夠知道泄漏的內(nèi)存塊是在哪里分配的,你需要實現(xiàn)類似MFC的映射功能,把new,maolloc等函數(shù)映射到_malloc_dbg函數(shù)上。這里我不再贅述,你可以參考MFC的源代碼。
由于Debug Function實現(xiàn)在MS C-RuntimeLibrary中,所以它只能檢測到堆內(nèi)存的泄漏,而且只限于malloc,realloc或strdup等分配的內(nèi)存,而那些系統(tǒng)資 源,比如HANDLE,GDI Object,或是不通過C-Runtime Library分配的內(nèi)存,比如VARIANT,BSTR的泄漏,它是無法檢測到的,這是這種檢測法的一個重大的局限性。另外,為了能記錄內(nèi)存塊是在哪里 分配的,源代碼必須相應的配合,這在調(diào)試一些老的程序非常麻煩,畢竟修改源代碼不是一件省心的事,這是這種檢測法的另一個局限性。
對于開發(fā)一個大型的程序,MS C-Runtime Library提供的檢測功能是遠遠不夠的。接下來我們就看看外掛式的檢測工具。我用的比較多的是BoundsChecker,一則因為它的功能比較全 面,更重要的是它的穩(wěn)定性。這類工具如果不穩(wěn)定,反而會忙里添亂。到底是出自鼎鼎大名的NuMega,我用下來基本上沒有什么大問題。
2.3.3.2 使用BoundsChecker檢測內(nèi)存泄漏
BoundsChecker采用一種被稱為 Code Injection的技術(shù),來截獲對分配內(nèi)存和釋放內(nèi)存的函數(shù)的調(diào)用。簡單地說,當你的程序開始運行時,BoundsChecker的DLL被自動載入進 程的地址空間(這可以通過system-level的Hook實現(xiàn)),然后它會修改進程中對內(nèi)存分配和釋放的函數(shù)調(diào)用,讓這些調(diào)用首先轉(zhuǎn)入它的代碼,然后 再執(zhí)行原來的代碼。BoundsChecker在做這些動作的時,無須修改被調(diào)試程序的源代碼或工程配置文件,這使得使用它非常的簡便、直接。
這里我們以malloc函數(shù)為例,截獲其他的函數(shù)方法與此類似。
需要被截獲的函數(shù)可能在DLL中,也可能在程序的代碼里。比如,如果靜態(tài)連結(jié)C-Runtime Library,那么malloc函數(shù)的代碼會被連結(jié)到程序里。為了截獲住對這類函數(shù)的調(diào)用,BoundsChecker會動態(tài)修改這些函數(shù)的指令。
以下兩段匯編代碼,一段沒有BoundsChecker介入,另一段則有BoundsChecker的介入:
126: _CRTIMP void * __cdecl malloc (
127: size_t nSize
128: )
129: {
00403C10 push ebp
00403C11 mov ebp,esp
130: return _nh_malloc_dbg(nSize, _newmode, _NORMAL_BLOCK, NULL, 0);
00403C13 push 0
00403C15 push 0
00403C17 push 1
00403C19 mov eax,[__newmode (0042376c)]
00403C1E push eax
00403C1F mov ecx,dword ptr [nSize]
00403C22 push ecx
00403C23 call _nh_malloc_dbg (00403c80)
00403C28 add esp,14h
131: }
以下這一段代碼有BoundsChecker介入:
126: _CRTIMP void * __cdecl malloc (
127: size_t nSize
128: )
129: {
00403C10 jmp 01F41EC8
00403C15 push 0
00403C17 push 1
00403C19 mov eax,[__newmode (0042376c)]
00403C1E push eax
00403C1F mov ecx,dword ptr [nSize]
00403C22 push ecx
00403C23 call _nh_malloc_dbg (00403c80)
00403C28 add esp,14h
131: }
當BoundsChecker介入后,函數(shù)malloc的前三條匯編指令被替換成一條jmp指令,原來的三條指令被搬到地址01F41EC8處了。當 程序進入malloc后先jmp到01F41EC8,執(zhí)行原來的三條指令,然后就是BoundsChecker的天下了。大致上它會先記錄函數(shù)的返回地址 (函數(shù)的返回地址在stack上,所以很容易修改),然后把返回地址指向?qū)儆贐oundsChecker的代碼,接著跳到malloc函數(shù)原來的指令,也 就是在00403c15的地方。當malloc函數(shù)結(jié)束的時候,由于返回地址被修改,它會返回到BoundsChecker的代碼中,此時 BoundsChecker會記錄由malloc分配的內(nèi)存的指針,然后再跳轉(zhuǎn)到到原來的返回地址去。
如果內(nèi)存分配/釋放函數(shù)在DLL中,BoundsChecker則采用另一種方法來截獲對這些函數(shù)的調(diào)用。BoundsChecker通過修改程序的DLL Import Table讓table中的函數(shù)地址指向自己的地址,以達到截獲的目的。
截獲住這些分配和釋放函數(shù),BoundsChecker就能記錄被分配的內(nèi)存或資源的生命周期。接下來的問題是如何與源代碼相關(guān),也就是說當 BoundsChecker檢測到內(nèi)存泄漏,它如何報告這塊內(nèi)存塊是哪段代碼分配的。答案是調(diào)試信息(Debug Information)。當我們編譯一個Debug版的程序時,編譯器會把源代碼和二進制代碼之間的對應關(guān)系記錄下來,放到一個單獨的文件里 (.pdb)或者直接連結(jié)進目標程序,通過直接讀取調(diào)試信息就能得到分配某塊內(nèi)存的源代碼在哪個文件,哪一行上。使用Code Injection和Debug Information,使BoundsChecker不但能記錄呼叫分配函數(shù)的源代碼的位置,而且還能記錄分配時的Call Stack,以及Call Stack上的函數(shù)的源代碼位置。這在使用像MFC這樣的類庫時非常有用,以下我用一個例子來說明:
void ShowXItemMenu()
{
…
CMenu menu;
menu.CreatePopupMenu();
//add menu items.
menu.TrackPropupMenu();
…
}
void ShowYItemMenu( )
{
…
CMenu menu;
menu.CreatePopupMenu();
//add menu items.
menu.TrackPropupMenu();
menu.Detach();//this will cause HMENU leak
…
}
BOOL CMenu::CreatePopupMenu()
{
…
hMenu = CreatePopupMenu();
…
}
當調(diào)用ShowYItemMenu()時,我們故意造成HMENU的泄漏。但是,對于BoundsChecker來說被泄漏的HMENU是在 class CMenu::CreatePopupMenu()中分配的。假設的你的程序有許多地方使用了CMenu的CreatePopupMenu()函數(shù),如 CMenu::CreatePopupMenu()造成的,你依然無法確認問題的根結(jié)到底在哪里,在ShowXItemMenu()中還是在 ShowYItemMenu()中,或者還有其它的地方也使用了CreatePopupMenu()?有了Call Stack的信息,問題就容易了。BoundsChecker會如下報告泄漏的HMENU的信息:
Function
File
Line
CMenu::CreatePopupMenu
E:"8168"vc98"mfc"mfc"include"afxwin1.inl
1009
ShowYItemMenu
E:"testmemleak"mytest.cpp
100
這里省略了其他的函數(shù)調(diào)用
如此,我們很容易找到發(fā)生問題的函數(shù)是ShowYItemMenu()。當使用MFC之類的類庫編程時,大部分的API調(diào)用都被封裝在類庫的class里,有了Call Stack信息,我們就可以非常容易的追蹤到真正發(fā)生泄漏的代碼。
記錄Call Stack信息會使程序的運行變得非常慢,因此默認情況下BoundsChecker不會記錄Call Stack信息。可以按照以下的步驟打開記錄Call Stack信息的選項開關(guān):
1. 打開菜單:BoundsChecker|Setting…
2. 在Error Detection頁中,在Error Detection Scheme的List中選擇Custom
3. 在Category的Combox中選擇 Pointer and leak error check
4. 鉤上Report Call Stack復選框
5. 點擊Ok
基于Code Injection,BoundsChecker還提供了API Parameter的校驗功能,memory over run等功能。這些功能對于程序的開發(fā)都非常有益。由于這些內(nèi)容不屬于本文的主題,所以不在此詳述了。
盡管BoundsChecker的功能如此強大,但是面對隱式內(nèi)存泄漏仍然顯得蒼白無力。所以接下來我們看看如何用Performance Monitor檢測內(nèi)存泄漏。
2.3.3.3 使用Performance Monitor檢測內(nèi)存泄漏
NT的內(nèi)核在設計過程中已經(jīng)加入了系統(tǒng)監(jiān)視功能,比如CPU 的使用率,內(nèi)存的使用情況,I/O操作的頻繁度等都作為一個個Counter,應用程序可以通過讀取這些Counter了解整個系統(tǒng)的或者某個進程的運行 狀況。Performance Monitor就是這樣一個應用程序。
為了檢測內(nèi)存泄漏,我們一般可以監(jiān)視Process對象的Handle Count,Virutal Bytes 和Working Set三個Counter。Handle Count記錄了進程當前打開的HANDLE的個數(shù),監(jiān)視這個Counter有助于我們發(fā)現(xiàn)程序是否有Handle泄漏;Virtual Bytes記錄了該進程當前在虛地址空間上使用的虛擬內(nèi)存的大小,NT的內(nèi)存分配采用了兩步走的方法,首先,在虛地址空間上保留一段空間,這時操作系統(tǒng)并 沒有分配物理內(nèi)存,只是保留了一段地址。然后,再提交這段空間,這時操作系統(tǒng)才會分配物理內(nèi)存。所以,Virtual Bytes一般總大于程序的Working Set。監(jiān)視Virutal Bytes可以幫助我們發(fā)現(xiàn)一些系統(tǒng)底層的問題; Working Set記錄了操作系統(tǒng)為進程已提交的內(nèi)存的總量,這個值和程序申請的內(nèi)存總量存在密切的關(guān)系,如果程序存在內(nèi)存的泄漏這個值會持續(xù)增加,但是 Virtual Bytes卻是跳躍式增加的。
監(jiān)視這些Counter可以讓我們了解進程使用內(nèi)存的情況,如果發(fā)生了泄漏,即使是隱式內(nèi)存泄漏,這些Counter的值也會持續(xù)增加。但是,我們知 道有問題卻不知道哪里有問題,所以一般使用Performance Monitor來驗證是否有內(nèi)存泄漏,而使用BoundsChecker來找到和解決。
當Performance Monitor顯示有內(nèi)存泄漏,而BoundsChecker卻無法檢測到,這時有兩種可能:第一種,發(fā)生了偶發(fā)性內(nèi)存泄漏。這時你要確保使用 Performance Monitor和使用BoundsChecker時,程序的運行環(huán)境和操作方法是一致的。第二種,發(fā)生了隱式的內(nèi)存泄漏。這時你要重新審查程序的設計,然 后仔細研究Performance Monitor記錄的Counter的值的變化圖,分析其中的變化和程序運行邏輯的關(guān)系,找到一些可能的原因。這是一個痛苦的過程,充滿了假設、猜想、驗 證、失敗,但這也是一個積累經(jīng)驗的絕好機會。
3 探討C++內(nèi)存回收
3.1 C++內(nèi)存對象大會戰(zhàn)
如果一個人自稱為程序高手,卻對內(nèi)存一無所知,那么我可以告訴你,他一定在吹牛。用C或C++寫程序,需要更多地關(guān)注內(nèi)存,這不僅僅是因為內(nèi)存的分配 是否合理直接影響著程序的效率和性能,更為主要的是,當我們操作內(nèi)存的時候一不小心就會出現(xiàn)問題,而且很多時候,這些問題都是不易發(fā)覺的,比如內(nèi)存泄漏, 比如懸掛指針。筆者今天在這里并不是要討論如何避免這些問題,而是想從另外一個角度來認識C++內(nèi)存對象。
我們知道,C++將內(nèi)存劃分為三個邏輯區(qū)域:堆、棧和靜態(tài)存儲區(qū)。既然如此,我稱位于它們之中的對象分別為堆對象,棧對象以及靜態(tài)對象。那么這些不同的內(nèi)存對象有什么區(qū)別了?堆對象和棧對象各有什么優(yōu)劣了?如何禁止創(chuàng)建堆對象或棧對象了?這些便是今天的主題。
3.1.1 基本概念
先來看看棧。棧,一般用于存放局部變量或?qū)ο?#xff0c;如我們在函數(shù)定義中用類似下面語句聲明的對象:
stack_object便是一個棧對象,它的生命期是從定義點開始,當所在函數(shù)返回時,生命結(jié)束。
另外,幾乎所有的臨時對象都是棧對象。比如,下面的函數(shù)定義:
這 個函數(shù)至少產(chǎn)生兩個臨時對象,首先,參數(shù)是按值傳遞的,所以會調(diào)用拷貝構(gòu)造函數(shù)生成一個臨時對象object_copy1 ,在函數(shù)內(nèi)部使用的不是使用的不是object,而是object_copy1,自然,object_copy1是一個棧對象,它在函數(shù)返回時被釋放;還 有這個函數(shù)是值返回的,在函數(shù)返回時,如果我們不考慮返回值優(yōu)化(NRV),那么也會產(chǎn)生一個臨時對象object_copy2,這個臨時對象會在函數(shù)返 回后一段時間內(nèi)被釋放。比如某個函數(shù)中有如下代碼:
上面的第二個語句的執(zhí)行情況是這樣的,首先函數(shù)fun返回時生成一個臨時對象object_copy2 ,然后再調(diào)用賦值運算符執(zhí)行
看到了嗎?編譯器在我們毫無知覺的情況下,為我們生成了這么多臨時對象,而生成這些臨時對象的時間和空間的開銷可能是很大的,所以,你也許明白了,為什么對于“大”對象最好用const引用傳遞代替按值進行函數(shù)參數(shù)傳遞了。
接下來,看看堆。堆,又叫自由存儲區(qū),它是在程序執(zhí)行的過程中動態(tài)分配的,所以它最大的特性就是動態(tài)性。在C++中,所有堆對象的創(chuàng)建和銷毀都要由程 序員負責,所以,如果處理不好,就會發(fā)生內(nèi)存問題。如果分配了堆對象,卻忘記了釋放,就會產(chǎn)生內(nèi)存泄漏;而如果已釋放了對象,卻沒有將相應的指針置為 NULL,該指針就是所謂的“懸掛指針”,再度使用此指針時,就會出現(xiàn)非法訪問,嚴重時就導致程序崩潰。
那么,C++中是怎樣分配堆對象的?唯一的方法就是用new(當然,用類malloc指令也可獲得C式堆內(nèi)存),只要使用new,就會在堆中分配一塊內(nèi)存,并且返回指向該堆對象的指針。
再來看看靜態(tài)存儲區(qū)。所有的靜態(tài)對象、全局對象都于靜態(tài)存儲區(qū)分配。關(guān)于全局對象,是在main()函數(shù)執(zhí)行前就分配好了的。其實,在 main()函數(shù)中的顯示代碼執(zhí)行之前,會調(diào)用一個由編譯器生成的_main()函數(shù),而_main()函數(shù)會進行所有全局對象的的構(gòu)造及初始化工作。而 在main()函數(shù)結(jié)束之前,會調(diào)用由編譯器生成的exit函數(shù),來釋放所有的全局對象。比如下面的代碼:
實際上,被轉(zhuǎn)化成這樣:
所 以,知道了這個之后,便可以由此引出一些技巧,如,假設我們要在main()函數(shù)執(zhí)行之前做某些準備工作,那么我們可以將這些準備工作寫到一個自定義的全 局對象的構(gòu)造函數(shù)中,這樣,在main()函數(shù)的顯式代碼執(zhí)行之前,這個全局對象的構(gòu)造函數(shù)會被調(diào)用,執(zhí)行預期的動作,這樣就達到了我們的目的。剛才講的 是靜態(tài)存儲區(qū)中的全局對象,那么,局部靜態(tài)對象了?局部靜態(tài)對象通常也是在函數(shù)中定義的,就像棧對象一樣,只不過,其前面多了個static關(guān)鍵字。局部 靜態(tài)對象的生命期是從其所在函數(shù)第一次被調(diào)用,更確切地說,是當?shù)谝淮螆?zhí)行到該靜態(tài)對象的聲明代碼時,產(chǎn)生該靜態(tài)局部對象,直到整個程序結(jié)束時,才銷毀該 對象。
還有一種靜態(tài)對象,那就是它作為class的靜態(tài)成員。考慮這種情況時,就牽涉了一些較復雜的問題。
第一個問題是class的靜態(tài)成員對象的生命期,class的靜態(tài)成員對象隨著第一個class object的產(chǎn)生而產(chǎn)生,在整個程序結(jié)束時消亡。也就是有這樣的情況存在,在程序中我們定義了一個class,該類中有一個靜態(tài)對象作為成員,但是在程 序執(zhí)行過程中,如果我們沒有創(chuàng)建任何一個該class object,那么也就不會產(chǎn)生該class所包含的那個靜態(tài)對象。還有,如果創(chuàng)建了多個class object,那么所有這些object都共享那個靜態(tài)對象成員。
第二個問題是,當出現(xiàn)下列情況時:
請 注意上面標為黑體的三條語句,它們所訪問的s_object是同一個對象嗎?答案是肯定的,它們的確是指向同一個對象,這聽起來不像是真的,是嗎?但這是 事實,你可以自己寫段簡單的代碼驗證一下。我要做的是來解釋為什么會這樣?我們知道,當一個類比如Derived1,從另一個類比如Base繼承時,那 么,可以看作一個Derived1對象中含有一個Base型的對象,這就是一個subobject。一個Derived1對象的大致內(nèi)存布局如下:
讓我們想想,當我們將一個Derived1型的對象傳給一個接受非引用Base型參數(shù)的函數(shù)時會發(fā)生切割,那么是怎么切割的呢?相信現(xiàn)在你已經(jīng)知道 了,那就是僅僅取出了Derived1型的對象中的subobject,而忽略了所有Derived1自定義的其它數(shù)據(jù)成員,然后將這個 subobject傳遞給函數(shù)(實際上,函數(shù)中使用的是這個subobject的拷貝)。
所有繼承Base類的派生類的對象都含有一個Base型的subobject(這是能用Base型指針指向一個Derived1對象的關(guān)鍵所在,自然 也是多態(tài)的關(guān)鍵了),而所有的subobject和所有Base型的對象都共用同一個s_object對象,自然,從Base類派生的整個繼承體系中的類 的實例都會共用同一個s_object對象了。上面提到的example、example1、example2的對象布局如下圖所示:
3.1.2 三種內(nèi)存對象的比較
棧對象的優(yōu)勢是在適當?shù)臅r候自動生成,又在適當?shù)臅r候自動銷毀,不需要程序員操心;而且棧對象的創(chuàng)建速度一般較堆對象快,因為分配堆對象時,會調(diào)用 operator new操作,operator new會采用某種內(nèi)存空間搜索算法,而該搜索過程可能是很費時間的,產(chǎn)生棧對象則沒有這么麻煩,它僅僅需要移動棧頂指針就可以了。但是要注意的是,通常棧 空間容量比較小,一般是1MB~2MB,所以體積比較大的對象不適合在棧中分配。特別要注意遞歸函數(shù)中最好不要使用棧對象,因為隨著遞歸調(diào)用深度的增加, 所需的棧空間也會線性增加,當所需棧空間不夠時,便會導致棧溢出,這樣就會產(chǎn)生運行時錯誤。
堆對象,其產(chǎn)生時刻和銷毀時刻都要程序員精確定義,也就是說,程序員對堆對象的生命具有完全的控制權(quán)。我們常常需要這樣的對象,比如,我們需要創(chuàng)建一 個對象,能夠被多個函數(shù)所訪問,但是又不想使其成為全局的,那么這個時候創(chuàng)建一個堆對象無疑是良好的選擇,然后在各個函數(shù)之間傳遞這個堆對象的指針,便可 以實現(xiàn)對該對象的共享。另外,相比于棧空間,堆的容量要大得多。實際上,當物理內(nèi)存不夠時,如果這時還需要生成新的堆對象,通常不會產(chǎn)生運行時錯誤,而是 系統(tǒng)會使用虛擬內(nèi)存來擴展實際的物理內(nèi)存。
接下來看看static對象。
首先是全局對象。全局對象為類間通信和函數(shù)間通信提供了一種最簡單的方式,雖然這種方式并不優(yōu)雅。一般而言,在完全的面向?qū)ο笳Z言中,是不存在全局對 象的,比如C#,因為全局對象意味著不安全和高耦合,在程序中過多地使用全局對象將大大降低程序的健壯性、穩(wěn)定性、可維護性和可復用性。C++也完全可以 剔除全局對象,但是最終沒有,我想原因之一是為了兼容C。
其次是類的靜態(tài)成員,上面已經(jīng)提到,基類及其派生類的所有對象都共享這個靜態(tài)成員對象,所以當需要在這些class之間或這些class objects之間進行數(shù)據(jù)共享或通信時,這樣的靜態(tài)成員無疑是很好的選擇。
接著是靜態(tài)局部對象,主要可用于保存該對象所在函數(shù)被屢次調(diào)用期間的中間狀態(tài),其中一個最顯著的例子就是遞歸函數(shù),我們都知道遞歸函數(shù)是自己調(diào)用自己 的函數(shù),如果在遞歸函數(shù)中定義一個nonstatic局部對象,那么當遞歸次數(shù)相當大時,所產(chǎn)生的開銷也是巨大的。這是因為nonstatic局部對象是 棧對象,每遞歸調(diào)用一次,就會產(chǎn)生一個這樣的對象,每返回一次,就會釋放這個對象,而且,這樣的對象只局限于當前調(diào)用層,對于更深入的嵌套層和更淺露的外 層,都是不可見的。每個層都有自己的局部對象和參數(shù)。
在遞歸函數(shù)設計中,可以使用static對象替代nonstatic局部對象(即棧對象),這不僅可以減少每次遞歸調(diào)用和返回時產(chǎn)生和釋放nonstatic對象的開銷,而且static對象還可以保存遞歸調(diào)用的中間狀態(tài),并且可為各個調(diào)用層所訪問。
3.1.3 使用棧對象的意外收獲
前面已經(jīng)介紹到,棧對象是在適當?shù)臅r候創(chuàng)建,然后在適當?shù)臅r候自動釋放的,也就是棧對象有自動管理功能。那么棧對象會在什么會自動釋放了?第一,在其 生命期結(jié)束的時候;第二,在其所在的函數(shù)發(fā)生異常的時候。你也許說,這些都很正常啊,沒什么大不了的。是的,沒什么大不了的。但是只要我們再深入一點點, 也許就有意外的收獲了。
棧對象,自動釋放時,會調(diào)用它自己的析構(gòu)函數(shù)。如果我們在棧對象中封裝資源,而且在棧對象的析構(gòu)函數(shù)中執(zhí)行釋放資源的動作,那么就會使資源泄漏的概率 大大降低,因為棧對象可以自動的釋放資源,即使在所在函數(shù)發(fā)生異常的時候。實際的過程是這樣的:函數(shù)拋出異常時,會發(fā)生所謂的 stack_unwinding(堆棧回滾),即堆棧會展開,由于是棧對象,自然存在于棧中,所以在堆棧回滾的過程中,棧對象的析構(gòu)函數(shù)會被執(zhí)行,從而釋 放其所封裝的資源。除非,除非在析構(gòu)函數(shù)執(zhí)行的過程中再次拋出異常――而這種可能性是很小的,所以用棧對象封裝資源是比較安全的。基于此認識,我們就可以 創(chuàng)建一個自己的句柄或代理來封裝資源了。智能指針(auto_ptr)中就使用了這種技術(shù)。在有這種需要的時候,我們就希望我們的資源封裝類只能在棧中創(chuàng) 建,也就是要限制在堆中創(chuàng)建該資源封裝類的實例。
3.1.4 禁止產(chǎn)生堆對象
上面已經(jīng)提到,你決定禁止產(chǎn)生某種類型的堆對象,這時你可以自己創(chuàng)建一個資源封裝類,該類對象只能在棧中產(chǎn)生,這樣就能在異常的情況下自動釋放封裝的資源。
那么怎樣禁止產(chǎn)生堆對象了?我們已經(jīng)知道,產(chǎn)生堆對象的唯一方法是使用new操作,如果我們禁止使用new不就行了么。再進一步,new操作執(zhí)行時會 調(diào)用operator new,而operator new是可以重載的。方法有了,就是使new operator 為private,為了對稱,最好將operator delete也重載為private。現(xiàn)在,你也許又有疑問了,難道創(chuàng)建棧對象不需要調(diào)用new嗎?是的,不需要,因為創(chuàng)建棧對象不需要搜索內(nèi)存,而是直 接調(diào)整堆棧指針,將對象壓棧,而operator new的主要任務是搜索合適的堆內(nèi)存,為堆對象分配空間,這在上面已經(jīng)提到過了。好,讓我們看看下面的示例代碼:
NoHashObject現(xiàn)在就是一個禁止堆對象的類了,如果你寫下如下代碼:
上 面代碼會產(chǎn)生編譯期錯誤。好了,現(xiàn)在你已經(jīng)知道了如何設計一個禁止堆對象的類了,你也許和我一樣有這樣的疑問,難道在類NoHashObject 的定義不能改變的情況下,就一定不能產(chǎn)生該類型的堆對象了嗎?不,還是有辦法的,我稱之為“暴力破解法”。C++是如此地強大,強大到你可以用它做你想做 的任何事情。這里主要用到的是技巧是指針類型的強制轉(zhuǎn)換。
上面的實現(xiàn)是麻煩的,而且這種實現(xiàn)方式幾乎不會在實踐中使用,但是我還是寫出來路,因為理解它,對于我們理解C++內(nèi)存對象是有好處的。對于上面的這么多強制類型轉(zhuǎn)換,其最根本的是什么了?我們可以這樣理解:
某塊內(nèi)存中的數(shù)據(jù)是不變的,而類型就是我們戴上的眼鏡,當我們戴上一種眼鏡后,我們就會用對應的類型來解釋內(nèi)存中的數(shù)據(jù),這樣不同的解釋就得到了不同的信息。
所謂強制類型轉(zhuǎn)換實際上就是換上另一副眼鏡后再來看同樣的那塊內(nèi)存數(shù)據(jù)。
另外要提醒的是,不同的編譯器對對象的成員數(shù)據(jù)的布局安排可能是不一樣的,比如,大多數(shù)編譯器將NoHashObject的ptr指針成員安排在對象空間的頭4個字節(jié),這樣才會保證下面這條語句的轉(zhuǎn)換動作像我們預期的那樣執(zhí)行:
但是,并不一定所有的編譯器都是如此。
既然我們可以禁止產(chǎn)生某種類型的堆對象,那么可以設計一個類,使之不能產(chǎn)生棧對象嗎?當然可以。
3.1.5 禁止產(chǎn)生棧對象
前面已經(jīng)提到了,創(chuàng)建棧對象時會移動棧頂指針以“挪出”適當大小的空間,然后在這個空間上直接調(diào)用對應的構(gòu)造函數(shù)以形成一個棧對象,而當函數(shù)返回時, 會調(diào)用其析構(gòu)函數(shù)釋放這個對象,然后再調(diào)整棧頂指針收回那塊棧內(nèi)存。在這個過程中是不需要operator new/delete操作的,所以將operator new/delete設置為private不能達到目的。當然從上面的敘述中,你也許已經(jīng)想到了:將構(gòu)造函數(shù)或析構(gòu)函數(shù)設為私有的,這樣系統(tǒng)就不能調(diào)用構(gòu) 造/析構(gòu)函數(shù)了,當然就不能在棧中生成對象了。
這樣的確可以,而且我也打算采用這種方案。但是在此之前,有一點需要考慮清楚,那就是,如果我們將構(gòu)造函數(shù)設置為私有,那么我們也就不能用 new來直接產(chǎn)生堆對象了,因為new在為對象分配空間后也會調(diào)用它的構(gòu)造函數(shù)啊。所以,我打算只將析構(gòu)函數(shù)設置為private。再進一步,將析構(gòu)函數(shù) 設為private除了會限制棧對象生成外,還有其它影響嗎?是的,這還會限制繼承。
如果一個類不打算作為基類,通常采用的方案就是將其析構(gòu)函數(shù)聲明為private。
為了限制棧對象,卻不限制繼承,我們可以將析構(gòu)函數(shù)聲明為protected,這樣就兩全其美了。如下代碼所示:
接著,可以像這樣使用NoStackObject類:
呵 呵,是不是覺得有點怪怪的,我們用new創(chuàng)建一個對象,卻不是用delete去刪除它,而是要用destroy方法。很顯然,用戶是不習慣這種怪異的使用 方式的。所以,我決定將構(gòu)造函數(shù)也設為private或protected。這又回到了上面曾試圖避免的問題,即不用new,那么該用什么方式來生成一個 對象了?我們可以用間接的辦法完成,即讓這個類提供一個static成員函數(shù)專門用于產(chǎn)生該類型的堆對象。(設計模式中的singleton 模式就可以用這種方式實現(xiàn)。)讓我們來看看:
現(xiàn)在可以這樣使用NoStackObject類了:
現(xiàn)在感覺是不是好多了,生成對象和釋放對象的操作一致了。
3.2 淺議C++ 中的垃圾回收方法
許多 C 或者 C++ 程序員對垃圾回收嗤之以鼻,認為垃圾回收肯定比自己來管理動態(tài)內(nèi)存要低效,而且在回收的時候一定會讓程序停頓在那里,而如果自己控制內(nèi)存管理的話,分配和 釋放時間都是穩(wěn)定的,不會導致程序停頓。最后,很多 C/C++ 程序員堅信在C/C++ 中無法實現(xiàn)垃圾回收機制。這些錯誤的觀點都是由于不了解垃圾回收的算法而臆想出來的。
其實垃圾回收機制并不慢,甚至比動態(tài)內(nèi)存分配更高效。因為我們可以只分配不釋放,那么分配內(nèi)存的時候只需要從堆上一直的獲得新的內(nèi)存,移動堆頂?shù)闹羔?就夠了;而釋放的過程被省略了,自然也加快了速度。現(xiàn)代的垃圾回收算法已經(jīng)發(fā)展了很多,增量收集算法已經(jīng)可以讓垃圾回收過程分段進行,避免打斷程序的運行 了。而傳統(tǒng)的動態(tài)內(nèi)存管理的算法同樣有在適當?shù)臅r間收集內(nèi)存碎片的工作要做,并不比垃圾回收更有優(yōu)勢。
而垃圾回收的算法的基礎通常基于掃描并標記當前可能被使用的所有內(nèi)存塊,從已經(jīng)被分配的所有內(nèi)存中把未標記的內(nèi)存回收來做的。C/C++ 中無法實現(xiàn)垃圾回收的觀點通常基于無法正確掃描出所有可能還會被使用的內(nèi)存塊,但是,看似不可能的事情實際上實現(xiàn)起來卻并不復雜。首先,通過掃描內(nèi)存的數(shù) 據(jù),指向堆上動態(tài)分配出來內(nèi)存的指針是很容易被識別出來的,如果有識別錯誤,也只能是把一些不是指針的數(shù)據(jù)當成指針,而不會把指針當成非指針數(shù)據(jù)。這樣, 回收垃圾的過程只會漏回收掉而不會錯誤的把不應該回收的內(nèi)存清理。其次,如果回溯所有內(nèi)存塊被引用的根,只可能存在于全局變量和當前的棧內(nèi),而全局變量 (包括函數(shù)內(nèi)的靜態(tài)變量)都是集中存在于 bss 段或 data段中。
垃圾回收的時候,只需要掃描 bss 段, data 段以及當前被使用著的棧空間,找到可能是動態(tài)內(nèi)存指針的量,把引用到的內(nèi)存遞歸掃描就可以得到當前正在使用的所有動態(tài)內(nèi)存了。
如果肯為你的工程實現(xiàn)一個不錯的垃圾回收器,提高內(nèi)存管理的速度,甚至減少總的內(nèi)存消耗都是可能的。如果有興趣的話,可以搜索一下網(wǎng)上已有的關(guān)于垃圾回收的論文和實現(xiàn)了的庫,開拓視野對一個程序員尤為重要。
發(fā)表于 @ 2010年07月02日 09:27:00?|?評論(?0?)?|?舉報|?收藏
總結(jié)
- 上一篇: 高等数学同济第七版课后答案上册
- 下一篇: 批处理保存windows10开机壁纸