MSDN Magazin > Home > Ausgaben > 2007 > January >  Debuggen von Speicherverlusten in Anwendungen: ...
Debuggen von Speicherverlusten in Anwendungen
Erkennen und Verhindern von Speicherverlusten in verwaltetem Code
James Kovacs

Themen in diesem Artikel:
  • Verstehen von Speicherverlusten in verwalteten Anwendungen
  • Nicht verwalteter Speicher, der in .NET-Anwendungen verwendet wird
  • Unterstützen von .NET Garbage Collector
In diesem Artikel werden folgende Technologien verwendet:
.NET Framework
Laden Sie den Code für diesen Artikel herunter: MemoryLeaks2007_01.exe (163 KB)
Code online durchsuchen
Die erste Reaktion vieler Entwickler ist, dass Speicherverluste in verwaltetem Code nicht möglich sind. Die Freispeichersammlung (Garbage Collector, GC) kümmert sich schließlich um die gesamte Speicherverwaltung, oder nicht? Die Freispeichersammlung verarbeitet nur verwalteten Speicher. An vielen Stellen wird jedoch nicht verwalteter Speicher in Microsoft® .NET Framework-basierten Anwendungen verwendet, entweder von der CLR (Common Language Runtime) selbst oder explizit vom Programmierer bei der Arbeit mit nicht verwaltetem Code. Es gibt auch Gelegenheiten, bei denen es scheint, dass die Freispeichersammlung ihrer Aufgabe nicht gewachsen ist und den verwalteten Speicher nicht effektiv verarbeitet. Dies wird in der Regel durch kleine (oder nicht so kleine) Programmierfehler verursacht, die verhindern, dass die Freispeichersammlung ihre Aufgabe ausführt. Bei der Entwicklung von Anwendungen muss sichergestellt werden, dass sie keine Speicherverluste verursachen und den erforderlichen Speicher effizient nutzen.

Speicher in .NET-Anwendungen
Wie Sie wahrscheinlich wissen, nutzen .NET-Anwendungen verschiedene Speichertypen: den Stapel, den nicht verwalteten Heap und den verwalteten Heap. Hier einige Informationen zur Erinnerung.
Stapel Im Stapel werden lokale Variablen, Methodenparameter, Rückgabewerte und andere temporäre Werte während der Ausführung einer Anwendung gespeichert. Ein Stapel wird auf Pro-Thread-Basis reserviert und dient als Entwurfsbereich, damit der Thread seine Aufgabe erfüllen kann. Die Freispeichersammlung ist nicht für das Bereinigen des Stapels verantwortlich, da der für einen Methodenaufruf reservierte Speicherplatz im Stapel automatisch bei der Rückgabe einer Methode freigegeben wird. Beachten Sie jedoch, dass die Freispeichersammlung die Verweise auf die im Stapel enthaltenen Objekte erkennt. Wenn ein Objekt in einer Methode instanziiert wird, wird sein Verweis (je nach Plattform eine ganze 32-Bit- oder 64-Bit-Zahl) im Stapel behalten. Das Objekt selbst wird aber im verwalteten Heap gespeichert und von der Freispeichersammlung gesammelt, sobald die Variable den Bereich verlässt.
Nicht verwalteter Heap Der nicht verwaltete Heap wird für Laufzeitdatenstrukturen, Methodentabellen, MSIL (Microsoft Intermediate Language), JIT-kompilierten Code usw. verwendet. Nicht verwalteter Code weist Objekte abhängig davon, wie das Objekt instanziiert ist, im nicht verwalteten Heap oder Stapel zu. Verwalteter Code kann nicht verwalteten Heapspeicher direkt durch den Aufruf in nicht verwaltete Win32®-APIs oder durch Instanziieren von COM-Objekten reservieren. Die CLR selbst verwendet den nicht verwalteten Heap sehr häufig für eigene Datenstrukturen und Code.
Verwalteter Heap Im verwalteten Heap werden verwaltete Objekte zugewiesen. Hier befindet sich die Domäne der Freispeichersammlung. Die CLR verwendet eine generationsbasierte, komprimierende Freispeichersammlung. Die Freispeichersammlung ist generationsbasiert, da sie Objekte altern lässt, wenn sie Freispeichersammlungen überstehen. Dies ist eine Leistungsverbesserung. Alle Versionen von .NET Framework verwenden drei Generationen: Gen0, Gen1 und Gen2 (von der jüngsten bis zur ältesten Generation). Die Freispeichersammlung ist komprimierend, da sie Objekte im verwalteten Heap verschiebt, um Lücken zu vermeiden und den freien Speicher zusammenhängend zu halten. Das Verschieben großer Objekte ist aufwändig. Daher weist die Freispeichersammlung diese in einem separaten Heap für große Objekte (Large Object Heap) zu, der nicht komprimiert wird. Weitere Informationen zum verwalteten Heap und zur Freispeichersammlung finden Sie in der zweiteiligen Reihe von Jeffrey Richter, „Garbage Collection: Automatic Memory Management in the Microsoft .NET Framework“ und „Garbage Collection-Part 2: Automatic Memory Management in the Microsoft .NET Framework“. Obgleich sich die Artikel auf .NET Framework 1.0 beziehen, haben sich die grundlegenden Konzepte in den Versionen 1.1 oder 2.0 nicht geändert, auch wenn .NET Garbage Collector seitdem verbessert wurde.

