文章出處

 

1.    什么是.Net內存泄露

(1).NET 應用程序中的內存

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

以運行庫為目標的代碼稱為托管代碼,而不以運行庫為目標的代碼稱為非托管代碼。

在運行庫的控制下執行的代碼稱作托管代碼。相反,在運行庫之外運行的代碼稱作非托管代碼。COM 組件、ActiveX 接口和 Win32 API 函數都是非托管代碼的示例。

COM/COM++組件,ActiveX控件,API函數,指針運算,自制的資源文件...這些的非托管的,其它就是托管的.在CLR上編譯運行的代碼就是托管代碼。  非CLR編譯運行的代碼就是非托管代碼  。非托管代碼用dispose free using 釋放 。即使在擁有GC的托管堆上,也有可能發生內存泄漏!

堆棧 堆棧用于存儲應用程序執行過程中的局部變量、方法參數、返回值和其他臨時值。堆棧按照每個線程進行分配,并作為每個線程完成其工作的一個暫存區。垃圾收集器并不負責清理堆棧,因為為方法調用預留的堆棧會在方法返回時被自動清理。但是請注意,垃圾收集器知道在堆棧上存儲的對象的引用。當對象在一種方法中被實例化時,該對象的引用(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 版是保持一致的。

可能很多.NET的用戶(甚至包括一些dot Net開發者)對Net的內存泄露不是很了解,甚至會說.Net不存在內存泄露,因為“不是有GC機制嗎?----”恩,是有這么回事,它可以讓你在通常應用中不用考慮令人頭疼的資源釋放問題,但很遺憾的是這個機制不保證你開發的程序就不存在內存泄露。甚至可以說,dot Net中內存泄露是很常見的。這是因為: 一方面,GC機制本身的缺陷造成的;另一方面,Net中托管資源和非托管資源的處理是有差異的,托管資源的處理是由GC自動執行的(執行時機是不可預知的),而非托管資源 (占少部分,比如文件操作,網絡連接等)必須顯式地釋放,否則就可能造成泄露。綜合起來說的話,由于托管資源在Net中占大多數,通常不做顯式的資源釋放是可以的,不會造成明顯的資源泄露,而非托管資源則不然,是發生問題的主戰場,是最需要注意的地方。 另外,很多情況下,衰老測試主要關注的是有沒有內存泄露的發生,而對其他泄露的重視次之。這是因為,內存跟其他資源是正相關的,也就是說沒有內存泄露的發生,其他泄露的發生概率也較小,其根本原因在于幾乎所有的資源最后都會在內存上有所反應。

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

 

2 內存泄漏的種類及原因

 

1)堆棧內存泄漏

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

 Figure  清理錯誤線程

 

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 返回后或者自身被直接終止的情況下,托管線程才會退出(其線程堆棧的內存不會釋放)。因此,如果托管線程的終止方式不正確,分配至其線程堆棧的內存就會發生泄漏。

 

2)非托管堆內存泄漏

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

與非托管代碼進行互操作:這是內存泄漏的起因之一,涉及到與非托管代碼的互操作,例如在 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。

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

 

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。如果包含,則返回該實例。如果不包含,則創建一個新的實例,并將其存儲在哈希表中返回給調用方。

3)“泄漏”托管堆內存

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

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

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

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

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

 

 CLRProfiler 中的大型對象堆 (單擊該圖像獲得較大視圖)

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

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

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

(6)中年危機 中年危機不會使應用程序去購買一輛保時捷。但它卻可以造成托管堆內存的過度使用,并使垃圾收集器花費過多的處理器時間。正如前面所提到的,垃圾收集器使用分代式算法,采取試探性的推斷,它會認為如果對象已經存活一段時期,則有可能存活更長的一段時期。例如,在 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 分鐘才可以緩存查詢結果的情況。查詢是屬于頁面查詢,并基于查詢字符串參數來進行。當一項內容從緩存中刪除時,事件處理程序將進行記錄,以監視緩存行為。(參見下)。

 記錄從緩存中移除的項

 

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 代對象一樣以很低的開銷來進行收集。

 

3..Net內存泄露的檢測

(1)如何檢測泄漏

很多跡象能夠表明應用程序正在發生內存泄漏。或許應用程序正在引發 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 保持不變,則表明非托管內存發生泄漏。如果上述兩個計數器均有所增加,則表明托管堆中的內存消耗在增長。  有沒有內存泄露的發生?判斷依據是那些?

  如果程序報“Out of memory”之類的錯誤,事實上也占據了很大部分的內存,應該說是典型的內存泄露,這種情況屬于徹底的Bug,解決之道就是找到問題點,改正。但我的經驗中,這種三下兩下的就明顯的泄露的情況較少,除非有人在很困的情況下編碼,否則大多是隱性或漸進式地泄露,這種需經過較長時間的衰老測試才能發現,或者在特定條件下才出現,對這種情況要確定問題比較費勁,有一些工具(詳見1.3)可以利用,但我總感覺效果一般,也可能是我不會使用吧,我想大型程序估計得無可奈何的用這個,詳細的參見相關手冊。

  需要強調的是,判斷一個程序是不是出現了"memory leak",關鍵不是看它占用的內存有多大,而是放在一個足夠長的時期(程序進入穩定運行狀態后)內,看內存是不是還是一直往上漲,因此,剛開始的漲動或者前期的漲動不能做為泄露的充分證據。

  以上是些比較感性的說法,實際操作中是通過一些性能計數器來測定的。大多數時候,主要關注Process 里的以下幾個指標就能得出結論,如果這些量整體來看是持續上升的,基本可以判斷是有泄露情況存在的。

  A.Handle Count

  B.Thread Count

  C.Private Bytes

  D.Virtual Bytes

  E.Working Set

  F.另外.NET CLR Memory下的Bytes in all heeps也是我比較關注的。

  通過觀察,如果發現這些參數是在一個區間內震蕩的,應該是沒有大的問題,但如果是一個持續上漲的狀態,那就得注意,很可能存在內存泄露。

