Bei all der Abstraktion von der physikalischen Hardwareplattform, mit der heutige Anwendungsentwickler beim Einsatz von auf virtuellen Maschinen basierenden Programmiersprachen wie Java, C# und allen .NET-basierten Sprachen heutzutage wie selbstverständlich umgehen, darf man nie die unumstößliche Tatsache vergessen, dass jedes Stückchen Code letztlich auf einem realen Prozessor ausgeführt und jede Instanz einer Klasse in realem - und das heißt vor allem: in seiner Größe begrenzten - Arbeitsspeicher abgelegt werden muss. Nicht selten ist das Vergessen oder Verdrängen dieses Fakts der Grund für zwar theoretisch ganz toll und elegant aussehende Routinen und Datenmodelle, die sich im realen Betrieb aber dann als überraschend ineffizient und/oder speicherhungrig erweisen.
Grundkenntnisse über die verwendete Prozessorarchitektur lohnen sich daher auch für einen Anwendungsentwickler, der mit maschinennaher Programmierung in C oder gar Assembler nichts am Hut hat. Dasselbe gilt für Kenntnisse über den Aufbau der in der jeweiligen Sprache gängigen Datenstrukturen im Speicher. Derartiges Wissen kann helfen, den "Memory Footprint" einer Software schon im Vorfeld möglichst niedrig zu halten - oder ihn im Bedarfsfall mit möglichst wenig Aufwand zu reduzieren. Ein paar kleine Tricks haben hier mitunter durchschlagende Wirkung.
Man sollte aber auch in diesem Fall den Grundsatz, dass "premature optimization" generell zu vermeiden ist, nicht komplett außer Acht lassen. Aufwendige Optimierungen sollten besser erst nach eingehender Analyse einer voll funktionsfähigen Datenstruktur mittels Profiling-Tools durchgeführt werden, um zu vermeiden, dass unvernünftig viel Zeit und Mühe in komplexe Optimierungsvorhaben fließen, die sich im Nachhinein als unbedeutend erweisen - etwa weil der betreffende Teil der Datenstruktur nur verhältnismäßig wenig Anteil am gesamten Speicherbedarf hat. Unpassende Optimierungen können sich sogar nachteilig auswirken, denn nicht selten erkauft man sich einen Speicherplatzvorteil durch zusätzlich nötige CPU-Zyklen - wenn hierbei der Gewinn den zu zahlenden Preis nicht rechtfertigt, sollte eine entsprechende "Optimierung" besser unterlassen werden.
In meinem konkreten Fall ging es um eine einzige Datenstruktur in der WoW-Datenbank SpeedyDragon, die jedoch recht komplex aus Instanzen von hunderten unterschiedlicher Klassen zusammengesetzt wird - einige Klassen werden nur wenige Male instanziiert, von anderen wiederum gibt es mehrere Zehntausend Instanzen. Dabei kommen exzessiv verschiedene Standard-Datenstrukturen wie z.B. Arrays oder HashMaps zur Organisation der Objekte zum Einsatz. Die gesamte Struktur musste dabei zweimal in den Speicher der laufenden Anwendung passen: während eine Instanz für den Betrieb genutzt wird, wird eine zweite in regelmäßigen Abständen aus den Daten in einer relationalen Datenbank im Hintergrund neu aufgebaut und anschließend gegen die erste ausgetauscht. Obendrauf kommt noch der Speicherbedarf der Anwendung, die eine ganze Reihe von In-Memory-Cache-Mechanismen zur effizienteren Abarbeitung von Anfragen nutzt. Dementsprechend interessant waren für mich Möglichkeiten zur Optimierung des Speicherbedarfs dieser Datenstruktur.
Bevor ich zur in der Überschrift angedeuteten Frage komme sei aber erst mal ein sehr einfacher Optimierungsschritt genannt, der ohne den Verzicht auf den Einsatz von ArrayLists den benötigten Speicherbedarf deutlich reduzieren kann - vorausgesetzt, die Anzahl der Objekte in den betreffenden ArrayLists bleibt weitgehend konstant. Dann nämlich lohnt es sich, eine ArrayList im Anschluss an ihre Befüllung durch den Aufruf ihrer "trimToSize()"-Methode auf ihre tatsächlich benötigte Mindestgröße zu reduzieren.
Zum Verständnis der Funktion dieser Methode sind einige Grundkenntnisse über den Aufbau einer ArrayList nötig. Diese ist im Grunde nichts weiter als ein klassisches Array, gekapselt in der Klasse namens ArrayList. Diese Kapselung ermöglicht so praktische Dinge wie ein dynamisches Wachsen des internen Arrays beim Hinzufügen von Elementen - und implementiert ist diese Funktionalität auf denkbar einfache Weise. Jede ArrayList startet mit einem internen Array einer festen Größe, das normalerweise nur wenige Elemente fasst. Werden weitere hinzugefügt, alloziiert die ArrayList intern ein neues, größeres Array und kopiert die Elemente aus dem alten Array hinüber, bevor sie die neuen hinzufügt. Da dieser Kopiervorgang bei wachsender Elementanzahl mehr und mehr CPU-Zyklen benötigt, erhöht die ArrayList die Größe der zusätzlich alloziierten freien Arrayplätze kontinuierlich: während bei 10 vorhandenen Elementen für das 11. Element z.B. ein neues Array von insgesamt 20 Elementen angelegt wird, werden nach 1000 Elementen für das 1001. womöglich 2000 Arrayplätze alloziiert. Es ergibt sich also fast immer ein enormer Anteil von "Verschnitt" - leeren Arrayplätzen, die möglicherweise niemals gefüllt werden.
Für genau diese Situationen gibt es "trimToSize()". Diese Methode kürzt das interne Array, so dass es exakt die für die vorhandene Elementanzahl benötigte Größe hat und kein Speicherplatz ungenutzt vergeudet wird. Es erklärt sich von selbst, dass das Array bei auch nur einem weiteren hinzugefügten Element wieder vergrößert werden müsste; wie bei allen Optimierungsmaßnahmen ist diese also auch kein Allheilmittel, sondern nur in passenden Situationen hilfreich, nämlich wenn keine weiteren Elemente zu erwarten sind.
Aaaber: wenn wir ohnehin schon in die interne Organisation einer ArrayList eingreifen unter der Annahme, dass wir ihre dynamischen Resizing-Funktionalitäten nicht brauchen, warum sollte dann überhaupt eine ArrayList zum Einsatz kommen? Womöglich tut ja ein simples, klassisches Array auch den Job! Dass das möglicherweise Speicherplatz sparen könnte, liegt auf der Hand - immerhin basiert die ArrayList auch auf einem Array, fügt aber den Overhead einer weiteren Objektinstanz hinzu. Interessant ist die Frage, wie viel Speicher dabei tatsächlich zu holen ist.
Um das zu klären, konstruieren wir eine kleine Beispielapplikation für die Untersuchung durch ein Profiling-Tool (ich benutze dazu in der Regel den jProfiler, aber die meisten vergleichbaren Tools können dasselbe leisten):
public static void main(String[] args) {
ArrayList<Object> testList1=new ArrayList<Object>();
testList1.trimToSize();
Object[] testList2=new Object[0];
try {
System.in.read();
} catch (IOException e) {
e.printStackTrace();
}
}
}
Ein Blick in den Heap der gestarteten Anwendung (diese bleibt ja nach Allokation der beiden leeren Listen bei "System.in.read()" stehen, wartet auf eine Eingabe und hält die zwei Objekte derweil im Heap) offenbart: die ArrayList selbst erfordert 40 Byte plus 24 Byte für das leere interne Array, im Gesamten also 64 Byte. Demgegenüber steht das nackte Array mit 24 Byte Speicherbedarf - ergibt 40 Byte Overhead. An diesem ändert sich auch nichts, wenn das Array gefüllt wäre, pro durch ein klassisches Array ersetzter ArrayList gewinnt man folglich immer 40 Bytes. Klingt nach wenig, aber wie der Schwabe sagt: "s' läppert sich"! Bei einer Million ersetzter ArrayList-Instanzen spart man bereits 40 Megabyte; jeder kann dieselbe Rechnung nun für seine eigene Applikation aufmachen. In meinem Fall konnte ich gut 100 Megabyte allein durch diese Maßnahme einsparen - das entsprach einer Reduktion des Platzverbrauchs einer Instanz des Datenmodells um fast 25%.
Am Code in den Methoden geht diese Änderung natürlich nicht gänzlich spurlos vorbei: die ArrayLists werden in mancher Hinsicht anders angesprochen wie Arrays, obwohl Arrays viele der Möglichkeiten von ArrayLists auch bieten. So kann die Größe eines Arrays durch Zugriff auf ihre Instanzvariable "length" ermittelt werden, während die Anzahl der Elemente in einer ArrayList durch Aufruf der Methode "size()" erhältlich ist. Auch das Befüllen eines Arrays erfolgt anders und ist leider meistens unkomfortabler wie das Befüllen einer ArrayList, denn das Array kennt keine "add()"-Methode. Aber auch dafür gibt es eine wenig aufwendige Lösung: man fülle einfach zunächst eine temporäre ArrayList mit den Elementen und erzeuge anschließend das nackte Array. Dieses Vorhaben klingt zunächst kompliziert und nach viel manueller Array-Kopierarbeit, es ist durch geschickte Nutzung von "toArray()" aber in praktisch einer Zeile umsetzbar:
testList1.add(new Object());
testList1.add(new Object());
Object[] testList2=testList1.toArray(new Object[0]);
Iterieren kann man über klassische Arrays glücklicherweise wie über ArrayLists auch per for-Schleife und Iterationsoperator ":" - an dieser Stelle muss man also nicht umdenken.
Der folgende kleine Trick, der zum Zweck hat, Dubletten zu eliminieren, kann daher nur angewendet werden, wenn sicher ist, dass die betrachteten Objekte sich auch in Zukunft nie voneinander unterscheiden müssen. In jedem Fall trifft dies auf Strings zu - diese sind in Java schließlich erst gar nicht veränderbar - und ansonsten ist die Situation gegeben, wenn Objekte ohnehin nur gelesen werden.
Sind die Voraussetzungen gegeben, kann mit dem Speicher-sparen begonnen werden. Im Kern steht dabei die Idee, Dubletten durch einen Lookup in einer Hashtable zu erkennen und nach Möglichkeit ein zweites Objekt stillschweigend durch eine Referenz auf ein schon vorhandenes, identisches Objekt zu ersetzen. Für Strings kann das z.B. folgendermaßen aussehen:
private static Hashtable<String, String> stringCache=new Hashtable<String, String>();
public static String saveMemory(String str) {
if(str==null) return null;
String oldStr=stringCache.get(str);
if(oldStr!=null) return oldStr;
stringCache.put(str, str);
return str;
}
}
Diese nicht zur Instanziierung bestimmte Klasse StringCache definiert eine einzige Klassenmethode namens "saveMemory". Und der Name ist Programm: Ruft man sie mit einem String auf, so versucht sie, ein schon bestehendes, identisches String-Objekt aus der im Hintergrund erzeugten Hashtable zu fischen. Glückt das, gibt sie den alten String zurück, ansonsten fügt sie den neuen String in die Hashtable ein (man beachte den Key, unter dem sie den String einfügt: Key ist der String selbst, so dass er bei späteren Aufrufen gefunden wird, wenn ein identischer String des Weges kommt) und gibt ihn unverändert zurück.
Man kann also die "saveMemory"-Methode ganz einfach im Code nutzen, indem man sie bei einer Zuweisung dazwischenklemmt:
Wenn in einer Anwendung oft dieselben Strings vorkommen (wie das in meiner Anwendung durchaus der Fall war), kann ein solcher String-Cache durchaus enormes Sparpotenzial bieten. Ich konnte gut 60 Megabyte durch diese Maßnahme, die sich im laufenden Betrieb überhaupt nicht und beim Aufbau des Datenmodells nur vernachlässigbar gering auf die Performance auswirkt, rausholen.
Dasselbe Konzept kann je nach Situation auch auf andere Objekte übertragen werden. In meinem Fall habe ich außer Strings noch Dubletten leerer Arrays eliminiert - davon gibt es in meiner Anwendung eine ganze Menge, und für sie gilt prinzipiell dasselbe wie für Strings: Sie können nicht verändert, sondern nur ersetzt werden, weil klassische Arrays in Java nicht in ihrer Länge modifiziert werden können und ein Array der Länge 0 auch keinerlei Objekte enthält, die man über die Laufzeit verändern könnte. Dennoch fressen auch leere Arrays wie oben bereits beschrieben je 24 Byte - das Einsparen der Duplikate lohnt also und brachte mir nochmals 30-40 Megabyte.

Danke, sehr informativ :)