Prüfen auf Speicherverluste
Es gibt eine Reihe klarer Anzeichen dafür, dass in einer Anwendung ein Speicherverlust auftritt. Unter Umständen wird ein OutOfMemoryException-Fehler ausgegeben. Oder die Anwendung reagiert immer langsamer, da virtueller Speicher auf die Festplatte ausgelagert wird. Vielleicht wächst die Speichernutzung im Task-Manager allmählich (oder recht zügig) an. Wenn Sie einen Speicherverlust vermuten, müssen Sie zunächst ermitteln, bei welchem Speichertyp der Verlust auftritt, damit Sie sich beim Debuggen auf den richtigen Bereich konzentrieren können. Verwenden Sie PerfMon, um die folgenden Leistungsindikatoren für die Anwendung zu untersuchen: Prozess/Private Bytes, .NET CLR-Speicher/Anzahl der Bytes in den Heaps und .NET CLR-Sperren und -Threads/Anzahl der aktuellen logischen Threads. Der Leistungsindikator „Prozess/Private Bytes“ gibt den gesamten Speicher an, der exklusiv für einen Prozess reserviert ist und nicht gemeinsam mit anderen Prozessen im System genutzt werden kann. Der Leistungsindikator „.NET CLR-Speicher/Anzahl der Bytes in den Heaps“ gibt die kombinierte Gesamtgröße von Gen0, Gen1, Gen2 sowie von Heaps für große Objekte an. Der Leistungsindikator „.NET CLR-Sperren und -Threads/Anzahl der aktuellen logischen Threads“ gibt die Anzahl der logischer Threads in einer Anwendungsdomäne an. Wenn die Anzahl der logischen Threads einer Anwendung unerwartet ansteigt, treten Verluste bei Threadstapeln auf. Wenn die Anzahl der privaten Bytes ansteigt, die Anzahl der Bytes in den Heaps aber konstant bleibt, treten Verluste beim nicht verwalteten Speicher auf. Wenn beide Leistungsindikatoren ansteigen, wächst der Speicher in den verwalteten Heaps an.

Verluste im Stapel
Obwohl ein Überlauf im Stapel möglich ist, was zu einem StackOverflowException-Fehler im verwalteten Bereich führt, wird Stapelspeicher, der während eines Methodenaufrufs belegt wird, freigegeben, sobald diese Methode abgeschlossen ist. Daher gibt es nur zwei Möglichkeiten dafür, dass Verluste im Stapelspeicher auftreten. Die erste Möglichkeit ist ein Methodenaufruf, der die entsprechenden Stapelressourcen belegt und nicht mehr zurückgibt, sodass der zugeordnete Stapelrahmen nicht mehr freigegeben wird. Die zweite Möglichkeit sind Speicherverluste in einem Thread, was den gesamten Stapel des Threads betrifft. Wenn eine Anwendung Arbeitsthreads für die Durchführung von Hintergrundarbeit erstellt, aber versäumt, diese ordnungsgemäß zu beenden, kann es zu Verlusten in den Threadstapeln kommen. Standardmäßig beträgt die Stapelgröße auf modernen Desktop- und Serverversionen von Windows® 1 MB. Wenn also der Leistungsindikator „Prozess/Private Bytes“ einer Anwendung regelmäßig in 1 MB-Schritten mit gleichzeitigem Anstieg des Leistungsindikators „.NET CLR-Sperren und -Threads/Anzahl der aktuellen logischen Threads“ anwächst, ist ein Verlust im Threadstapel wahrscheinlich der Auslöser. Abbildung 1 zeigt ein Beispiel einer fehlerhaften Threadbereinigung, die durch (absichtlich schlechte) Multithreadlogik ausgelöst wird.
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();
    }
  }
}


