Beliebte Suchanfragen
|
//

Speicher sparen mit String Interning in Java

12.3.2012 | 7 Minuten Lesezeit

Attila Szegedis Vortrag über „lessons learned about the JVM “ überraschte mich insbesondere dadurch dass er relativ ausgiebig darauf einging, wie viel Speicher eigentlich durch Daten belegt wird. Dies ist für Enterprise Java Entwickler eher untypisch. Doch er hatte einige gute Anwendungsbeispiele aus seiner Zeit bei Twitter.

Speicherverbrauch von Daten

Frage: Wie viel Speicher benötigt eigentlich der String „Hello World“ ?
Antwort: 62/86 Bytes (32/64 bit Java)!
Dies teilt sich auf in 8/16 (Object Header für String) + 11 * 2 (Zeichen) + [8/16 (Object Header char Array) + 4 (array Länge) aufgefüllt auf 16/24] + 4 (Offset) + 4 (Count) + 4 (HashCode) + 4/8 (Referenz aufs char Array). [Auf 64Bit ist das String Objekt auf 40 aufgefüllt].

Das Problem

Stellen wir uns vor, wir haben jede Menge Locations, welche an unseren Tweets hängen und die Position des Users beschreiben. Die Implementierung könnte dann etwa so aussehen

1class Location {
2    String city;
3    String region;
4    String countryCode;
5    double long;
6    double lat;
7}

Wenn wir nun alle Orte aller jemals gemachten Tweets laden, so ist klar, dass dies ziemlich viele String Objekte erzeugt. Und bei der Größe von Twitter ist es auch sehr wahrscheinlich, dass diese Strings zu einem großen Teil aus Duplikaten bestehen. Attila sagte in seinem Vortrag, dass diese Daten nicht in einen 32 GB Heap passten. Also ist die Frage: Wie bekommen wir die Daten kleiner, so das sie alle in den Speicher passen?

Schauen wir uns zwei Lösungen an, die sogar kombiniert werden können.

Attilas Lösung

Da es eine mehr oder weniger gut versteckte Abhängigkeit der Daten zueinander gibt kann man, sobald man sie entdeckt hat, den Speicherbedarf ohne technische Tricks reduzieren. Wir können eine einfache Normalisierung der Daten durchführen:

1class SharedLocation {
2    String city;
3    String region;
4    String countryCode;
5}
6class Location {
7    SharedLocation sharedLocation;
8    double long;
9    double lat;
10}

Dies ist eine elegante Lösung, da Städte selten ihre Region oder ihr Land wechseln. Diese Kombination ist also eigentlich immer eindeutig. Und diese Darstellung ist dennoch flexibel genug um Abweichungen abbilden zu können. Dies ist insbesondere bei von Benutzern eingegebenen Daten sinnvoll. Nach der Normalisierung belegen mehrere Tweets aus „Solingen, NRW, DE“ nur eine SharedLocation.
Jedoch würde „Ratingen, NRW, DE“ immer noch 3 weitere Strings im Speicher anlegen, anstatt nur den neuen „Ratingen“ zur erstellen. In dem Fall von Twitter hat nach diesem Refactoring die Datenmenge in 20GB Heap gepasst.

String Interning

Was aber, wenn man entweder das Modell nicht verändern kann (oder will), oder man in dem Fall von Twitter auch keine 20GB Heap gehabt hätte?
Die Antwort ist String Interning, was dafür sorgt, dass jeder String nur ein einziges Mal im Speicher bleibt.
Leider gibt es einige Verwirrung über den Sinn von String interning. Viele Entwickler fragen ob dies denn den Vergleich zweier Strings beschleunigt, da dann die Strings ja sogar identisch sind. Dies ist zwar der Fall (wie bei allen Objekten)


// java.lang.String
public boolean equals(Object anObject) {
  if (this == anObject) {
    return true;
  }
  //...
}

Jedoch ist die Performance von „equals“ nicht der Grund aus dem man Interning verwenden sollte. Es ist dafür gedacht Speicher zu sparen.

Verwendet String.intern() nur auf Strings die häufig vorkommen und auch nur um Speicher zu sparen

Die Effizienz von Interning ist durch das Verhältnis von Dubletten zu einmaligen Strings bestimmt. Und es hängt auch davon ab wie einfach der Code an den stringerzeugenden Stellen zu modifizieren ist.

Also, wie geht das nun?

String Interning verwendet eine Instanz eines Strings (der also schon im Heap existiert) und prüft ob eine identische Kopie in der StringTable existiert.
Diese StringTable ist so etwas wie ein HashSet das den String in der Permanent Generation speichert. Der alleinige Zweck dieser Table ist eine einzige Instanz pro Zeichenkette am Leben zu halten. Falls der String dort schon ist, wird diese Instanz zurückgeliefert. Ansonsten wird diese Zeichenkette dort gespeichert:

1// OpenJDK 6 code
2JVM_ENTRY(jstring, JVM_InternString(JNIEnv *env, jstring str))
3  JVMWrapper("JVM_InternString");
4  JvmtiVMObjectAllocEventCollector oam;
5  if (str == NULL) return NULL;
6  oop string = JNIHandles::resolve_non_null(str);
7  oop result = StringTable::intern(string, CHECK_NULL);
8  return (jstring) JNIHandles::make_local(env, result);
9JVM_END
10 
11oop StringTable::intern(Handle string_or_null, jchar* name,
12                        int len, TRAPS) {
13  unsigned int hashValue = hash_string(name, len);
14  int index = the_table()->hash_to_index(hashValue);
15  oop string = the_table()->lookup(index, name, len, hashValue);
16 
17  // Found
18  if (string != NULL) return string;
19 
20  // Otherwise, add to symbol to table
21  return the_table()->basic_add(index, string_or_null, name, len,
22                                hashValue, CHECK_NULL);
23}

Als Ergebnis existiert jeder String nur ein Mal.

Interne Strings richtig verwendet

Die richtige Stelle zur Verwendung von String Interning ist der Ort an dem Zeichenketten aus externen Quellen, z.B. Datenbanken, gelesen werden. Alle Strings die bereits hartcodiert im Quelltext stehen werden durch den Compiler zu automatisch internen Strings.
Ein Beispiel sieht so aus:

1String city = resultSet.getString(1);
2String region = resultSet.getString(2);
3String countryCode = resultSet.getString(3);
4double city = resultSet.getDouble(4);
5double city = resultSet.getDouble(5);
6 
7Location location = new Location(city.intern(), region.intern(), countryCode.intern(), long, lat);
8allLocations.add(location);

Alle neu erstellten Location Objekte werden den internen String verwenden. Die temporären Strings werden anschließend durch die Garbage Collection entfernt.

Wie effektiv ist String Interning

Am besten verwendet man zur Bestimmung der Sinnhaftigkeit von Interning einen Heap Dump eines recht vollen Heaps. Es kann sogar einer von einem OutOfMemoryError sein.
Wenn man ihn dann in Eclipse MAT öffnet und den java.lang.String aus dem Histogram auswählt, kann man im Kontextmenü „Java Basics“ und „Group By Value“ wählen

Je nach Größe des Heaps kann dies nun eine lange Zeit dauern. Am Ende kommt ein Ergebnis wie dieses heraus, dass man entweder nach Anzahl Objekten oder nach Belegtem Speicher sortieren kann:

In dem Screenshot sieht man eine erstaunliche Menge von zwei Millionen leerer Strings! Diese belegen unglaubliche 130 MB Speicher. Als nächstes folgt JavaScript und diverse technische Strings, wie die keys die hier für Lokalisierung verwendet werden. Und es gibt auch einige fachlich motivierte Strings in hoher Anzahl.
Bei den fachlichen Strings lässt sich wahrscheinlich noch am einfachsten feststellen wo sie erstellt werden. Für alle Anderen müssten wir die Option „Merge shortest Path to GC Root“ verwenden um die Codestellen zu finden wo sie erzeugt werden.

Kompromisse

Warum macht man denn nicht aus allen Zeichenketten interne Strings? Weil es den Code verlangsamt! Hier ein kleines Beispiel:

1private static final int MAX = 40000000;
2public static void main(String[] args) throws Exception {
3    long t = System.currentTimeMillis();
4    String[] arr = new String[MAX];
5    for (int i = 0; i < MAX; i++) {
6        arr[i] = new String(DB_DATA[i % 10]);
7        // and: arr[i] = new String(DB_DATA[i % 10]).intern();
8    }
9    System.out.println((System.currentTimeMillis() - t) + "ms");
10    System.gc();
11    System.out.println(arr[0]);
12}

Dieser Code erstellt stark referenzierte String Objekte. Am Schluss geben wir auch noch ein Element aus, da sonst das ganze Array „arr“ wegoptimiert werden könnte. Anschließend laden wir nur 10 verschieden Strings aus unserer Datenbank. Ich verwende hier „new String()“ um die temporären Strings zu simulieren. Am Schluss führe ich noch eine Garbage Collection durch, so das alle temporären Ojekte nicht betrachtet werden.

