.Net記憶體洩露原因及解決辦法

小y發表於2017-06-29

 

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的過載沒有使用快取。假如開發人員使用了它們,又沒有提供在一定程度的應用程式級別的快取,那麼隨著本質上相同的程式碼的新例項不斷被載入到記憶體中,記憶體將會慢慢發生洩漏。

相關文章