Es wird ein Thread gestartet, der die eigene Thread-ID anzeigt und dann versucht, eine Verknüpfung (Join) auf sich selbst zu erstellen. Durch die Verknüpfung wird der aufrufende Thread blockiert, da er auf die Beendigung des anderen Threads wartet. Aus diesem Grund ist der Thread wie bei einem Huhn-oder-Ei-Szenario gefangen: Er wartet auf seine eigene Beendigung. Beobachten Sie dieses Programm im Task-Manager, um zu sehen, wie dessen Speicherbelegung immer dann um 1 MB (Größe eines Threadstapels) zunimmt, wenn die <Eingabetaste> gedrückt wird.
Der Verweis auf das Threadobjekt wird jedes Mal durch die Schleife gelöscht, aber die Freispeichersammlung gibt den Speicher, der für diesen Threadstapel reserviert ist, nicht frei. Die Lebensdauer eines verwalteten Threads ist unabhängig vom Threadobjekt, das ihn erstellt. Dies hat einen entscheidenden Vorteil: Die Freispeichersammlung soll einen Thread, der noch seine Aufgaben ausführt, nicht beenden, nur weil Sie alle Verweise auf das zugehörige Threadobjekt verloren haben. Deshalb sammelt die Freispeichersammlung das Threadobjekt, aber nicht den eigentlichen verwalteten Thread. Der verwaltete Thread wird nicht beendet (und der Speicher für dessen Threadstapel nicht freigegeben), bis dessen ThreadProc zurückgegeben oder er explizit beendet wird. Wenn also ein verwalteter Thread nicht ordnungsgemäß beendet wird, entstehen Speicherverluste in seinem reservierten Threadstapel.

