3 Verfahren zur Speicherbereinigung
- 3 Verfahren zur Speicherbereinigung 21
- 3.1 Problemstellung 21
- 3.2 Verteilte Speicherbereinigung 22
- 3.3 Feststellung der Referenzierbarkeit 22
- 3.3.1 Durchwandern mittels Tiefensuche 22
- 3.3.2 Deutsch-Schorr-Waite 24
- 3.3.2.1 Herleitung nach Derschowitz 24
- 3.3.2.1.1 Elimination der Parameter und lokalen Variablen 24
- 3.3.2.1.2 Elimination der Rekursion 25
- 3.3.2.2 Herleitung nach Broy und Pepper 25
- 3.3.2.2.1 Lastcall- Optimierung 26
- 3.3.2.3 Vermeidung des Zählers 26
- 3.3.3 Durchwandern mittels Breitensuche 26
- 3.3.4 Referenzzähler -- ein verzögertes Markieren 26
- 3.3.4.1 Ein-Bit 27
- 3.3.4.2 Gewichtete Referenzzähler 27
- 3.3.4.3 Verallgemeinerte Referenzzähler 28
- 3.3.4.4 Deutsch-Bobrow 28
- 3.4 Verfahren zur Speicherrückgabe 29
- 3.4.1 Mark and sweep 29
- 3.4.1.1 Dijkstras On-the-Fly 30
- 3.4.1.2 Abraham und Patel 31
- 3.4.2 Kompaktierende Algorithmen 31
- 3.4.3 Knuths LISP 2 31
- 3.4.4 Table Compactors, Haddon & Waite, Wegbreit 31
- 3.4.5 Edwards Kompaktierer 32
- 3.4.6 Kopieralgorithmen 33
- 3.4.6.1 Minsky 33
- 3.4.6.2 Fenichel-Jochelson - Twospace Kompaktierung 33
- 3.4.6.3 Scavenging Algorithms 34
- 3.4.6.3.1 Bakers Kopieralgorithmus 34
- 3.4.6.3.2 Ringkompaktierung 34
- 3.4.6.3.3 Liebermann und Hewitt 35
- 3.4.6.3.4 Ungar generation scavenging 35
- 3.4.6.4 Anwendungen 36
- 3.4.7 Zeigerumkehrtechniken 36
- 3.4.7.1 Auffädelung 36
- 3.4.7.2 Morris I 38
- 3.4.7.3 Jonkers 41
- 3.4.7.4 Dewar-McCann 41
- 3.4.7.5 Thorelli 41
- 3.4.7.6 Morris II 42
- 3.4.7.7 Vergleich 43
- 3.4.8 Aufwand 44
3 Verfahren zur Speicherbereinigung
In diesem Kapitel wird versucht, eine systematische Darstellungen existierender
Verfahren zu geben. Durch Kombination der unter 3.3 und 3.4 vorgestellten
Algorithmen können alle bekannten Algorithmen hergeleitet oder zumindest
motiviert werden. Alle Algorithmen müssen im wesentlichen zwei Aufgaben
erfüllen: Die Referenzierbarkeit von Zellen muß festgestellt werden (Kapitel
3.1); und der Speicher muß reorganisiert werden (Kapitel 3.2). Eine Kombination
von Algorithmen aus diesen beiden Klassen kann im einfachsten Fall das
Hintereinanderausführen oder aber auch eine entsprechende Faltung [Darl81b] der
Verfahren bedeuten. Leider gibt es bezüglich der Algorithmen zur Reorganisation
des Speichers bisher noch keine Erfahrungen, inwieweit Techniken der
Programmtransformation angewendet werden können, ähnlich, wie es sie etwa schon
für Sortieralgorithmen, aber auch für die Markierungsalgorithmen gibt. Es wurde
daher versucht, durch Parametrisierung einen Großteil der nötigen
Transformationen herauszuheben. Dadurch können bis zu einem gewissen Grad ad hoc
adaptive Varianten der Verfahren realisiert werden; eine vollständige
automatische Generierung wird allerdings nur durch Techniken der
Programmtransformation gepaart mit datentyporientieren Methoden i.e. der
algebraischen Spezifikation [EhrMa85] möglich sein.
3.1 Problemstellung
Eine Maschine wird durch ihr Verhalten nach außen charakterisiert. Ihre
Repräsentation stellt einen Zustand dar, der im allgemeinsten Fall -wie bei der
Turing-Maschine- das gesamte Verhalten der Maschine bestimmt. Der Zustand wird
mittels eines endlichen Speichers realisiert. Durch Transformation des Zustandes
unter Beibehaltung des Verhaltens möchte man das innere Verhalten der Maschine,
das heißt die Laufzeit oder die Belegung des Speichers, verbessern, entweder die
Zahl der benötigten Speicherelemente vermindern, oder die Anordnung der
benötigten Elemente verändern. Der Einfachheit halber wird beim Zustand einer
Maschine zwischen einem während der Laufzeit der Maschine statischen Teil, dem
Programm der Maschine, und einem dynamischen Teil, den Daten, unterschieden. Das
Arbeiten der Maschine in einem derartigen Teilzustand heißt Ausführung des
Programms.
Werden die statischen Teile der Maschine vor der Laufzeit in äquivalente Teile
umgeformt, spricht man von Programmtransformation. Bei einem Spezialfall dieser
Problemstellung wird eine existierende Maschine angenommen. Durch Übergang von
dynamischen Teilen in statische erhält man eine Maschine mit einem "größeren"
Programm. Die Umformung eines derartigen Programms in ein effizienteres also in
ein kleineres oder ein schnelleres nennt man partial evaluation [Fea87].
Die Umformung der rein dynamischen Teile während der Laufzeit wird zwar
wesentlich häufiger angewandt, es gibt aber für sie keine äquivalente
Bezeichnung. Meist spricht man von Speicherverwaltung, im besonderen
Speicherbereinigung. Eine solche Umformung könnte durch Einfügungen von dazu
erforderlichen Programmteilen in das ursprüngliche Programm erklärt werden und
damit als Spezialfall der Programmtransformation angesehen werden. Die
Operationen der darzustellenden Maschine beschreiben meistens das Verhalten auf
einer höheren Ebene, die den eigentlichen Speicher abstrahiert, sodaß eine
weitere Maschine zur Verwaltung und Transformation der Daten im Speicher
getrennt betrachtet werden muß. Adressen eines Prozessors in virtuellen
Speichern abstrahieren etwa die Darstellung im Hauptspeicher oder auf einem
externen Speichermedium. Sie werden durch eine gesonderte MMU (Memory Management
Unit) verwaltet. Variablen abstrahieren die Adressen im virtuellen Speicher;
Objekte die Art der sie repräsentierenden Datenstrukturen als Blöcke auf einer
Speicherplatte, als Hauptspeicher oder als ein externes Gerät. Symbole
abstrahieren ihre Repräsentation und die Identität dieser (zeitlichen)
Repräsentation.
In der klassischen Problemstellung der Speicherbereinigung wird ein gerichteter
möglicherweise zyklischer Graph, dessen Knoten durch Zellen in einem homogenen
Speicher und dessen Kanten als Zeiger (Adressen) auf andere Zellen realisiert
sind, verändert und erweitert. Der Speicher soll nun so umgeformt werden, daß
Speicherelemente, die nicht Teil des Graphen sind, für neue Zellen
wiederverwendet werden können, und daß der Graph auf einen isomorphen Graphen,
möglicherweise unter Beibehaltung einer zusätzlichen Relation (meist einer
Ordnung oder die Zugehörigkeit zu gewissen Mengen) abgebildet wird. Es werden
bei der Beschreibung der Algorithmen Zellen sog. CONS-Zellen oder Zellen mit
einer im header der Zelle erkennbaren Größe angenommen.
3.2 Verteilte Speicherbereinigung
In letzter Zeit wurden auch leicht veränderte Problemstellungen betrachtet. Bei
der verteilten Speicherbereinigung werden mehrere physisch voneinander getrennte
Speicher eines verteilten Systems, die miteinander unter größerem zeitlichen
Aufwand kommunizieren, angenommen. Innerhalb dieser Diplomarbeit wird
allerdings nicht auf diese Verfahren eingegangen. [Gu88,Sche88,Decou86] sind
neuere Arbeiten auf diesem Gebiet.
3.3 Feststellung der Referenzierbarkeit
Im einfachsten Ansatz geht man davon aus, daß alle jene Elemente noch benötigt
werden, auf die die Maschine entweder direkt oder über für sie erreichbare
Elemente zugreifen kann. Die Referenzierbarkeit von Zellen wird also durch
Zugehörigkeit zum Graphen festgelegt. In den Kapiteln 4.2.2 und 4.4 finden sich
Beispiele dafür, daß dieser Begriff der Referenzierbarkeit noch nicht allgemein
genug formuliert ist. Für die klassische Problemstellung reicht diese Annahme
allerdings aus. Vorerst wird angenommen, daß die Maschine über eine Wurzel auf
eine Struktur zugreifen kann, die durch einen gerichteten -möglicherweise
zyklischen- Graphen dargestellt wird. Zur Feststellung der Referenzierbarkeit ist
es erforderlich, diesen Graphen zu durchwandern. Meist wird dabei der Graph
verändert oder markiert, sodaß zyklische Graphen keiner Sonderbehandlung
bedürfen.
3.3.1 Durchwandern mittels Tiefensuche
Die rekursive Variante beschreibt den Markierungsvorgang am einfachsten.
mark(ptr z) {
int i;
if(!z->marked){/* nach head */
z->marked = true;
for (i = 0; i < size(z); i++)
mark(z->args[i]);
}
}
Durch Vermeidung der letzten Rekursion können flache Listen ohne
Speicherbelegung des Prozedurstacks markiert werden. Üblicherweise wird der
Rekursionsaufruf durch explizite Stackmanipulation ersetzt. Mit diesem
Algorithmus kann zumindest (worst case) ein Graph bestehend aus n
Listenelementen durch n Elemente am Stack markiert werden. Bei einer Struktur
beliebiger Größe wird der Stack um einen Faktor größer werden.
%% Nur von Baumtiefe abhängig
mark(ptr z) {
stack s;
init(s);
L1: if(!z->marked){/* nach head */
z->marked = true;
if(list(z)){
push(z->tail,s); z = z->head; goto L1;
}
}
if (s == empty) return;
z = pop(s); goto L1;
}
Der Nachteil dieser Technik ist, daß gerade zum Zeitpunkt der Garbage Collection
kaum freier Platz für einen derart großen Stack vorhanden sein wird. Eine
Verbesserung wird durch die Entfaltung einer Rekursionsebene erreicht, wodurch
bei einer Listenstruktur mittels n Stackelementen bereits 2n Listenelemente
markiert werden. In [Kuro81] wird ein ähnliches Verfahren für Lisp 1.9
angegeben.
mark(ptr z) {
stack s;
init(s);
L1: if(!z->marked){/* nach head */
z->marked = true;
if(list(z)){
L2: y = z->head; x = z->tail;
if (y->marked)
z = x;
else {
y->marked = true;
if (list(y)){
if (!x->marked){
x->marked = true;
if (list(x))
push(x,s);
}
z = y;
} else z = x;
}
goto L1;
}
}
if (s == empty) return;
z = pop(s); goto L2; /* z ist sicher eine Liste */
}
Man könnte den Algorithmus auf Kosten höherer Laufzeit analog weiter entfalten.
Häufig werden auch adaptive Verfahren eingesetzt, die möglichst lange mit dieser
einfachen Variante auszukommen versuchen. Falls der Stack überläuft, werden
andere Verfahren angewandt [Knu73]. Kurokawa [Kuro81] überprüft dann etwa den
Stack auf redundante Einträge.
3.3.2 Deutsch-Schorr-Waite
Während der Markierung des Graphen mittels des naiven rekursiven Algorithmus
gelten folgende Behauptungen: Einerseits sind alle Elemente, die am Stack
stehen, bereits markiert, andererseits können alle noch nicht markierten zum
Graph gehörigen Elemente von den Elementen am Stack erreicht werden. Auf dem
Stack steht stets der Pfad, der von der Wurzel zur gerade markierten Zelle
führt. Der aktuelle Stack stellt also die Kopie eines Teils des vollständigen
Graphen dar:
Markierung des Graphen; am Stack steht der aktuelle Pfad
Der Deutsch-Schorr-Waite Algorithmus nützt diese Redundanz der Darstellung aus
und faltet den Stack in den Graphen. Durch Programmtransformation zeigte Veillon
(laut [Co81]) und Derschowitz [Der79], daß dieser Algorithmus direkt aus der
rekursiven Variante herleitbar ist. Es wird das Verfahren von Derschowitz
erläutert und dann kurz auf das Verfahren von Broy und Pepper [Bro82]
eingegangen. In [Bro82] Kapitel 7 findet sich auch eine umfangreiche Übersicht
über andere Arbeiten zu diesem Algorithmus.
3.3.2.1 Herleitung nach Derschowitz
3.3.2.1.1 Elimination der Parameter und lokalen Variablen
Der Stack, der die einzelnen Knoten beschreibt, wird direkt in den Graphen
gefalten, die Laufvariable für die Argumente kann entweder zu jeder einzelnen
Zelle gespeichert werden -dies ist naheliegend, wenn nur Listen (ld(2) = 1bit )
verwaltet werden sollen- oder auf einen eigenen wesentlich kleineren Stack gelegt
werden. Der Einfachheit halber wird im folgenden Programm angenommen, daß ein
Knoten einen zusätzlichen Eintrag der Größe ld(maxarg) besitzt. Bei der
rekursiven Variante wurde der Zeiger auf den aktuellen Knoten über den Parameter
der Prozedur übergeben. Nun wird diese Übergabe in ein Argument des Knotens
geschrieben. Da der Knoten ja bereits markiert wurde, ist es unmöglich, dieses
veränderte Argument innerhalb der darauffolgenden Rekursion anzutreffen. Bei der
Rückkehr werden alle Zeiger wieder in den ursprünglichen Zustand versetzt; das
nächste Argument kann markiert werden.
mark(ptr p){
z = p;
x = nil;
t_mark();
}
t_mark() {
if(!z->marked){/* nach head */
z->marked = true;
for(z->count=0; z->countcount++){
t = z->args[z->count];
z->args[z->count] = x;
x = z;
z = t;
t_mark();
t = x->args[x->count];
x->args[x->count]= z;
z = x;
x = t;
}
}
}
3.3.2.1.2 Elimination der Rekursion
Die Prozedur t_mark() konnte entweder von mark() oder t_mark() aufgerufen
werden. Bei der Elimination der Rekursion müssen wir daher diese beiden Fälle
der Rückkehr unterscheiden. Da x nur beim Aufruf in mark() den Wert nil besitzen
kann, genügt eine entsprechende Überprüfung, falls kein weiteres Argument oder
keine weitere nichtmarkierte Zelle gefunden werden kann.
t_mark() {
L1: z->marked = true;
for(z->count = 1; z->count=count++){
t = arg(z->count,z);
if (!t->marked){
arg(z->count,z) = x; x = z; z = t;
goto L1;
L2: t = arg(x->count,x);
arg(x->count,x) = z;
z = x;
x = t;
}
}
if (x != nil) goto L2;
}
3.3.2.2 Herleitung nach Broy und Pepper
Broy und Pepper [Bro82] formulieren die Problemstellung noch wesentlich
allgemeiner als Derschowitz. Sie gehen von einer mathematischen Spezifikation
des Problems aus, der Berechnung der reflexiven und transitiven Hülle einer
Relation und erhalten als Ergebnis ein effizientes prozedurales Programm. Die
Entwicklung ist in einige Phasen, abhängig von der verwendeten Beschreibungs-
resp. Programmiersprache, unterteilt. Ausgehend von der mathematischen
Spezifikation wird ein rekursiver Algorithmus mittels Tiefensuche hergeleitet,
der in ein funktionales Programm transformiert wird, das letzten Endes zu einem
nichtrekursiven prozeduralen Programm umgeformt wird. Durch die Verwendung von
abstrakten Datentypen im funktionalen Programm wird die Herleitung der
Zeigermanipulationen unterstützt. In den meisten anderen Ansätzen oder bei der
Beschreibung von Algorithmen zur Zeigermanipulation werden ja üblicherweise
keinerlei Typisierungen vorgenommen. Durch eine gesonderte (und bewiesene)
Transformationregel werden die Rekursionen aufgelöst. Generalization bzw.
embedding (die Verallgemeinerung der Problemstellung zu einem einfacher lösbaren
Problem, konkret wird z.B. eine neue Funktion definiert, die die zu
implementierende Funktion durch konkrete Parameter bzw. durch einen Teil ihres
Resultats einschließt) wird in fast allen Transformationsschritten verwendet.
3.3.2.2.1 Lastcall- Optimierung
In den meisten Anwendungen dieses Markierungsalgorithmus hat man es größtenteils
mit listenartigen Strukturen zu tun. Um eine einfache Liste zu markieren,
benötigt man keinen Stack: Das erste Argument der Liste ist eine Konstante, nur
das letzte Argument enthält einen Zeiger. Nur [Bro82 Kapitel 5, "Tuning the
Solution"] bemerken ausdrücklich, daß der Deutsch-Schorr-Waite Algorithmus
ebenfalls diese Lastcall-Optimierung nachvollziehen kann. Durch ihre sehr
abstrakte Herleitung ist es auch kein Problem, diese Optimierung zu erkennen. Die
Behauptung von Cohen [Co81], der Deutsch-Schorr-Waite Algorithmus würde jede
Zelle drei- statt zweimal besuchen, bezieht sich also nur auf die ihm
vorliegende Literatur.
3.3.2.3 Vermeidung des Zählers
Eine andere Variante könnte pro Speicherzelle anstatt des Zählers für die
Gesamtstruktur ein zusätzliches Markierungsbit mit sich führen, das angibt, ob
das nächste Element noch zur Struktur gehört. Ein derartiges Verfahren wurde von
[Appleby88] entwickelt, ohne sich aber der Ähnlichkeit mit dem
Deutsch-Schorr-Waite Algorithmus bewußt zu sein. Dieses Verfahren ist besonders
effizient, wenn vor allem nur sehr kleine Knoten vorkommen, die nicht durch
mögliche große Knoten belastet werden sollen. Vor Ausführung der Markierung
müssen alle mark_me Bits false sein. Das erste Argument wird nicht markiert,
damit ein Knoten, der direkt davor liegt nicht irrtümlich dieses Argument
betrachtet. Diese Verfahren ist effizienter, wenn man vor allem zweielementige
Strukturen markieren muß.
3.3.3 Durchwandern mittels Breitensuche
Bereits Verfahren mit Tiefensuche wurden durch die Größe des Stacks in ihrer
Verwendbarkeit begrenzt. Eine Breitensuche würde noch wesentlich mehr Platz
benötigen. Es müßten während des Durchwanderns der Tiefe n alle Zellen auf
dieser Tiefe zwischengespeichert werden. Der Algorithmus von Baker [Bak78] macht
sich diese Tatsache zunutze, indem er mit dem Markieren zugleich auch das
Kopieren der Strukturen verbindet.
3.3.4 Referenzzähler -- ein verzögertes Markieren
Die bisher aufgezeigten Verfahren zur Bestimmung der Referenzierbarkeit waren in
ihrer Laufzeit stets von der Größe des zu betrachtenden Graphen abhängig. Um
möglichst frühzeitig nicht mehr referenzierte Zellen zu erkennen, müßte man nach
jeder Operation auf dem Graphen einen entsprechenden Markierungsalgorithmus
anwenden. Zur Lokalisierung dieses Aufwands kann man sich vorerst darauf
beschränken, nur die veränderten Teile zu beobachten. Das Programm, das den
Graphen bearbeitet, muß bei jeder Veränderung des Graphen zusätzlich Operationen
ausführen. Die so erhaltenen Informationen können zumindest sicher entscheiden,
daß ein Element nicht mehr benötigt wird.
Pro Zelle wird ein Zähler verwendet, der gleich der Summe aller auf diese Zelle
zeigenden Zellen ist. Bei jeder Manipulation eines Zeigers auf die Zelle wird
der Referenzzähler entsprechend verändert. Der Aufwand für die Markierung wird
also gleichmäßig auf alle Zeigeroperationen verteilt. Das Programm muß daher um
einen Faktor langsamer laufen. Weizenbaum [Wei62,63] verwendete als einer der
ersten dieses Verfahren für die Listenmanipulationssysteme SLIP und KLS. Schon
in [McB63] wurde dieses Verfahren kritisiert, da es zur Erkennung von Zyklen
wesentlich ineffizienter ist. %% als was. Nicht eindeutig? %%%
Man betrachte daher den Referenzähler nur als ein Mittel, um eine eigentliche
Markierung aufzuschieben. Durch Betrachten der Anzahl der Zeiger auf eine
spezielle Zelle kann man nicht entscheiden, ob die Zelle noch zum aktiven
Graphen gehört. Sie kann ja Teil eines unabhängigen zyklischen Graphen geworden
sein. Falls die Anzahl der Zeiger die Kapazität des Zählers überschreitet, kann
die Zelle auch nicht mehr freigegeben werden. Bei einem beschränkten Adressraum
stellt dies zumeist aber kein Problem dar.
Die Beobachtung, daß fast alle Zellen nur genau einmal referenziert werden,
führt zu einer vereinfachten Variante. Statt die genaue Anzahl der Zeiger zu
verwalten, wird nur zwischen den Fällen unterschieden, daß eine Zelle genau
einmal oder mehr als einmal referenziert wird. Falls eine Referenz auf diese
Zelle zerstört wird und die Zelle nur genau einmal referenziert wurde, kann
diese Zelle sofort freigegeben werden. Im anderen Fall bleibt das Zählbit
gesetzt, da sie ja noch mehr als diese eine Referenz besitzen kann. Zellen, die
mehr als einmal referenziert wurden, müssen durch einen anderen Algorithmus
freigegeben werden. Da das Zählbit nur von Interesse ist, wenn genau ein Zeiger
vorhanden ist, kann es in den Zeiger hineingezogen werden. Beim Kopieren des
Zeigers muß nur mehr der Zeiger selbst verändert werden, die referenzierte Zelle
wird dazu nicht benötigt. Der Vorgang des Kopierens eines Zeigers wird dadurch
zu einer lokalen Operation wie in einer Implementierung ohne GC. Ein erweitertes
Verfahren findet sich in [Wi77].
3.3.4.2 Gewichtete Referenzzähler
Die Idee, die notwendigen Operationen auf die Referenzen zu verschieben, kann
noch etwas verallgemeinert werden. Jede Referenz besitzt statt des Bits ein
Gewicht. Die Summe der Gewichte der Zeiger einer Zelle ist stets gleich dem Wert
des Referenzzählers. Beim Kopieren eines Zeigers wird der Wert des Gewichts auf
beide Zellen aufgeteilt P beispielsweise halbiert. Falls eine Zelle freigegeben
wird, wird der Referenzzähler um den Wert des Gewichts verkleinert. Dieses
Verfahren kann solange angewendet werden, bis eine Referenz mit einem nicht mehr
aufteilbaren Gewicht kopiert werden soll. In diesem Fall kann der
Referenzzähler, falls möglich, erhöht werden. Das Gewicht der zu kopierenden
Referenz wird um den gleichen Betrag erhöht, sodaß die Summe aller Gewichte
wieder gleich dem Referenzzähler ist. Bei einer ganzzahligen Halbierung der
Gewichtung bei jedem Kopieren und einem anfänglichen wie maximalen Wert n und
minimalen Wert 1 des Referenzzählers muß frühestens (worst case ) eine andere
Strategie nach 2log2(n)+1neuen geschaffenen Zeigern aktiviert werden. Der
anfängliche Wert des Referenzzählers muß natürlich nicht dem maximalen Wert
entsprechen.
3.3.4.3 Verallgemeinerte Referenzzähler
Referenzzähler können zusammenfassend durch folgende Parameter an die
Eigenheiten des Systems angepaßt werden:
- Wahl eines Datentyps zur Darstellung der Referenz und der Gewichte. Am
naheliegensten ist die Implementierung durch ganze Zahlen. Aber auch andere
Typen, die einer abelschen Gruppe entsprechen, können verwendet werden. Gerade
bei verteilten Implementierungen wird ein lokaler Mehraufwand, verglichen mit
den sehr hohen Kosten einer globalen Reorganisation, bei der u.U. auf tertiären
Speicher oder etwa über nicht immer zur Verfügung stehende Verbindungen eines
weiten in Bezug auf seine Verfügbarkeit unsicheren Netzwerkes zugegriffen werden
müßte, gerne entgegengenommen. Zur Bewahrung der Kommunikationsautonomie [Kü89]
bieten sich hier Festkommazahlen als Erweiterung an, deren aufwendigere
Repräsentation erst dann benötigt wird, falls die Gewichte durch ganze Zahlen
nicht mehr darstellbar sind.
- Wahl einer Aufteilungsstrategie. Durch Kenntnis der erwarteten Lebensdauer der
Referenzen kann eine differenziertere Gewichtung vorgenommen werden. So wird
etwa permanenten Referenzen aus einer Datenbank mehr Gewicht beigemessen, da von
ihnen zu erwarten ist, daß von ihnen aus häufiger neue Zeiger kopiert werden als
von den flüchtigen Zeigern in Prozeduren.
- Wahl des Anfangswerts. Falls durch die Wahl der Aufteilungsstrategie die
Anzahl der tatsächlich realisierbaren existierenden Referenzen noch immer zu
weit von der maximal möglichen Anzahl von Referenzen liegt, kann durch Variieren
des Anfangswert versucht werden, den worst case zu verbessern. Dieser kann von
2ld(n)+1 auf n reduziert werden.
Aus diesen Überlegungen sieht man, daß der gewöhnliche Referenzzähler, wie auch
der ein-Bit-Algorithmus einen Spezialfall des gewichteten Referenzzählens
darstellen: Die Aufteilungsstrategie stößt immer auf einen unteilbaren Wert des
Gewichts, sodaß der Referenzzähler stets um 1 erhöht werden muß. Der Anfangswert
des Referenzzählers beträgt ebenfalls 1. Gerade bei objektbasierten verteilten
Systemen z.B. [Balt87] können die einander widersprechenden Anforderungen von
flüchtigen und permanenten Daten durch ein deratig parametrisiertes
Implementierungsschema, das durch eine entsprechende Klassenhierarchie zur
Verfügung gestellt werden kann, vereint werden.
3.3.4.4 Deutsch-Bobrow
Aufbauend auf der Erfahrung, daß die meisten Zellen nur genau einmal
referenziert werden, halten Deutsch und Bobrow [Deu76] diese Information statt
in einem Bit in zwei Tabellen fest, die die Adressen der entsprechenden Zellen
enthalten. Die Tabelle mit den Zellen, auf die mehrere Zellen zeigen, kann zudem
noch getrennt Zähler für jede Zelle enthalten. Beim Verändern von Zeigern wird
in der entsprechenden Tabelle nach der Zelle über ihre Adresse als Hashwert
gesucht. Diese Änderungsoperationen können auch vollkommen getrennt von einem
eigenen Prozessor durchgeführt werden. Eine Analyse in [But87] hat dennoch
ergeben, daß dieses Verfahren bei objektbasierten Datenbanken mit
Kopieralgorithmen nicht vergleichbar ist. Dafür wird dieses Verfahren in einigen
LISP Systemen verwendet [All78,Gab85]. Sannsonnet [San82] implementierte eine
leicht erweiterte Variante mit einem 3-Bit Zähler. Bei ihrem mit LEM (Language
for Emulation) realisierten Lisp-System wurde in dem M3L-Projekt gezeigt, daß
98% aller Zellen mit diesem Zähler auskommen. Die temporären Referenzen aus
Registern werden in einem gesonderten Bit verwaltet.
3.4 Verfahren zur Speicherrückgabe
In diesem Kapitel wird angenommen, daß durch eine der in Kapitel 3.3
erwähnten Methoden nicht mehr referenzierbare Zellen bereits erkannt wurden.
Der freiwerdende Platz kann daher wieder für neue Objekte vergeben werden.
Im wesentlichen bieten sich hier zwei Richtungen an:
- i) Die freien Zellen können über eine eigene Verwaltung direkt für neue Zellen
verwendet werden.
- ii) Man verlegt die referenzierten bzw. freien Zellen in einen geschlossenen
Bereich.
Je nach Beschaffenheit der Zellen, ihrer Größe, ihrer Lebenserwartung und der
Anzahl der in ihnen enthaltenen Zeiger auf andere Zellen und der Charakeristika
des Speichermediums von schnellem cache bis zu langsamen Archiven und Netzwerken
wird man eine Variante zwischen i) und ii) wählen. In dieser Arbeit wird nur auf
jene Verfahren eingegangen, die bei der Implementierung von flüchtigen
Datenstrukturen applikativer Sprachen von Interesse sein könnten; andere
Verfahren finden bei der Implementierung von permanenten (persistent)
Datenstrukturen, wie etwa bei Filesystemen, Anwendung. Eine sehr umfangreiche
Analyse dieses Problembereichs und zahlreiche Algorithmen findet sich in
[Wie87].
3.4.1 Mark and sweep
Eine einfache Implementierung, die Punkt 1 gerecht wird, stellen etwa
Freispeicherlisten dar. Nach der Markierungsphase werden alle nichtmarkierten
Zellen in die Freispeicherliste gestellt. Die Organisation der Freispeicherliste
kann direkt mit dem Algorithmus zur Feststellung der Referenzierbarkeit
gekoppelt werden. Die einfachste Variante -mark and sweep- wurde bereits von
McCarthy [McC60] verwendet. Eine Analyse von komplexeren Verfahren zur
Verwaltung von Zellen unterschiedlicher Größe findet sich etwa in [Niel77]. Ein
großer Nachteil dieser Verfahren stellt die Fragmentierung des Speichers dar.
Gerade bei virtuellem Speicher sind diese Verfahren mit Vorsicht einzusetzen,
da der Speicher bei flüchtigen Datenstrukturen schnell "altert", da ja die
verbleibenden referenzierten Zellen über den gesamten Adressbereich verstreut
sein können. Das Verfahren hat u.U. bei Datenbanken den Vorteil, daß die
einzelnen Zellen von Anfang an fest an ihre Adresse gebunden sind (pinned down).
Dadurch kann eine Indirektion beim Zugriff auf die Zelle vermieden werden,
ohne die dazugehörigen Zeiger jemals verändern zu müssen. Der Aufwand zum
Anlegen einer Speicherzelle hängt von der Komplexität der Freispeicherliste ab.
Davies[Dav84] zeigte, daß die Fragmentierung des Speichers bei einer first
fit-Strategie gar nicht so sehr von der unterschiedlichen Größe der Zellen
abhängt, als von der Verteilung der Lebenserwartungen.
Durch vollkommene Parallelisierung der Markierungs-und Reorganisationsphase
haben sich etwa [Dijk78] erhofft, den mark and sweep Algorithmus zu verbessern.
Die Nachteile bezüglich der Freispeicherorganisation wurden bereits erwähnt.
[Dijk78] betonen allerdings ausdrücklich, daß sie sich kaum praktische
Anwendungen für ihr Verfahren erhoffen. Um nicht durch die Synchronisation die
Unabhängigkeit der Prozessoren, und damit die erhoffte Parallelisierung
einzuschränken gelang es Dijkstra u.a. einen Algorithmus ohne jegliche
Synchronisation auf Programmebene zu entwickeln. Auf Prozessorebene muß
allerdings noch immer der exklusive Zugriff auf eine Speicherzelle zugesichert
werden. Man beachte den Unterschied zwischen verteilten Algorithmen, die auf
unabhängigen Teilen arbeiten, und parallelen Algorithmen, bei denen zugleich auf
einem Speicherbereich gearbeitet wird.
3.4.1.1 Dijkstras On-the-Fly
Eine Zelle kann drei unterschiedliche Zustände annehmen, die durch die Farben
weiß (unmarkiert), schwarz (markiert) und grau (Die Zelle wurde vom Programm
benötigt) dargestellt werden. Die grauen Zellen können u.U. schwarz werden. Wenn
das Programm (mutator) eine weiße Zelle verwendet, setzt es diese grau. Falls
keine freien Zellen mehr vorhanden sind, muß der mutator u.U. auf den collector
warten, um eine neue freie Zelle zu bekommen. Der collector markiert die
verwendeten Zellen, die Zellen der Freispeicherliste und jede graue Zelle wie
folgt: Die verwendeten (=aktiven) Zellen und die Zellen der Freispeicherliste
werden schrittweise grau gefärbt, dabei wird die vorhergehende Zelle, von der
man gekommen ist, schwarz gefärbt. Danach werden die verbleibenden weißen Zellen
in die Freispeicherliste eingehängt und alle schwarzen Zellen auf weiß gesetzt.
Inaktive graue Zellen werden daher vorerst schwarz und dann weiß. Im nächsten
Zyklus des collectors können sie dann freigegeben werden.
Ein besonders großer Nachteil dieses Verfahrens ist die Tatsache, daß die Zellen
in der Freispeicherliste genauso aufwendig behandelt werden müssen wie
tatsächlich referenzierte Zellen. Ein derartiger Durchlauf wird daher stets
ähnlich viel Zeit benötigen. Bei einer großen Menge von referenzierten Zellen,
wird der collector häufig gleichzeitig mit dem mutator auf eine Zelle zugreifen
wollen, wodurch sich auch bei Hardwareversionen Verzögerungen beim
Speicherzugriff ergeben.
Allein der Entwicklungsaufwand für diesen (scheinbar) einfachen Algorithmus war
laut [Dijk78] sehr hoch. Hickey und Cohen[Hick84] analysieren das Verhalten von
On-the-Fly mit bedingten Differentialgleichungen unter der etwas realitätsfernen
Annahme, daß ein gleichförmiger Speicher mit konstanter Zugriffszeit vorhanden
wäre. Die Alterung des Speichers wird also nicht beachtet. Als Vergleichsbasis
wird der mark and sweep Algorithmus herangezogen; auch nicht unbedingt der
schnellste existierende Algorithmus. Die Produktivität läßt sich laut ihrer
Analyse um zumindest 150% (bei doppelter Prozessorleistung) steigern. Da der
mutator auch bei der Zuweisung einer referenzierten schwarzen Zelle an eine
andere diese grau färbt, werden unnötigerweise schwarze Zellen wieder grau. Ehn
[Ehn89] stellt einen diesbezüglich verbesserten Algorithmus vor. Ben [Ben84]
vereinfacht On-the-Fly, um einen einfacheren Beweis, der in [Snep87] noch
weiter vereinfacht wird, für seine Korrektheit zu erhalten. %% unklar??
Ben erhält zudem eine inkrementelle Variante, bei der nicht immer alle Zellen
besucht werden müssen. Falls genügend freie Zellen gefunden worden sind, kann
Ben die nachfolgenden Zellen grau färben und so ihre Markierung verschieben.
Der Aufwand von On-the-Fly ist im wesentlichen abhängig von der Flüchtigkeit der
Datenstrukturen (Cohen verwendet den Begriff throughput für die Anzahl der neu
angelegten, bzw. freiwerdenden Zellen). Je kurzlebiger diese sind, umso
zweifelhafter ist der Einsatz dieses Algorithmus. Eine
potentielle Verdopplung
der Geschwindigkeit durch Entlastung des mutators steht in keinem Verhältnis zu
dem schwerwiegendsten Nachteil, der Fragmentierung des Speichers. Dies gilt
ebenso für die entwickelten Mehrprozessorversionen. Auch im festen RAM kann
dieser Algorithmus gerade bei applikativen Sprachen unmöglich eingesetzt werden.
Der collector müßte sofort jede kurzlebige Datenstruktur erkennen, um mit dem
mutator Schritt halten zu können. Wadler [Wad76] zeigte, daß der collector
zumindest doppelt so schnell sein müßte, um Wartezeiten des mutators zu
vermeiden. Vor allem wegen der flüchtigen Datenstrukturen muß der collector
schneller sein. Die einzige kommerzielle Implementierung einer leichten
Modifikation dieses Algorithmus dürfte George Bosworths Digitalk*
Smalltalk-Implementierung für den IBM-PC "Methods" gelungen sein [Bos88]. Seine
nachfolgenden Implementierungen verwenden allerdings ausschließlich Ungars
[Un84] Generation Scavenging Algorithmus. On-the-Fly dürfte also, wenn
überhaupt, nur mit anderen Hardware-Voraussetzungen zu effizienten
Implementierungen führen. Dies gilt aber auch für alle anderen Verfahren.
Mittels eines assoziativen Speichers könnte die Verwendung einer
Freispeicherliste entfallen. Auch die grauen Zellen könnten so wesentlich
schneller erkannt werden [Shin87].
3.4.1.2 Abraham und Patel
Abraham und Patel [Abr87] trennen in ihrem System mutator und collector
vollständig voneinander, da ja der collector auch in einer älteren Version
nichtreferenzierte Zellen erkennen kann. Unterstützt durch virtuellen Speicher
können so seitenweise die Freispeicherlisten aufgebaut werden. Solange der
mutator keine Zelle einer Seite verändert, wird für beide Systeme dieselbe Seite
verwendet. Falls auf eine Seite vom mutator geschrieben wird, muß die
Repräsentation der Seite aufgeteilt werden. Im nachhinein werden die beiden
Versionen der Seite wieder zusammengesetzt, indem die Werte für die
referenzierten Zellen vom mutator übernommen werden; die für die
nichtreferenzierten Zellen vom collector. Ein derartiges Verfahren kann sehr
leicht auf herkömmlichen System mit virtuellem Speicher in einem lokalen
Netzwerk realisiert werden.
3.4.2 Kompaktierende Algorithmen
Die in diesem Kapitel vorgestellte Klasse von Algorithmen versucht die Menge der
aktiven Zellen in einem möglichst kleinen Bereich zu halten. Dadurch können
insbesondere in virtuellen Speichern
[Co67, Bob67] wesentlich bessere Ergebnisse
erzielt werden. Durch die Lokalität der Referenzen wird aber auch der cache
besser ausgenützt. Die Erhaltung der Ordnung der Zellen untereinander ist
ein -für die meisten Prologsysteme- wichtiges Kriterium.
3.4.3 Knuths LISP 2
Um die Zellen in einem Bereich zu kompaktieren, ist es naheliegend, bei jeder
Zelle ihre neue Adresse in einem eigenen Feld abzuspeichern, das auch zur
Markierung verwendet wird [Knu73]. Pro zu verwaltender Zelleinheit benötigt man
daher einen weiteren Zeiger; bei Prolog würde man also fast den doppelten
Speicherplatz benötigen. In der ersten Phase werden die Markierungsbits der
einzelnen Zellen betrachtet und die neuen Adressen zu jeder Zelle gespeichert.
Zur Beschleunigung des Verfahrens werden u.U. zusammenhängende
nichtreferenzierte Zellen zusammengefaßt. Durch getrennte Verwaltung der
Markierungsbits kann man auch ohne Sortieren diese Phase beschleunigen. Die
zweite Phase richtet alle Zeiger auf die neuen Adressen, in der dritten Phase
werden schließlich alle Elemente blockweise kopiert. Die zwei letzten Phasen
könnten auch, falls sehr viele Zeiger existieren, zusammengefaltet werden.
3.4.4 Table Compactors, Haddon & Waite, Wegbreit
Anstatt für jede Speicherzelle die zukünftige Adresse zu berechnen, wird in dem
von Haddon und Waite [Weg72,Fitc78] entwickelten Algorithmus gleich mit dem
Verschieben der Zellen begonnen. In den freiwerdenden Zellen wird diese
Verschiebung, das Paar <ursprüngliche Adresse,Offset> für einen verschobenen
Block, der aus mehreren Zellen bestehen kann, registriert. Damit man ohne
zusätzlichen Speicherplatz auskommt, muß die kleinste freiwerdende Zelle
zumindest einen derartigen Eintrag enthalten können. Um nicht die gesamte
Tabelle bei jedem Schritt neu zu kopieren, wird man nur die Einträge am Rand zu
den kompaktierten Zellen kopieren. Die Tabelle wird so durch den freiwerdenden
Speicher "gerollt", hin und wieder kommen neue Einträge auf einer Seite hinzu.
Nach dieser Phase sind alle Zellen bereits an ihrem zukünftigen Ort. Die Tabelle
der Verschiebungen befindet sich geschlossen im freien Bereich, die Einträge
innerhalb der Tabelle sind aber leider "durcheinandergeschüttelt" worden.
Table-Compactor während Phase I
In der zweiten Phase werden die Einträge daher sortiert, damit man in der
dritten Phase alle Zeiger über diese Tabelle auf ihre entsprechenden Zellen
setzten kann. [Fitc78] gibt als Obergrenze für den Aufwand dieses Verfahrens n
logn an, mit n der Größe des gesamten Speicherbereichs. Allerdings gilt ebenso,
daß dieser Algorithmus in Abhängigkeit von n referenzierten Zellen ebenso n logn
benötigt. Je weniger markierte Zellen über einen Bereich verstreut sind, umso
viel besser wird sich dieser Algorithmus verhalten. Da durch das Rotieren und
Einfügen von Elementen in die Verschiebungstabelle üblicherweise ein Großteil
der Ordnung erhalten bleibt, wird man zum Sortieren der Einträge Shells
Sortieralgorithmus oder Smoothsort [Gon84] verwenden.
Viele weitere Heuristiken können in diesen Algorithmus einfließen: Durch ein
lookahead bei der Kompaktierung kann ein Großteil des Rollens der Tabelle
vermieden werden, indem man Verschiebungseinträge hinter die gerade zu
kopierende Zelle in einen unmarkierten Bereich setzt. Die Beobachtung, daß
zumeist größere zusammenhängende Bereiche auf einmal frei werden, kann das
Kopieren der gesamten Tabelle in einen solchen Bereich, unter Beibehaltung der
inneren Ordnung, ermöglichen. Auch für Algol 68 wurde dieses Verfahren verwendet
[Wo71].
3.4.5 Edwards Kompaktierer
Dieser Kompaktierer [Knu73] verwendet zwei Zeiger, die den zu kompaktierenden
Bereich nach markierten Zellen in einer Richtung und in entgegengesetzter
Richtung nach freien Zellen sucht. Falls eine markierte Zelle auf der einen
Seite gefunden wird, wird diese an die Stelle der ersten freien Zelle auf der
anderen Seite kopiert und ein Vorwärtszeiger auf die neue Position in der alten
Zelle hinterlassen. Falls die beiden Zeiger zusammentreffen, sind alle Zellen
kompaktiert. In der darauffolgenden Phase werden die Zeiger im kompaktierten
Bereich gesucht, die auf verschobene Zellen zeigen und durch den an ihrer Stelle
befindlichen Zeiger ersetzt. Dieser Algorithmus behält also nicht die Ordnung
der Elemente bei. Trotz Kompaktierung werden bei einer größeren Anzahl von
Zellen die Zugriffe nicht lokal erfolgen können, da ja die Zellen in
willkürlicher Reihenfolge kompaktiert wurden. Kopieralgorithmen vermeiden diesen
Nachteil. Eine parallele Variante [Ste75] wurde von Steele entwickelt.
3.4.6 Kopieralgorithmen
Kopieralgorithmen falten die Markierung und das Kopieren der Zellen. Beim
Markieren eines Graphen muß man die gesamte Struktur durchwandern. Das gilt auch
für das Kopieren einer Struktur. Also ist es naheliegend, diese beiden Phasen
zu falten (fusion). Nach dem vollständigen Durchlaufen der referenzierten Zellen
befinden sich sämtliche aktive Zellen in einem durchgehenden Block, in dem auch
alle Zeiger gerichtet sind. Der restliche Speicher ist der neue Freispeicher. Im
Wesentlichen benötigt man bei den gefalteten Kopieralgorithmen nur mehr genau
eine Phase, deren Aufwand im direkten Verhältnis zur Anzahl der referenzierten
Zellen steht.
Durch die Verwendung einer Freispeicherliste zerfällt der Speicher in kleine
unzusammenhängende Stücke (Fragmentierung). Bei der Verwendung von virtuellem
Speicher wird allein die Bearbeitung von aktiven Datenstrukturen durch das
häufige Laden von Seiten (thrashing) untragbar. Schon sehr früh [Co67] begann
man sich daher mit diesem Phänomen auseinanderzusetzen. Die folgenden
Algorithmen wurden alle in Hinblick auf die Bewahrung der Lokalität von
Referenzen entwickelt.
1963 entwickelte Minsky [Co81] den ersten Algorithmus dieser Klasse. Die
vorhandenen Zellen werden markiert, und für jede CONS-Zelle wird ein Tripel
bestehend aus der zukünftigen Position der Zelle und ihrer Zeiger auf z.B. ein
File geschrieben. In der im Hauptspeicher verbleibenden Zelle wird ebenfalls die
zukünftige Adresse gespeichert.
3.4.6.2 Fenichel-Jochelson - Twospace Kompaktierung
Der zu verwaltende Speicher wird in zwei gleich große Bereiche A und B
aufgeteilt. In A werden sämtliche Speicherelemente angelegt. Falls der
Speicherbereich von A verbraucht wurde, werden sämtliche aktive Zellen nach B
kopiert. An ihren alten Plätzen in A werden wiederum Verweise in den neuen
Speicherbereich gesetzt. Falls man beim Kopieren auf einen solchen Zeiger nach B
stößt, wird dieser Zeiger als Wert genommen. Dadurch verweisen nach dem Kopieren
alle Zeiger in B wieder auf die entsprechenden Zellen in B. B wird danach nun
der aktive Bereich und A wird bis zum nächsten Kopieren nicht mehr verwendet.
Durch Adressvergleich kann so ohne ein gesondertes Markierungsbit festgestellt
werden, ob eine Zelle bereits markiert worden ist. Zum Durchwandern der
Zellstrukturen wird der rekursive Algorithmus verwendet. Es wäre, wie angemerkt
wird, bei Listen auch denkbar, in den CDR der Liste den Deutsch-Schorr-Wait
Algorithmus zu integrieren. Eine weitere Verbesserung des Kopierens ohne einen
Stack findet sich in [Cla76].
Verglichen mit den mark-scan Algorithmen besitzen Kopieralgorithmen wesentlich
bessere Laufzeiten. Sie sind nur mehr von der Anzahl der momentan aktiven Zellen
abhängig und nicht mehr von der Anzahl aller vorhandenen Zellen. Besonders
flüchtige Datenstrukturen können durch diese Algorithmen sehr effizient
verwaltet werden. Bei permanenten Datenstrukturen ist der Aufwand proportional
zur Zeitdauer der verwendeten Daten. Es besteht nun die Möglichkeit, diese
permanenten Zellen von einem getrennten System verwalten zu lassen oder aber
adaptive Varianten zu entwickeln, die sich sowohl für flüchtige als auch für
permanente Daten akzeptabel verhalten.
3.4.6.3 Scavenging Algorithms
3.4.6.3.1 Bakers Kopieralgorithmus
Auch hier wird der Speicher wieder in zwei gleich große Bereiche aufgeteilt.
Anstatt sofort alle Zellen in den neuen Bereich zu kopieren wird nur eine fixe
Anzahl von Elementen kopiert und die Ausführung der Programme fortgesetzt. Beim
Anlegen einer neuen Zelle wird eine konstante Anzahl von Elementen mitkopiert,
bis sich sämtliche Zellen im neuen Bereich befinden. Beim Zugriff auf eine Zelle
wird sichergestellt, daß sie sich bereits im neuen Speicherbereich befindet.
Falls nicht, wird sie in diesen Bereich kopiert. Das Programm verhält sich so
als ob sofort nach Aufruf der Speicherbereinigung alle Zellen in den neuen
Bereich kopiert worden sind.
Sämtliche im neuen Bereich befindlichen Zellen werden nach alten etwaigen
Referenzen durchsucht und die verbleibenden Referenzen kopiert. Um bei diesem
Kopiervorgang einen Stack zu vermeiden, werden die Zellen direkt in den neuen
Bereich kopiert. Das Verfahren durchsucht nun sämtliche Referenzen des neuen
Bereichs, und damit auch die gerade kopierten Zellen. Auf diese Weise werden die
Zellen implizit mittels Breitensuche kopiert.
Es handelt sich hier [Bek86] um eine einfache Verallgemeinerung des Bakerschen
Algorithmus, die für MALI [Bek86] entwickelt wurde. Anstatt die Zellen zwischen
zwei abgegrenzten Bereichen hin und her zu kopieren, ist es genausogut möglich,
einen Ringspeicher zu nehmen. Der aktive Bereich arbeitet sich in einer Richtung
durch den Speicher. Auf der einen Seite werden neue Elemente angelegt, die
andere Seite läßt nur mehr "tote" Zellen zurück. Bildlich kann man sich diesen
Algorithmus durch eine quergelegte Tonne vorstellen, in der sich Sand befindet.
Durch das Rollen der Tonne fallen die Sandkörner von der einen Seite zurück und
bewegen so den Sand weiter. Sandkörner, die an der Tonne kleben
bleiben -nichtreferenzierte Zellen- werden so von den aktiven Zellen getrennt.
Im allgemeinen wird dieses Verfahren sich ähnlich verhalten wie der
two-space-Kompaktierer Bakers.
MALI's Ringkompaktierer
Bakers Algorithmus ermöglicht es zwar, den Kopiervorgang auf kleinere Schritte
aufzuteilen, dennoch muß innerhalb eines Zyklus die Gesamtheit aller aktiven
Zellen bewegt werden.
3.4.6.3.3 Liebermann und Hewitt
Die bekannte Beobachtung, daß es besonders kurzlebige (flüchtige) und permanente
Datenstrukturen gibt, legt nahe, die kurzlebigen Zellen in einem gesonderten
Bereichen zu halten. Durch Aufteilung des Speichers in viele kleinere Bereiche
können so [sowohl] die flüchtigen Datenstrukturen besonders effizient verwaltet
werden -unter ihnen wird es kaum referenzierte Zellen geben- , als auch die
permanenten Datenstrukturen. In [Lieb83] wird dieser Algorithmus vorgestellt.
Jede derartige Generation von Zellen kann für sich bereinigt werden, ohne ältere
Generationen zu stören, jüngere Generationen können so häufiger bereinigt
werden. Es werden viele kleine Speicherbereiche verwendet, die jeweils durch
eine Generations- und Versionnummer gekennzeichnet sind. Die Garbage Collection
beginnt in einem Bereich, indem erreichbare Zellen aus diesen Bereich kopiert
werden, falls sie während der Ausführung des Programms gefunden werden. Wie auch
in Bakers Algorithmus müssen hier entsprechende Verweise auf den neuen Ort der
Zelle zurückgelassen werden.
Um alle erreichbaren Zellen möglichst schnell zu erkennen, werden die Zeiger
nach ihrer Richtung unterschieden. In LISP-Systemen werden ja fast nur Zeiger
von jüngeren Zellen auf ältere erzeugt. Dadurch können nun die jüngeren
Generationen vollkommen unabhängig von den älteren behandelt werden. Falls
dennoch ältere Zellen auf jüngere zeigen, müssen diese über eine eigene Tabelle
pro Bereich geführt werden. Bereiche könne so unabhängig voneinander berarbeitet
werden. Wie [Un88] anmerkt, gibt es leider noch keine praktischen Erfahrungen
mit diesem Algorithmus. In Prolog jedoch werden derartige Generationen schon von
der Semantik der Sprache benötigt: Jeder Wahlpunkt muß den momentanen Zustand
der Daten bewahren. Alle Änderungen werden auf dem Trail ohnehin schon markiert.
Es ist daher besonders einfach Liebermanns Verfahren für Prolog zu adaptieren,
ohne auch nur den geringsten Mehraufwand zur Laufzeit in Kauf nehmen zu müssen
[Bru84,Pit85].
3.4.6.3.4 Ungar generation scavenging
Dieses Verfahren [Ung84] wurde in Hinblick auf Berkeley Smalltalk entwickelt.
Man versuchte hier einen einfacher zu implementierenden Algorithmus zu
entwickeln, der ebenfalls auf die typische Verteilung der Lebenserwartung einer
Zelle eingeht. Jede Zelle wird entweder als neu oder alt klassifiziert. Alte
Zellen befinden sich in einem eigenen Gebiet. Alte Zellen, die auf neue Zellen
verweisen, gehören einer eigenen Menge (remembered set) an. Falls eine alte
Zelle eine Referenz auf eine neue zugewiesen bekommt, wird sie in diese Menge
aufgenommen. Zellen aus dieser Menge, die nicht mehr auf neue Zellen zeigen,
werden zum Zeitpunkt der Bereinigung (scavenge ) aus dieser Menge
herausgenommen. Alle erreichbaren neuen Zellen müssen über dieses remembered set
erreichbar sein. Zur Feststellung der Referenzierbarkeit kann man also bei
dieser Menge beginnen. Die neuen Zellen selbst werden in drei verschiedene
Bereiche unterteilt:
- NewSpace zur Allokierung neuer Zellen
- PastSurvivorSpace Zellen, die bereits eine Bereiningung hinter sich haben
- FutureSurvivorSpace zur Laufzeit leer; wird Ziel des nächsten Kopierens
Während der Bereinigung werden die erreichbaren Zellen aus NewSpace und
PastSurvivorSpace nach FutureSurvivorSpace kopiert. FutureSurvivorSpace und
PastSurvivorSpace werden hierauf miteinander vertauscht. Wenn eine Zelle lang
genug als neu markiert war, wird sie in den Bereich der alten Zellen kopiert.
Diese Entscheidung wird tenuring (tenure: Besitzdauer) genannt. Die alten
Zellen werden sehr selten auf ihre Referenzierbarkeit überprüft. Dadurch ist es
möglich, daß etwas länger lebende Zellen als alt anerkannt werden, und dennoch
bald nicht mehr benötigt werden.
Die meisten existierenden Smalltalksysteme basieren auf diesem äußerst einfachen
Algorithmus. Zur Vermeidung von nichtreferenzierten Zellen im alten Bereich
können spezielle Zellen (bitmaps und strings) gesondert behandelt werden. Durch
adaptive Varianten kann dieses Verfahren noch wesentlich beschleunigt werden.
Abhängig von der Anzahl der überlebenden Zellen werden neue Zellen früher oder
später den alten Zellen zugeordnet. Dadurch können längere Pausen fast
vollständig vermieden werden. Insgesamt haben allerdings die Analysen von Ungar
[Un88] ergeben, daß dieses Verfahren primär für interaktive Anwendungen
Verwendung finden sollte.
Kopieralgorithmen haben sich vor allem bei funktionalen und objektorientierten
Sprachen bewährt. Untersuchungen für objektbasierte Datenbanken finden sich in
[But87]. Der Mechanismus der Vorwärtsreferenzen wird in einigen Lisp-Systemen
sogar hardwaremäßig unterstützt. Der Explorer* (trademark of Texas Instruments
Inc.) stellt etwa die Konsistenzüberprüfung parallel zum Schreiben zur
Verfügung. Wenn in einen alten Bereich geschrieben wird, wird der entsprechende
Exception-Handler aufgerufen. Durch eine Unzahl von Heuristiken [Moon84,Cour88]
können diese Verfahren noch weiter verbessert werden. [Appel87] diskutiert
effiziente Implementierungen innerhalb des Massive Memory Machine-Projektes für
Standard-ML. Hier wurde auch gemessen, daß eine Vergrößerung des Hauptspeichers
einen überproportionalen Gewinn für die Effizienz der Kopieralgorithmen
darstellt. In [Appel89] stellt er eine Implementierungsvariante für
konventionelle Architekturen vor.
3.4.7 Zeigerumkehrtechniken
Diese Techniken beruhen auf der Tatsache, daß zur Verschiebung einer Zelle k
die nötige Information in den auf sie zeigenden Zellen selbst abgespeichert
werden kann. Durch Auffädelung der Zellen auf eine Liste und Rücksetzung nach
Verschiebung von k kann man die Position von Zellen beliebig verändern.
Durch Auffädelung (threading) aller auf k zeigenden Referenzen, nämlich
S[i_1] = S[i_2]= ... =S[i_n]=k
"Die n Zellen i_1 ... i_n zeigen auf Zelle k"
S[k] = v
"Die Zelle k hat den Wert v"
durch Ausführung der folgenden Operation für jedes der n Elemente in beliebiger
Reihenfolge,
S[i_j] := S[k], S[k] :=i_j, j = 1 ... n 1)
wird eine Liste ausgehend vom zu verschiebenden Element erzeugt, sodaß die
letzte Zelle der Liste den ursprünglichen Wert v der Zelle k erhält, also
S[k] := i_j, S[i_j] := i_k, ..., S[i_l] := v
"Kette; die erste der umgekehrten Zellen
enthält den ursprünglichen Wert v."
(j,k,l, bezeichnen alle Zellen)
gilt.
Auffädelung der Zellen
Nach erfolgreicher Verschiebung der Zelle von k nach k' kann nun nach
Übernehmen des Wertes v die Liste bestehend aus den n Zeigern wieder auf k'
gerichtet werden. n-mal wird daher ein Element aus der Liste ausgehängt und auf
die neue Adresse gesetzt.
t := S[S[k]], S[S[k]] := k', S[k] := t 2)
k', die neue Position der Zelle, wird in den vorgestellten Algorithmen in einem
kontinuierlichen Bereich bestimmt, in dem alle zu bewegenden Zellen in gleicher
Reihenfolge wie zuvor angelegt werden. Es wäre allerdings auch denkbar eine
Freispeicherliste zu verwenden.
nach Auflösung der Liste; die Zeiger sind bereits auf die neue Position der
Zelle gerichtet
Fisher[Co81] entwickelte anfänglich einen
derartigen Algorithmus allerdings nur für gleichgerichtete Zeiger.
Um die folgenden zwar sehr einfachen, aber anfangs nicht leicht verständlichen
Algorithmen besser darstellen zu können, werden die Zellen, bei denen angenommen
wird, daß sie genau aus einem Zeiger bestehen, durch eine Adjazenzmatrix
dargestellt. Ein Eintrag in der i-ten Zeile und k-ten Spalte bedeutet, daß die
i-te Zelle einen Zeiger auf die k-te Zelle besitzt. Dadurch ist es sehr einfach,
die Menge der Aufwärts- und Abwärtszeiger darzustellen. Die Zellen, die auf sich
selbst zeigen, befinden sich auf der Diagonale, Zellen mit Aufwärtszeigern
entsprechend oberhalb.
Unterscheidung von Aufwärts und Abwärtszeigern mittels Matrixdarstellung
Eine spezielle Struktur wäre etwa wie folgt dargestellt:
Darstellung der Zeiger
durch Pfeile vs. Matrix
Diese Notation hat zudem den Vorteil, nicht nur einen speziellen Fall, sondern
die Menge aller möglichen Zeigerkombinationen in einer Zeichnung darstellen zu
können. Für den im Folgenden vorgestellten Algorithmus wird in [Mor78] auch ein
teilweise ausgeführter Beweis für seine Korrektheit angegeben. Durch die
graphische Darstellung kann man informell genau die einzelnen Zusicherungen des
Beweises darstellen und "induktiv" zeigen, daß während der einzelnen Phases des
Algorithmus die Zusicherungen gelten und das angestrebte Ziel erreicht wird.
Der ursprüngliche Algorithmus betrachtet eine Menge von Zellen, die genau einen
Zeiger enthalten können. Da die Ordnung der einzelnen Zellen untereinander
erhalten bleibt, können komplexere Zellstrukturen den gleichen Algorithmus
verwenden; jeder Zellteil stellt für den Algorithmus eine eigene Zelle dar.
Aufwärts- und Abwärtszeiger werden getrennt in zwei Phasen behandelt. Zuvor
müssen sämtliche Zellen bereits markiert worden sein. Durch die Anzahl der
markierten Zellen kann bereits der Bereich berechnet werden, in den die
kompaktierten Zellen verschoben werden sollen.
- Phase I
- Die in Leserichtung befindlichen Aufwärtszeiger werden nach 1) aufgefädelt. Alle
aufgefädelten Zellen werden gesondert markiert. Falls man auf eine bereits
markierte Zelle stößt, hat man alle in Leserichtung befindlichen Zeiger auf
diese Zelle hinter sich und kann diese bereits auf den neuen Ort der Zelle
richten und den Wert der Zelle wiederherstellen. Diese Zelle kann entweder einen
Zeiger in Leserichtung enthalten, dann wird der Zeiger gemäß 1) aufgefädelt,
oder aber einen Zeiger gegen die Leserichtung oder auf sich selbst enthalten; in
diesem Fall kann die Zelle übergangen werden.


