20. Apr 2017
Lesedauer 17 Min.
Stack, Heap & GC
Von Datentypen und Speicherverwaltung
Ein Blick hinter die Kulissen der Speicherverwaltung von .NET und JavaScript.

Den Speicher eines Computers kann man sich wie ein langes Band vorstellen, das aus vielen Zellen besteht, von denen jede einzelne ein Byte speichern kann. Das Betriebssystem des Computers ist dafür zuständig, dieses Band so in Abschnitte zu unterteilen, dass jeder gestartete Prozess einen eigenen Bereich erhält. Auf diesem Weg können sich die Prozesse nicht gegenseitig stören. Doch wie verwalten Prozesse den ihnen zugewiesenen Bereich? Da Daten nicht alle gleichartig aufgebaut sind, liegt die Frage nahe, ob es je nach Art der Daten unterschiedliche Strategien gibt. Auf der einen Seite stehen sehr einfache Datentypen wie bool oder int, zu deren interner Darstellung wenige Bytes genügen. Dem gegenüber stehen sehr komplexe Datentypen wie zum Beispiel Objekte, die unter Umständen sehr viel Speicher benötigen.
Tatsächlich werden nicht alle Datentypen gleich verwaltet. Der Speicher wird unterteilt in den Stack und den Heap. Die beiden befinden sich an gegenüberliegenden Enden des dem Prozess zugeordneten Speichers und bewegen sich im Lauf der Zeit aufeinander zu, siehe Bild 1.
Einen Stapel, bitte!
Von den beiden Speicherbereichen ist der Stack der einfacher zu verstehende Abschnitt. Wer in C# programmiert, kennt vermutlich die Klasse Stack<T> [1] aus dem Namensraum System.Collections.Generic. JavaScript-Entwicklern hingegen dürfte die Stack-Klasse [2] aus dem Modul Immutable.js [3] ein Begriff sein.Auch auf der Ebene des Betriebssystems ist ein Stack als eine Datenstruktur organisiert, die dem LIFO-Prinzip (Last In, First Out) folgt. Der zuletzt gespeicherte Wert wird bei einem Lesezugriff als erster wieder ausgelesen.Einen solchen Speicher legt das Betriebssystem im Speicherbereich des Prozesses automatisch für jeden Thread an. Auch wenn eine Anwendung nicht mit mehreren Threads arbeitet, existiert ein Stack. Bei mehreren Threads gibt es entsprechend mehr Stacks, einen für jeden Thread.Der Stack dient primär dazu, bei einem Funktionsaufruf die Parameter und die Rücksprungadresse abzulegen, außerdem enthält er auch die lokalen Variablen der aufgerufenen Funktionen. Da sich die Größe des Stacks nicht dynamisch verändern lässt, muss bereits beim Aufruf einer Funktion feststehen, wie viel Speicher sie im Stack benötigt. Es ist die Aufgabe des Compilers, diese Informationen zu ermitteln und in der ausführbaren Datei abzulegen, sodass sie zur Laufzeit zur Verfügung stehen.Aufräumen, Verwalten & Co.
Zum Reservieren eines Speicherbereichs im Stack genügt das Verschieben eines Pointers. Das ist der sogenannte Stack-Pointer. Damit eine Funktion auf ihre Parameter und lokalen Variablen zugreifen kann, reicht es, den aktuellen Wert des Stack-Pointers auszulesen und von dort relativ zu adressieren, siehe Bild 2.
Wird eine Funktion beendet, wird der Stack-Pointer auf den vorigen Wert zurückgesetzt. Die Parameter und lokalen Variablen bleiben also im Speicher erhalten, werden aber beim nächsten Aufruf von den dann gültigen Werten überschrieben. Da jede Funktion stets nur auf ihren Bereich im Stack zugreift, funktioniert das Verfahren.Ein angenehmer Seiteneffekt ist, dass das Aufräumen des Stacks erfolgt, indem der Stack-Pointer verschoben wird. Man benötigt dazu weder komplexe Algorithmen noch eine Garbage-Collection (GC). Der Speicherbereich wird schlichtweg als frei deklariert. Mehr ist nicht zu erledigen.Darüber hinaus erfolgt der Zugriff auf den Stack sehr schnell. Dazu trägt zum einen die einfache Verwaltung bei, zum anderen aber auch die Art des Inhalts. Da Funktionen auf ihre Parameter und lokalen Variablen in der Regel sehr häufig zugreifen, landen die entsprechenden Werte rasch im Cache des Prozessors. Das beschleunigt den Zugriff im Vergleich zu einem Zugriff im Arbeitsspeicher enorm.
Was ist ein Stack-Overflow?
Wie bereits erwähnt lässt sich die Größe des Stacks nicht dynamisch verändern. Das wirft die Frage auf, was passiert, wenn der Stack voll ist. Die Antwort ist einfach und naheliegend. Da der Stack maßgeblich am Aufruf von Funktionen beteiligt ist, lassen sich keine Funktionen mehr aufrufen, wenn dort kein Platz mehr zur Verfügung steht.Dieses Szenario bezeichnet man als Stack-Overflow, was in C# durch eine Ausnahme vom Typ StackOverflowException [4] signalisiert wird. Interessant ist dabei der Hinweis im Microsoft Developer Network (MSDN), dass eine solche Ausnahme nicht behandelt werden kann, sondern immer zum Beenden des Prozesses führt:„Starting with the .NET Framework 2.0, you can’t catch a StackOverflowException object with a try/catch block, and the corresponding process is terminated by default.“ Führt man sich vor Augen, was die Ausnahme aussagt, dann ist das allerdings auch wenig verwunderlich. Wenn kein Speicher mehr für einen Funktionsaufruf zur Verfügung steht, gilt logischerweise das Gleiche für den Code, der den Fehler behandeln soll. Da selbst die Ausgabe des Fehlers mit der Funktion Console.WriteLine einen Funktionsaufruf bedingt, gibt es ohnehin kaum sinnvolle Möglichkeiten, außer dem Beenden des Prozesses.Um die Situation zu provozieren, genügt als Beispiel eine rekursive Funktion, die sich selbst aufruft:
using System;
public class Program {
private static void Recursive() {
Recursive();
}
public static void Main() {
Recursive();
}
}
Führt man das Programm aus, erhält man nach kurzer Zeit die Fehlermeldung mit der erwarteten Ausnahme:
Stack overflow: IP: 0x4092fe7e,
fault addr: 0x7fffbdf10ff8
Stacktrace:
at Program.Recursive () <0x00010>
<...>
at Program.Main () <0x00010>
at (wrapper runtime-invoke)
object.runtime_invoke_void(object,intptr,intptr,
intptr) <0xffffffff>
exited with non-zero status
Häufig lässt sich ein Stack-Overflow auf einen Programmierfehler zurückführen, weil beispielsweise die Implementierung des Abbruchkriteriums vergessen wurde oder sie fehlerhaft ist. Dennoch gibt es auch reguläre Situationen, in denen ein Stack-Overflow fast unausweichlich ist. Das gilt immer dann, wenn die rekursive Lösung eines Problems so viele Schritte benötigt, dass die Größe des Stacks nicht ausreicht.Die Situation lässt sich lösen, indem man anstelle eines rekursiven Algorithmus einen iterativen Ansatz wählt. Da sich die Funktionsaufrufe in dem Fall nicht stapeln, kann es nicht zu einem Stack-Overflow kommen.
Rekursion optimieren
Allerdings birgt das Vorgehen einen Haken. Prinzipiell ist es möglich, jedes rekursive Problem auch iterativ zu formulieren. Jedoch gibt es diverse Probleme, die sich rekursiv weitaus eleganter formulieren lassen – allein deshalb, weil die Rekursion in ihrer Natur liegt. Lassen sich solche Probleme rekursiv formulieren, ohne dass man einen Stack-Overflow provoziert?Die überraschende Antwort lautet, dass das entgegen den bisherigen Aussagen sehr wohl möglich ist. Der Trick besteht darin, die sogenannte Endrekursion zu verwenden. Was das ist, lässt sich gut an einem Beispiel zeigen. Als Aufgabe sei gestellt, eine Zeichenkette umzukehren und dazu einen rekursiven Algorithmus zu verwenden. Die folgende JavaScript-Funktion löst die Aufgabe:
const revert = function (text) {
if (text.length === 0) {
return '';
}
const first = text[0],
rest = text.substr(1);
return revert(rest) + first;
};
Wie sich leicht erkennen lässt, handelt es sich um eine rekursive Funktion. Durch das Übergeben von immer größeren Strings lässt sich zügig ein Stack-Overflow provozieren:
/Users/golo/Desktop/revert.js:9
rest = text.substr(1);
^
RangeError: Maximum call stack size exceeded
at String.substr (native)
at revert (/Users/golo/Desktop/revert.js:9:21)
at revert (/Users/golo/Desktop/revert.js:11:10)
Wandelt man die Funktion geringfügig ab, lassen sich auf einmal beliebig große Zeichenketten umkehren, ohne dass es zu einem Stack-Overflow kommt:
const revert = function (text, acc = '') {
if (text.length === 0) {
return acc;
}
const first = text[0],
rest = text.substr(1);
return revert(rest, first + acc);
};
Das Geheimnis dabei liegt in der jeweils letzten Anweisung der Funktion. Im ersten Fall bewirkt die Zeile
return revert(rest) + first;
zunächst einen rekursiven Aufruf der Funktion, anschließend muss sie noch eine Konkatenation der Zeichenketten ausführen. Die letzte Anweisung ist also das Zusammenfügen der Zeichenketten. Vergleicht man das mit der letzten Zeile des zweiten Beispiels, fällt auf, dass in dem Fall der rekursive Aufruf die letzte Anweisung ist:
return revert(rest, first + acc);
Möglich wird das durch die Verwendung des neuen Parameters acc, der die Zwischenergebnisse speichert. Das Zusammenfügen der Zeichenketten erfolgt in dem Fall vor dem Aufruf der Funktion, da es bereits für die Berechnung des Parameters erforderlich ist.Eine solche Situation nennt man Endrekursion, oder auf Englisch „Tail Call Recursion“. Das Besondere daran ist, dass sich eine Endrekursion vom Compiler in eine iterative Version umwandeln lässt, sodass die eigentliche Rekursion verschwindet. Das ist möglich, weil die Funktion nach der letzten Anweisung weder die Parameter noch die lokalen Variablen benötigt und daher der Bereich im Stack für den nächsten Aufruf wiederverwendet werden kann.Mit anderen Worten: Statt einen vollwertigen Funktionsaufruf durchzuführen, werden lediglich die Werte im Stack verändert und die Funktion daraufhin erneut ausgeführt. Damit das funktioniert, muss die jeweilige Laufzeitumgebung das Verfahren allerdings unterstützen.
Den Heap verstehen
Dem Stack steht eine gänzlich andere Art von Speicher gegenüber, der sogenannte Heap. Er ist nicht mit dem LIFO- oder einem ähnlichen Prinzip strukturiert, sondern ermöglicht stattdessen den wahlfreien Zugriff. Er entspricht eher einem großen Lager, in dem sich beliebige Daten ablegen lassen, sofern man eine freie Stelle findet.Der Heap dient daher der dynamischen Speicherzuweisung zur Laufzeit des Prozesses. Der Prozess kann beliebig große Blöcke reservieren, muss sich aber auch wieder um deren Freigabe kümmern. Auch das lässt sich gut mit der Analogie eines Lagers erklären: Wer Lagerplatz bucht, muss beziehungsweise sollte ihn auch irgendwann wieder kündigen.Eine weitere Parallele zur Realität ist die Organisation des Heaps. Anders als beim Stack gibt es hier keine fest vorgegebene Struktur, sondern es liegt völlig in der Verantwortung der Anwendung, eine für sie sinnvolle Struktur zu nutzen. Das alles macht die Verwaltung des Heaps schwieriger als die eines Stacks.Hinzu kommt, dass der falsche Umgang mit dem Heap auch schwerwiegende Fehler provozieren kann. Vergisst man, nicht mehr benötigten Speicher wieder freizugeben, belegt die eigene Anwendung nach und nach zunehmend mehr Speicher. Das ist eine der Hauptursachen von Speicherlecks, und somit zugleich eines der stärksten Argumente für den Einsatz einer automatischen Speicherverwaltung, beispielsweise einer Garbage-Collection wie in .NET oder JavaScript.Da das Betriebssystem, wenn nicht explizit etwas anderes gefordert wird, pro Prozess nur einen Heap vorsieht, muss dieser zudem Thread-safe sein. Das macht den Umgang mit dem Heap langsamer als den mit dem Stack, sodass der Heap im Vergleich zum Stack bereits zwei gravierende Nachteile aufweist: die weitaus umständlichere Verwaltung und die schlechtere Performance. Warum ist der Heap trotzdem eine interessante und erforderliche Datenstruktur?Dynamischen Speicher verwenden
Die Antwort auf diese Frage lautet, dass erst der Heap das Verwenden von Daten ermöglicht, deren Größe nicht im Vorfeld feststeht. Wie zuvor erwähnt, muss der Compiler die erforderliche Stack-Größe für eine Funktion ermitteln und in die ausführbare Datei eintragen. Das funktioniert problemlos, wenn beispielsweise mit 4 Bytes großen Integer-Werten gerechnet werden soll, doch was, wenn zum Beispiel Zeichenketten oder Objekte verarbeitet werden sollen, deren Größe erst zur Laufzeit feststeht?Solche Daten werden auf dem Heap abgelegt. Da er beliebig strukturiert werden kann, ist er ideal für solche dynamischen Daten geeignet. Zudem kann bei Bedarf die Laufzeitumgebung anfordern, dass das Betriebssystem den zur Verfügung stehenden Platz für den Heap vergrößert. Beim Stack ist das nicht möglich.Für den Zugriff auf den Heap benötigt die Anwendung einen Verweis, wo im Heap die gewünschten Daten zu finden sind. Das ist anders als beim Stack, auf den der Programmcode direkten Zugriff hat: Es wird eine zusätzliche Indirektion benötigt. Diesen Verweis nennt man Pointer oder Referenz. Da er lediglich eine Adresse im Heap enthält, hat er eine feste Größe: Auf einem 64-Bit-System sind das 64 Bit, auf einem 32-Bit-System entsprechend 32 Bit.Aus dem gleichen Grund enthält eine lokale Variable oder ein Parameter für einen Datentyp, der auf dem Heap abgelegt wird, also nie den eigentlichen Wert, sondern stets nur die Adresse im Speicher, wo der Wert zu finden ist.Anders formuliert: Ist die Variable x vom Typ int und repräsentiert den Wert 23, dann enthält die Speicherstelle im Stack, die für x reserviert ist, tatsächlich den Wert 23. Ist x hingegen vom Typ object, dann enthält die Speicherstelle im Stack, die für x reserviert ist, nicht das Objekt an sich, sondern lediglich die Adresse eines Speicherbereichs im Heap, in dem dann das eigentliche Objekt zu finden ist.Der Heap fragmentiert
Das regelmäßige Reservieren und Freigeben von Speicher im Heap bewirkt, dass der Heap im Lauf der Zeit fragmentiert. Zwar können die entstehenden Löcher prinzipiell wieder gestopft werden, doch gilt das nur, wenn sie groß genug für den angeforderten Speicher sind. Wenn nicht, muss eine andere Stelle im Heap gesucht werden, die mehr Platz bietet.Im Extremfall kann das dazu führen, dass im Heap für ein Objekt kein Platz mehr ist, obwohl er insgesamt noch über mehr Speicher verfügt als das Objekt benötigt. Um das zu verhindern, kümmert sich die Garbage-Collection von .NET regelmäßig darum, die Fragmentierung zu beheben. Sie schiebt dazu alle noch erforderlichen Daten im Heap zusammen und füllt auf dem Weg die Löcher.Dieser Vorgang kostet allerdings Zeit, weshalb man bemüht ist, den Einfluss der Garbage-Collection auf die Ausführung der Anwendung so gering wie möglich zu gestalten. In .NET ist das über das Konzept der Generationen gelöst, sodass nicht bei jedem Lauf der Garbage-Collection der gesamte Heap bereinigt werden muss.Die Garbage-Collection im Detail
Um zu verstehen, wie die Garbage-Collection im Detail funktioniert, muss man zunächst verstehen, wie das Reservieren von Speicher im Heap funktioniert. Intern verwendet .NET dazu einen Pointer, ähnlich dem Stack-Pointer. Er wird als Next-Object-Pointer bezeichnet und zeigt auf den nächsten freien Speicherbereich im Heap. Fordert die Anwendung Speicher an, wird der Pointer entsprechend verschoben. Da der Verwaltungsaufwand gegen null tendiert, erfolgt das Erzeugen neuer Objekte in .NET ausgesprochen schnell.Wenn für alle jemals in einer Anwendung erzeugten Objekte ausreichend Speicher im Heap zur Verfügung stünde, könnte man an der Stelle einen Punkt machen. Die Realität sieht leider anders aus, weshalb es über kurz oder lang erforderlich ist, nicht mehr benötigte Objekte aus dem Speicher zu entfernen. Damit das nicht von Hand erfolgen muss, übernimmt die Garbage-Collection diese Aufgabe.Dazu wird periodisch in einem eigenen Thread im Hintergrund geprüft, auf welche Objekte die Anwendung noch zugreift. Dazu werden unter anderem die lokalen Variablen und Parameter ausgewertet. Die Garbage-Collection bezeichnet diese Verweise als Roots. Alle Objekte, die durch einen Root entweder direkt oder indirekt referenziert werden, dürfen nicht entfernt werden. Alles, was von der Analyse nicht erfasst wird, kann entfernt werden. Dazu wird der Speicher wie bereits erwähnt zusammengeschoben und der Next-Object-Pointer wird versetzt.Zugleich bedeutet das aber auch, dass sämtliche Referenzen in der Anwendung aktualisiert werden müssen, immerhin haben sich während des Zusammenschiebens des Speichers zahlreiche Adressen geändert. Um das System nicht unnötig zu belasten, hängt die Ausführung der Garbage-Collection davon ab, wie viel Speicher eine Anwendung bereits belegt. Erst nach dem Überschreiten eines bestimmten Grenzwerts wird sie aktiv.Finalizer & Co.
Manche Objekte müssen noch interne Aufräumarbeiten ausführen, bevor sie aus dem Speicher entfernt werden können. Dazu zählt beispielsweise das Freigeben von nicht verwalteten Ressourcen. Dazu können Objekte eine spezielle Methode verwenden, den sogenannten Finalizer:
public class Foo {
protected override void Finalize() {
// Free any unmanaged resources.
// ...
// Always call the base class's finalizer.
base.Finalize();
}
}
Da .NET die Garbage-Collection nur gelegentlich ausführt, lässt sich der exakte Ausführungszeitpunkt eines Finalizers nicht vorhersagen. Da außerdem auch die Reihenfolge nicht vorhersagbar ist, in der .NET die einzelnen Objekte freigibt, darf man in einem Finalizer ausschließlich auf nicht verwaltete Ressourcen zugreifen.Um den händischen Aufruf des Finalizers zu verhindern und zudem zu garantieren, dass stets der Finalizer der Basisklasse ausgeführt wird, verfügt C# über eine abkürzende Syntax für Finalizer:
public class Foo {
~Foo() {
// Free any unmanaged resources.
}
}
Der Aufruf des Finalizers der Basisklasse erfolgt in dem Fall automatisch. Obwohl diese Methode von ihrer Syntax her aussieht wie ein Destruktor in C++, ist es kein solcher.
Objekte leben in Generationen
Das Ausführen der Garbage-Collection und das Ermitteln und Ausführen der Finalizer kostet Zeit, was die Anwendung beeinflusst. Um diesen Effekt zu minimieren, wird nicht in jedem Durchlauf der gesamte Heap aufgeräumt. Das geschieht nur beim ersten Mal. Alle Objekte, die den ersten Durchlauf überleben, werden fortan als Generation 1 bezeichnet, sämtliche ab diesem Zeitpunkt neu hinzugekommenen Objekte bilden die Generation 0. Wird nun der Speicher knapp, durchläuft die GC generell nur die Objekte in Generation 0 – es sei denn, das resultiert noch nicht in ausreichend freiem Speicher. Erst dann kümmert sich die Garbage-Collection auch um die Objekte in Generation 1.Alle Objekte, die auch ein Aufräumen von Generation 1 überleben, werden fortan als Generation 2 bezeichnet. Diese wird wiederum nur dann aufgeräumt, wenn das Aufräumen von Generation 0 und Generation 1 nicht ausreichend freien Speicher erbracht hat. Objekte in Generation 2 sind also ausgesprochen langlebig. Aus dem Grund sollte man es vermeiden, speicherintensive Objekte überhaupt erst in diese Generation gelangen zu lassen.Als Stolperfalle zählen dabei statische Klassen, die per Definition nie aufgeräumt werden können. Halten sie eine Referenz auf ein Objekt, überlebt es dauerhaft und setzt sich in Generation 2 fest. Ist es zudem auch noch sehr groß, kann es den Speicherbedarf einer Anwendung deutlich negativ beeinflussen.Implementiert ein Objekt einen Finalizer, wird ein zusätzlicher Root angelegt. Stößt die Garbage-Collection auf ein nicht mehr verwendetes Objekt, das aber noch von diesem zweiten Root referenziert wird, entfernt sie das Objekt nicht. Stattdessen führt sie den zugehörigen Finalizer aus und entfernt anschließend den künstlichen Root. Erst im nächsten Durchlauf wird das Objekt dann tatsächlich entfernt.Aus dem Grund werden Objekte, die einen Finalizer implementieren, generell erst beim zweiten Durchlauf der Garbage-Collection entfernt. Microsoft empfiehlt daher, Finalizer nur ausgesprochen sparsam einzusetzen.Typen in Schubladen sortieren
Nachdem nun bekannt ist, wie der Stack und der Heap grundlegend funktionieren, stellt sich die Frage, welche Daten wo abgelegt werden. Dazu unterscheidet man zwei Arten von Typen. Die Wertetypen haben eine feste Größe, während sie bei den Referenztypen dynamisch ist. Bereits aus dem Begriff Referenztyp geht hervor, dass zu dessen Ansprache eine Referenz (also ein Pointer) erforderlich ist. Demnach werden solche Typen auf dem Heap abgelegt und indirekt adressiert.Tabelle 1: Referenz- und Wertetypen in C#
|
Zu den Wertetypen zählen in C# Ganzzahlen und Dezimalzahlen, einzelne Zeichen, logische Werte und Strukturen. Zeichenketten und Objekte bilden hingegen die Referenztypen – siehe Tabelle 1.In JavaScript sieht es ähnlich aus. Hier zählen Zahlen, logische Werte und undefined zu den Wertetypen. Zeichenketten, Funktionen und Objekte bilden die Referenztypen – siehe Tabelle 2.
Tabelle 2: Datentypen in JavaScript
|
Als Besonderheit bei den Referenztypen besteht die Möglichkeit, dass zwar eine Variable auf dem Stack angelegt, aber kein Speicher für den Wert auf dem Heap reserviert wurde. Um anzuzeigen, dass die Variable eines Referenztyps keinen Wert repräsentiert, gibt es in beiden Sprachen das Schlüsselwort null. In JavaScript kann es allerdings ausschließlich mit dem Typ object verwendet werden, in C# hingegen mit jedem Referenztyp.Außerdem gibt es in C# den Sonderfall der nullbaren Wertetypen, bei denen ein beliebiger Wertetyp um die Fähigkeit für null erweitert werden kann. Dazu ist ein Fragezeichen an den Typbezeichner anzuhängen:
int? x = 23;
Da ein Wertetyp aus naheliegenden Gründen niemals null sein kann, wendet C# an der Stelle einen Trick an. Es verpackt den Wertetyp in einen Referenztyp. Nullbare Wertetypen werden also generell auch auf dem Heap und nicht auf dem Stack abgelegt, obwohl sie von ihrem Wesen her den Wertetypen deutlich stärker ähneln als den Referenztypen.Eine weitere Besonderheit stellt in beiden Sprachen der Typ string dar, der rein technisch betrachtet ein Referenztyp ist. Er verhält sich allerdings wie ein Wertetyp, weshalb es hier schnell zu Verwechslungen kommen kann.
Übergabe by ...?
Nimmt man etwas Abstand zu all dem bislang Erklärten, stellt sich die Frage, wozu man das überhaupt wissen muss. So interessant das Thema aus technischer Sicht sein mag, ist die Frage nach der Praxisrelevanz doch berechtigt. Tatsächlich ist diese jedoch höher als vielleicht zunächst angenommen, denn es lassen sich rasch einige Beispiele finden, denen man in der Praxis häufig begegnet und bei denen Kleinigkeiten durchaus große Auswirkungen haben können.So trifft man beispielsweise regelmäßig auf das Muster, dass Objekte in einer Schleife erzeugt und nur dort genutzt werden:
for (var i = 0; i < 1000000; i++) {
var foo = new Foo();
// ...
}
Wird die Schleife wie im Beispiel eine Million Mal durchlaufen, erzeugt die Anwendung entsprechend auch eine Million Objekte. Die Garbage-Collection muss all diese Objekte bei Gelegenheit wieder vom Heap entfernen, was sehr zeitaufwendig ist.Eine bessere Option ist es gegebenenfalls, nur ein einziges Objekt vor Beginn der Schleife zu erzeugen und es in jedem Durchlauf zu recyceln, indem man die Werte zurücksetzt, beispielsweise mit einer speziell zu dem Zweck geschriebenen Reset-Methode:
var foo = new Foo();
for (var i = 0; i < 1000000; i++) {
foo.Reset();
// ...
}
Ein anderes Beispiel, dem man in JavaScript häufig begegnet, betrifft den Einsatz von Parameter-Objekten. Die meisten Funktionen, die solche Objekte erwarten, prüfen sie zunächst und initialisieren gegebenenfalls Standardwerte:
const foo = function (options) {
options = options || {};
options.foo = options.foo || 'foo';
options.bar = options.bar || 'bar';
// ...
};
Was hierbei gerne vergessen wird, ist, dass bei einem Objekt als Parameter nicht das eigentliche Objekt übergeben wird, sondern nur eine Referenz darauf. Das bedeutet, dass die Funktion foo das gleiche Objekt in Händen hält wie der aufrufende Code. Das wiederum bedeutet, dass jegliche Änderungen am Objekt sich auch beim Aufrufer bemerkbar machen – was häufig nicht erwünscht oder zumindest nicht erwartet ist.Übrigens gilt in C# genau das Gleiche: Auch hier werden Objekte nicht als Objekt übergeben, sondern nur deren Referenz. Das heißt, auch in C# schlagen sich Änderungen an einem Objekt innerhalb einer Methode auf das gleiche Objekt im aufrufenden Code nieder.Die zuvor erwähnte Besonderheit beim Typ string ist nun, dass er zwar ein Referenztyp ist und bei einem Funktionsaufruf also nie die Zeichenkette an sich, sondern stets nur ein Verweis auf sie übergeben wird, er aber keine Änderungen des Werts zulässt. Jede Änderung resultiert in einer neuen Kopie, die die veränderten Daten enthält. Datentypen, die auf diesem Weg funktionieren und praktisch nicht geändert werden können, nennt man immutable, also unveränderlich.Das Konzept der Unveränderlichkeit stammt aus der funktionalen Programmierung und wird daher vor allem in entsprechenden Sprachen gelebt, beispielsweise in Clojure oder F#. Doch auch in C# und JavaScript halten immer mehr funktionale Elemente Einzug, wie sich exemplarisch an der eingangs erwähnten Bibliothek Immutable.js [3] für JavaScript ablesen lässt.
Fazit
Die Unterscheidung von Werte- und Referenztypen findet sich in C# und in JavaScript und spielt bei beiden in der Praxis eine gleichermaßen wichtige Rolle. Man kommt zwar erstaunlich weit, ohne die zugrunde liegenden Details zu kennen, es können sich aber subtile Fehler einschleichen. Außerdem kann der falsche Umgang mit den Datentypen zu Einbußen bei der Performance einer Anwendung führen.Die Grundlage für die beiden Arten von Typen bilden die Speicherstrukturen Stack und Heap, die auf verschiedene Szenarien optimiert sind: Während im Stack eher kleine, statische Daten abgelegt werden, dient der Heap dem Speichern von dynamischen Daten, deren Größe erst zur Laufzeit feststeht. Der größte Vorteil des Stacks ist die einfache Verwaltung, einschließlich der Besonderheit, dass es keinerlei besonderen Codes bedarf, um ihn aufzuräumen.Das sieht beim Heap ganz anders aus. Hier ist komplexer Verwaltungscode erforderlich. Fehler bei der manuellen Freigabe von Speicher provozieren Speicherlecks. Die automatische Speicherbereinigung umgeht das zwar, erhöht aber die Last auf dem System.Schlussendlich lässt sich nicht festhalten, dass der eine Speicher besser oder schlechter als der andere ist. Wie so oft kommt es auf den jeweiligen Anwendungsfall an.Trotzdem kann man sagen, dass der verantwortungsbewusste Umgang mit Speicher auch im Jahr 2017 noch zu den Aufgaben und Anforderungen an einen professionellen Entwickler gehört.Fussnoten
- Klasse Stack
, http://www.dotnetpro.de/SL1705StackHeap1 - JavaScript-Klasse Stack, http://www.dotnetpro.de/SL1705StackHeap2
- Immutable.js, http://www.dotnetpro.de/SL1705StackHeap3
- StackOverflowException, http://www.dotnetpro.de/SL1705StackHeap4