Verluste im nicht verwalteten Heapspeicher
Steigt die gesamte Speichernutzung an, während die Leistungsindikatoren für logische Threads und der verwaltete Heapspeicher konstant bleiben, treten Verluste im nicht verwalteten Heap auf. Im Folgenden werden einige gemeinsame Ursachen für Verluste im nicht verwalteten Heap untersucht. Dazu zählt auch das Zusammenwirken mit nicht verwaltetem Code, abgebrochenen Finalizern und Assemblyverlusten.
Zusammenwirken mit nicht verwaltetem Code. Eine Ursache für Speicherverluste ist das Zusammenwirken mit nicht verwaltetem Code, wenn z. B. DLLs im C-Stil durch P/Invoke sowie COM-Objekte durch COM-Intertop verwendet werden. Die Freispeichersammlung ignoriert nicht verwalteten Speicher, und folglich treten hier Verluste aufgrund eines Programmierfehlers im verwalteten Code auf, der nicht verwalteten Speicher verwendet. Wenn eine Anwendung mit nicht verwaltetem Code zusammenwirkt, durchsuchen Sie Schritt für Schritt den Code, und untersuchen Sie die Speichernutzung vor und nach dem nicht verwalteten Aufruf, um sicherzustellen, dass der Speicher ordnungsgemäß freigegeben wurde. Ist dies nicht der Fall, suchen Sie mithilfe traditioneller Debugmethoden nach den Verlusten in der nicht verwalteten Komponente.
Abgebrochene Finalizer. Ein sehr „heimtückischer“ Verlust tritt auf, wenn der Finalizer eines Objekts nicht aufgerufen wird und Code enthält, um den vom Objekt reservierten, nicht verwalteten Speicher zu bereinigen. Unter normalen Bedingungen werden Finalizer aufgerufen, aber die CLR übernimmt keinerlei Garantie. Obwohl sich dies in Zukunft ändern kann, verwenden aktuelle Versionen der CLR nur einen Finalizerthread. Betrachten Sie einen fehlerhaften Finalizer, der versucht, Informationen in einer Datenbank zu protokollieren, die offline ist. Wenn dieser fehlerhafte Finalizer irrtümlich immer wieder versucht, auf die Datenbank zuzugreifen, und kein Ergebnis zurückgegeben wird, kann der fehlerfreie Finalizer nicht ausgeführt werden. Dieses Problem kann sich sehr sporadisch ergeben, weil es von der Reihenfolge der Finalizer in der Finalisierungswarteschlange sowie vom Verhalten anderer Finalizer abhängig ist.
Wenn eine Anwendungsdomäne beendet wird, versucht die CLR, die Finalisierungswarteschlange zu bereinigen, indem alle Finalizer ausgeführt werden. Ein verzögerter Finalizer kann verhindern, dass die CLR die Anwendungsdomäne vollständig beendet. Um dies zu berücksichtigen, legt die CLR ein Timeout für diesen Prozess fest, nach dem der Finalisierungsprozess gestoppt wird. Dies ist in der Regel nicht weiter problematisch, da die meisten Anwendungen nur eine Anwendungsdomäne haben. Deren Beendigung wird durch das Beenden des Prozesses verursacht. Wenn ein Betriebssystemprozess beendet wird, werden dessen Ressourcen vom Betriebssystem wiederhergestellt. Leider bedeutet die Beendigung der Anwendungsdomäne in einer Hostsituation wie z. B. ASP.NET oder SQL Server™ nicht die Beendigung des Hostprozesses. Eine weitere Anwendungsdomäne kann im selben Prozess vorhanden sein. Ein nicht verwalteter Speicher, bei dem Verluste durch eine Komponente aufgetreten sind, weil ihr Finalizer nicht ausgeführt wurde, ist weiterhin ohne Verweis und unerreichbar und belegt Speicherplatz. Dies kann äußerst problematisch sein, da mit der Zeit immer mehr Speicherverluste auftreten.
In .NET 1.x bestand die einzige Lösung darin, den Prozess zu beenden und neu zu starten. .NET Framework 2.0 verfügt über wichtige Finalizer, die anzeigen, dass ein Finalizer nicht verwaltete Ressourcen bereinigt und dass ihm ermöglicht werden muss, während der Beendigung der Anwendungsdomäne ausgeführt zu werden. Weitere Informationen finden Sie im Artikel von Stephen Toub, „Keep Your Code Running with the Reliability Features of the .NET Framework“.
Assemblyverluste. Assemblyverluste kommen relativ häufig vor und werden durch die Tatsache verursacht, dass nach dem Laden einer Assembly diese erst wieder entladen werden kann, wenn die Anwendungsdomäne entladen ist. In den meisten Fällen stellt dies kein Problem dar, es sei denn, Assemblys werden dynamisch generiert und geladen. Lassen Sie uns einen genaueren Blick auf dynamische Codegenerierungsverluste und insbesondere auf XmlSerializer-Verluste werfen.
Dynamische Codegenerierungsverluste. Manchmal muss Code dynamisch generiert werden. Unter Umständen verfügt die Anwendung über eine Erweiterungsschnittstelle für Makroskripts, ähnlich wie Microsoft Office. Vielleicht muss ein Preisberechnungsmodul für Wertpapiere die Preisberechnungsregeln dynamisch laden, damit Endbenutzer eigene Wertpapiertypen erstellen können. Vielleicht ist die Anwendung ein(e) Dynamic Language Runtime/Compiler für Python. In vielen Fällen ist es wünschenswert, die Makros, die Preisberechnungsregeln oder den Code zu MSIL aus Leistungsgründen zu kompilieren. Mithilfe von System.CodeDom kann MSIL dynamisch generiert werden.
Der Code in Abbildung 2 generiert dynamisch eine Assembly im Speicher. Er kann problemlos immer wieder aufgerufen werden. Wenn jedoch das Makro, die Preisberechnungsregel oder der Code geändert wird, muss die dynamische Assembly erneut generiert werden. Die alte Assembly wird nicht mehr verwendet, aber es gibt keine Möglichkeit, diese ohne Entladen der Anwendungsdomäne, in die die Assembly geladen wurde, aus dem Speicher zu entfernen. Im nicht verwalteten Heapspeicher, der für den Code, die JIT-kompilierten Methoden und andere Laufzeitdatenstrukturen verwendet wird, sind Verluste aufgetreten. (Im verwalteten Speicher sind ebenfalls Verluste in Form statischer Felder auf den dynamisch generierten Klassen aufgetreten.) Es gibt keine Patentrezept, um dieses Problem zu erkennen. Wenn Sie MSIL dynamisch unter Verwendung von System.CodeDom generieren, überprüfen Sie, ob Sie Code erneut generieren. Ist dies der Fall, treten Verluste im nicht verwalteten Heapspeicher auf.
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);

Es gibt im Wesentlichen zwei Möglichkeiten, um dieses Problem zu lösen. Die erste Möglichkeit besteht darin, die dynamisch generierte MSIL in eine untergeordnete Anwendungsdomäne zu laden. Die untergeordnete Anwendungsdomäne kann entladen werden, wenn der generierte Code sich ändert und eine neue Anwendungsdomäne erstellt wird, um die aktualisierte MSIL zu hosten. Diese Methode funktioniert bei allen Versionen von .NET Framework.
Die zweite Möglichkeit, die in .NET Framework 2.0 eingeführt wurde, besteht in vereinfachter Codegenerierung, auch bekannt als dynamische Methoden. Mithilfe einer DynamicMethod werden MSIL-Op-Codes explizit ausgegeben, um den Methodentext zu definieren. Anschließend wird die DynamicMethod entweder direkt über DynamicMethod.Invoke oder einen passenden Delegaten aufgerufen.
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);
Der Hauptvorteil von dynamischen Methoden besteht darin, dass die MSIL und alle zugehörigen Codegenerierungs-Datenstrukturen dem verwalteten Heap zugeordnet werden. Dies bedeutet, dass die Freispeichersammlung den Speicher freigeben kann, sobald der letzte Verweis auf die DynamicMethod entfernt wird.
XmlSerializer-Verluste. Teile des .NET Framework, wie z. B. der XmlSerializer, verwenden intern dynamische Codegenerierung. Betrachten Sie den folgenden typischen XmlSerializer-Code:
XmlSerializer serializer = new XmlSerializer(typeof(Person));
serializer.Serialize(outputStream, person);
Der XmlSerializer-Konstruktor generiert ein Klassenpaar, das von XmlSerializationReader und XmlSerializationWriter abgeleitet ist, indem die Person-Klasse mithilfe von Reflexion analysiert wird. Er erstellt temporäre C#-Dateien, kompiliert die resultierenden Dateien in eine temporäre Assembly und lädt schließlich diese Assembly in den Prozess. Codegenerierung wie diese ist relativ aufwändig. Daher führt der XmlSerializer eine Zwischenspeicherung der temporären Assemblys auf einer Pro-Typ-Basis durch. Dies bedeutet, dass bei der nächsten Erstellung eines XmlSerializer für die Person-Klasse die zwischengespeicherte Assembly statt einer neu generierten Assembly verwendet wird.
Standardmäßig ist der vom XmlSerializer verwendete XmlElement-Name der Name der Klasse. Folglich würde Person wie folgt serialisiert werden:
<?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>
Manchmal ist es notwendig, den Stammelementnamen zu ändern, ohne den Klassennamen zu ändern. (Der Stammelementname könnte erforderlich sein, um Kompatibilität mit einem vorhandenen Schema zu gewährleisten.) So kann es sein, dass Person als <PersonInstance> serialisiert werden muss. Zum Glück gibt es eine Überladung des XmlSerializer-Konstruktors, der den Stammelementnamen als zweiten Parameter verwendet:
XmlSerializer serializer = new XmlSerializer(typeof(Person), 
  new XmlRootAttribute("PersonInstance"));
Wenn die Anwendung mit dem Serialisieren/Deserialisieren von Person-Objekten beginnt, funktioniert alles, bis ein OutOfMemoryException-Fehler ausgegeben wird. Diese Überlastung des XmlSerializer-Konstruktors führt keine Zwischenspeicherung der dynamisch generierten Assembly durch, sondern generiert bei jedem Instanziieren eines neuen XmlSerializer eine neue temporäre Assembly. Bei der Anwendung treten Verluste im nicht verwalteten Speicher in Form temporärer Assemblys auf.
Verwenden Sie zur Lösung dieses Problems XmlRootAttribute für die Klasse, um den Stammelementnamen des serialisierten Typs zu ändern:
[XmlRoot("PersonInstance")]
public class Person {
  // code
}
Wenn das Attribut direkt auf den Typ angewendet wird, führt der XmlSerializer eine Zwischenspeicherung der generierten Assemblys für den Typ durch, und es treten keine Verluste auf. Müssen Stammelementnamen dynamisch gewechselt werden, kann die Anwendung die Zwischenspeicherung der XmlSerializer-Instanzen selbst durchführen, indem sie diese mithilfe einer Factory abruft:
XmlSerializer serializer = XmlSerializerFactory.Create(
  typeof(Person), "PersonInstance");
XmlSerializerFactory ist eine von mir erstellte Klasse, die mithilfe des PersonInstance-Stammelementnamens prüft, ob ein Dictionary<TKey, TValue> einen XmlSerializer für Person enthält. Ist dies der Fall, wird die Instanz zurückgegeben. Ist dies nicht der Fall, wird eine neue Instanz erstellt, die in der Hashtabelle gespeichert und an den Aufrufer zurückgegeben wird.