während Phase I in Morris I:
Auffädelung der Aufwärtzeiger. Alle Aufwärtszeiger bis zur aktuellen Zelle sind
bereits auf die neue Position gesetzt; mittels der bisher besuchten
nichtreferenzierten Zellen wird die neue Position berechnet; es gibt keine
Zeiger auf nichtmarkierte Zellen (schwarze Balken)
Nach dieser Phase zeigen alle Aufwärtszeiger bereits auf die richtige neue
Position.
nach Phase I in Morris I: Alle Aufwärtszeiger sind auf die neue
Position gesetzt
- Phase II
- Es wird in umgekehrter Richtung vorgegangen. Zeiger in Leserichtung werden nun
so aufgefädelt, daß auf sie bereits an ihrem zukünftigen Ort verwiesen wird.

während Phase II in Morris I: Auffädelung der Abwärtszeiger; alle Zeiger außer
die Abwärtszeiger vor die aktuelle Zelle zeigen bereits auf die neuen
Positionen
Nach dieser Phase sind auch sämtliche Aufwärtszeiger richtiggestellt,
und man kann die Zellen in einer weiteren Phase kompaktieren.
nach Phase II in Morris I:
Alle Zeiger sind auf die neuen Positionen der Zellen gerichtet
Betrachtet man den Ablauf der zweiten Phase, so erkennt man, daß alle Zellen,
die bereits besucht worden sind, sowie alle Aufwärtszeiger nicht mehr verändert
werden. Lediglich die Abwärtzeiger, die vor die aktuelle Position zeigen,
müssen noch verändert werden. Es ist daher naheliegend, das Kopieren der
einzelnen Zellen in die Phase II zu falten. Anstatt wie bei Phase I bei der
Zeigerumkehrung den momentan gültigen Zeiger auf die Zelle zu verwenden, werden
für die Abwärtszeiger ihr neuer Wert, der Zeiger auf die gerade berechnete Zelle
verwendet.
nach Phase II bzw. III: die Zellen sind in den unteren Bereich
verschoben
Die Zellen werden also alle in zur Phase II entgegengesetzten Richtung
kompaktiert.
Morris verbindet direkt die II. Phase mit dem Verschieben der Zellen. Es ist
leider unmöglich, Phasen direkt weiter zusammenzufalten. Man beachte jedoch,
daß die durch Umkehrung aufgebaute Liste erst zum Zeitpunkt ihrer Auflösung
benötigt wird. In der Zwischenzeit können die einzelnen Elemente dieser Liste
beliebig mit anderen Werten überschrieben werden, da der Algorithmus zum
Einhängen eines Elements nur vom Inhalt der Zelle abhängt, auf die gezeigt wird.
Falls bereits Zeiger auf diese Zelle umgekehrt worden sind, steht in dieser
Zelle bereits ein umgekehrter Zeiger, der in die neu ankommende Zelle kopiert
wird. Die alten aufgefädelten Zeiger sind von dieser Operation nicht betroffen
und können daher, wie noch in Kapitel 4.2.4.1 gezeigt werden wird,
für andere Zeigerketten verwendet werden.
Unter erleichterten Bedingungen ist es direkt möglich, effizientere Algorithmen
zu entwickeln. In einigen Systemen und Programmiersprachen -etwa Snobol- werden
komplexere Zellen als die cons-Zellen von Lisp benötigt. Pro Zelle müssen
Informationen -etwa über die Größe und den Typ der Zelle, ähnlich auch in
Prolog- verwaltet werden, die sicher keine Referenzen enthalten. Unter diesen
Umständen bestehen wesentlich mehr Möglichkeiten, den Algorithmus den
vorhandenen Strukturen besser anzupassen. Auch fallen die Probleme mit
selbstreferenzierenden Zellen weg. Während der Auffädelung der Zellen könnte der
freie Bereich etwa zur -teilweisen- Faltung der beiden Phasen verwendet werden.
Der nach Morris I von Jonkers veröffentlichte Algorithmus[Jon79] nützt diese
Tatsache aus:
Die erste Phase läuft ähnlich wie Morris I ab. In der zweiten Phase werden ein
weiteres Mal in derselben Leserichtung die Zellen nach umgekehrten Zeigern
durchsucht und dann an ihren neuen Ort kopiert. Der Vorteil dieses Algorithmus
liegt darin, daß in der zweiten Phase pro Zelle nur der u.U. aufgefädelte Zeiger
betrachtet werden muß, während bei Morris I auch in der II. Phase jede einzelne
Speicherzelle betrachtet werden muß.
Kompaktierungsalgorithmen werden üblicherweise gemeinsam mit einer
vorhergehenden Markierungsphase verwendet. Da es ja offensichtlich unmöglich
ist, die beiden letzten Phasen der beschriebenen Algorithmen weiter zu falten,
ist es naheliegend, die erste Phase mit der Markierungsphase zu falten. Auf
dieser Tatsache beruhen eine Menge von Algorithmen, die im besonderen für Snobol
Verwendung finden.
Die einfachste Realisierung dieser Idee wurde von
Dewar und McCann [Dew77] -ein
Jahr vor Morris I- mit einem Hinweis auf Knuths Lisp 2-Algorithmus entwickelt.
Es werden Zellen mit einem zeigerfreien Teil verwaltet, die in einer
Leserichtung im Speicher unabhängig von den auf sie zeigenden Zellen erkannt
werden können. Auf Teile der Zellen dürfen keine zu verändernden Referenzen
existieren. Zeiger im Wertteil der Zelle müssen erkennbar sein. (Bei der
Realisierung von arrays in Snobol müssen daher zur array-Zelle relative Adressen
verwendet werden.)
- Phase I: Markierung und Auffädeln
- Beim Durchlaufen des Graphen werden alle Referenzen gemäß 1) aufgefädelt.
- Phase II: Zeigerumsetzung
- Der gesamte Speicher wird in Leserichtung nach markierten Zellen durchsucht;
diese können durch Zeiger -statt des eigentlichen Wertes- erkannt werden. Zum
Zeitpunkt des Auffindens einer derartigen Zelle ist bereits die zukünftige
Adresse bekannt -die Summe der Größe aller bisher gefundenen Zellen. Mittels 2)
werden alle existierenden Referenzen auf die neue Adresse gesetzt. Für die
nächste Phase werden, ähnlich wie in Lisp 2, die markierten Zellen durch eine
Kette im nicht benötigen Speicher verbunden.
- Phase III: Kopieren
-
Durch die Kette der markierten Zellen werden die Blöcke von Zellen kopiert.
Hanson [Han77] verwendet eine sehr ähnliche Variante, kombiniert mit dem
Deutch-Schorr-Waite Algorithmus.
Zur gleichen Zeit wurde auch von Thorelli fast der gleiche Algorithmus
entwickelt[Thor76]. Neben einer ausführlicheren Beschreibung des Verfahrens,
wird auch eine Beweis der Korrekheit des Algorithmus angegeben. Es wird
bewiesen, daß die referenzierte Struktur nicht verändert wird, und keine
"Löcher" im referenzierten Bereich verbleiben. Im Algorithmus selbst verwendet
Thorelli statt der Liste der referenzierten Blöcke ein Markierungsbit. Wenn auch
die Algorithmen von Dewar-McCann,Thorelli und Hanson bereits die
Markierungsphase für 1) nützen können, müssen sie dennoch zwei weitere Phasen
realisieren. Sie benötigen zumindest das Zurücksetzten einiger aufgefädelter
Zellen. Da allerdings nicht festgestellt werden kann, ob die betrachteten Zellen
bereits bewegt werden können, ist eine weitere Phase für das Bewegen der Zellen
erforderlich. Morris schlägt nun, ähnlich wie in seinem ersten Algorithmus
Morris I eine Trennung in Aufwärt- und Abwärtzeiger vor. Dadurch werden
insgesamt nur mehr zwei Phasen benötigt.
Unter den Voraussetzungen von Dewar-McCann und einem zusätzlichen
Markierungsbit [Mor82].
- Phase I Markierung und Auffädeln von Abwärtszeigern.
- Alle
Abwärtszeiger werden entsprechend aufgefädelt, aber nicht, wie in Morris I
wieder zurückgesetzt.
nach der Markierungsphase in Morris II: Alle
Abwärtszeiger sind aufgefädelt; eine Zelle kann hier n Zeiger enthalten,
entsprechend müßte jeder Eintrag in die Matrix aus n Teilen bestehen
- Phase II Auffädeln in Aufwärtsrichtung und Kopieren
- In Aufwärtsrichtung werden
nun die Zeiger auf die Header der einzelnen Zellen gesetzt. Für die Argumente
einer Zelle wird wie in Morris I vorgegangen: Die Aufwärtsreferenzen werden
aufgefädelt, während bei den Abwärtsreferenzen nur mehr der Zellteil an seinen
neuen Ort kopiert werden muß.

