joe

Speicheroptimierung in Java

In einem unserer Projekte gab es vor kurzem die Anforderung den Speicherverbrauch zu reduzieren. In diesem Artikel werde ich deshalb beschreiben, welche Möglichkeiten es dafür gibt, und wo deren Vor- und Nachteile liegen. Dieser Artikel beschäftigt sich jedoch explizit nicht mit Memory-Leaks. Es geht vielmehr darum, den minimal benötigten RAM einer Applikation zu senken.

Konkret handelte es sich bei dem Projekt um eine JavaEE-Applikation, die periodisch mehr als 100.000 Emails synchronisiert – den Labelizer. Die Anforderung, den RAM-Verbrauch zu reduzieren, resultierte aus ökonomischen Überlegungen. Bisher lief die Applikation auf einer virtuellen Instanz in Google’s Compute Engine mit 3,8 GB RAM. Das Ziel war es,  sie allerdings auch auf der kleinsten Instanz mit lediglich 640 MB RAM zum Laufen zu bekommen. Das würde eine Kostenersparnis um den Faktor 6 bedeuten. Soviel zur Ausgangslage/Motivation. Um überhaupt einmal einen Anhaltspunkt zu bekommen, wo die großen Speicherfresser in einer Applikation sind, muss zunächst ein Heapdump der laufenden Anwendung erstellt werden. Das kann über verschiedene Wege erfolgen, am einfachsten ist es aber wahrscheinlich mit dem Tool jmap:

jmap -dump:live,format=b,file=heapdump.hprof <PID des Java-Prozesses>

Dieses File kann anschließend mithilfe des Eclipse Memory Analyzers analysiert werden. In der Histogram-Ansicht sortiert man die Liste absteigend nach Shallow Heap und sieht dadurch die größten direkten RAM-Verbraucher ganz oben, in etwa so:

Anhand dieses Beispiels werde ich nun verschiedene Möglichkeiten zur Speicherbedarfsreduzierung erläutern:

Problem: Zu viele Strings mit gleichem Inhalt

Wie zu sehen ist, gab es in der Applikation fast 2,5 Millionen Strings samt dazugehöriger char-Arrays. Diese verbrauchten satte 190 MB. Mithilfe von Rechtsklick auf die char[]-Zeile -> Java Basics -> Group By Value kann man sie nach Wert gruppieren. Es zeigte sich, dass es zu jedem Email-String etwa 10.000 bis 90.000 Duplikate gab. Diese Anzahl ist nicht weiter verwunderlich, wenn man berücksichtigt, dass zu diesem Zeitpunkt über 117.000 Emails geladen waren. Klarerweise sind die Dubletten komplett unnötig, womit wir auch schon bei der Lösung des Problems wären.

Lösung: String.intern()

Um doppelte  String -Objekte mit gleichem Inhalt z u vermeiden, kann String-Interning verwendet wer den. Dieses sollte an jenen Stellen eingebaut werden, an denen zur Laufzeit neu erhaltene Strings (also z.B. von einer Datenbank oder, wie in unserem Fall, von einer IMAP-Message) an dauerhaft im RAM befindliche Referenzen zugewiesen werden. Strings, die bereits zur Compile-Time feststehen, werden vom Compiler automatisch interned. Das ganze sieht für das Speichern des Subject-Felds einer neu erhaltenen IMAP-Message z.B. so aus: 

public class MessageLoader{
	private subject;
	...

	public void loadMessage(FetchResponse r){
		String subject = r.readString();
		subject = (subject == null ? null : subject.intern());
		...
	}
}

In Zeile 6 wird durch readString() ein neues String-Objekt aus der FetchResponse generiert. Die subject-Referenz zeigt in dieser Zeile also in jedem Fall auf ein neues Objekt. Die String.intern()-Methode in Zeile 7 funktioniert nach dem Singleton-Pattern. Sie überprüft zunächst ob es bereits einen String mit gleichem Inhalt in einer internen HashMap gibt. Falls ja, wird die Referenz auf diesen returniert. Falls nicht, wird die this-Instanz in der HashMap gespeichert und die Referenz auf diese zurückgegeben. Die subject-Referenz zeigt in dieser Zeile also entweder auf das gleiche Objekt wie vorher (falls es noch keinen gleichen String gab) oder auf das applikationsweit selbe String-Objekt mit gleichem Inhalt. In diesem Fall ist das in Zeile 6 neuerstellte String-Objekt frei für die Garbage-Collection.

Wie man sieht, handelt es sich dabei um eine sehr einfache (Einzeiler!) Methode, mit der man richtig viel RAM sparen kann. Als Nachteil ist zu erwähnen, dass der Aufruf der intern()-Methode zusätzliche Laufzeit benötigt, da im Prinzip einmal get() und ggfs. einmal put() auf die interne HashMap aufgerufen werden. Die durchschnittliche Laufzeit dieser Methoden ist O(1), also konstant, und fällt somit nicht ins Gewicht. Standardmäßig hat die HashMap des internen String-Pools aber nur eine Größe von 1009. Bei umfangreicher Verwendung von String.intern() läuft sie also recht schnell voll und es kommt zu Kollisionen. Die Laufzeit für die get()– und put()-Methoden degradiert dabei auf O(n), wird also linear in Abhängigkeit von der Anzahl der gespeicherten String-Objekte. Um das zu vermeiden, empfiehlt es sich, die Größe des String-Pools mittels folgendem VM-Argument auf z.B. 1000003 zu ändern: 

