程序師世界是廣大編程愛好者互助、分享、學習的平台,程序師世界有你更精彩!
首頁
編程語言
C語言|JAVA編程
Python編程
網頁編程
ASP編程|PHP編程
JSP編程
數據庫知識
MYSQL數據庫|SqlServer數據庫
Oracle數據庫|DB2數據庫
 程式師世界 >> 編程語言 >> .NET網頁編程 >> 關於.NET >> 調試內存洩漏的應用程序: 發現並防止托管代碼中出現內存洩漏

調試內存洩漏的應用程序: 發現並防止托管代碼中出現內存洩漏

編輯:關於.NET

本文討論:

理解托管應用程序中的內存洩漏問題

.NET 應用程序中所用的非托管內存

幫助 .NET 垃圾收集器發揮應有功效

本文使用了以下技術:

.NET Framework

目錄

.NET 應用程序中的內存

檢測洩漏

堆棧內存洩漏

非托管堆內存洩漏

“洩漏”托管堆內存

總結

一提到托管代碼中出現內存洩漏,很多開發人員的第一反應都認為這是不可能的。畢竟垃圾收集器 (GC) 會負責管理所有的內存,沒錯吧?但要知道,垃圾收集器只處理托管內存。基於 Microsoft® .NET Framework 的應用程序中大量使用了非托管內存,這些非托管內存既可以被公共語言運行庫 (CLR) 使用,也可以在與非托管代碼進行互操作時被程序員顯式使用。在某些情況下,垃圾管理器似乎在逃避自己的職責,沒有對托管內存進行有效處理。這通常是由於不易察覺的(也可能是非常明顯的)編程錯誤妨礙了垃圾收集器的正常工作而造成的。作為經常與內存打交道的程序員,我們仍需要檢查自己的應用程序,確保它們不會發生內存洩漏並能夠合理有效地使用所需內存。

.NET 應用程序中的內存

您大概已經知道,.NET 應用程序中要使用多種類型的內存,包括:堆棧、非托管堆和托管堆。這裡我們需要簡單回顧一下。

堆棧 堆棧用於存儲應用程序執行過程中的局部變量、方法參數、返回值和其他臨時值。堆棧按照每個線程進行分配,並作為每個線程完成其工作的一個暫存區。垃圾收集器並不負責清理堆棧,因為為方法調用預留的堆棧會在方法返回時被自動清理。但是請注意,垃圾收集器知道在堆棧上存儲的對象的引用。當對象在一種方法中被實例化時,該對象的引用(32 位或 64 位整型值,取決於平台類型)將保留在堆棧中,而對象自身卻存儲於托管堆中,並在變量超出范圍時被垃圾收集器收集。

非托管堆 非托管堆用於運行時數據結構、方法表、Microsoft 中間語言 (MSIL)、JITed 代碼等。非托管代碼根據對象的實例化方式將其分配在非托管堆或堆棧上。托管代碼可通過調用非托管的 Win32® API 或實例化 COM 對象來直接分配非托管堆內存。CLR 出於自身的數據結構和代碼原因廣泛地使用非托管堆。

托管堆 托管堆是用於分配托管對象的區域,同時也是垃圾收集器的域。CLR 使用分代壓縮垃圾收集器。垃圾收集器之所以稱為分代式,是由於它將垃圾收集後保留下來的對象按生存時間進行劃分,這樣做有助於提高性能。所有版本的 .NET Framework 都采用三代分代方法:第 0 代、第 1 代和第 2 代(從年輕代到年老代)。垃圾收集器之所以稱為壓縮式,是因為它將對象重新定位於托管堆上,從而能夠消除漏洞並保持可用內存的連續性。移動大型對象的開銷很高,因此垃圾收集器將這些大型對象分配在獨立的且不會壓縮的大型對象堆上。有關托管堆和垃圾收集器的詳細信息,請參閱 Jeffrey Richter 所著的分為兩部分的系列文章“垃圾收集器:Microsoft .NET Framework 中的自動內存管理”和“垃圾收集器 - 第 2 部分:Microsoft .NET Framework 中的自動內存管理”。雖然該文的寫作是基於 .NET Framework 1.0,而且 .NET 垃圾收集器已經有所改進,但是其中的核心思想與 1.1 版或 2.0 版是保持一致的。

檢測洩漏

很多跡象能夠表明應用程序正在發生內存洩漏。或許應用程序正在引發 OutOfMemoryException。或許應用程序因啟動了虛擬內存與硬盤的交換而變得響應遲緩。或許出現任務管理器中內存的使用率逐漸(也可能突然地)上升。當懷疑應用程序發生內存洩漏時,必須首先確定是哪種類型的內存發生洩漏,以便您將調試工作的重點放在合適的區域。使用 PerfMon 來檢查用於應用程序的下列性能計數器:Process/Private Bytes、.NET CLR Memory/# Bytes in All Heaps 和 .NET CLR LocksAndThreads/# of current logical Threads。Process/Private Bytes 計數器用於報告系統中專門為某一進程分配而無法與其他進程共享的所有內存。.NET CLR Memory/# Bytes in All Heaps 計數器報告第 0 代、第 1 代、第 2 代和大型對象堆的合計大小。.NET CLR LocksAndThreads/# of current logical Threads 計數器報告 AppDomain 中邏輯線程的數量。如果應用程序的邏輯線程計數出現意想不到的增大,則表明線程堆棧發生洩漏。如果 Private Bytes 增大,而 # Bytes in All Heaps 保持不變,則表明非托管內存發生洩漏。如果上述兩個計數器均有所增加,則表明托管堆中的內存消耗在增長。

堆棧內存洩漏

雖然有可能出現堆棧空間不足而導致在受托管的情況下引發 StackOverflowException 異常,但是方法調用期間使用的任何堆棧空間都會在該方法返回後被回收。因此,實際上只有在兩種情況下才會發生堆棧空間洩漏。一種情況是進行一種極其耗費堆棧資源並且從不返回的方法調用,從而使關聯的堆棧幀無法得到釋放。另一種情況是發生線程洩漏,從而使線程的整個堆棧發生洩漏。如果應用程序為了執行後台工作而創建了工作線程,但卻忽略了正常終止這些進程,則可引起線程洩漏。默認情況下,最新桌面機和服務器版的 Windows® 堆棧大小均為 1MB。因此如果應用程序的 Process/Private Bytes 定期增大 1MB,同時 .NET CLR LocksAndThreads/# of current logical Threads 也相應增大,那麼罪魁禍首很可能是線程堆棧洩漏。圖 1 顯示了(惡意的)多線程邏輯導致的不正確的線程清理示例。

Figure1清理錯誤線程

using System;
using System.Threading;
namespace MsdnMag.ThreadForker {
 class Program {
  static void Main() {
   while(true) {
    Console.WriteLine(
     "Press <ENTER> to fork another thread...");
    Console.ReadLine();
    Thread t = new Thread(new ThreadStart(ThreadProc));
    t.Start();
   }
  }
  static void ThreadProc() {
   Console.WriteLine("Thread #{0} started...",
    Thread.CurrentThread.ManagedThreadId);
   // Block until current thread terminates - i.e. wait forever
   Thread.CurrentThread.Join();
  }
 }
}

當一個線程啟動後會顯示其線程 ID,然後嘗試自聯接。聯接會導致調用線程停止等待另一線程的終止。這樣該線程就會陷入一個類似於先有雞還是先有蛋的尴尬局面之中 — 線程要等待自身的終止。在任務管理器下查看該程序,會發現每次按 <Enter> 時,其內存使用率會增長 1MB(即線程堆棧的大小)。

每次經過循環時,Thread 對象的引用都會被刪除,但垃圾收集器並未回收分配給線程堆棧的內存。托管線程的生存期並不依賴於創建它的 Thread 對象。如果您只是因為丟失了所有與 Thread 對象相關聯的引用而不希望垃圾收集器將一個仍在運行的進程終止,這種不依賴性是非常有好處的。由此可見,垃圾收集器只是收集 Thread 對象,而非實際托管的線程。只有在其 ThreadProc 返回後或者自身被直接終止的情況下,托管線程才會退出(其線程堆棧的內存不會釋放)。因此,如果托管線程的終止方式不正確,分配至其線程堆棧的內存就會發生洩漏。

非托管堆內存洩漏

如果總的內存使用率增加,而邏輯線程計數和托管堆內存並未增加,則表明非托管堆出現內存洩漏。我們將對導致非托管堆中出現內存洩漏的一些常見原因進行分析,其中包括與非托管代碼進行互操作、終結器被終止以及程序集洩漏。

與非托管代碼進行互操作:這是內存洩漏的起因之一,涉及到與非托管代碼的互操作,例如在 COM Interop 中通過 P/Invoke 和 COM 對象使用 C 樣式的 DLL。垃圾收集器無法識別非托管內存,而正是在托管代碼的編寫過程中錯誤地使用了非托管內存,才導致內存出現洩漏。如果應用程序與非托管代碼進行互操作,要逐步查看代碼並檢查非托管調用前後內存的使用情況,以驗證內存是否被正確回收。如果內存未被正確回收,則使用傳統的調試方法在非托管組件中查找洩漏。

終結器被終止:當一個對象的終結器未被調用,並且其中含有用於清理對象所分配的非托管內存的代碼時,會造成隱性洩漏。在正常情況下,終結器都將被調用,但是 CLR 不會對此提供任何保證。雖然未來可能會有所變化,但是目前的 CLR 版本僅使用一個終結器線程。請考慮這樣一種情況,運行不正常的終結器試圖將信息記錄到脫機的數據庫。如果該運行不正常的終結器反復嘗試對數據庫進行錯誤的訪問而從不返回,則“運行正常”的終結器將永遠沒有機會運行。該問題會不時出現,因為這取決於終結器在終結隊列中的位置以及其他終結器采取何種行為。

當 AppDomain 拆開時,CLR 將通過運行所有終結器來嘗試清理終結器隊列。被延遲的終結器可阻止 CLR 完成 AppDomain 拆開。為此,CLR 在該進程上做了超時操作,隨後將停止該終止進程。但是這並不意味著世界末日已經來臨。因為通常情況下,大多數應用程序只有一個 AppDomain,而只有進程被關閉才會導致 AppDomain 的拆開。當操作系統進程被關閉,操作系統會對該進程資源進行恢復。但不幸的是,在諸如 ASP.NET 或 SQL Server™ 之類的宿主情況下,AppDomain 的拆開並不意味著宿主進程的結束。另一個 AppDomain 會在同一進程中啟動。任何因自身終結器未運行而被組件洩漏的非托管內存都將繼續保持未引用狀態,無法被訪問,並且占用一定空間。因為內存的洩漏會隨著時間的推移越來越嚴重,所以這將帶來災難性的後果。

在 .NET 1.x 中,唯一的解決方法是結束並重新啟動該進程。.NET Framework 2.0 中引入了關鍵的終結器,指明在 AppDomain 關閉期間,終結器將清理非托管資源並必須獲得運行的機會。有關詳細信息,請參閱 Stephen Toub 的文章:“利用 .NET Framework 的可靠性功能確保代碼穩定運行”。

程序集洩漏:程序集洩漏相對來說要常見一些。一旦程序集被加載,它只有在 AppDomain 被卸載的情況下才能被卸載。程序集洩漏也正是由此引發的。大多數情況下,除非程序集是被動態生成並加載的,否則這根本不算個問題。下面我們就來看一看動態代碼生成造成的洩漏,特別要詳細分析 XmlSerializer 的洩漏。

動態代碼生成有時會洩漏我們需要動態生成代碼。也許應用程序具有與 Microsoft Office 相似的宏腳本編寫接口來提高其擴展性。也許某個債券定價引擎需要動態加載定價規則,以便最終用戶能夠創建自己的債券類型。也許應用程序是用於 Python 的動態語言運行庫/編譯器。在很多情況下,出於性能方面的考慮,最好是通過編寫宏、定價規則或 MSLI 代碼來解決問題。您可以使用 System.CodeDom 來動態生成 MSLI。

圖 2 中的代碼可在內存中動態生成一個程序集。該程序集可被重復調用而不會出現問題。遺憾的是,一旦宏、定價規則或代碼有所改變,就必須重新生成新的動態程序集。原有的程序集將不再使用,但是卻無法從內存中清除,加載有程序集的 AppDomain 也無法被卸載。其代碼、JITed 方法和其他運行時數據結構所用的非托管堆內存已經被洩漏。(托管內存也在動態生成的類上以任意靜態字段的形式被洩漏。)要檢測到這一問題,我們尚無良方妙計。如果您正使用 System.CodeDom 動態地生成 MSLI,請檢查是否重新生成了代碼。如果有代碼生成,那麼您的非托管堆內存正在發生洩漏。

Figure2在內存中動態生成程序集

CodeCompileUnit program = new CodeCompileUnit();
CodeNamespace ns = new
 CodeNamespace("MsdnMag.MemoryLeaks.CodeGen.CodeDomGenerated");
ns.Imports.Add(new CodeNamespaceImport("System"));
program.Namespaces.Add(ns);
CodeTypeDeclaration class1 = new CodeTypeDeclaration("CodeDomHello");
ns.Types.Add(class1);
CodeEntryPointMethod start = new CodeEntryPointMethod();
start.ReturnType = new CodeTypeReference(typeof(void));
CodeMethodInvokeExpression cs1 = new CodeMethodInvokeExpression(
 new CodeTypeReferenceExpression("System.Console"), "WriteLine",
  new CodePrimitiveExpression("Hello, World!"));
start.Statements.Add(cs1);
class1.Members.Add(start);
CSharpCodeProvider provider = new CSharpCodeProvider();
CompilerResults results = provider.CompileAssemblyFromDom(
 new CompilerParameters(), program);

目前有兩種主要方法可解決這一問題。第一種方法是將動態生成的 MSLI 加載到子 AppDomain 中。子 AppDomain 能夠在所生成的代碼發生改變時被卸載,並運行一個新的子 AppDomain 來托管更新後的 MSLI。這種方法在所有版本的 .NET Framework 中都是行之有效的。

.NET Framework 2.0 中還引入了另外一種叫做輕量級代碼生成的方法,也稱動態方法。使用 DynamicMethod 可以顯式發出 MSLI 的操作碼來定義方法體,然後可以直接通過 DynamicMethod.Invoke 或通過合適的委托來調用 DynamicMethod。

DynamicMethod dm = new DynamicMethod("tempMethod" +
 Guid.NewGuid().ToString(), null, null, this.GetType());
ILGenerator il = dm.GetILGenerator();
il.Emit(OpCodes.Ldstr, "Hello, World!");
MethodInfo cw = typeof(Console).GetMethod("WriteLine",
 new Type[] { typeof(string) });
il.Emit(OpCodes.Call, cw);
dm.Invoke(null, null);

動態方法的主要優勢是 MSLI 和所有相關代碼生成數據結構均被分配在托管堆上。這意味著一旦 DynamicMethod 的最後一個引用超出范圍,垃圾收集器就能夠回收內存。

XmlSerializer 洩漏:.NET Framework 中的某些部分(例如 XmlSerializer)會在內部使用動態代碼生成。請看下列典型的 XmlSerializer 代碼:

XmlSerializer serializer = new XmlSerializer(typeof(Person));
serializer.Serialize(outputStream, person);

XmlSerializer 構造函數將使用反射來分析 Person 類,並藉此生成一對由 XmlSerializationReader 和 XmlSerializationWriter 派生而來的類。它將創建臨時的 C# 文件,將結果文件編譯成臨時程序集,並最終將該程序集加載到進程。通過這種方式生成的代碼同樣需要相當大的開銷。因此 XmlSerializer 對每種類型的臨時程序集進行緩存。也就是說,下一次為 Person 類創建 XmlSerializer 時,會使用緩存的程序集,而不再生成新的程序集。

默認情況下,XmlSerializer 所使用的 XmlElement 名稱就是該類的名稱。因此,Person 將被序列化為:

<?xml version="1.0" encoding="utf-8"?>
<Person xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
 xmlns:xsd="http://www.w3.org/2001/XMLSchema">
<Id>5d49c002-089d-4445-ac4a-acb8519e62c9</Id>
<FirstName>John</FirstName>
<LastName>Doe</LastName>
</Person>

有時有必要在不改變類名稱的前提下改變根元素的名稱。(要與現有架構兼容可能需要根元素名稱。)因此 Person 可能需要被序列化為 <PersonInstance>。XmlSerializer 構造函數能夠很方便地被重載,將根元素名稱作為第二參數,如下所示:

XmlSerializer serializer = new XmlSerializer(typeof(Person),
 new XmlRootAttribute("PersonInstance"));

當應用程序開始對 Person 對象進行序列化/反序列化時,一切運轉正常,直至引發 OutOfMemoryException。對 XmlSerializer 構造函數的重載並不會對動態生成的程序集進行緩存,而是在每次實例化新的 XmlSerializer 時生成新的臨時程序集。這時應用程序以臨時程序集的形式洩漏非托管內存。

要修復該洩漏,請在類中使用 XmlRootAttribute 以更改序列化類型的根元素名稱:

[XmlRoot("PersonInstance")]
public class Person {
 // code
}

如果直接將屬性賦予類型,則 XmlSerializer 對為類型所生成的程序集進行緩存,從而避免了內存的洩漏。如果需要對根元素名稱進行動態切換,應用程序能夠利用工廠對其進行檢索,從而對 XmlSerializer 實例自身進行緩存。

XmlSerializer serializer = XmlSerializerFactory.Create(
 typeof(Person), "PersonInstance");

XmlSerializerFactory 是我創建的一個類,它可以使用 PersonInstance 根元素名稱來檢查 Dictionary<Tkey, Tvalue> 中是否包含有用於 Person 的 Xmlserializer。如果包含,則返回該實例。如果不包含,則創建一個新的實例,並將其存儲在哈希表中返回給調用方。

“洩漏”托管堆內存

現在讓我們關注一下托管內存的“洩漏”。在處理托管內存時,垃圾收集器會幫助我們完成絕大部分的工作。我們需要向垃圾收集器提供工作所需的信息。但是,在很多場合下,垃圾收集器無法有效地工作,導致需要使用比正常工作要求更高的托管內存。這些情況包括大型對象堆碎片、不必要的根引用以及中年危機。

大型對象堆碎片 如果一個對象的大小為 85,000 字節或者更大,就要被分配在大型對象堆上。請注意,這裡是指對象自身的大小,並非任何子對象的大小。以下列類為例:

public class Foo {
 private byte[] m_buffer = new byte[90000]; // large object heap
}

由於 Foo 實例僅含有一個 4 字節(32 位框架)或 8 字節(64 位框架)的緩沖區引用,以及一些 .NET Framework 使用的內務數據,因此將被分配在普通的分代式托管堆上。緩沖區將分配在大型對象堆上。

與其他的托管堆不同,由於移動大型對象耗費資源,所以大型對象堆不會被壓縮。因此,當大型對象被分配、釋放並清理後,就會出現空隙。根據使用模式的不同,大型對象堆中的這些空隙可能會使內存使用率明顯高於當前分配的大型對象所需的內存使用率。本月下載中包含的 LOHFragmentation 應用程序會在大型對象堆中隨機分配和釋放字節數組,從而用實例證實了這一點。應用程序運行幾次後,能通過釋放字節數組的方式創建出恰好與空隙相符的新的字節數組。在應用程序的另外幾次運行中,則未出現這種情況,內存需要量遠遠大於當前分配的字節數組的內存需要量。您可以使用諸如 CLRProfiler 的內存分析器來將大型對象堆的碎片可視化。圖 3 中的紅色區域為已分配的字節數組,而白色區域則代表未分配的空間。

圖 3CLRProfiler 中的大型對象堆