während der Kompaktierungsphase in Morris II : Alle Aufwärtszeiger der Zellen
bis zur aktuellen Zelle sind aufgefädelt; die Abwärtszeiger auf Zellen bis zur
aktuellen sind bereits auf die neue Position gesetzt
void mark(int i) {
int p,j;
p = S[i];
if (ispointer(p)) {
if (!marked[i]) {
marked[i] = true;
for(j = p + 1; j < p + size(p); j++)
mark(j);
)
if (p < i) {
S[i] = S[p];
S[p] = i;
}
}
}
int i, old, new, t, z;
for(i = 1; i =< r; i++) mb[i] = false;
mark(ex1);...;mark(exn);
old = new = l;
while( old =< r)
if (marked[old]) {
while(!isheader(S[old]) {
t = S[old]; S[old] = S[t];S[t] = new;
}
z = size(old);
S[new] = S[old];
for (i = 1; i < z; i++)
if(ispointer(S[old+i]) && S[old+i] > old) {
S[new+i] = S[S[old + i]];
S[S[old + i]] = new + i;
} else S[new + i] = S[old + i];
new = new + z; old = old + z;
} else old = old + size(old);
}
Man beachte, daß Jonkers, Dewar et al. und Morris II im wesentlichen dieselben
Zeigermanipulationen durchführen. Bei Jonkers und Dewar kann in der zweiten
Phase auf ein Durchsuchen der Argumente verzichtet werden. Morris II hat zwar
gegenüber Dewar et al. nur zwei Phasen, muß aber in beiden Phasen sämtliche
Argumente einer Zelle lesen. Je nach Größe der Zellen dürfte daher entweder
Morris II -bei kleineren Zellen- ,oder Dewar et al. der Vorzug gegeben werden.
Der traditionelle mark-and-sweep Algorithmus benötigt Zeit proportional zur
Anzahl der erreichbaren und unerreichbaren Zellen. Die in den letzten
Abschnitten vorgestellten Algorithmen hängen allerdings nur von der Zahl der
erreichbaren Zellen ab. Bei den Zeigerumkehrungsalgorithmen kommt zwar ein sehr
geringer Faktor abhängig von der Größe des zu bearbeitenden Speichers hinzu,
ihre Vorteile liegen jedoch in der Bewahrung der Reihenfolge der Elemente.
Beide Klassen von Algorithmen müssen allerdings alle referenzierten Zellen
bearbeiten. Diese werden bei der entsprechenden Kompaktierung in einen
zusammenhängenden Block gebracht. Durch die Einteilung in Generationen, im
allgemeinen durch aging läßt sich auch dieser Aufwand verringern.
In praktischen Anwendungen bei nichtsymbolischen Sprachen kommen verschiedenste
Typen von Zellen vor, die sich auch völlig unterschiedlich verhalten. In diesen
Fällen zahlt es sich aus, aus mehreren Strategien zusammengesetzte Algorithmen
zu verwenden. Boehm [Boe88] verwendet je nach Größe der Zelle andere Verfahren
zum Anlegen der Zellen. Nach seinen Erfahrungen kann man auch in herkömmlichen
Applikationen stop and collect, bzw. copy-Verfahren verwenden. Nilsen [Nil88]
betrachtet eine ähnliche Problemstellung für die Verwaltung von Strings, bei
welchen Zeiger auch auf Teile existieren dürfen. Ein Großteil der besprochenen
Algorithmen wird auch tatsächlich in der Praxis eingesetzt. Die meisten von
ihnen können auch auf traditioneller Hardware respektable Ergebnisse erzielen.
Die diversen Kopieralgorithmen werden wie schon bemerkt für Smalltalk und
Lisp-Systeme verwendet, die Kompaktierungsalgorithmen werden von Algol68, Lisp,
Snobol bis Prolog eingesetzt. Die bestechende Schlichtheit dieser Algorithmen
ist sicher auch mit ein Grund, warum diese in kommerziellen Systemen Verwendung
finden.