Geschichte der Frage
Vor Java 8 Update 20 mussten Entwickler, die den Heap-Verbrauch durch doppelte String-Instanzen reduzieren wollten, ausschließlich auf String.intern() zurückgreifen. Diese Methode platzierte Strings im permanenten Generation (später Metaspace), was explizite API-Aufrufe erforderte und potenziell Druck im Intern-Pool verursachte. Mit JEP 192 führte der G1-Garbage-Collector die automatische String-Deduplication ein, eine transparente Optimierung, die sich mit dem weit verbreiteten Problem von redundanten Zeichenarrays in Unternehmensanwendungen befasst.
Das Problem
In datenintensiven Java-Anwendungen – wie solchen, die XML, JSON oder Datenbankergebnisse analysieren – bestehen String-Objekte oft aus 25-50% des lebenden Heaps. Ein erheblicher Teil dieser Strings ist zeichenweise identisch, befindet sich jedoch in unterschiedlichen char[] (oder byte[] ab Java 9 Compact Strings) Unterstützungsarrays. Ohne Eingreifen verschwenden diese doppelten Arrays Speicher und erhöhen die GC-Häufigkeit. Die Herausforderung bestand darin, diese Redundanz zu beseitigen, ohne zusätzliche Stop-the-World-Pausen einzuführen oder Codeänderungen zu erfordern.
Die Lösung
G1 führt opportunistische Deduplication während seiner bestehenden Evakuierungs-Pause (wenn Threads bereits gestoppt sind) durch. Wenn aktiviert über -XX:+UseStringDeduplication, durchsucht der Collector Objekte in der jungen Generation. Für jeden String, der mindestens -XX:StringDeduplicationAgeThreshold Garbage Collections (Standard 3) überlebt hat, berechnet G1 einen Hash seines Unterstützungsarrays. Er konsultiert dann eine Deduplicierungs-Tabelle. Wenn ein identisches Array vorhanden ist, verwendet G1 eine compare-and-swap (CAS)-Operation, um das value-Feld des Strings auf das vorhandene Array umzuleiten, wodurch das Duplikat im nächsten Zyklus zurückgefordert werden kann. Dies nutzt die vorhandene Pause, was nur einen marginalen CPU-Overhead hinzufügt.
// Keine Codeänderungen erforderlich; JVM-Flags aktivieren die Optimierung: // -XX:+UseG1GC -XX:+UseStringDeduplication -XX:StringDeduplicationAgeThreshold=3 public class DeduplicationExample { public static void main(String[] args) { // Diese beiden Strings teilen sich nach der Deduplication dasselbe Unterstützungsarray String a = new String("FinancialInstrument".toCharArray()); String b = new String("FinancialInstrument".toCharArray()); // Nach ausreichenden GC-Zyklen und Evakuierungs-Pausen, // a.value == b.value (interner Array-Referenzgleichheit) } }
Eine Hochfrequenz-Handelsplattform, die FIX-Protokollnachrichten verarbeitet, erlebte schwere G1-Pausenzeiten von über 200 ms. Profiling ergab, dass 30% des 64GB Heaps von String-Objekten konsumiert wurden, die Standard-Tags (z.B. "55", "150", "EUR/USD") und enum-ähnliche Werte repräsentieren, die aus eingehenden Byte-Streams analysiert wurden. Jede Nachrichteninstanziierung erzeugte neue String-Instanzen über new String(byte[], Charset), was pro Minute Millionen von doppelten Unterstützungsarrays zur Folge hatte.
Mehrere Lösungen wurden evaluiert. String.intern() wurde abgelehnt, weil es invasive Änderungen über 50+ Nachrichtentypen erforderte und das Risiko bestand, den Metaspace mit permanenten Referenzen zu sättigen, die niemals garbage collected würden. Ein benutzerdefinierter WeakHashMap-basierten Cache wurde prototypisch entwickelt, führte jedoch zu komplexen Nebenläufigkeits-Overheads und Logik zur Bereinigung veralteter Einträge, die paradoxerweise den GC-Druck durch zusätzliche WeakReference-Verarbeitung erhöhten.
Das Team aktivierte letztendlich die G1 String-Deduplication mit dem Standard-Altersschwellenwert von 3. Dieser transparente Ansatz erforderte null Codeänderungen und funktionierte während bestehender Evakuierungs-Pausen, wodurch neue Stop-the-World-Phasen vermieden wurden.
Das Ergebnis war eine Reduzierung des Heap-Ausstoßes um 22% und einen Rückgang der 95. Perzentil-Pausenzeiten auf unter 50 ms. Der gemessene CPU-Overhead betrug ungefähr 1.5% während der Spitzenmarktzeiten, ein akzeptabler Kompromiss für die Einsparungen im Speicher und die Verbesserung der Latenz.
Wie interagiert die String-Deduplication mit den Compact Strings von Java 9, die Latin-1-Text als byte[] anstelle von char[] speichern?
Antwort. Die String-Deduplication wurde aktualisiert, um mit byte[]-Arrays zu arbeiten, wenn Compact Strings aktiviert sind (Standard seit Java 9). Die Deduplication-Logik untersucht das coder-Feld (LATIN1 oder UTF16) und hash das entsprechende byte[] oder char[] Unterstützungsarray entsprechend. Die Deduplicierungs-Tabelle speichert Einträge, die sowohl nach Hash als auch nach Array-Typ indiziert sind, sodass Latin-1-Strings gegen andere Latin-1-Strings und vollformatige UTF-16-Strings gegen ihre Kollegen dedupliziert werden. Kandidaten glauben oft fälschlicherweise, dass die Funktion mit Compact Strings nicht mehr verfügbar ist, aber sie bleibt vollständig kompatibel.
Warum legt die JVM einen Alters-Schwellenwert (Standard 3 GCs) fest, bevor ein String für die Deduplication eligible wird?
Antwort. Der Alters-Schwellenwert verhindert, dass das System CPU-Zyklen für die Deduplication kurzlebiger, ephemerer Strings verschwendet, die wahrscheinlich in der nächsten jungen Sammlung sterben werden. Indem es erfordert, dass der String mehrere G1 Evakuierungszyklen übersteht (von Eden zu Survivor-Regionen und schließlich in Richtung Tenured), stellt die Heuristik sicher, dass nur "reife" Strings – die eine hohe Wahrscheinlichkeit des langfristigen Überlebens haben – verarbeitet werden. Dies amortisiert die Kosten der Hashberechnung und Tabellensuche über die erwartete Lebensdauer des Objekts.
Beeinflusst die String-Deduplication die Unveränderlichkeit oder die HashCode-Stabilität der String-Instanz?
Antwort. Nein. Der Deduplication-Prozess ist strikt ein Implementierungsdetail der Änderung der value-Feldreferenz. Da das Ersetzungsarray identische Bytes oder Zeichen enthält, bleibt der logische Zustand des Strings und der hashCode unverändert. Der hashCode wird in einem transienten Feld innerhalb des String-Objekts selbst zwischengespeichert, und da der Inhalt identisch ist, bleibt der zwischengespeicherte Wert gültig. Der equals-Vertrag wird gewahrt, da die Inhaltsgleichheit bedeutet, dass die Referenzgleichheit des Unterstützungs-Speichers für den API-Vertrag irrelevant ist. Die Operation ist aus der Sicht der Anwendung atomar und bewahrt das Unveränderlichkeitsgarant des Strings.