Verluste im verwalteten Heapspeicher
Werfen wir jetzt einen Blick auf die Verluste im verwalteten Speicher. Beim Umgang mit verwaltetem Speicher übernimmt die Freispeichersammlung die meiste Arbeit für uns. Wir müssen für die Freispeichersammlung die Informationen bereitstellen, die diese für ihre Arbeit benötigt. Es gibt jedoch eine Vielzahl von Szenarios, die verhindern, dass die Freispeichersammlung ihre Aufgaben effizient ausführt, und zu einer größeren Belegung des verwalteten Speichers führen, als sonst erforderlich wäre. Zu diesen Situationen gehören die Fragmentierung des Heaps für große Objekte, ungewünschte, vom Stamm ausgehende Verweise und eine so genannte Midlife Crisis.
Fragmentierung des Heaps für große Objekte Wenn ein Objekt 85.000 Byte oder mehr umfasst, wird es dem Heap für große Objekte zugewiesen. Beachten Sie, dass dies die Größe des Objekts selbst ist und untergeordnete Objekte nicht mitgezählt werden. Nehmen Sie die folgende Klasse als Beispiel:
public class Foo {
  private byte[] m_buffer = new byte[90000]; // large object heap
}
Foo-Instanzen würden dem normalen generationsbasierten verwalteten Heap zugewiesen, da dieser nur einen 4-Byte-Verweis (32-Bit-Framework) oder einen 8-Byte-Verweis (64-Bit-Framework) auf den Puffer sowie einige von .NET Framework verwendete Verwaltungsdaten enthält. Der Puffer würde im Heap für große Objekte zugewiesen werden.
Anders als der Rest des verwalteten Heaps ist der Heap für große Objekte aufgrund des Aufwands für das Verschieben großer Objekte nicht komprimiert. Wenn also große Objekte zugewiesen, freigegeben und bereinigt werden, entstehen Lücken. Abhängig von den Nutzungsmustern können die Lücken im Heap für große Objekte zu einer wesentlich größeren Speicherbelegung führen, als für die derzeit zugewiesenen großen Objekte erforderlich ist. Die LOHFragmentation-Anwendung, die im Download dieses Monats enthalten ist, demonstriert dies, indem Bytearrays im Heap für große Objekte nach dem Zufallsprinzip reserviert und freigegeben werden. Wenn die Anwendung ausgeführt wird, passen die neu erstellten Bytearrays manchmal genau in die Lücken, die durch freigegebene Bytearrays entstanden sind. Ein andermal ist dies nicht der Fall, und der erforderliche Speicher ist viel größer als der Speicher, der für die derzeit zugewiesenen Bytearrays erforderlich ist. Um sich die Fragmentierung des Heaps für große Objekte visuell vorzustellen, verwenden Sie einen Speicherprofiler, wie z. B. CLRProfiler. Die roten Bereiche in Abbildung 3 sind zugewiesene Bytearrays, während weiße Bereiche nicht reservierten Speicherplatz darstellen.
Abbildung 3 Der Heap für große Objekte in CLRProfiler (Klicken Sie zum Vergrößern auf das Bild)
Es gibt keine einfache Lösung, um eine Fragmentierung des Heaps für große Objekte zu vermeiden. Untersuchen Sie mit Tools wie CLRProfiler, wie die Anwendung Speicher und speziell die Objekttypen nutzt, die sich im Heap für große Objekte befinden. Wird die Fragmentierung durch erneute Reservierung von Puffern verursacht, behalten Sie einen festen Satz von Puffern bei, der wiederverwendet wird. Wird die Fragmentierung durch eine große Anzahl von Zeichenfolgen verursacht, untersuchen Sie, ob die System.Text.StringBuilder-Klasse die Anzahl der erstellten temporären Zeichenfolgen verringern kann. Die grundlegende Strategie besteht darin zu ermitteln, wie die Abhängigkeit der Anwendung von temporären großen Objekten verringert werden kann, die Lücken im Heap für große Objekte verursachen.
Nicht benötigte, vom Stamm ausgehende Verweise Betrachten Sie, wie die Freispeichersammlung festlegt, wann Speicher wieder freigegeben werden soll. Wenn die CLR versucht, Speicher zu reservieren, und nicht genügend Speicherplatz in Reserve hat, wird eine Freispeichersammlung durchgeführt. Die Freispeichersammlung listet alle vom Stamm ausgehenden Verweise auf, einschließlich statischer Felder und lokaler In-Scope-Variablen auf der Aufrufliste von Threads. Sie kennzeichnet diese Verweise als erreichbar und folgt allen Verweisen, die diese Objekte enthalten, wobei sie diese ebenfalls als erreichbar kennzeichnet. Sie setzt diesen Prozess fort, bis alle erreichbaren Verweise untersucht wurden. Alle nicht gekennzeichneten Objekte sind nicht erreichbar und werden daher als freier Speicher angesehen. Die Freispeichersammlung komprimiert den verwalteten Heap, bereinigt Verweise, damit sie auf den neuen Ort im Heap zeigen, und gibt die Kontrolle an die CLR zurück. Wurde genügend Speicher freigegeben, wird die Speicherreservierung mit diesem freigegebenen Speicher fortgesetzt. Ist dies nicht der Fall, wird zusätzlicher Speicher vom Betriebssystem angefordert.
Wenn vergessen wird, vom Stamm ausgehende Verweise auf null zu setzen, wird die Freispeichersammlung daran gehindert, Speicher so schnell wie möglich freizugeben, wodurch eine noch größere Speicherbeanspruchung für die Anwendung entsteht. Das Problem kann schwierig sein, z. B. wenn eine Methode ein großes Diagramm mit temporären Objekten erstellt, bevor sie einen Remoteaufruf (etwa eine Datenbankabfrage oder einen Aufruf an einen Webdienst) tätigt. Wenn während des Remoteaufrufs eine Freispeichersammlung durchgeführt wird, wird das gesamte Diagramm als erreichbar gekennzeichnet und nicht gesammelt. Dies wird sogar noch aufwändiger, weil Objekte, die eine Sammlung überstehen, auf die nächste Generation hochgestuft werden, was zu einer so genannten Midlife Crisis führen kann.
Midlife Crisis Eine so genannte Midlife Crisis kann zu übermäßiger Belegung des verwalteten Heapspeichers und zu viel Prozessorzeit für die Freispeichersammlung führen. Wie bereits erwähnt, verwendet die Freispeichersammlung einen generationsbasierten Algorithmus, der auf der folgenden Heuristik basiert: Wenn ein Objekt eine Weile aktiv ist, ist es wahrscheinlich, dass es noch länger aktiv bleiben wird. Beispiel: In einer Windows Forms-Anwendung wird das Hauptformular beim Start der Anwendung erstellt, und die Anwendung wird beendet, wenn das Hauptformular geschlossen wird. Für die Freispeichersammlung ist es zu aufwändig, ständig zu überprüfen, ob ein Verweis auf das Hauptformular vorhanden ist. Wenn das System Speicher anfordert, um eine Speicheranforderung zu erfüllen, wird zunächst eine Gen0-Sammlung durchgeführt. Ist nicht genügend Speicher verfügbar, wird eine Gen1-Sammlung durchgeführt. Kann die Speicheranforderung noch immer nicht erfüllt werden, wird eine Gen2-Sammlung durchgeführt, bei der eine aufwändige Bereinigung des gesamten verwalteten Heaps vorgenommen wird. Gen0-Sammlungen sind relativ unproblematisch, da nur kürzlich zugewiesene Objekte für die Sammlung in Betracht gezogen werden.
Eine Midlife Crisis entsteht, wenn Objekte dazu neigen, bis Gen1 oder gar bis Gen2 aktiv zu bleiben, aber kurz danach inaktiv werden. Dies kann dazu führen, dass aus unkomplizierten Gen0-Sammlungen viel aufwändigere Gen1- bzw. Gen2-Sammlungen werden. Wie kann es dazu kommen? Sehen Sie sich den folgenden Code an:
class Foo {
  ~Foo() { }
}
Dieses Objekt wird immer in einer Gen1-Sammlung freigegeben! Der Finalizer, ~Foo(), ermöglicht es uns, Bereinigungscode für unsere Objekte zu implementieren, die, außer im Falle eines abrupten Endes der Anwendungsdomäne, ausgeführt werden, bevor der Speicher des Objekts freigegeben wird. Die Aufgabe der Freispeichersammlung besteht darin, so viel verwalteten Speicher so schnell wie möglich freizugeben. Finalizer sind von Benutzern geschriebener Code und sehr vielseitig einsetzbar. Obwohl es nicht empfohlen wird, könnte ein Finalizer etwas Unsinniges machen, wie z. B. die Protokollierung in eine Datenbank vornehmen oder Thread.Sleep(int.MaxValue) aufrufen. Wenn also die Freispeichersammlung ein Objekt ohne Verweis mit einem Finalizer findet, stellt sie das Objekt in die Finalisierungswarteschlange und fährt fort. Das Objekt hat eine Freispeichersammlung überstanden und wird um eine Generation hochgestuft. Es gibt sogar einen Leistungsindikator hierfür: .NET CLR-Speicher/Aufgrund ausstehender Objektfestlegung beibehaltene Objekte. Dies ist die Anzahl der Objekte, die die letzte Freispeichersammlung aufgrund eines Finalizers überstanden haben. Schließlich führt der Finalizerthread den Finalizer des Objekts aus, und die Sammlung kann erfolgen. Sie haben durch einfaches Hinzufügen eines Finalizers eine unkomplizierte Gen0-Sammlung in eine Gen1-Sammlung umgewandelt.
In den meisten Fällen sind Finalizer beim Schreiben von verwaltetem Code nicht erforderlich. Sie sind nur notwendig, wenn ein verwaltetes Objekt einen Verweis auf eine nicht verwaltete Ressource enthält, die bereinigt werden muss. Auch dann sollten Sie einen von SafeHandle abgeleiteten Typ verwenden, um die nicht verwaltete Ressource zu wrappen, statt einen Finalizer zu implementieren. Wenn Sie nicht verwaltete Ressourcen oder andere verwaltete Typen verwenden, die IDisposable implementieren, sollten Sie zudem das Dispose-Muster implementieren, um es Benutzern des Objekts zu ermöglichen, die Ressourcen gründlich zu bereinigen und jegliche zugehörige Finalisierung zu vermeiden.
Wenn ein Objekt nur Verweise auf andere verwaltete Objekte enthält, bereinigt die Freispeichersammlung die Objekte ohne Verweis. Dies steht in starkem Gegensatz zu C++, wo der Löschvorgang für untergeordnete Objekte aufgerufen werden muss. Wenn ein Finalizer leer ist oder Verweise auf untergeordnete Objekte einfach auf null gesetzt wurden, entfernen Sie ihn. Er beeinträchtigt die Leistung, indem das Objekt unnötigerweise auf eine ältere Generation hochgestuft wird und dadurch aufwändiger zu bereinigen ist.
Es gibt andere Möglichkeiten, in eine Midlife Crisis zu geraten, wie z. B. das Festhalten an Objekten, bevor ein blockierender Aufruf (z. B. eine Datenbankabfrage) erfolgt, das Blockieren eines anderen Threads oder das Aufrufen eines Webdiensts. Während des Aufrufs können eine oder mehrere Sammlungen stattfinden und zu unkomplizierten Gen0-Objekten führen, die auf eine spätere Generation hochgestuft werden, was wiederum zu einer viel höheren Speicherauslastung und höherem Sammlungsaufwand führt.
Es gibt einen noch schwerer erkennbaren Fall, der in Verbindung mit Ereignishandlern und Rückrufen auftritt. Als Beispiel wird ASP.NET verwendet, aber derselbe Problemtyp kann auch in einer anderen Anwendung vorkommen. Sie könnten ein aufwändige Abfrage ausführen und die Ergebnisse für 5 Minuten zwischenspeichern. Die Abfrage ist seitenspezifisch und basiert auf Abfragezeichenfolgeparametern. Um das Verhalten beim Zwischenspeichern zu überwachen, protokolliert ein Ereignishandler, wann ein Element aus dem Cache entfernt wird (siehe Abbildung 4).
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
}