目前尚無一種單一的解決方案能夠避免大型對象堆碎片的產生。您可以使用類似 CLRProfiler 的工具對應用程序的內存使用情況,特別是大型對象堆中的對象類型進行檢查。如果碎片是由於重新分配緩沖區而產生的,則請保持固定數量的重用緩沖區。如果碎片是由於大量字符串串連而產生的,請檢查 System.Text.StringBuilder 類是否能夠減少創建臨時字符串的數量。基本策略是要確定如何降低應用程序對臨時大型對象的依賴,而臨時大型對象正是大型對象堆中產生空隙的原因所在。

不必要的根引用 讓我們思考一下垃圾收集器是如何決定回收內存的時間。當 CLR 試圖分配內存並保留不足的內存時,它就在扮演著垃圾收集器的角色。垃圾收集器列出了所有的根引用,包括位於任何線程的調用堆棧上的靜態字段和域內局部變量。垃圾收集器將這些引用標記為可訪問,並跟據這些對象所包含的引用,將其同樣標記為可訪問。這一過程將持續進行,直至所有可訪問的引用均被訪問。任何沒有被標記的對象都是無法訪問的,因此是垃圾。垃圾收集器對托管堆進行壓縮,整理引用以指向它們在堆中的新位置,並將控件返回給 CLR。如果釋放充足的內存,則使用此釋放的內存進行分配。如果釋放的內存不足,則向操作系統請求額外的內存。

如果我們忘記清空根引用,系統會立即阻止垃圾收集器有效地釋放內存,從而導致應用程序需要更多的內存。問題可能微妙,例如一種方法,它能夠在做出與查詢數據庫或調用某個 Web 服務相類似的遠程調用前為臨時對象創建大型圖形。如果垃圾收集發生在遠程調用期間,則整個圖形被標記為可訪問的,並不會收集。這樣會導致更大的開銷,因為在收集中得以保留的對象將被提升到下一代,這將引起所謂的中年危機。

中年危機 中年危機不會使應用程序去購買一輛保時捷。但它卻可以造成托管堆內存的過度使用,並使垃圾收集器花費過多的處理器時間。正如前面所提到的,垃圾收集器使用分代式算法,采取試探性的推斷,它會認為如果對象已經存活一段時期,則有可能存活更長的一段時期。例如,在 Windows 窗體應用程序中,應用程序啟動時會創建主窗體,主窗體關閉時應用程序則退出。對於垃圾收集器來說,持續地驗證主窗體是否正在被引用是一件浪費資源的事。當系統需要內存以滿足分配請求時,會首先執行第 0 代收集。如果沒有足夠的可用內存,則執行第 1 代收集。如果仍然無法滿足分配請求,則繼續執行第 2 代收集,這將導致整個托管堆以極大的開銷進行清理工作。第 0 代收集的開銷相對較低,因為只有當前被分配的對象才被認為是需要收集的。

如果對象有繼續存活至第 1 代(或更嚴重至第 2 代)的趨勢,但卻隨即死亡,此時就會出現中年危機。這樣做的效果是使得開銷低的第 0 代收集轉變為開銷大得多的第 1 代(或第 2 代)收集。為什麼會發生這種現象呢?請看下面的代碼:

class Foo {
 ~Foo() { }
}

對象將始終在第 1 代收集中被回收!終結器 ~Foo() 使我們可以實現對象的代碼清理,除非強行終止 AppDomain,否則代碼將在對象內存被釋放前運行。垃圾收集器的任務是盡快地釋放盡可能多的托管內存。終結器是由用戶編寫的代碼,並且毫無疑問可以執行任何操作。雖然我們並不建議,但是終結器也會執行一些愚蠢的操作,例如將日志記錄到數據庫或調用 Thread.Sleep(int.MaxValue)。因此,當垃圾收集器發現具有終結器但未被引用的對象時,會將該對象加入到終結隊列中,並繼續工作。該對象由此在垃圾收集中得以保留,被提升一代。這裡甚至為其准備了一個性能計數器:.NET CLR Memory-Finalization Survivors,可顯示最後一次垃圾收集期間由於具有終結器而得以保留的對象的數量。最後,終結器線程將運行對象的終結器,隨後對象即被收集。但此時您已經從開銷低的第 0 代收集轉變為第 1 代收集,而您僅僅是添加了一個終結器!

大多數情況下,編寫托管代碼時終結器並不是必不可少的。只有當托管對象具有需要清理的非托管資源的引用時,才需要終結器。而且即使這樣,您也應該使用 SafeHandle 派生類型來對非托管資源進行包裝,而不要使用終結器。此外,如果您使用非托管資源或其他實現 Idispoable 的托管類型,請實現 Dispose 模式來讓使用對象的用戶大膽地清理資源,並避免使用任何相關的終結器。

如果一個對象僅擁有其他托管對象的引用,垃圾收集器將對未引用的對象進行清理。這一點與 C++ 截然不同,在 C++ 中必須在子對象上調用刪除命令。如果終結器為空或僅僅將子對象引用清空,請將其刪除。將對象不必要地提升至更高一代將對性能造成影響,使清理開銷更高。

還有一些做法會導致中年危機,例如在進行查詢數據庫、在另一線程上阻塞或調用 Web 服務等阻塞調用之前保持對對象的持有。在調用過程中,可以發生一次或多次收集,並由此使得開銷低的第 0 代對象提升至更高一代,從而再次導致更高的內存使用率和收集成本。

還有一種情況,它與事件處理程序和回調一起發生並且更難理解。我將以 ASP.NET 為例,但同樣類型的問題也會發生在任何應用程序中。考慮一下執行一次開銷很大的查詢,然後等上 5 分鐘才可以緩存查詢結果的情況。查詢是屬於頁面查詢,並基於查詢字符串參數來進行。當一項內容從緩存中刪除時,事件處理程序將進行記錄,以監視緩存行為。(參見圖 4)。

Figure4記錄從緩存中移除的項

protected void Page_Load(object sender, EventArgs e) {
 string cacheKey = buildCacheKey(Request.Url, Request.QueryString);
 object cachedObject = Cache.Get(cacheKey);
 if(cachedObject == null) {
  cachedObject = someExpensiveQuery();
  Cache.Add(cacheKey, cachedObject, null,
   Cache.NoAbsoluteExpiration,
   TimeSpan.FromMinutes(5), CacheItemPriority.Default,
   new CacheItemRemovedCallback(OnCacheItemRemoved));
 }
 ... // Continue with normal page processing
}
private void OnCacheItemRemoved(string key, object value,
        CacheItemRemovedReason reason) {
 ... // Do some logging here
}

看上去正常的代碼實際上隱含著嚴重的錯誤。所有這些 ASP.NET Page 實例都變成了“永世長存”的對象。OnCacheItemRemoved 是一個實例方法,CacheItemRemovedCallback 委托中包含了一個隱式的“this”指針,這裡的“this”即為 Page 實例。該委托被添加至 Cache 對象。這樣,就會產生一個從 Cache 到委托再到 Page 實例的依賴關系。在進行垃圾收集時,可以一直從根引用(Cache 對象)訪問 Page 實例。這時,Page 實例(以及在呈現時它所創建的所有臨時對象)至少需要等待五分鐘才能被收集,在此期間,它們都有可能被提升至第 2 代。幸運地是,有一種簡單的方法能夠解決該示例中的問題。請將回調函數變為靜態。Page 實例上的依賴關系就會被打破,從而可以像第 0 代對象一樣以很低的開銷來進行收集。

總結

我已經就 .NET 應用程序中能夠導致內存洩漏或內存消耗過度的各種問題進行了討論。雖然 .NET 可減少您對內存方面的關注程度,但是您仍必須關注應用程序的內存使用情況,以確保應用程序高效正常運行。雖然應用程序被托管,但這並不意味著您可以依靠垃圾收集器就能解決所有問題而將良好的軟件工程實踐束之高閣。雖然在應用程序的開發和測試階段,您必須對其內存性能進行持續不斷的監視。但是這樣做非常值得。要記住,只有讓用戶滿意才稱得上是功能良好的應用程序。

本文配套源碼

  1. 上一頁:
  2. 下一頁:
Copyright © 程式師世界 All Rights Reserved