(2)內存泄露診斷工具

  1.1如何測定以上的性能計數器

  大多使用windows自帶的perfmon.msc。

  1.2其他一些重要的性能計數器

  重要的計數器

  1.3其他檢測工具

  用過的工具里面CLRProfiler 和dotTrace還行,windeg也還行。不過坦白的說,準確定位比較費勁,最好還是按常規的該Dispose的加Dispose,也可以加 GC.Collect()。

4.如何防止內存泄露

(1) Dispose()的使用

  如果使用的對象提供Dispose()方法,那么當你使用完畢或在必要的地方(比如Exception)調用該方法,特別是對非托管對象,一定要加以調 用,以達到防止泄露的目的。另外很多時候程序提供對Dispose()的擴展,比如Form,在這個擴展的Dispose方法中你可以把大對象的引用什么 的在退出前釋放。

  對于DB連接,COM組件(比如OLE組件)等必須調用其提供的Dispose方法,沒有的話最好自己寫一個。

(2) using的使用

using除了引用Dll的功用外,還可以限制對象的適用范圍,當超出這個界限后對象自動釋放,比如

using語句的用途

定義一個范圍,將在此范圍之外釋放一個或多個對象。

可以在 using 語句中聲明對象:
using (Font font1 = new Font("Arial", 10.0f))

{
   // use font1

}

或者在 using 語句之前聲明對象:

Font font2 = new Font("Arial", 10.0f);

using (font2)

{

// use font2

}

可以有多個對象與 using 語句一起使用,但是必須在 using 語句內部聲明這些對象:
using (Font font3 = new Font("Arial", 10.0f),font4 = new Font("Arial", 10.0f))

{

// Use font3 and font4.

}

(3) 事件的卸載

  這個不是必須的,推薦這樣做。之前注冊了的事件,關閉畫面時應該手動注銷,有利于GC回收資源。

(4) API的調用

  一般的使用API了就意味著使用了非托管資源,需要根據情況手動釋放所占資源,特別是在處理大對象時。 4.5繼承 IDisposable實現自己內存釋放接口 Net 如何繼承IDisposable接口,實現自己的Dispose()函數

(5)弱引用(WeakReference )

  通常情況下,一個實例如果被其他實例引用了,那么他就不會被GC回收,而弱引用的意思是,如果一個實例沒有被其他實例引用(真實引用),而僅僅是被弱引 用,那么他就會被GC回收。

(6)析構函數(Finalize())

  使用了非托管資源的時候,可以自定義析構函數使得對象結束時釋放所占資源;

  對僅使用托管資源的對象,應盡可能使用它自身的Dispose方法,一般不推薦自定義析構函數。

根據普遍意義上的內存泄漏定義,大多數的.NET內存對象在不再被使用后都會有短暫的一段時間的內存泄漏,因為要等待下一個GC時才有可能會被釋放。但這種情況并不會對系統造成大的危害。

 

其實真正影響系統的嚴重內存泄漏情況如:

1:大對象的分配。

根據CLR的設計,.NET中的大對象將分配在托管堆內的一個特殊的區域,在回收大對象的時候,并不會像變通區域回收完成時要做內存碎片整理,這是因為這個區域都是大對象,對大對象的移動成本太大了。因此如果本來有三個連續的大對象,現在中間這個要釋放掉了,然后新分配進來一個稍小點的大對象,這樣勢必在中間產生小的內存碎片,這個部分又無法利用。就造成了內存泄漏,并且除非碎片相鄰的大對象被釋放掉外,沒法解決。   因此在編程時要注意大對象的操作,盡量減少大對象的分配次數。

2:避免根引用對象的分配

所謂的根引用對象就是那些GC不會去釋放的對象引用。比如類的公共靜態變量。 GC會視該變量對象在整個程序生命周期中都有效。因此就不會釋放它。當它本身比較大,或者它內部又想用了其它很多對象時,這一連串的對象都無法在整個生命周期中得到釋放。造成了較大的內存泄漏,應該時時注意這種風險的發生。

3:不合理的Finalize() 方法定義。

 

5.總結

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

關于.NET有一個鮮有人言及的問題,它和使用動態代碼生成有關。簡而言之,在XML序列化、正則表達式和XLST轉換中用到的動態代碼生成功能會引起內存泄漏。

盡管公共語言運行時(Common Language Runtime,CLR)能卸載整個應用程序域(App Domain),但是它無法卸載個別的Assemblies。代碼生成依賴于創建臨時Assemblies。通常這些Assemblies會被加載進主應用程序域中,這也就是說,不到應用程序退出時,它們都無法被卸載。

對于諸如XML序列化的庫來說,這個問題并不大。通常,一個給定類型的序列化代碼都會緩存起來,這樣應用程序則被限制在每類型只有一個臨時Assembly。但有些XMLSerializer的重載沒有使用緩存。假如開發人員使用了它們,又沒有提供在一定程度的應用程序級別的緩存,那么隨著本質上相同的代碼的新實例不斷被加載到內存中,內存將會慢慢發生泄漏。


文章列表


不含病毒。www.avast.com
arrow
arrow
    全站熱搜
    創作者介紹
    創作者 大師兄 的頭像
    大師兄

    IT工程師數位筆記本

    大師兄 發表在 痞客邦 留言(0) 人氣()