C#的內(nèi)存管理知識 收藏
本章介紹內(nèi)存管理和內(nèi)存訪問的各個方面。盡管運行庫負(fù)責(zé)為程序員處理大部分內(nèi)存管理工作,但程序員仍必須理解內(nèi)存管理的工作原理,了解如何處理未托管的資源。 如果很好地理解了內(nèi)存管理和C#提供的指針功能,也就能很好地集成C#代碼和原來的代碼,并能在非常注重性能的系統(tǒng)中高效地處理內(nèi)存。
本章的主要內(nèi)容如下:
● 運行庫如何在堆棧和堆上分配空間
● 垃圾收集的工作原理
● 如何使用析構(gòu)函數(shù)和System.IDisposable接口來確保正確釋放未托管的資源
● C#中使用指針的語法
● 如何使用指針實現(xiàn)基于堆棧的高性能數(shù)組
11.1 后臺內(nèi)存管理
C#編程的一個優(yōu)點是程序員不需要擔(dān)心具體的內(nèi)存管理,尤其是垃圾收集器會處理所有的內(nèi)存清理工作。用戶可以得到像C++語言那樣的效率,而不需要考慮像在C++中那樣內(nèi)存管理工作的復(fù)雜性。雖然不必手工管理內(nèi)存,但如果要編寫高效的代碼,就仍需理解后臺發(fā)生的事情。本節(jié)要介紹給變量分配內(nèi)存時計算機(jī)內(nèi)存中發(fā)生的情況。 注意:
本節(jié)的許多內(nèi)容是沒有經(jīng)過事實證明的。您應(yīng)把這一節(jié)看作是一般規(guī)則的簡化向?qū)?,而不是實現(xiàn)的確切說明。
11.1.1 值數(shù)據(jù)類型
Windows使用一個系統(tǒng):虛擬尋址系統(tǒng),該系統(tǒng)把程序可用的內(nèi)存地址映射到硬件內(nèi)存中的實際地址上,這些任務(wù)完全由Windows在后臺管理,其實際結(jié)果是32位處理器上的每個進(jìn)程都可以使用4GB的內(nèi)存—— 無論計算機(jī)上有多少硬盤空間。(在64位處理器上,這個數(shù)字會更大)。這個4GB內(nèi)存實際上包含了程序的所有部分,包括可執(zhí)行代碼、代碼加載的所有DLL,以及程序運行時使用的所有變量的內(nèi)容。這個4GB內(nèi)存稱為虛擬地址空間,或虛擬內(nèi)存,為了方便起見,本章將它簡稱為內(nèi)存。 4GB中的每個存儲單元都是從0開始往上排序的。要訪問存儲在內(nèi)存的某個空間中的一個值,就需要提供表示該存儲單元的數(shù)字。在任何復(fù)雜的高級語言中,例如C#、VB、C++和Java,編譯器負(fù)責(zé)把人們可以理解的變量名稱轉(zhuǎn)換為處理器可以理解的內(nèi)存地址。
在進(jìn)程的虛擬內(nèi)存中,有一個區(qū)域稱為堆棧。堆棧存儲不是對象成員的值數(shù)據(jù)類型。另外,在調(diào)用一個方法時,也使用堆棧存儲傳遞給方法的所有參數(shù)的復(fù)本。為了理解堆棧的工作原理,需要注意在C#中變量的作用域。如果變量a在變量b之前進(jìn)入作用域,b就會先出作用域。下面的代碼:
{
int a;
// do something
{
int b;
// do something else
}
}
首先聲明a。在內(nèi)部的代碼塊中聲明了b。然后內(nèi)部的代碼塊終止,b就出作用域,最后a出作用域。所以b的生存期會完全包含在a的生存期中。在釋放變量時,其順序總是與給它們分配內(nèi)存的順序相反,這就是堆棧的工作方式。
我們不知道堆棧在地址空間的什么地方,這些信息在進(jìn)行C#開發(fā)是不需要知道的。堆棧指針(操作系統(tǒng)維護(hù)的一個變量) 表示堆棧中下一個自由空間的地址。程序第一次運行時,堆棧指針指向為堆棧保留的內(nèi)存塊末尾。堆棧實際上是向下填充的,即從高內(nèi)存地址向低內(nèi)存地址填充。當(dāng)數(shù)據(jù)入棧后,堆棧指針就會隨之調(diào)整,以始終指向下一個自由空間。這種情況如圖11-1所示。在該圖中,顯示了堆棧指針800000(十六進(jìn)制的0xC3500),下一個自由空間是地址799999。
圖 11-1 下面的代碼會告訴編譯器,需要一些存儲單元以存儲一個整數(shù)和一個雙精度浮點數(shù),這些存儲單元會分別分配給nRacingCars和engineSize,聲明每個變量的代碼表示開始請求訪問這個變量,閉合花括號表示這兩個變量出作用域的地方。
{
int nRacingCars = 10;
double engineSize = 3000.0;
// do calculations;
}
假定使用如圖11-1所示的堆棧。變量nRacingCars進(jìn)入作用域,賦值為10,這個值放在存儲單元799996~799999上,這4個字節(jié)就在堆棧指針?biāo)缚臻g的下面。有4個字節(jié)是因為存儲int要使用4個字節(jié)。為了容納該int,應(yīng)從堆棧指針中減去4,所以它現(xiàn)在指向位置799996,即下一個自由空間 (799995)。
下一行代碼聲明變量engineSize(這是一個double),把它初始化為3000.0。double要占用8個字節(jié),所以值3000.0占據(jù)棧上的存儲單元799988~799995上,堆棧指針減去8,再次指向堆棧上的下一個自由空間。
當(dāng)engineSize出作用域時,計算機(jī)就知道不再需要這個變量了。因為變量的生存期總是嵌套的,當(dāng)engineSize在作用域中時,無論發(fā)生什么情況,都可以保證堆棧指針總是會指向存儲engineSize的空間。為了從內(nèi)存中刪除這個變量,應(yīng)給堆棧指針遞增8,現(xiàn)在指向engineSize使用過的空間。此處就是放置閉合花括號的地方。當(dāng)nRacingCars也出作用域時,堆棧指針就再次遞增4,此時如果內(nèi)存中又放入另一個變量,從799999開始的存儲單元就會被覆蓋,這些空間以前是存儲nRacingCars的。
如果編譯器遇到像int i、j這樣的代碼,則這兩個變量進(jìn)入作用域的順序就是不確定的:兩個變量是同時聲明的,也是同時出作用域的。此時,變量以什么順序從內(nèi)存中刪除就不重要了。編譯器在內(nèi)部會確保先放在內(nèi)存中的那個變量后刪除,這樣就能保證該規(guī)則不會與變量的生存期沖突。
11.1.2 引用數(shù)據(jù)類型
堆棧有非常高的性能,但對于所有的變量來說還是不太靈活。變量的生存期必須嵌套,在許多情況下,這種要求都過于苛刻。通常我們希望使用一個方法分配內(nèi)存,來存儲一些數(shù)據(jù),并在方法退出后的很長一段時間內(nèi)數(shù)據(jù)仍是可以使用的。只要是用new運算符來請求存儲空間,就存在這種可能性——例如所有的引用類型。此時就要使用托管堆。 如果以前編寫過需要管理低級內(nèi)存的C++代碼,就會很熟悉堆(heap)。托管堆和C++使用的堆不同,它在垃圾收集器的控制下工作,與傳統(tǒng)的堆相比有很顯著的性能優(yōu)勢。
托管堆(簡稱為堆)是進(jìn)程的可用4GB中的另一個內(nèi)存區(qū)域。要了解堆的工作原理和如何為引用數(shù)據(jù)類型分配內(nèi)存,看看下面的代碼:
void DoWork()
{
Customer arabel;
arabel = new Customer();
Customer otherCustomer2 = new EnhancedCustomer();
}
在這段代碼中,假定存在兩個類Customer 和 EnhancedCustomer。EnhancedCustomer類擴(kuò)展了Customer類。
首先,聲明一個Customer引用arabel,在堆棧上給這個引用分配存儲空間,但這僅是一個引用,而不是實際的Customer對象。arabel引用占用4個字節(jié)的空間,包含了存儲Customer對象的地址(需要4個字節(jié)把內(nèi)存地址表示為0到4GB之間的一個整數(shù)值)。
然后看下一行代碼:
arabel = new Customer();
這行代碼完成了以下操作:首先,分配堆上的內(nèi)存,以存儲Customer實例(一個真正的實例,不只是一個地址)。然后把變量arabel的值設(shè)置為分配給新Customer對象的內(nèi)存地址(它還調(diào)用合適的Customer()構(gòu)造函數(shù)初始化類實例中的字段,但我們不必?fù)?dān)心這部分)。
Customer實例沒有放在堆棧中,而是放在內(nèi)存的堆中。在這個例子中,現(xiàn)在還不知道一個Customer對象占用多少字節(jié),但為了討論方便,假定是32字節(jié)。這32字節(jié)包含了Customer實例字段,和.NET用于識別和管理其類實例的一些信息。 為了在堆上找到一個存儲新Customer對象的存儲位置,.NET運行庫在堆中搜索,選取第一個未使用的、32字節(jié)的連續(xù)塊。為了討論方便,假定其地址是200000,arabel引用占用堆棧中的799996~799999位置。這表示在實例化arabel對象前,內(nèi)存的內(nèi)容應(yīng)如圖11-2所示。
圖 11-2 給Customer對象分配空間后,內(nèi)存內(nèi)容應(yīng)如圖11-3所示。注意,與堆棧不同,堆上的內(nèi)存是向上分配的,所以自由空間在已用空間的上面。
圖 11-3 下一行代碼聲明了一個Customer引用,并實例化一個Customer對象。在這個例子中,需要在堆棧上為mrJones引用分配空間,同時,也需要在堆上為它分配空間:
Customer otherCustomer2 = new EnhancedCustomer();
該行把堆棧上的4字節(jié)分配給otherCustomer2引用,它存儲在799992~799995位置上,而otherCustomer2對象在堆上從200032開始向上分配空間。
從這個例子可以看出,建立引用變量的過程要比建立值變量的過程更復(fù)雜,且不能避免性能的降低。實際上,我們對這個過程進(jìn)行了過分的簡化,因為.NET運行庫需要保存堆的狀態(tài)信息,在堆中添加新數(shù)據(jù)時,這些信息也需要更新。盡管有這些性能損失,但仍有一種機(jī)制,在給變量分配內(nèi)存時,不會受到堆棧的限制。把一個引用變量的值賦予另一個相同類型的變量,就有兩個引用內(nèi)存中同一對象的變量了。當(dāng)一個引用變量出作用域時,它會從堆棧中刪除,如上一節(jié)所述,但引用對象的數(shù)據(jù)仍保留在堆中,一直到程序停止,或垃圾收集器刪除它為止,而只有在該數(shù)據(jù)不再被任何變量引用時,才會被刪除。
這就是引用數(shù)據(jù)類型的強大之處,在C#代碼中廣泛使用了這個特性。這說明,我們可以對數(shù)據(jù)的生存期進(jìn)行非常強大的控制,因為只要有對數(shù)據(jù)的引用,該數(shù)據(jù)就肯定存在于堆上。
11.1.3 垃圾收集
由上面的討論和圖可以看出,托管堆的工作方式非常類似于堆棧,在某種程度上,對象會在內(nèi)存中一個挨一個地放置,這樣就很容易使用指向下一個空閑存儲單元的堆指針,來確定下一個對象的位置。在堆上添加更多的對象時,也容易調(diào)整。但這比較復(fù)雜,因為基于堆的對象的生存期與引用它們的基于堆棧的變量的作用域不匹配。 在垃圾收集器運行時,會在堆中刪除不再引用的所有對象。在完成刪除動作后,堆會立即把對象分散開來,與已經(jīng)釋放的內(nèi)存混合在一起,如圖11-4所示。
圖 11-4 如果托管的堆也是這樣,在其上給新對象分配內(nèi)存就成為一個很難處理的過程,運行庫必須搜索整個堆,才能找到足夠大的內(nèi)存塊來存儲每個新對象。但是,垃圾收集器不會讓堆處于這種狀態(tài)。只要它釋放了能釋放的所有對象,就會壓縮其他對象,把它們都移動回堆的端部,再次形成一個連續(xù)的塊。因此,堆可以繼續(xù)像堆棧那樣確定在什么地方存儲新對象。當(dāng)然,在移動對象時,這些對象的所有引用都需要用正確的新地址來更新,但垃圾收集器也會處理更新問題。
垃圾收集器的這個壓縮操作是托管的堆與舊未托管的堆的區(qū)別所在。使用托管的堆,就只需要讀取堆指針的值即可,而不是搜索鏈接地址列表,來查找一個地方來放置新數(shù)據(jù)。因此,在.NET下實例化對象要快得多。有趣的是,訪問它們也比較快,因為對象會壓縮到堆上相同的內(nèi)存區(qū)域,這樣需要交換的頁面較少。Microsoft相信,盡管垃圾收集器需要做一些工作,壓縮堆,修改它移動的所有對象引用,致使性能降低,但這些性能會得到彌補。
注意:
一般情況下,垃圾收集器在.NET運行庫認(rèn)為需要時運行??梢酝ㄟ^調(diào)用System. GC.Collect(),強迫垃圾收集器在代碼的某個地方運行,System.GC是一個表示垃圾收集器的.NET基類, Collect()方法則調(diào)用垃圾收集器。但是,這種方式適用的場合很少,例如,代碼中有大量的對象剛剛停止引用,就適合調(diào)用垃圾收集器。但是,垃圾收集器的邏輯不能保證在一次垃圾收集過程中,所有未引用的對象都從堆中刪除。
11.2 釋放未托管的資源垃圾收集器的出現(xiàn)意味著,通常不需要擔(dān)心不再需要的對象,只要讓這些對象的所有引用都超出作用域,并允許垃圾收集器在需要時釋放資源即可。但是,垃圾收集器不知道如何釋放未托管的資源(例如文件句柄、網(wǎng)絡(luò)連接和數(shù)據(jù)庫連接)。托管類在封裝對未托管資源的直接或間接引用時,需要制定專門的規(guī)則,確保未托管的資源在回收類的一個實例時釋放。
在定義一個類時,可以使用兩種機(jī)制來自動釋放未托管的資源。這些機(jī)制常常放在一起實現(xiàn),因為每個機(jī)制都為問題提供了略為不同的解決方法。這兩個機(jī)制是: ● 聲明一個析構(gòu)函數(shù)(或終結(jié)器),作為類的一個成員 ● 在類中執(zhí)行System.IDisposable接口 下面依次討論這兩個機(jī)制,然后介紹如何同時實現(xiàn)它們,以獲得最佳的效果。 11.2.1 析構(gòu)函數(shù) 前面介紹了構(gòu)造函數(shù)可以指定必須在創(chuàng)建類的實例時進(jìn)行的某些操作,在垃圾收集器刪除對象之前,也可以調(diào)用析構(gòu)函數(shù)。由于執(zhí)行這個操作,所以析構(gòu)函數(shù)初看起來似乎是放置釋放未托管資源、執(zhí)行一般清理操作的代碼的最佳地方。但是,事情并不是如此簡單。 注意:
在討論C#中的析構(gòu)函數(shù)時,在底層的.NET結(jié)構(gòu)中,這些函數(shù)稱為終結(jié)器(finalizer)。在C#中定義析構(gòu)函數(shù)時,編譯器發(fā)送給程序集的實際上是Finalize()方法。這不會影響源代碼,但如果需要查看程序集的內(nèi)容,就應(yīng)知道這個事實。 C++開發(fā)人員應(yīng)很熟悉析構(gòu)函數(shù)的語法,它看起來類似于一個方法,與包含類同名,但前面加上了一個發(fā)音符號(~)。它沒有返回類型,不帶參數(shù),沒有訪問修飾符。下面是一個例子:
class MyClass
{
~MyClass()
{
// destructor implementation
}
}
C#編譯器在編譯析構(gòu)函數(shù)時,會隱式地把析構(gòu)函數(shù)的代碼編譯為Finalize()方法的對應(yīng)代碼,確保執(zhí)行父類的Finalize()方法。下面列出了編譯器為~MyClass()析構(gòu)函數(shù)生成的IL的對應(yīng)C#代碼:
protected override void Finalize()
{
try
{
// destructor implementation
}
finally
{
base. Finalize();
}
}
如上所示,在~MyClass()析構(gòu)函數(shù)中執(zhí)行的代碼封裝在Finalize()方法的一個try塊中。對父類Finalize()方法的調(diào)用放在finally塊中,確保該調(diào)用的執(zhí)行。第13章會討論try塊和finally塊。
有經(jīng)驗的C++開發(fā)人員大量使用了析構(gòu)函數(shù),有時不僅用于清理資源,還提供調(diào)試信息或執(zhí)行其他任務(wù)。C#析構(gòu)函數(shù)的使用要比在C++中少得多,與C++析構(gòu)函數(shù)相比,C#析構(gòu)函數(shù)的問題是它們的不確定性。在刪除C++對象時,其析構(gòu)函數(shù)會立即運行。但由于垃圾收集器的工作方式,無法確定C#對象的析構(gòu)函數(shù)何時執(zhí)行。所以,不能在析構(gòu)函數(shù)中放置需要在某一時刻運行的代碼,也不應(yīng)使用能以任意順序?qū)Σ煌悓嵗{(diào)用的析構(gòu)函數(shù)。如果對象占用了寶貴而重要的資源,應(yīng)盡快釋放這些資源,此時就不能等待垃圾收集器來釋放了。
另一個問題是C#析構(gòu)函數(shù)的執(zhí)行會延遲對象最終從內(nèi)存中刪除的時間。沒有析構(gòu)函數(shù)的對象會在垃圾收集器的一次處理中從內(nèi)存中刪除,但有析構(gòu)函數(shù)的對象需要兩次處理才能刪除:第一次調(diào)用析構(gòu)函數(shù)時,沒有刪除對象,第二次調(diào)用才真正刪除對象。另外,運行庫使用一個線程來執(zhí)行所有對象的Finalize()方法。如果頻繁使用析構(gòu)函數(shù),而且使用它們執(zhí)行長時間的清理任務(wù),對性能的影響就會非常顯著。
11.2.2 IDisposable接口在C#中,推薦使用System.IDisposable接口替代析構(gòu)函數(shù)。IDisposable接口定義了一個模式(具有語言級的支持),為釋放未托管的資源提供了確定的機(jī)制,并避免產(chǎn)生析構(gòu)函數(shù)固有的與垃圾函數(shù)器相關(guān)的問題。IDisposable接口聲明了一個方法Dispose(),它不帶參數(shù),返回void,Myclass的方法Dispose()的執(zhí)行代碼如下:
class Myclass : IDisposable
{
public void Dispose()
{
// implementation
}
}
Dispose()的執(zhí)行代碼顯式釋放由對象直接使用的所有未托管資源,并在所有實現(xiàn)IDisposable接口的封裝對象上調(diào)用Dispose()。這樣,Dispose()方法在釋放未托管資源的時間方面提供了精確的控制。
假定有一個類ResourceGobbler,它使用某些外部資源,且執(zhí)行IDisposable接口。如果要實例化這個類的實例,使用它,然后釋放它,就可以使用下面的代碼:
ResourceGobbler theInstance = new ResourceGobbler();
// do your processing
theInstance.Dispose();
如果在處理過程中出現(xiàn)異常,這段代碼就沒有釋放theInstance使用的資源,所以應(yīng)使用try塊(詳見第13章),編寫下面的代碼:
ResourceGobbler theInstance = null;
try
{
theInstance = new ResourceGobbler();
// do your processing
}
finally
{
if (theInstance != null)
{
theInstance.Dispose();
}
}
即使在處理過程中出現(xiàn)了異常,這個版本也可以確??偸窃趖heInstance上調(diào)用Dispose(),總是釋放由theInstance使用的資源。但是,如果總是要重復(fù)這樣的結(jié)構(gòu),代碼就很容易被混淆。C#提供了一種語法,可以確保在執(zhí)行IDisposable接口的對象的引用超出作用域時,在該對象上自動調(diào)用Dispose()。該語法使用了using關(guān)鍵字來完成這一工作—— 但目前,在完全不同的環(huán)境下,它與命名空間沒有關(guān)系。下面的代碼生成與try塊相對應(yīng)的IL代碼:
using (ResourceGobbler theInstance = new ResourceGobbler())
{
// do your processing
}
using語句的后面是一對圓括號,其中是引用變量的聲明和實例化,該語句使變量放在隨附的語句塊中。另外,在變量超出作用域時,即使出現(xiàn)異常,也會自動調(diào)用其Dispose()方法。如果已經(jīng)使用try塊來捕獲其他異常,就會比較清晰,如果避免使用using語句,僅在已有的try塊的finally子句中調(diào)用Dispose(),還可以避免進(jìn)行額外的縮進(jìn)。
注意:
對于某些類來說,使用Close()方法要比Dispose()更富有邏輯性,例如,在處理文件或數(shù)據(jù)庫連接時就是這樣。在這些情況下,常常實現(xiàn)IDisposable接口,再執(zhí)行一個獨立的Close()方法,來調(diào)用Dispose()。這種方法在類的使用上比較清晰,還支持C#提供的using語句。
11.2.3 實現(xiàn)IDisposable接口和析構(gòu)函數(shù)
前面的章節(jié)討論了類所使用的釋放未托管資源的兩種方式: ● 利用運行庫強制執(zhí)行的析構(gòu)函數(shù),但析構(gòu)函數(shù)的執(zhí)行是不確定的,而且,由于垃圾收集器的工作方式,它會給運行庫增加不可接受的系統(tǒng)開銷。
● IDisposable接口提供了一種機(jī)制,允許類的用戶控制釋放資源的時間,但需要確保執(zhí)行Dispose()。
一般情況下,最好的方法是執(zhí)行這兩種機(jī)制,獲得這兩種機(jī)制的優(yōu)點,克服其缺點。假定大多數(shù)程序員都能正確調(diào)用Dispose(),同時把執(zhí)行析構(gòu)函數(shù)作為一種安全的機(jī)制,以防沒有調(diào)用Dispose()。下面是一個雙重實現(xiàn)的例子:
public class ResourceHolder : IDisposable
{
private bool isDispose = false;
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
protected virtual void Dispose(bool disposing)
{
if (!isDisposed)
{
if (disposing)
{
// Cleanup managed objects by calling their
// Dispose() methods.
}
// Cleanup unmanaged objects
}
isDisposed=true;
}
~ResourceHolder()
{
Dispose (false);
}
public void SomeMethod()
{
// Ensure object not already disposed before execution of any method
if(isDisposed)
{
throw new ObjectDisposedException("ResourceHolder");
}
// method implementation…
}
}
可以看出,Dispose()有第二個protected重載方法,它帶一個bool參數(shù),這是真正完成清理工作的方法。Dispose(bool)由析構(gòu)函數(shù)和IDisposable.Dispose()調(diào)用。這個方式的重點是確保所有的清理代碼都放在一個地方。
傳遞給Dispose(bool)的參數(shù)表示Dispose(bool)是由析構(gòu)函數(shù)調(diào)用,還是由IDisposable. Dispose()調(diào)用—— Dispose(bool)不應(yīng)從代碼的其他地方調(diào)用,其原因是:
● 如果客戶調(diào)用IDisposable.Dispose(),該客戶就指定應(yīng)清理所有與該對象相關(guān)的資源,包括托管和非托管的資源。
● 如果調(diào)用了析構(gòu)函數(shù),原則上所有的資源仍需要清理。但是在這種情況下,析構(gòu)函數(shù)必須由垃圾收集器調(diào)用,而且不應(yīng)訪問其他托管的對象,因為我們不再能確定它們的狀態(tài)了。在這種情況下,最好清理已知的未托管資源,希望引用的托管對象還有析構(gòu)函數(shù),執(zhí)行自己的清理過程。
isDisposed成員變量表示對象是否已被刪除,并允許確保不多次刪除成員變量。它還允許在執(zhí)行實例方法之前測試對象是否已釋放,如SomeMethod()所示。這個簡單的方法不是線程安全的,需要調(diào)用者確保在同一時刻只有一個線程調(diào)用方法。要求客戶進(jìn)行同步是一個合理的假定,在整個.NET類庫中反復(fù)使用了這個假定(例如在集合類中)。第18章將討論線程和同步。
最后,IDisposable.Dispose()包含一個對System.GC.SuppressFinalize()方法的調(diào)用。GC 表示垃圾收集器,SuppressFinalize()方法則告訴垃圾收集器有一個類不再需要調(diào)用其析構(gòu)函數(shù)了。因為Dispose()已經(jīng)完成了所有需要的清理工作,所以析構(gòu)函數(shù)不需要做任何工作。調(diào)用SuppressFinalize()就意味著垃圾收集器認(rèn)為這個對象根本沒有析構(gòu)函數(shù)。
11.3 不安全的代碼
如前面的章節(jié)所述,C#非常擅長于隱藏基本內(nèi)存管理,因為它使用了垃圾收集器和引用。但是,有時需要直接訪問內(nèi)存,例如由于性能問題,要在外部(非.NET環(huán)境)的DLL中訪問一個函數(shù),該函數(shù)需要把一個指針當(dāng)作參數(shù)來傳遞(許多Windows API函數(shù)就是這樣)。本節(jié)將論述C#直接訪問內(nèi)存內(nèi)容的功能。 11.3.1 指針
下面把指針當(dāng)作一個新論題來介紹,而實際上,指針并不是新東西,因為在代碼中可以自由使用引用,而引用就是一個類型安全的指針。前面已經(jīng)介紹了表示對象和數(shù)組的變量實際上包含存儲相應(yīng)數(shù)據(jù)(引用)的內(nèi)存地址。指針只是一個以與引用相同的方式存儲地址的變量。其區(qū)別是C#不允許直接訪問引用變量包含的地址。有了引用后,從語法上看,變量就可以存儲引用的實際內(nèi)容。 C#引用主要用于使C#語言易于使用,防止用戶無意中執(zhí)行某些破壞內(nèi)存中內(nèi)容的操作,另一方面,使用指針,就可以訪問實際內(nèi)存地址,執(zhí)行新類型的操作。例如,給地址加上4字節(jié),就可以查看甚至修改存儲在新地址中的數(shù)據(jù)。
下面是使用指針的兩個主要原因: ● 向后兼容性。盡管.NET運行庫提供了許多工具,但仍可以調(diào)用內(nèi)部的Windows API 函數(shù)。 對于某些操作來說,這可能是完成任務(wù)的唯一方式。這些API函數(shù)都是用C語言編寫的,通常要求把指針作為其參數(shù)。但在許多情況下,還可以使用DllImport聲明,以避免使用指針,例如使用System.IntPtr類。
● 性能。在一些情況下,速度是最重要的,而指針可以提供最優(yōu)性能。假定用戶知道自己在做什么,就可以確保以最高效的方式訪問或處理數(shù)據(jù)。但是,注意在代碼的其他區(qū)域中,不使用指針,也可以對性能做必要的改進(jìn)。請使用代碼配置文件,查找代碼中的瓶頸,代碼配置文件隨VS2005一起安裝。
但是,這種低級內(nèi)存訪問也是有代價的。使用指針的語法比引用類型更復(fù)雜。而且,指針使用起來比較困難,需要非常高的編程技巧和很強的能力,仔細(xì)考慮代碼所完成的邏輯操作,才能成功地使用指針。如果不仔細(xì),使用指針很容易在程序中引入微妙的難以查找的錯誤。例如很容易重寫其他變量,導(dǎo)致堆棧溢出,訪問某些沒有存儲變量的內(nèi)存區(qū)域,甚至重寫.NET運行庫所需要的代碼信息,因而使程序崩潰。
另外,如果使用指針,就必須為代碼獲取代碼訪問安全機(jī)制的高級別信任,否則就不能執(zhí)行。在默認(rèn)的代碼訪問安全策略中,只有代碼運行在本地機(jī)器上,這才是可能的。如果代碼必須運行在遠(yuǎn)程地點,例如Internet,用戶就必須給代碼授予額外的許可,代碼才能工作。除非用戶信任您和代碼,否則他們不會授予這些許可,第19章將討論代碼訪問安全性。
盡管有這些問題,但指針在編寫高效的代碼時是一種非常強大和靈活的工具,這里就介紹指針的使用。 注意:
這里強烈建議不要使用指針,因為如果使用指針,代碼不僅難以編寫和調(diào)試,而且無法通過CLR的內(nèi)存類型安全檢查(詳見第1章)。
1. 編寫不安全的代碼
因為使用指針會帶來相關(guān)的風(fēng)險,所以C#只允許在特別標(biāo)記的代碼塊中使用指針。標(biāo)記代碼所用的關(guān)鍵字是unsafe。下面的代碼把一個方法標(biāo)記為unsafe: unsafe int GetSomeNumber()
{
// code that can use pointers
}
任何方法都可以標(biāo)記為unsafe—— 無論該方法是否應(yīng)用了其他修飾符(例如,靜態(tài)方法、虛擬方法等)。在這種方法中,unsafe修飾符還會應(yīng)用到方法的參數(shù)上,允許把指針用作參數(shù)。還可以把整個類或結(jié)構(gòu)標(biāo)記為unsafe,表示所有的成員都是不安全的:
unsafe class MyClass
{
// any method in this class can now use pointers
}
同樣,可以把成員標(biāo)記為unsafe:
class MyClass
{
unsafe int *pX; // declaration of a pointer field in a class
}
也可以把方法中的一個代碼塊標(biāo)記為unsafe:
void MyMethod()
{
// code that doesn't use pointers
unsafe
{
// unsafe code that uses pointers here
}
// more 'safe' code that doesn't use pointers
}
但要注意,不能把局部變量本身標(biāo)記為unsafe:
int MyMethod()
{
unsafe int *pX; // WRONG
}
如果要使用不安全的局部變量,就需要在不安全的方法或語句塊中聲明和使用它。在使用指針前還有一步要完成。C#編譯器會拒絕不安全的代碼,除非告訴編譯器代碼包含不安全的代碼塊。標(biāo)記所用的關(guān)鍵字是unsafe。因此,要編譯包含不安全代碼塊的文件MySource.cs(假定沒有其他編譯器選項),就要使用下述命令:
csc /unsafe MySource.cs
或者 csc –unsafe MySource.cs
注意: 如果使用Visual Studio 2005,就可以在項目屬性窗口中找到編譯不安全代碼的選項。
2. 指針的語法
把代碼塊標(biāo)記為unsafe后,就可以使用下面的語法聲明指針: int* pWidth, pHeight;
double* pResult;
byte*[] pFlags;
這段代碼聲明了4個變量,pWidth和pHeight是整數(shù)指針,pResult是double型指針,pFlags是byte型的指針數(shù)組。我們常常在指針變量名的前面使用前綴p來表示這些變量是指針。在變量聲明中,符號*表示聲明一個指針,換言之,就是存儲特定類型的變量的地址。
提示:
C++開發(fā)人員應(yīng)注意,這個語法與C#中的語法是不同的。C#語句中的int* pX, pY; 對應(yīng)于C++ 語句中的 int *pX, *pY;在C#中,*符號與類型相關(guān),而不是與變量名相關(guān)。
聲明了指針類型的變量后,就可以用與一般變量的方式使用它們,但首先需要學(xué)習(xí)另外兩個運算符:
● & 表示“取地址”,并把一個值數(shù)據(jù)類型轉(zhuǎn)換為指針,例如int轉(zhuǎn)換為*int。這個運算符稱為尋址運算符。
● * 表示“獲取地址的內(nèi)容”,把一個指針轉(zhuǎn)換為值數(shù)據(jù)類型(例如,*float轉(zhuǎn)換為float)。這個運算符稱為“間接尋址運算符”(有時稱為“取消引用運算符”)。
從這些定義中可以看出,&和*的作用是相反的。
注意:
符號&和*也表示按位AND(&)和乘法(*)運算符,那么如何以這種方式使用它們?答案是在實際使用時它們是不會混淆的:用戶和編譯器總是知道在什么情況下這兩個符號有什么含義,因為按照新指針的定義,這些符號總是以一元運算符的形式出現(xiàn)—— 它們只作用于一個變量,并出現(xiàn)在代碼中變量的前面。另一方面,按位AND和乘法運算符是二元運算符,它們需要兩個操作數(shù)。
下面的代碼說明了如何使用這些運算符:
int x = 10;
int* pX, pY;
pX = &x;
pY = pX;
*pY = 20;
首先聲明一個整數(shù)x,其值是10。接著聲明兩個整數(shù)指針pX和pY。然后把pX設(shè)置為指向x(換言之,把pX的內(nèi)容設(shè)置為x的地址)。把pX的值賦予pY,所以pY也指向x。最后,在語句*pY = 20中,把值20賦予pY指向的地址。實際上是把x的內(nèi)容改為20,因為pY指向x。注意在這里,變量pY和x之間沒有任何關(guān)系。只是此時pY碰巧指向存儲x的存儲單元而已。
要進(jìn)一步理解這個過程,假定x存儲在堆棧的存儲單元0x12F8C4到0x12F8C7中(十進(jìn)制就是1243332到1243335,即有4個存儲單元,因為int占用4字節(jié))。因為堆棧向下分配內(nèi)存,所以變量pX存儲在0x12F8C0到 0x12F8C3的位置上,pY存儲在0x12F8BC 到 0x12F8BF的位置上。注意,pX和pY也分別占用4字節(jié)。這不是因為int占用4字節(jié),而是因為在32位處理器上,需要用4字節(jié)存儲一個地址。利用這些地址,在執(zhí)行完上述代碼后,堆棧應(yīng)如圖11-5所示。
圖 11-5 注意:
這個示例使用int來說明該過程,其中int存儲在32位處理器中堆棧的連續(xù)空間上,但并不是所有的數(shù)據(jù)類型都會存儲在連續(xù)的空間中。原因是32位處理器最擅長于在4字節(jié)的內(nèi)存塊中獲取數(shù)據(jù)。這種機(jī)器上的內(nèi)存會分解為4字節(jié)的塊,在Windows上,每個塊都時常稱為DWORD,因為這是32位無符號int在.NET出現(xiàn)之前的名字。這是從內(nèi)存中獲取DWORD的最高效的方式—— 跨越DWORD邊界存儲數(shù)據(jù)通常會降低硬件的性能。因此,.NET運行庫通常會給某些數(shù)據(jù)類型加上一些空間,使它們占用的內(nèi)存是4的倍數(shù)。例如,short數(shù)據(jù)占用2字節(jié),但如果把一個short放在堆棧中,堆棧指針仍會減少4,而不是2,這樣,下一個存儲在堆棧中的變量就仍從DWORD的邊界開始存儲。
可以把指針聲明為任意一種數(shù)據(jù)類型—— 即任何預(yù)定義的數(shù)據(jù)類型uint、int和byte等,也可以聲明為一個結(jié)構(gòu)。但是不能把指針聲明為一個類或數(shù)組,因為這么做會使垃圾收集器出現(xiàn)問題。為了正常工作,垃圾收集器需要知道在堆上創(chuàng)建了什么類實例,它們在什么地方。但如果代碼使用指針處理類,將很容易破壞堆中.NET運行庫為垃圾收集器維護(hù)的與類相關(guān)的信息。在這里,垃圾收集器可以訪問的數(shù)據(jù)類型稱為托管類型,而指針只能聲明為非托管類型,因為垃圾收集器不能處理它們。
3. 將指針轉(zhuǎn)換為整數(shù)類型
由于指針實際上存儲了一個表示地址的整數(shù),所以任何指針中的地址都可以轉(zhuǎn)換為任何整數(shù)類型。指針到整數(shù)類型的轉(zhuǎn)換必須是顯式指定的,隱式的轉(zhuǎn)換是不允許的。例如,編寫下面的代碼是合法的: int x = 10;
int* pX, pY; pX = &x; pY = pX; *pY = 20; uint y = (uint)pX; int* pD = (int*)y; 把指針pX中包含的地址轉(zhuǎn)換為一個uint,存儲在變量y中。接著把y轉(zhuǎn)換回int*,存儲在新變量pD中。因此pD也指向x的值。 把指針的值轉(zhuǎn)換為整數(shù)類型的主要目的是顯示它。Console.Write()和Console. WriteLine()方法沒有帶指針的重載方法,所以必須把指針轉(zhuǎn)換為整數(shù)類型,這兩個方法才能接受和顯示它們:
Console.WriteLine("Address is" + pX); // wrong – will give a
// compilation error
Console.WriteLine("Address is" + (uint) pX); // OK
可以把一個指針轉(zhuǎn)換為任何整數(shù)類型,但是,因為在32位系統(tǒng)上,地址占用4字節(jié),把指針轉(zhuǎn)換為不是uint、long 或 ulong的數(shù)據(jù)類型,肯定會導(dǎo)致溢出錯誤(int也可能導(dǎo)致這個問題,因為它的取值范圍是–20億~20億,而地址的取值范圍是0~40億
本文來自CSDN博客,轉(zhuǎn)載請標(biāo)明出處:http://blog.csdn.net/dz_huanbao/archive/2008/11/17/3313449.aspx |
|