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