String Deduplication – Ein neues Feature in Java 8 Update 20

1 Kommentar

Strings verbrauchen jede Menge Speicher in allen Java Anwendungen. Insbesondere das char[], welches die einzelnen UTF-16 Zeichen enthält, trägt am meisten zum Gesamtverbrauch der JVM bei, da jedes Zeichen 2 Bytes verbraucht.
Es ist nicht ungewöhnlich, dass 30% des Speichers von Strings verbraucht wird. Das liegt nicht nur daran, dass String das beste Format ist um mit Menschen zu interagieren, sondern dass auch die populären HTTP APIs viele Strings verwenden. Mit Java 8 Update 20 haben wir nun Zugriff auf ein neues Feature, genannt String Deduplication, mit dem der Speicherverbrauch reduziert werden kann. Es benötigt den G1 Garbage Collector und ist aktuell noch standardmäßig ausgeschaltet.
String Deduplication nutzt aus, dass das char Array ein internes finales Feld in String ist und deshalb der JVM Freiraum gibt, es zu modifizieren.

Verschiedene Strategien für String Deduplication wurden erwogen, aber die nun implementierte funktioniert wie folgt:
Immer wenn der Garbage Collector einen String bearbeitet, speichert er den Hash des char Arrays zusammen mit einer Weak Reference auf das char Array. Sobald er einen String mit dem gleichen Hash findet, werden die char Arrays elementweise verglichen.
Wenn beide übereinstimmen, wird der eine String modifiziert, so dass seine char Array Referenz auf das Array des anderen Strings zeigt. Das zweite char Array wird dadurch nicht mehr referenziert und kann garbage collected werden.

Der ganze Vorgang bringt natürlich einiges an Overhead mit sich, welcher durch enge Limits kontrolliert wird. So wird zum Beispiel ein String, für den eine Weile kein Duplikat gefunden wurde, nicht weiter betrachtet.

Wie funktioniert das nun in der Praxis? Zuallererst benötigen wir das kürzlich veröffentlichte Java 8 Update 20.

Dann kann folgender Code mit diesen VM Parametern gestartet werden: -Xmx256m -XX:+UseG1GC

public class LotsOfStrings {
 
  private static final LinkedList<String> LOTS_OF_STRINGS = new LinkedList<>();
 
  public static void main(String[] args) throws Exception {
    int iteration = 0;
    while (true) {
      for (int i = 0; i < 100; i++) {
        for (int j = 0; j < 1000; j++) {
          LOTS_OF_STRINGS.add(new String("String " + j));
        }
      }
      iteration++;
      System.out.println("Survived Iteration: " + iteration);
      Thread.sleep(100);
    }
  }
}

Dieser Code wird nach 30 Iterationen mit einem OutOfMemoryError abbrechen.

Als Nächstes starten wir es mit aktivierter String Deduplication:
-Xmx256m -XX:+UseG1GC -XX:+UseStringDeduplication -XX:+PrintStringDeduplicationStatistics

Der Code läuft nun signifikant länger und wird erst nach 50 Iterationen abbrechen.

Die JVM liefert nun auch Informationen darüber, was sie tut. Das sieht so aus:

[GC concurrent-string-deduplication, 4658.2K->0.0B(4658.2K), avg 99.6%, 0.0165023 secs]
   [Last Exec: 0.0165023 secs, Idle: 0.0953764 secs, Blocked: 0/0.0000000 secs]
      [Inspected:          119538]
         [Skipped:              0(  0.0%)]
         [Hashed:          119538(100.0%)]
         [Known:                0(  0.0%)]
         [New:             119538(100.0%)   4658.2K]
      [Deduplicated:       119538(100.0%)   4658.2K(100.0%)]
         [Young:              372(  0.3%)     14.5K(  0.3%)]
         [Old:             119166( 99.7%)   4643.8K( 99.7%)]
   [Total Exec: 4/0.0802259 secs, Idle: 4/0.6491928 secs, Blocked: 0/0.0000000 secs]
      [Inspected:          557503]
         [Skipped:              0(  0.0%)]
         [Hashed:          556191( 99.8%)]
         [Known:              903(  0.2%)]
         [New:             556600( 99.8%)     21.2M]
      [Deduplicated:       554727( 99.7%)     21.1M( 99.6%)]
         [Young:             1101(  0.2%)     43.0K(  0.2%)]
         [Old:             553626( 99.8%)     21.1M( 99.8%)]
   [Table]
      [Memory Usage: 81.1K]
      [Size: 2048, Min: 1024, Max: 16777216]
      [Entries: 2776, Load: 135.5%, Cached: 0, Added: 2776, Removed: 0]
      [Resize Count: 1, Shrink Threshold: 1365(66.7%), Grow Threshold: 4096(200.0%)]
      [Rehash Count: 0, Rehash Threshold: 120, Hash Seed: 0x0]
      [Age Threshold: 3]
   [Queue]
      [Dropped: 0]