Dieser harmlos wirkende Code birgt ein großes Problem. Sämtliche ASP.NET-Seiteninstanzen sind gerade langlebige Objekte geworden. OnCacheItemRemoved ist eine Instanzmethode, und der CacheItemRemovedCallback-Delegat enthält einen impliziten this-Zeiger, wobei this die Seiteninstanz ist. Der Delegat wird dem Cache-Objekt hinzugefügt. Es besteht jetzt also eine Abhängigkeit vom Cache zum Delegaten zur Seiteninstanz. Bei einer Freispeichersammlung bleibt die Seiteninstanz vom Stamm ausgehenden Verweises, dem Cache-Objekt, aus erreichbar. Die Seiteninstanz (und alle temporären Objekte, die es beim Rendern erstellt hat) müssen jetzt mindestens fünf Minuten warten, bis sie gesammelt werden. In der Zwischenzeit werden sie wahrscheinlich auf Gen2 hochgestuft. Glücklicherweise hat dieses Beispiel eine einfache Lösung. Machen Sie die Rückruffunktion statisch. Die Abhängigkeit von der Seiteninstanz wird unterbrochen, und diese kann jetzt problemlos als Gen0-Objekt gesammelt werden.

Schlussbemerkung
Es wurden verschiedene Probleme in .NET-Anwendungen diskutiert, die zu Speicherverlusten oder übermäßiger Speicherauslastung führen können. Obwohl Sie sich in .NET kaum mit dem Speicher befassen müssen, sollten Sie dennoch auf die Speicherauslastung Ihrer Anwendung achten, um sicherzustellen, dass diese keine Probleme verursacht und effizient ist. Nur weil eine Anwendung verwaltet wird, bedeutet dies nicht, dass Sie bewährte Vorgehensweisen der Softwareentwicklung über Bord werfen und sich darauf verlassen können, dass die Freispeichersammlung alles für Sie erledigt. Sie werden weiterhin die Speicherleistungsindikatoren Ihrer Anwendung während des Entwicklungs- und Testprozesses überwachen müssen. Aber es lohnt sich. Bedenken Sie, eine gut funktionierende Anwendung bedeutet zufriedene Kunden.

James Kovacs ist unabhängiger Architekt, Entwickler und Schulungsleiter und hat äußerst vielseitige Interessen. Er lebt in Calgary, Kanada, und hat sich auf .NET Framework, Sicherheit und die Entwicklung von Unternehmensanwendungen spezialisiert. Er ist Microsoft MVP für Lösungsarchitektur und besitzt einen Masters-Abschluss der Harvard University. James Kovacs kann unter jkovacs@post.harvard.edu oder www.jameskovacs.com

Page view tracker