Diesen Code habe ich auf einem 64bit Windows, JDK 1.6.0_27, i5-2520M CPU mit 8GB Ram laufen lassen. Dazu habe ich die Parameter -XX:+PrintGCDetails -Xmx6G -Xmn3G gesetzt. Hier die Ausgaben:
Ohne intern()

11519ms
2[GC [PSYoungGen: 2359296K->393210K(2752512K)] 2359296K->2348002K(4707456K), 5.4071058 secs] [Times: user=8.84 sys=1.00, real=5.40 secs] 
3[Full GC (System) [PSYoungGen: 393210K->392902K(2752512K)] [PSOldGen: 1954792K->1954823K(1954944K)] 2348002K->2347726K(4707456K) [PSPermGen: 2707K->2707K(21248K)], 5.3242785 secs] [Times: user=3.71 sys=0.20, real=5.32 secs] 
4DE
5Heap
6 PSYoungGen      total 2752512K, used 440088K [0x0000000740000000, 0x0000000800000000, 0x0000000800000000)
7  eden space 2359296K, 18% used [0x0000000740000000,0x000000075adc6360,0x00000007d0000000)
8  from space 393216K, 0% used [0x00000007d0000000,0x00000007d0000000,0x00000007e8000000)
9  to   space 393216K, 0% used [0x00000007e8000000,0x00000007e8000000,0x0000000800000000)
10 PSOldGen        total 1954944K, used 1954823K [0x0000000680000000, 0x00000006f7520000, 0x0000000740000000)
11  object space 1954944K, 99% used [0x0000000680000000,0x00000006f7501fd8,0x00000006f7520000)
12 PSPermGen       total 21248K, used 2724K [0x000000067ae00000, 0x000000067c2c0000, 0x0000000680000000)
13  object space 21248K, 12% used [0x000000067ae00000,0x000000067b0a93e0,0x000000067c2c0000)

Mit intern()

14838ms
2[GC [PSYoungGen: 2359296K->156506K(2752512K)] 2359296K->156506K(2757888K), 0.1962062 secs] [Times: user=0.69 sys=0.01, real=0.20 secs] 
3[Full GC (System) [PSYoungGen: 156506K->156357K(2752512K)] [PSOldGen: 0K->18K(5376K)] 156506K->156376K(2757888K) [PSPermGen: 2708K->2708K(21248K)], 0.2576126 secs] [Times: user=0.25 sys=0.00, real=0.26 secs] 
4DE
5Heap
6 PSYoungGen      total 2752512K, used 250729K [0x0000000740000000, 0x0000000800000000, 0x0000000800000000)
7  eden space 2359296K, 10% used [0x0000000740000000,0x000000074f4da6f8,0x00000007d0000000)
8  from space 393216K, 0% used [0x00000007d0000000,0x00000007d0000000,0x00000007e8000000)
9  to   space 393216K, 0% used [0x00000007e8000000,0x00000007e8000000,0x0000000800000000)
10 PSOldGen        total 5376K, used 18K [0x0000000680000000, 0x0000000680540000, 0x0000000740000000)
11  object space 5376K, 0% used [0x0000000680000000,0x0000000680004b30,0x0000000680540000)
12 PSPermGen       total 21248K, used 2725K [0x000000067ae00000, 0x000000067c2c0000, 0x0000000680000000)
13  object space 21248K, 12% used [0x000000067ae00000,0x000000067b0a95d0,0x000000067c2c0000)

Wir sehen also, dass die Differenz durchaus signifikant ist. Mit intern() benötigte der Code 3,3 Sekunden länger. Jedoch war die Speicherersparnis enorm. Am Ende benötigten wir lediglich 253472K(250M) Speicher. Ohne intern() belegten die Strings ganze 2397635K (2.4G). Dies ist schon ein beachtlicher Unterschied und zeigt anschaulich welchen Effekt String.intern() zu welchem Preis haben kann.

|

Beitrag teilen

//

Gemeinsam bessere Projekte umsetzen.

Wir helfen deinem Unternehmen.

Du stehst vor einer großen IT-Herausforderung? Wir sorgen für eine maßgeschneiderte Unterstützung. Informiere dich jetzt.

Hilf uns, noch besser zu werden.

Wir sind immer auf der Suche nach neuen Talenten. Auch für dich ist die passende Stelle dabei.