-XX:StringTableSize=1000003

Das kostet zwar fix einige wenige MB RAM, ist im Vergleich zu der damit ermöglichten Ersparnis aber vernachlässigbar. Die Größe sollte dabei eine Primzahl (wie eben z.B. 1000003) sein, damit es seltener zu Kollisionen kommt.

Zusätzlich sollte noch erwähnt werden, dass String-Interning erst ab Java 7 praktisch einsetzbar ist, da der String-Pool im Heap gespeichert wird. In Java 6 wurde dieser noch im verhältnismäßig kleinen PermGenSpace gespeichert, der sofort vollläuft. Internierte Strings werden übrigens zur Garbage-Collection freigegeben, sobald keine Referenz mehr auf sie verweist. Es kommt also bei der Verwendung von String.intern() zu keinem Memory-Leak.

Problem : Zu viele non-String-Objekte

Neben den unzähligen String-Objekten gibt es auch noch relativ viele (mehr als 800.000)  IMAPAddress -Objekte im RAM, die selbst fast 60 MB verbrauchen. Diese Objekte werden von den 117.000 IMAPMessage-Objekten referenziert. Das kann man per Rechtsklick auf die IMAPAddress-Zeile -> list objects ->  with incoming references herausfinden. In unserer Applikation cachen wir alle Nachrichten (und somit auch die Adressen) im RAM, da wir sie zur Synchronisation benötigen und nicht ständig neu per IMAP abfragen wollen. Für dieses Problem gibt es zwei Lösungen:

Lösung 1: Objekte auf die Disk auslagern

Anstatt alle IMAPMessage-Objekte in einer Map-basierten Struktur im RAM zu cachen, können wir sie auch auf der Festplatte zwischenspeichern. Nur die konkret benötigten IMAPMessage-Objekte werden über den FileMessageCache geladen und sobald sie nicht mehr benötigt werden, wieder für die Garbage-Collection freigegeben.

Der große Nachteil dieser Methode ist natürlich, dass die Festplatten-Zugriffe die Laufzeit der Applikation erhöhen. Allerdings sind diese im Vergleich zu den IMAP-Zugriffen quer über den Globus immer noch rasend schnell und somit vertretbar.

Lösung 2: Objektgröße verkleinern

Wenn sonst nichts mehr hilft, kann man auch noch die Größe des Objekts selbst verkleinern. In unserem Fall haben wir den Großteil der Envelope-Objekte, die hinter den IMAPMessages liegen, von 304 Byte/Objekt auf 224 Byte/Objekt reduziert. Dazu haben wir eine eigene, abgespeckte Envelope-Implementierung (SmallEnvelopeImpl) geschrieben. Diese hat anstatt der drei separaten InternetAddress[] für fromsender und replyTo nur ein einziges InternetAddress-Feld. Fast alle verarbeiteten Emails können mit dieser simpleren Implementierung gespeichert werden, da senderfrom und replyTo im Regelfall gleich sind und auch nur eine einzige  Email-Adresse enthalten.

Zusätzlich gibt es noch eine MediumEvelopeImpl, die nur from und replyTo in ein Feld vereinigt. Diese ist speziell für Mailverteiler gedacht, bei denen sender sich von den anderen beiden Feldern unterscheiden kann. Die Entscheidung, mithilfe welcher Klasse (der Default-Implementierung oder unseren beiden Sparvarianten) der Envelope gespeichert werden soll, erfolgt klassischerweise mithilfe einer Factory:

public Envelope createEnvelope(FetchResponse r){
    //transform r to from, replyTo, sender, etc.
    ...
    
    if (from.equals(replyTo)) {
        if (from.equals(sender)) { //from == replyTo == sender
            return new SmallEnvelopeImpl(subject, from, ...);
        } else { // from == replyTo; sender ist anders
            return new MediumEnvelopeImpl(subject, from, ...);
        }
    }
    
    return new DefaultEnvelopeImpl(subject, from, ...);
}

Mithilfe der drei vorgestellten Lösungen konnten wir den minimalen Speicherverbrauch der Applikation massiv reduzieren. Insbesondere String.intern()ermöglichte dabei einfaches und effektives RAM-Sparen. Die Anzahl der Strings konnte mit nur wenigen Zeilen Code um den Faktor 10 verringert werden. Das allein resultierte in einer Einsparung von über 200 MB und lässt sich auch in der Auswertung des zweiten Heapdumps sehen:

Zusammenfassend bleibt festzuhalten: Das Senken des Speicherverbrauchs ist keine Hexerei. Hast du dich auch schon einmal näher mit dem Thema auseinandergesetzt? Weißt du vielleicht noch andere Möglichkeiten, mit denen man den RAM-Verbrauch effektiv senken kann? Oder kennst du weitere nützliche Tools zur Speicheranalyse? Ich freue mich auf deine Kommentare.

Johann Binder (Software Developer)