天天看點

在托管代碼中重新發現丢失的記憶體優化藝術在托管代碼中重新發現丢失的記憶體優化藝術

在托管代碼中重新發現丢失的記憶體優化藝術在托管代碼中重新發現丢失的記憶體優化藝術

show toc 歡迎來到 MSDN > .NET 開發

在托管代碼中重新發現丢失的記憶體優化藝術

釋出日期: 2/25/2005 | 更新日期: 2/25/2005

Erik Brown

本文讨論:

對象類型如何影響記憶體用法
對象池如何影響垃圾回收
通路大量資料時的資料流式傳輸
記憶體利用分析
本文使用下列技術: .NET Framework, C# 代碼下載下傳可從以下位置獲得: MemoryOptimization.exe (136KB)
在托管代碼中重新發現丢失的記憶體優化藝術在托管代碼中重新發現丢失的記憶體優化藝術
本頁内容
在托管代碼中重新發現丢失的記憶體優化藝術在托管代碼中重新發現丢失的記憶體優化藝術
類型大小調整
在托管代碼中重新發現丢失的記憶體優化藝術在托管代碼中重新發現丢失的記憶體優化藝術
單元素
在托管代碼中重新發現丢失的記憶體優化藝術在托管代碼中重新發現丢失的記憶體優化藝術
池機制
在托管代碼中重新發現丢失的記憶體優化藝術在托管代碼中重新發現丢失的記憶體優化藝術
資料流
在托管代碼中重新發現丢失的記憶體優化藝術在托管代碼中重新發現丢失的記憶體優化藝術
性能監視
在托管代碼中重新發現丢失的記憶體優化藝術在托管代碼中重新發現丢失的記憶體優化藝術
CLR 分析器
在托管代碼中重新發現丢失的記憶體優化藝術在托管代碼中重新發現丢失的記憶體優化藝術
小結

記憶體是所有程式都需要的一種資源,然而明智的記憶體用法正在變成丢失的藝術。為 Microsoft ® .NET Framework 編寫的托管應用程式依靠垃圾回收器來配置設定和清理記憶體。對于很多應用程式而言,花費 3% 到 5% 的 CPU 時間來執行垃圾回收 (GC) 是一個公平的折衷方案,這樣就無須擔心記憶體管理問題。

但是,對于 CPU 時間和記憶體都是寶貴資源的應用程式而言,盡量減少花費在垃圾回收方面的時間可以大大提高應用程式的性能和健壯性。如果應用程式可以更有效地使用可用記憶體,則垃圾回收器的運作頻率就會降低,并且運作的時間也會縮短。是以,請不要在應用程式中考慮垃圾回收器做什麼或者不做什麼,而要直接考慮記憶體用法。

大多數生産計算機都具有數量巨大的 RAM,并且從全局來看,諸如使用短整數而不是正常整數之類的優化可能似乎沒有多大意義。在本文中,我将改變您的看法。我将考察類型大小調整、各種設計技術以及如何分析程式的記憶體利用。我的示例将重點讨論 C#,但是該讨論同樣适用于 Visual Basic ® .NET、托管 C++ 以及您能夠想到的其他任何面向 .NET 的語言。

我假設您了解有關垃圾回收工作方式的基礎知識,包括相關的概念,如生成、處置模式和弱引用。如果您不熟悉這些概念,則請參閱 Jeffrey Richter 撰寫的有關垃圾回收的出色文章:Garbage Collection:Automatic Memory Management in the Microsoft .NET Framework。

類型大小調整

記憶體用法最終取決于程式中的程式集所定義和使用的類型,是以讓我們首先分析一下系統中各種類型的大小。

1

顯示了 System 命名空間中定義的核心 .NET 值類型的大小(位元組),以及它們等效的 C# 類型。我使用不安全的代碼和 C# sizeof 運算符來驗證這些值類型在托管記憶體中的大小。對于其中一些類型(包括 bool 和 char),使用 Marshal.SizeOf 方法而不是 sizeof 運算符會産生不同的值,這是由于 Marshal.SizeOf 計算封送處理類型的非托管大小,并且這些類型不是直接複制到本機結構中的(這意味着它們在托管代碼和非托管代碼之間傳遞時,可能需要轉換)。稍後将對此進行詳細讨論。