Netterweise liefert uns die Ausgabe auch die laufenden Gesamtsummen, so dass wir nicht selber alle Werte addieren müssen.
Die obige Ausgabe enthält Daten über die vierte Ausführung von String Deduplication. Sie benötigte 16ms und bearbeitete 120.000 Strings.
Alle wurden als „neu“ klassifiziert, was bedeutet, dass sie noch nie analysiert wurden. Diese Zahlen werden natürlich in echten Anwendungen ganz anders aussehen. Dort werden Strings übersprungen werden oder schon einen Hash haben (der Hash eines Strings wird ja bekanntermaßen lazy berechnet).

Im obigen Beispiel wurden alle Strings dedupliziert, was insgesamt 4.5MB Speicher freigegeben hat.
Der Table Bereich liefert Statistiken über die interne Verwaltungstabelle. Im Queue Abschnitt wird aufgelistet, wie viele Strings aus der Warteschlange entfernt wurden. Wenn viele Strings zu deduplizieren sind, kann sich diese Warteschlange aufstauen. Zur Overheadreduzierung werden dann dort Elemente entfernt.

Was ist eigentlich mit String Interning? Ich hatte bereits darüber gebloggt, wie gut String Interning Speicher sparen kann. In der Tat ist String Deduplication dem String Interning sehr ähnlich, mit der Ausnahme, das nicht ganze String Instanzen geteilt werden, sondern nur die char Arrays.

Das Hauptargument, welches die Autoren des JDK Enhancement Proposal 192 anführen, ist, dass Entwickler oft entweder nicht wissen wo eine gute Stelle zum String Interning wäre, oder dass sie darauf keinen Zugriff hätten, da es hinter Frameworks versteckt ist. Wie ich in dem Blog Eintrag beschrieb, ist es für Interning wichtig zu wissen wo Strings, wie z. B. Ländernamen, häufig verwendet werden.
String Deduplication betrifft zudem Strings anwendungsübergreifend und auch Dinge wie XML Schemas, URLs, jar Namen und so weiter, welche auf den ersten Blick gar nicht nach Duplikaten aussehen.

Im Gegensatz zu String Interning, welches im Anwendungsthread erfolgt, erfolgt die String Deduplication asynchron und nebenläufig während der Garbage Collection. Dies ist auch der Grund, warum das Codebeispiel oben ein Thread.sleep() benötigt. Ohne würde die GC überlastet und String Deduplication würde gar nicht ausgeführt werden. Aber in einer echten Anwendung ist das natürlich kein Problem, da dort ausreichend Zeit für GC und auch für String Deduplication ist.

Fabian Lange ist Lead Agent Engineer bei Instana und bei der codecentric als Performance Geek bekannt. Er baut leidenschaftlich gerne schnelle Software und hilft anderen dabei, das Gleiche zu tun.
Er kennt die Java Virtual Machine bis in die letzte Ecke und beherrscht verschiedenste Tools, die JVM, den JIT oder den GC zu verstehen.
Er ist beliebter Vortragender auf zahlreichen Konferenzen und wurde unter anderem mit dem JavaOne Rockstar Award ausgezeichnet.

Share on FacebookGoogle+Share on LinkedInTweet about this on TwitterShare on RedditDigg thisShare on StumbleUpon

Kommentare

  • Fabian Lange

    Because there is some controversy about this feature, I want to add this disclaimer:
    This is not my proposal, nor did I implement any of it. I did play with it and explained how it works.
    I am unsure about the real benefit or performance impact. For most apps, saving a couple of MBs of Memory from strings is for sure not worth it.

Kommentieren

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert.