Objective-C vs Java - Memory Management

veröffentlicht am : Mi, 29. 05. 2013 geändert am: Di, 16. 05. 2017

Kategorie: Computer --> Programmierung


Bei meinen Entwicklungen in Java und Objective-c musste ich mich zwangsläufig auch mit dem Memory Management auseinandersetzen. Das ist in Java meistens ja etwas, das insbesondere seit JDK 1.4 mehr oder minder im Hintergrund stattfindet. Das war eine der großen Neuerungen von Java und viele andere Sprachen haben es übernommen. Aber gerade das aufkommen der neuen mobilen Endgeräte mit doch mehr oder minder beschränkten Resourcen machen das MemoryManagement der eigenen Applikationen wieder etwas spannender.

Warum überhaupt Speichermanagement?

Früher war doch alles besser - im guten alten Assembler hat man einfach so im Speicher rumgeschrieben, ohne sich einen Dreck um irgendein Betriebssystem oder so zu kümmern. Das ist heute natürlich nicht einfach so möglich, denn das Betriebssystem benutzt ja auch RAM und man ist ja nicht das einzige Programm, das gerade läuft. So gesehen, war die gute alte 8-Bit Ära schon besser, weil einfacher! Heute muss man erst mal das System fragen, wenn man Speicher benutzen will, denn sonst wird das Programm schnell einfach beendet (Unter Unix ist das gerne mal ein „Segmentation Fault“) - schon aus Sicherheitsgründen - man könnte ja sonst wichtige Systemfunktionen oder ~daten überschreiben. Speicher allokiert man z.B. in C/C++ mit der Funktion malloc. Ihr gibt man einfach eine Größenangabe mit und als Ergebnis erhält man einen Zeiger auf den für uns zur Verfügung gestellten Speicherbereich. Das Gegenstück zu mallocist free- damit wird zuvor allokierter Speicher wieder frei gegeben. Alles eigentlich doch recht simpel. Naja… da könnte man doch fragen, was denn daran so kompliziert ist, den Speicher, den man sich vorher „geholt“ hat, dann auch wieder frei zu geben, wenn man ihn nicht mehr braucht. Im Grunde ist das richtig, vor allem für sehr einfache Programme kann man den Speicher so „verwalten“. Aber heutige Anwendungen haben schon andere Anforderungen an das Speichermanagement. Das fängt schon damit an, dass ich zum Erstellungszeitpunkt gar nicht genau weiß, wie viel Speicher ich eigentlich benötige. Person p=new Person(); p.setName(„Ein Name“); p.setAge(new Integer(44)); sieht simpel aus, oder? aber genaugenommen passiert folgendes: es wird Speicher für ein Objekt „Person“ allokiert. Die Person ist noch „leer“. Jetzt wird ein Name hinzugefügt. D.h. zum Zeitpunkt der Erstellung der Person wusste ich nicht, wie groß das Objekt eigentlich werden sollte. Ich kann also gar nicht wissen, wie viel Speicher ich bereit stellen muss.

Retain Cycle

Gleiches gilt für die Freigabe. Wann soll ich den Speicher wieder frei geben? Wann wird die Person pnicht mehr benötigt? Das wird besonders dann komplex, wenn man auch komplexere Datenstrukturen benötigt. Ein einfaches Beispiel dafür ist eine Baumstruktur:

 Class Parent extends Node{
      List children;
    }
    Class Child extends Node {
       Parent parentNode;
    }

solch eine Struktur ist normalerweise im Vorhinein nicht klar in der Größe festzulegen. Außerdem ergeben sich sog. Retain Zyklen. Hier hat jeder Knoten einen Zeiger auf seine Vaterknoten und dieser wiederum einen Zeiger auf alle Kindknoten. Das bedeutet, jeder dieser Knoten ist von irgendwoher erreichbar, es gibt ja eine Referenz darauf. Das Hauptprogramm:

root = new Parent();
//add children
// do something with root and the Data
Root = new Parent()

Das Hauptprogramm erzeugt hier einen Parent-Knoten mit alle seinen Kindknoten. Der der so belegte Speicher ist noch vom Hauptprogramm aus erreichbar, da wir ja die Referenz root haben. Wird diese allerdings ersetzt (letzte Zeile), ist der ganze Baum nicht mehr erreichbar und der Speicher könnte theoretisch freigegeben werden. Allerdings ergibt sich ein Retain Zyklus, da ja jeder knoten von einem anderen referenziert wird => Dilemma.