結構(值類型)的大小被計算為其字段大小的總和,外加由于将這些字段與其自然邊界對齊而增加的任何開銷。引用類型的大小是其字段大小向上舍入到下一個 4 位元組邊界,外加 8 位元組的開銷。(要了解您的引用類型使用多少空間,您可以度量堆大小在配置設定它們時的變化,或者可以使用稍後讨論的 CLR 分析器工具。)這意味着所有引用類型都至少占用 12 位元組,是以在 C# 中,長度小于 16 位元組的任何對象作為結構可能更有效一些。當然,如果您需要存儲類型的引用,則結構會有問題,因為頻繁的裝箱可能耗盡記憶體和 CPU 周期。因而,謹慎地使用結構是很重要的。

由于字段對齊可能影響類型的大小,是以類型内部的字段組織在其最終大小方面扮演重要的角色。類型的布局以及具有該布局的字段的組織受到應用于類型的 StructLayoutAttribute 的影響。預設情況下,C#、Visual Basic .NET 和 C++ 編譯器都将 StructLayoutAttribute 應用于結構,以指定 Sequential 布局。這意味着字段按照它們在源檔案中的順序布置在類型中。但是,在 .NET Framework 1.x 中,對 Sequential 布局的請求不會被即時編譯器 (JIT) 遵守,即使該請求是由封送拆收器提出的。在 .NET Framework 2.0 中,JIT 确實為值類型的托管布局實施了 Sequential 布局(如果指定的話),盡管前提是沒有引用類型字段成員。因而,在下一個版本的 Framework 中,類型的大小調整可能會更加重要。在所有版本中,對 Explicit 布局(其中,由開發人員指定每個字段的字段偏移量)的請求同時被 JIT 和封送拆收器遵守。

我之是以進行這一區分,是因為類型的封送布局通常與該類型的堆棧或 GC 堆布局不同。封送類型的布局必須與它的非托管等效類型的布局比對。但是,托管布局隻在由 JIT 編譯的托管代碼中使用。是以,JIT 能夠基于目前平台優化托管布局,而無須關注外部依賴項。

請考慮以下 C# 結構(為簡單起見,我已經避免為下列成員指定任何通路修飾符):

struct BadValueType
{
    char c1;
    int i;
    char c2;
}
      

就像非托管 C++ 中的預設封裝一樣,整數在四位元組邊界上布局,是以盡管第一個字元使用兩個位元組(托管代碼中的 char 是 Unicode 字元,因而占據兩個位元組),但該整數向上移動至下一個 4 位元組邊界,并且第二個字元使用随後的 2 個位元組。得到的結構在用 Marshal.SizeOf 度量時是 12 個位元組(當用在我的 32 位計算機上運作的 .NET Framework 2.0 上的 sizeof 度量時,也是 12 個位元組)。如果我将其重新組織為如下所示的結構,則對齊方式将如我所願,進而得到 8 位元組結構:

struct GoodValueType
{
    int i;
    char c1;
    char c2;
}
      

另一個值得注意的問題是較小的類型使用較少的記憶體。這似乎顯而易見,但是很多項目使用标準整數或十進制值,即使并不需要它們。在我的 GoodValueType 示例中,假定整數值永遠不會大于 32767 或小于 -32768,我可以通過使用短整數進一步降低該類型的大小,如下所示:

struct GoodValueType2
{
    short i;
    char c1;
    char c2;
}
      

正确地對齊該類型和進行大小調整可以将其從 12 位元組減小到 6 位元組。(Marshal.SizeOf 會為 GoodValueType2 報告 4 位元組,但那是因為 char 的預設封送處理是作為 1 位元組值進行的。)如果您予以關注的話,您會對結構和類的大小可以降低的幅度感到驚訝。

正如前面提到的那樣,非常重要的一點是意識到結構的托管布局可能與非托管布局極為不同,尤其是在 .NET Framework 1.x 中。封送處理的布局可能不同于内部布局,是以在使用 sizeof 運算符時,我已經描述的類型可能(實際上是非常可能)報告不同的結果。例如,我迄今為止已經說明的全部三個結構在 .NET Framework 1.x 中都具有 8 位元組的托管大小。您可以使用不安全的代碼和指針運算,通過 JIT 分析其中一個類型的布局:

unsafe
{
    BadValueType t = new BadValueType();
    Console.WriteLine("Size of t: {0}", sizeof(BadValueType));
    Console.WriteLine("Offset of i:  {0}", (byte*)&t.i - (byte*)&t);
    Console.WriteLine("Offset of c1: {0}", (byte*)&t.c1 - (byte*)&t);
    Console.WriteLine("Offset of c2: {0}", (byte*)&t.c2 - (byte*)&t);
}
      

在 .NET Framework 1.x 中,運作該代碼會産生以下輸出:

Size of BadValueType: 8
Offset of i:  0
Offset of c1: 4
Offset of c2: 6
      

然而,在 .NET Framework 2.0 中,相同的代碼将産生以下輸出:

Size of BadValueType: 12
Offset of i:  4
Offset of c1: 0
Offset of c2: 8
      

盡管較新版本的 Framework 增加該類型的大小似乎是一個倒退,但 JIT 現在遵守指定的布局實際上是令人期待的行為,并且是一件好事情。如果您甯願讓 JIT 自動确定最佳布局(進而産生與 1.x JIT 目前生成的輸出相同的輸出),則可以用 StructLayoutAttribute 顯式标記您的結構,以指定 LayoutKind.Auto。隻是需要記住,對于在 .NET Framework 1.x 上運作的不與非托管代碼進行任何互操作的純粹托管應用程式而言,通過對字段進行手動排序以獲得更好的對齊方式而努力節約的記憶體可能是難以捉摸的。

2

說明了其他一些注意事項。顯示的 Address 類表示美國位址。該類型為 36 位元組長:每個成員 4 位元組,外加與它的引用類型開銷相對應的 8 位元組(請注意,C# 中的 sizeof 運算符隻适用于值類型,是以我再次依賴于 Marshal.SizeOf 所報告的值)。對向醫生和醫院進行的支付行為進行管理的大型醫療應用程式可能需要同時處理成千上萬個位址。在這種情況下,最大限度減小該類的大小可能很重要。該類型内部的排序還不錯,但是請考慮 AddressType(參見

2

)。

盡管預設情況下枚舉被存儲為整數,但您可以指定要使用的整型基類型。

3

将 AddressType 枚舉定義為短整型。同時,通過将 IsPayTo 字段更改為 byte,我已經将每個 Address 執行個體的非托管大小減小了 10% 以上(從 36 位元組到 32 位元組),并且至少将托管大小減小了 2 位元組。

最後,字元串類型是引用類型,是以每個字元串執行個體都引用一個附加的記憶體塊,以存放實際的字元串資料。在 Address 類型中,如果我忽略各種美國其他類型的領土,則 state 字段具有 50 個可能的值。這裡,使用枚舉可能值得考慮,因為它可以消除對引用類型的需要,并且将值直接存儲到類中。枚舉的基類型可以是 byte 而不是預設的 int,進而導緻字段需要 1 位元組而不是 4 位元組。盡管這是一種可行的替代方案,但它确實會使資料顯示和存儲複雜化,因為每次通路或存儲整數值時,都必須将其轉換為使用者或存儲機制能夠了解的某種形式。這種情況揭示了計算方面的更常見的折衷方案中的一種:用速度換取記憶體。以一些 CPU 周期為代價來優化記憶體用法通常是可能的,反之亦然。

這裡,一種替代選擇是使用 Interned 字元串。CLR 維護一個名為“Intern pool”的表,該表包含程式中的文字字元串。這可以確定在代碼中重複使用相同的常量字元串時,可以利用相同的字元串引用。System.String 類提供了 Intern 方法,以確定字元串位于“Intern pool”中,并且傳回對它的引用。

3 對此進行了說明。

在我結束對類型大小調整的讨論之前,我還希望提一下基類。派生類的大小等于基類加上派生執行個體定義的其他成員(以及對齊所必需的任何額外空間 — 如前所述)的大小。是以,派生類型中未使用的任何基本字段都會浪費良好的記憶體。基類可以很好地定義常見功能,但是您必須確定所定義的每個資料元素都是真正需要的。

接下來,我将讨論一些用于有效管理記憶體的設計和實作技術。程式集需要的記憶體主要取決于該程式集所做的工作,但是程式集實際使用的記憶體受到應用程式從事其各種任務的方式的影響。在設計和實作應用程式時,這是一個需要記住的重要特點。我将分析單元素、記憶體池和資料流的概念。

在托管代碼中重新發現丢失的記憶體優化藝術在托管代碼中重新發現丢失的記憶體優化藝術

傳回頁首

單元素

應用程式的工作集是 RAM 中目前可用的記憶體頁的集合。初始工作集是應用程式在啟動期間消耗的記憶體頁。在應用程式啟動期間執行的任務和配置設定的記憶體越多,應用程式準備的時間就越長,初始工作集就越大。這對于桌面應用程式而言尤其重要,因為使用者通常盯着啟動畫面以等待應用程式進行準備。

單元素模式可以用來盡可能久地延遲對象的初始化。以下代碼顯示了在 C# 中實作該模式的一個方式。靜态字段存放單元素執行個體,該執行個體由 GetInstance 方法傳回。靜态構造函數(它由 C# 編譯器隐式生成,以執行所有靜态字段初始值設定項)被保證在第一次通路該類的成員之前執行并且初始化靜态執行個體,如以下代碼所示:

public class Singleton
{
    private static Singleton _instance = new Singleton();
    public static Singleton GetInstance()
    {
        return _instance;
    }
}
      

單元素模式可以確定應用程式通常隻使用類的單個執行個體,但是仍然允許根據需要建立備用執行個體。這可以節約記憶體,因為應用程式可以使用一個共享執行個體,而不是讓不同的元件配置設定它們自己的私有執行個體。使用靜态構造函數可以確定在應用程式的某個部分需要該共享執行個體之前,不會為該執行個體配置設定記憶體。這在支援很多不同類型功能的大型應用程式中可能很重要,因為隻有在實際使用該類時,才會配置設定對象的記憶體。

該模式和類似的技術有時稱為“惰性初始化”,原因是在實際需要之前不執行初始化。在很多情況下,當初始化可以作為針對對象的第一個請求的一部分發生時,惰性初始化很有用。在靜态方法足可以滿足需要的情況下,不應當使用它。換句話說,如果您要建立單元素以便通路該單元素類的一批執行個體成員,則請考慮通過靜态成員公開相同的功能是否更合理,因為那樣将不需要執行個體化該單元素。

在托管代碼中重新發現丢失的記憶體優化藝術在托管代碼中重新發現丢失的記憶體優化藝術

傳回頁首

池機制

一旦應用程式啟動并運作,記憶體利用就将受到系統需要的對象的數量和大小的影響。對象池機制可以降低應用程式所需的配置設定的數量,進而降低應用程式所需的垃圾回收的數量。池機制相當簡單:對象被重新使用,而不是讓它被垃圾回收器回收。對象存儲在某種類型的清單或數組(稱為“池”)中,并且根據請求分發給用戶端。如果對象的執行個體被反複使用,或者如果對象的建構具有開銷較大的初始化方面,以至于重新使用現有執行個體要比處置一個現有執行個體并且從頭建立一個全新的執行個體更好一些,則該機制尤其有用。

讓我們考慮一個可以有效使用對象池的方案。假設您要為一家大型保險公司編寫一個系統,以便将患者資訊存檔。醫生在白天收集資訊,并且在每個晚上将資訊傳輸到中心位置。代碼可能包含完成如下工作的循環:

while (IsRecordAvailable())
{
    PatientRecord record = GetNextRecord();
    ... // process record
}
      

在該循環中,一個新的 PatientRecord 在每次執行循環時傳回。GetNextRecord 方法的最顯而易見的實作是在每次調用時建立一個新對象,進而需要配置設定和初始化該對象,最終對該對象進行垃圾回收,以及終結該對象(如果該對象具有完成器的話)。在使用對象池時,配置設定、初始化、回收和終結隻發生一次,進而減少了記憶體使用和所需的處理時間。

在某些情況下,可以重新編寫代碼,以便用如下代碼利用該類型上的 Clear 方法:

PatientRecord record = new PatientRecord();
while (IsRecordAvailable())
{
    record.Clear();
    FillNextRecord(record);
    ... // process record
}
      

在該代碼片段中,建立了單個 PatientRecord 對象,并且 Clear 方法重置了内容以便可以在循環内部重新使用它。FillNextRecord 方法使用現有對象,進而避免了重複配置設定。當然,該代碼片段每次執行時,您仍然要付出配置設定、初始化和回收一次的代價(盡管如此,這仍然要比每次執行循環時付出相應的代價更好一些)。如果初始化的代價高昂,或者從多個線程中同時調用該代碼,則這一重複建立的影響仍然可能成為問題。

對象池的基本模式如下所示:

while (IsRecordAvailable())
{
    PatientRecord record = Pool.GetObject();
    record.Clear();
    FillNextRecord(record);
    ... // process record
    Pool.ReleaseObject(record);
}
      

在應用程式啟動時,建立了一個 PatientRecord 執行個體或者建立了執行個體池。代碼從池中檢索到一個執行個體,進而避免了記憶體配置設定、建構和最終的垃圾回收。這可以大大節約時間和記憶體,盡管它要求程式員顯式管理池中的對象。

.NET Framework 為 COM+ 程式集提供了對象池,以作為其 Enterprise Services 支援的一部分。對該功能的通路是通過 System.EnterpriseServices.ObjectPoolingAttribute 類提供的。Rocky Lhotka 撰寫了一篇有關該功能的好文章:Everyone Into the Pool。COM+ 自動提供池支援,是以您無須記住顯式檢索和傳回對象。另一方面,程式集必須在 COM+ 内部操作。

為了彙集任何 .NET 對象,我認為針對本文編寫一個通用的對象池會很有趣。我的對應于該類的接口顯示在

4

中。ObjectPool 類為任何 .NET 類型提供了彙聚。

在彙聚類型之前,首先必須注冊它。注冊可以辨別建立委托,以便在需要對象的新執行個體時調用。該委托隻是傳回剛剛執行個體化的對象,而将建構邏輯留待提供該委托的用戶端完成。像 Enterprise Services ObjectPooling 屬性一樣,它還接受要在池中保持活動的對象的最低數量,允許該池具有的對象的最高數量以及等待可用對象的時間長度的逾時值。如果逾時為零,則調用方将總是等待,直到空閑對象可用為止。在實時情況或其他情況(如果無法很快獲得對象則或許需要替代操作)下,非零逾時會很有用。在注冊調用傳回之後,該池就可以提供所請求的最低數量的對象。可以用 UnregisterType 方法終止給定類型的彙聚。

注冊之後,GetObject 和 ReleaseObject 方法可以分别從該池中檢索對象以及将對象傳回到該池中。ExecuteFromPool 方法除了接受所需類型以外,還接受委托和參數。Execute 方法用該池中的對象調用給定的委托,并且確定在該委托完成之後将檢索到的對象傳回到該池中。這會增加委托調用的開銷,但是使您無須手動管理該池。

在内部,類負責維護所彙聚的全部對象的哈希表。它定義了 ObjectData 類以存放與每個類型相關的内部資料。這裡沒有顯示該類,但是該類負責維護注冊資訊并記錄類型的使用資訊,而且維護所彙聚對象的隊列。

ReleaseObject 方法在内部使用私有的 ReturnToPool 方法,用給定對象重新填充該池,如

5

所示。Monitor 類用于鎖定該操作。如果可用對象的數量低于最低數量,則對象的引用被放置到隊列中。如果已經配置設定了最低數量的對象,則對象的弱引用被放置到隊列中。如果需要,則通知等待線程選取剛剛進入隊列的對象。

在此使用弱引用可以使除最低數量對象以外的對象盡可能久地保持活躍狀态,但是使它們可供 GC 根據需要使用。ObjectData 的 inUse 字段用于跟蹤提供給應用程式的對象,而 inPool 字段則用于跟蹤池中有多少實際引用。InPool 字段忽略任何弱引用。

在創作池時需要實作的最重要的功能之一是适當的對象生存期政策。弱引用構成了這樣的一個政策的基礎,但是還有其他一些機制,并且所要使用的政策取決于環境。

對于 GetObject 方法,内部的 RetrieveFromPool 方法顯示在

6

中。Monitor.TryEnter 方法用來確定應用程式不會為鎖等待太久。如果在逾時期間無法獲得鎖,則向調用方傳回 null。如果鎖被占有,則調用 DequeueFromPool 方法以從該池中檢索對象。請注意該方法如何用 do-while 循環來處理隊列中可能存在的弱引用。

重新觀察一下 RetrieveFromPool 代碼,如果在隊列中找不到項,則會通過 AllocateObject 方法配置設定一個新對象,前提是可用對象的數量低于最大數量。一旦達到最大數量,WaitForObject 方法就會等待對象,直到到達建立逾時。請注意在調用 WaitForObject 之前如何調整要等待的時間,以便将為擷取鎖而花費的時間考慮在内。這裡沒有顯示 WaitForObject 代碼,但是可在本文的下載下傳中得到。

對于在檢索逾時發生時應當發生的事情,有兩個選擇:傳回 null 或者引發異常。傳回 null 的缺點是,它強迫調用方在每次從池中獲得對象時都檢查是否為 null。引發異常可以避免該項檢查,但是使逾時的開銷變得更加高昂。如果預料不會逾時,則引發異常可能是一種更好的選擇。我決定傳回 null,因為當預料不會逾時的時候,可以跳過該檢查。當預料會逾時的時候,檢查是否為 null 的成本低于捕獲異常的成本。

7

顯示了 ExecuteFromPool 方法的代碼,但移除了錯誤檢查和注釋。該代碼使用私有方法從池中檢索對象,并且調用所提供的委托。Finally 塊確定了即使發生異常,也會将對象傳回到池中。

對象池機制有助于減小在堆上進行的配置設定的數量,因為可以彙聚應用程式中最常見的對象。這可以消除基于 .NET 的應用程式中托管堆大小常見的鋸齒模式,并且減少了應用程式為執行垃圾回收而花費的時間。稍後,我将考察一個使用 ObjectPool 類的示例程式。

請注意,托管堆在配置設定新對象方面非常有效,而垃圾回收器在收集大量生存期較短的小型對象方面非常有效。如果對象的使用頻率不高,或者對象的建立或銷毀成本不高,則對象池可能不是正确的政策。與任何性能決策一樣,分析應用程式是掌握代碼中真正瓶頸的最佳方式。

在托管代碼中重新發現丢失的記憶體優化藝術在托管代碼中重新發現丢失的記憶體優化藝術

傳回頁首

資料流

在管理大塊資料時,有時應用程式隻是需要很多記憶體。對象池隻是有助于減少類配置設定所需的記憶體以及對象建立和銷毀所需的時間。它并未真正關注某些程式必須處理很多資料以執行其工作這一事實。

當經常需要大量資料時,您所能做到的隻是盡可能完美地管理記憶體,或者壓縮資料或采用其他方式使其盡可能緊湊。(同樣,這裡會出現記憶體和速度之間的傳統折衷,因為壓縮減少了記憶體消耗,但該壓縮需要消耗 CPU 周期。)當臨時需要資料時,您或許能夠使用資料流來減少所利用的記憶體的數量。資料流是通過每次使用一部分資料,而不是一次性地使用全部或大部分資料實作的。請比較一下 System.Data 命名空間中的 DataSet 類和 DataReader 類。盡管您可以将查詢的結果直接加載到 DataSet 對象中,但大型查詢結果将消耗大量記憶體。DataSet 還要求兩次通路記憶體:一次用于填充表,一次用于以後讀取這些表。DataReader 類可以遞增地加載同一查詢的結果,并且一次向應用程式送出單個行。當實際上并不需要整個結果集時,這将是理想的,因為它能夠更有效地使用可用記憶體。

String 類提供了很多無意中消耗大量記憶體的機會。最簡單的示例是字元串串聯。以遞增方式串聯四個字元串(每次向新字元串中添加一個字元串)将在内部産生七個字元串對象,因為每個添加操作都會産生一個新的字元串。System.Text 命名空間中的 StringBuilder 類無須每次配置設定新的字元串執行個體即可将字元串連接配接在一起;這一效率大大改進了記憶體的利用。C# 編譯器也可以在此方面提供幫助,因為它将同一代碼語句中的一系列字元串串連操作轉換為對 String.Concat 的單個調用。

String.Replace 方法提供了另外一個示例。請考慮一個讀取并處理發送自某個外部源的很多輸入檔案的系統。這些檔案可能需要進行預處理,以便将它們轉換為适當的格式。為了便于讨論,假設我有一個系統,它必須将單詞“nation”的每個執行個體替換為“country”,将單詞“liberty”的每個執行個體替換為“freedom”。這可以很容易地用以下代碼片段完成:

using(StreamReader sr = new StreamReader(inPath))
{
    string contents = sr.ReadToEnd();
    string result = contents.Replace("nation", "country");
    result = result.Replace("liberty", "freedom");
    using(StreamWriter sw = new StreamWriter(outPath))
    {
        sw.Write(result)
    }
}
      

這可以完美地工作,代價是需要建立三個長度與檔案相同的字元串。Gettysburg Address 大約是 2400 個位元組的 Unicode 文本。U.S. Constitution 是 50,000 個位元組以上的 Unicode 文本。您可以看到這會有什麼後果。

現在,假設每個檔案為大約 1MB 的字元串資料,并且我必須并發地處理多達 10 個檔案。在我們的簡單示例中,讀取和處理這 10 個檔案将消耗大約 10MB 的字元串資料。垃圾回收器要經常配置設定和清理該記憶體,其數量是相當巨大的。

通過流式傳輸檔案,我們可以每次考慮一小部分資料。每當我找到 N 或 L,我都會尋找上述單詞并根據需要替換它們。示例代碼顯示在

8

中。我使用該代碼中的 FileStream 類來說明如何在位元組級别操作資料。如果您願意的話,則可以對其進行修改,并且改而使用 StreamReader 和 StreamWriter 類。

在該代碼中,ProcessFile 方法接收兩個流并每次讀取一個位元組,同時查找 N 或 L。當找到其中一個時,CheckForWordAndWrite 方法分析該流,以檢視随後的字元是否比對所需的單詞。如果找到比對項,則将替換單詞寫入輸出流。否則,将原來的字元放到輸出流中,并且将輸入流重置到原始位置。

該方法依靠 FileStream 類來适當地緩沖輸入和輸出檔案,以便代碼可以逐個位元組地執行必要的處理。預設情況下,每個 FileStream 都使用 8KB 的緩沖區,是以該實作所使用的記憶體要遠遠少于前面讀取并處理整個檔案的代碼。即使如此,對于輸入流中的大多數字元,該過程都會對 FileStream.ReadByte 進行函數調用,并且對 FileStream.WriteByte 進行函數調用。您或許能夠通過每次将一系列位元組讀取到緩沖區中找到更加令人高興的方法,進而減少方法調用。同樣,分析器可以成為您的朋友。

.NET 中建構流類的目的是使多個流可以在公共基礎流上協同工作。很多派生自 Stream 的類都包含一個采用現有 Stream 對象的構造函數,進而使一連串的 Stream 對象可以對傳入的資料進行操作,并且對該流産生一連串的修改或轉換。有關示例,請參閱 CryptoStream 類的 .NET Framework 文檔,它說明了如何加密來自傳入的 FileStream 對象的位元組數組。

既然我已經分析了一些與記憶體利用相關的設計和實作問題,那麼對測試和調整應用程式進行簡要讨論将是适當的。幾乎所有應用程式都一定會具有各種性能和記憶體問題。發現這些問題的最佳方式是顯式度量這些項目,并且在問題暴露時對它們進行跟蹤。Windows® 性能計數器以及 .NET CLR 分析器或其他分析器是實作這一目标的兩個非常好的方式。

在托管代碼中重新發現丢失的記憶體優化藝術在托管代碼中重新發現丢失的記憶體優化藝術

傳回頁首

性能監視

Windows 性能螢幕不會解決性能問題,但是它确實可以幫助您識别應當在哪裡尋找這些問題。在 Chapter 15 — Measuring .NET Application Performance 可以獲得與記憶體利用和其他性能規格相關的性能計數器的詳盡清單。

性能調整在理論上是一項疊代任務。在辨別了一組性能規格并且建立了可以應用這些規格的測試環境之後,就可以在該測試環境中運作應用程式。性能資訊是使用性能螢幕收集的。結果将被分析以産生一些建議進行改善的領域。應用程式或配置被基于這些建議進行修改,然後該過程将重新開始。

這一測試、收集、分析和修改系統的過程同樣恰當地适用于性能的所有方面,包括記憶體利用。系統修改可能包括重新編寫代碼的某個部分、更改系統内部的應用程式的配置或分發以及其他更改。

在托管代碼中重新發現丢失的記憶體優化藝術在托管代碼中重新發現丢失的記憶體優化藝術

傳回頁首

CLR 分析器

CLR 分析器工具非常适合于記憶體利用分析。它可以分析正在運作的應用程式的行為,并且提供有關配置設定記憶體的類型、為它們配置設定的記憶體的長度、每次垃圾回收的詳細資訊以及其他與記憶體相關的資訊的詳細報告。您可以從 Tools & Utilities 下載下傳該免費工具。

該分析工具具有很高的幹擾性,是以它不适合于正常的性能分析。但是,對于分析托管堆,它給人的印象非常深刻。為了檢視其功能的小型示例,我編寫了一個小型的 PoolingDemo 程式,以使用我在前面讨論的 ObjectPool 類。為了防備您認為池機制隻适用于大型對象或開銷較大的對象,該示範定義了一個 MyClass 對象,如下所示:

class MyClass {
    Random r = new Random();
    public void DoWork() {
        int x = r.Next(0, 100);
    }
}
      

該程式使您可以在非池測試和池測試之間進行選擇。非池測試的代碼完成以下工作:

public static void BasicNoPooling()
{
    for (int i = 0; i < Iterations; i++)
    {
        MyClass c = new MyClass();
        c.DoWork();
    }
}
      

在我的桌面計算機上,一百萬次疊代需要大約 12 秒鐘才能完成。池代碼避免了在循環内部配置設定 MyClass 對象:

public static void BasicPooling(){ // Register the MyClass type Pool.RegisterType(typeof(MyClass), ...); for (int i = 0; i < Iterations; i++) { MyClass c = (MyClass)Pool.GetObject(typeof(MyClass)); c.DoWork(); Pool.ReleaseObject(c); } Pool.UnregisterType(typeof(MyClass));}

在該代碼中,我使用了靜态 Pool 屬性來調用 ObjectPool.GetInstance。對于一百萬次疊代,該池測試需要大約 1.2 秒鐘才能完成,這大約比非池代碼快 10 倍。當然,我的示例的設計目的是強調與獲得和釋放對象執行個體的引用相關聯的成本。MyClass.DoWork 幾乎肯定由 JIT 編譯器内聯,而每次疊代節約的時間(一百萬次以上疊代節約 10 秒鐘)很小。盡管如此,該示例仍然說明了對象池如何消除一定數量的開銷。在這種開銷很重要或者建立或終結對象的時間很長的情況下,對象池可能證明是有益的。

在托管代碼中重新發現丢失的記憶體優化藝術在托管代碼中重新發現丢失的記憶體優化藝術
9

使用對象池的時間線視圖

将疊代次數減少到 100,000 次并且對該代碼運作 CLR 分析器可以産生一些有趣的結果。圖 9 顯示了使用對象池時的時間線視圖,而圖 10 顯示了沒有使用對象池時的時間線視圖。該視圖顯示了托管堆的時間線(不同的類型由不同的顔色表示),并且包含每次垃圾回收的計時。在圖 9 中,池機制産生了相當平坦的堆,并且隻在應用程式退出時執行一次垃圾回收。在圖 10 中,沒有使用池機制,堆必須恢複由每個 Random 類配置設定的資料。紅色表示整數數組,它是批量資料。不使用對象池時的時間線視圖顯示,非池測試執行了 11 次垃圾回收。

在托管代碼中重新發現丢失的記憶體優化藝術在托管代碼中重新發現丢失的記憶體優化藝術
10

未使用對象池時的時間線視圖

CLR 分析器還可以按類類型或者随着時間的流逝顯示配置設定視圖,辨別每個方法所配置設定的位元組數量,并且顯示在整個測試周期中執行的方法序列。CLR 分析器下載下傳(可在 MSDN?Magazine Web 站點獲得)包含一些内容相當廣泛的文檔,其中一個部分包含示例代碼,以說明常見的垃圾回收問題以及它們在各種 CLR 分析器視圖中是如何表現的。

在托管代碼中重新發現丢失的記憶體優化藝術在托管代碼中重新發現丢失的記憶體優化藝術

傳回頁首

小結

我敢肯定,現在您正在以不同的方式考慮代碼中的記憶體利用 — 哪裡很好,以及哪裡還有待改進。我已經在這裡讨論了一系列的問題 — 從類型的大小調整到可以幫助您發現代碼中記憶體問題的工具。我讨論了以下内容的性能和記憶體好處:彙聚頻繁使用的對象,而不是依靠 .NET 運作庫來配置設定對象,然後對對象進行垃圾回收,并且我将流式傳輸視為減少處理大型對象所需記憶體數量的一種方式。剩下的工作就留待您來完成了。

Erik Brown 是 Unisys Corporation(Microsoft 金牌認證夥伴)的一名進階開發人員和架構師。他是 Windows Forms Programming with C# (Manning Publications Company, 2002) 一書的作者。

轉到原英文頁面

在托管代碼中重新發現丢失的記憶體優化藝術在托管代碼中重新發現丢失的記憶體優化藝術
傳回頁首
個人資訊中心 | MSDN中文速遞郵件 | 聯系我們 ©2005 Microsoft Corporation. 版權所有.   保留所有權利 | 商标 | 隐私權聲明
在托管代碼中重新發現丢失的記憶體優化藝術在托管代碼中重新發現丢失的記憶體優化藝術

<script language="javascript" type="text/javascript"> var msviFooter2;if (document.getElementById){msviFooter2 = document.getElementById("msviFooter2");msviFooter2.style.filter = "";} </script>

<script language="javascript" type="text/javascript"> var framesValid = false; if (window.name == "MNPMainFrame") { var menuFrame = parent.frames["MNPMenuFrame"]; if (menuFrame) { framesValid = true; } } if (!framesValid) document.forms["MNPFramesForm"].submit(); else document.write('<layer visibility="hide"><div style="display:none"><img width="0" height="0" hspace="0" vspace="0" src="http://c.microsoft.com/trans_pixel.asp?source=www&amp;TYPE=PV&amp;p=china_MSDN_library_netFramework_netframework&amp;URI=%2fchina%2fMSDN%2flibrary%2fnetFramework%2fnetframework%2fMemoryOptim.mspx&amp;GUID=1F4FC18C-F71E-47FB-8FC9-612F8EE59C61&amp;r=%2fchina%2fMSDN%2flibrary%2fnetFramework%2fnetframework%2fMemoryOptim.mspx"></div></layer>'); top.document.title = self.document.title; </script>

在托管代碼中重新發現丢失的記憶體優化藝術在托管代碼中重新發現丢失的記憶體優化藝術