Wie funktioniert das in java - der Garbage Collector

Den Garbage Collector (oder kurz GC) kann man sich vorstellen, wie die Mutter eines Teenagers, die immer hinter ihm herläuft und auf- bzw. wegräumt. Genauso ist der Garbage Collector. Der GC erkennt die Objekte, die vom Programmthread (bzw von allen Programmthreads) aus erreichbar sind. Alle anderen werden gelöscht. Dafür erstellt der GC einen sog. Erreichbarkeitsgraphen aller erstellten Objekte. Und der Speicher aller Objekte, die vom einem laufenden Thread aus nicht mehr erreichbar sind, wird freigegeben. Klingt im ersten Moment recht simpel, wird aber insbesondere im Hinblick auf Retain Cycle etwas undurchsichtig. Kurz gesagt, der GC kann auch komplexere Datenstrukturen einfach aus dem Speicher räumen, da er ja die Erreichbarkeit von den Programmthreads aus berechnet und nur die Objekte abräumt, die nicht mehr erreichbar sind. => auch Retain Cycle werden abgeräumt. Manchmal wird geschrieben, dass der GC Retain Cycle erkennt, da er ja alle darin befindlichen Objekte im Speicher hält, wenn es auch nur eine Referenz auf das Objekt gibt.

Wie obiges Beispiel: Baumstruktur. Vaterknoten hält Referenzen auf Kindknoten und umgekehrt. Wenn der Vater abgeräumt wird, weil er nicht mehr erreichbar ist, werden auch alle Knoten, die daran hängen mit abgeräumt (es sei denn, das Hauptprogramm hält noch eine referenz auf einen der Kind-Knoten. Dann würde zumindest dieser Baumteil nicht mit abgeräumt).

Die erste Instanz von Parent() mit all seinen abhängigen Kindknoten sind am Ende des Beispiels zwar noch im RAM, aber vom den Programmthreads aus nicht mehr erreichbar. D.h. Der GC wird sie beim nächsten Lauf abräumen.

Manchmal ist es auch wichtig, diese Cyclen vom Programmseite aus zu verhindern bzw. zu vermeiden. Dafür gibt es in Java z.B. noch spezielle Referenzobjekte WeakReference und PhantomReference. Diese verhalten sich etwas anders beim berechnen des Erreichbarkeitsgraphen und beeinflussen so den GC. Eine Normale Variable ist in dem Zusammenhang immer als strong Referenz zu sehen.

Wie klappt das in Objective-C?

In Objective-C wird seit geraumer Zeit ein etwas anderer Ansatz gewählt, das Reference Counting. Dabei hält jedes Objekt einen Zähler, wie viele Zeiger darauf verweisen. An sich eine gute Idee, jedoch muss der Programmierer leider selbst dafür sorgen, dass diese Zähler erhöht oder verringert werden. Dafür stellt Objective-C die beiden Methoden retain und release bereit, die von NSObject aus vererbt werden. Mit retain wird der Reference Counter (auch Retain Counter) erhöht, mit release um eins reduziert. Ist der Reference Counter gleich 0, kann das Objekt gelöscht und der zugehörige Speicher freigegeben werden. Das war fürchterlich mühsam und auch fehleranfällig. Man musste höllisch aufpassen, wann man retain und wann release aufruft. Wurde letzteres zu früh aufgerufen, hatte man irgendwo einen nil-Pointer. Das verursacht zwar nicht unbedingt eine Fehlermeldung in Objective-C (zugriff auf nil pointer tut meistens einfach nix), aber tut eben auch nicht das, was man erwartet. Wurde ein release jedoch vergessen, hatte man ein Memoryleak produziert - auch nicht gerade wünschenswert.

ARC

In den neuen Versionen von Obective-C (OSX und IOS6) wurde das „Automatic Reference Counting“ eingeführt. Das einem diese ganze lästige Arbeit abnehmen sollte. Das funktioniert eigentlich wirklich super - wenn man ein wenig Hintergrundinfos hat. Im Endeffekt wird das Hauptprogramm nur in einem @autoreleasepool { } gestartet, was der Laufzeitumgebung sagt: „Hey, alles in diesen Geschweiften klammern bitte zählen“ Das funktioniert weitgehend zur Compiletime, d.h. der Compiler fügt Code ein, der die Referenzcounter erhöht oder verringert! Zur Laufzeit findet nur noch eine Prüfung auf reference_counter==0 statt. Der Ansatz ist also grundlegend anders als der des Garbage collectors. Auch birgt er so seine Tücken.

So kann mit dieser Methode ein Retain Cycle nicht erkannt werden bzw. die so belegten Speicherbereiche werden nicht freigegeben => Speicherloch! Um das zumindest von Programmiererseite verhindern zu können, kann man auch in Objective-C schwache weak referenzen verwenden. Nehmen wir die Baumstruktur noch mal als Beispiel, hier in Objective-C. Parent.h:

 @interface Parent: Node
 @property (strong, nonatomic) NSArray *children;
 @end

Child.h:

@interface Child: Node
@property (weak, nonatomic) Parent *parent;
@end

wichtig ist, dass der Child-Knoten nur eine weak-Referenz auf den Vaterknoten hat. Damit kann der ganze Baum abgeräumt werden, wenn der Vaterknoten nicht mehr erreichbar ist. im Programm:

Parent *p=[[Parent alloc]init];
//add children
//do something
p=[[Parent alloc]init];

Eine weak-Referenz erhöht den Reatain-Counter nämlich nicht. Wenn also das Hauptprogramm keinen Zeiger mehr auf den ursprünglich erzeugten Objektbaum hat, kann alles abgeräumt werden. Bei meinen Tests mit IOS7 ist mir aufgefallen, dass es sehr wohl sinnvoll sein kann, Objektpointer auf nil zu setzen. Wenn man das tut, wird der Speicher sofort wieder freigegeben. Und in einigen Fällen ist es das, was man braucht. (zur Erinnerung, das meiste beim ARC wird als Code automagisch eingefügt! Wenn ich irgendwas auf nil setze, weiß der Compiler auf jeden Fall, dass ich das Objekt nicht mehr brauche).

Aber das ist leider nicht alles. Das ARC wird von den Grenzen des Code-Blocks von @autoreleasepool bestimmt. Das bedeutet, erst wenn man den Block verlässt, wird sicher alles abgeräumt, was noch mit einem Retain-Counter von 0 im RAM rumidelt. So kann es nötig sein, dass man von Zeit zu Zeit mal einen neuen Autoreleasepool anlegt, damit die darin erzeugten Zwischenergebnisse vom Heap entfernt werden. Es ist schwer zu sagen, wann genau man das machen muss, aber Rekursionen und Schleifen sind heiße Kandidaten ;-)

Leider kommt man in Objective-C nicht immer nur mit den Objekten aus, die einem die Laufzeitumgebung zur Verfügung stellt. Da Objective-C eine Obermenge von C ist, kann man auch auf die gesamte C Funktionalität zurückgreifen und das ist leider manchmal nötig. Einige Systemaufrufe liefern als Ergebnis leider C-Structs und keine Objekte, die mit ARC wieder abgeräumt werden. Manchmal muss man solche Datenstrukturen auch anlegen, was dann natürlich am ARC „vorbei“ passiert. So kann man sich sehr einfach ein Speicherloch züchten, wenn man nicht aufpasst. Im Zweifel am besten immer auf die Objekte der Laufzeitumgebung setzen.

Bewertung beider Mechanismen

GC

  • Nachteil: die Laufzeianalyse ist evtl aufwändig, kann zu kurzzeitigem "einfrieren" führen insbesondere bei speicherintensiven Anwendungen, läuft irgendwann - der Zeitpunkt ist nicht klar
  • Vorteil: simpel für den Programmierer, Retain Cycle werden erkannt

ARC

  • Vorteil: keine aufwändige Laufzeitanalyse, alles zur Compiletime (Set Nil deswegen manchmal sinnvoll)
  • Nachteil: es wird automatisch Code erzeugt, das kann zu unerwartetem Verhalten führen, keine Erkennung von cyclen Problem: Memory allocation an dem Arc vorbei!

erstellt Stephan Bösebeck (stephan)