Historia pytania
Przed Java 8 aktualizacją 20, deweloperzy, którzy chcieli zmniejszyć zużycie sterty spowodowane zduplikowanymi instancjami String, musieli polegać wyłącznie na String.intern(). Ta metoda umieszczała łańcuchy w stałej generacji (później Metaspace), wymagając jawnych wywołań API i potencjalnie powodując presję na pamięć w puli internów. Wraz z JEP 192 kolektor śmieci G1 wprowadził automatyczną deduplikację łańcuchów, przezroczystą optymalizację, która adresuje powszechny problem nadmiarowych tablic znaków w aplikacjach biznesowych.
Problem
W intensywnie przetwarzających dane aplikacjach Java - takich jak te analizujące XML, JSON lub zestawy wyników z baz danych - obiekty String często stanowią 25-50% aktywnej sterty. Znaczna część tych łańcuchów jest identyczna dosłownie, ale znajdują się w różnych tablicach char[] (lub byte[] po Java 9 Compact Strings) będących ich zapleczem. Bez interwencji, te zduplikowane tablice marnują pamięć i zwiększają częstotliwość GC. Wyzwanie polegało na wyeliminowaniu tej redundancji bez wprowadzania dodatkowych przerw w świecie zatrzymania lub wymagania modyfikacji kodu.
Rozwiązanie
G1 przeprowadza deduplikację w sposób oportunistyczny podczas swoich istniejących przerw ewakuacyjnych (gdy wątki są już zatrzymane). Po włączeniu za pomocą -XX:+UseStringDeduplication, kolektor skanuje obiekty w młodej generacji. Dla każdego String, który przetrwał co najmniej -XX:StringDeduplicationAgeThreshold cyklów zbierania śmieci (domyślnie 3), G1 oblicza hasz jego tablicy zaplecza. Następnie konsultuje się z tabelą deduplikacji. Jeśli istnieje identyczna tablica, G1 używa operacji compare-and-swap (CAS) do przekierowania pola value łańcucha do istniejącej tablicy, umożliwiając odzyskanie zduplikowanego obiektu w następnym cyklu. Wykorzystuje to istniejącą przerwę, dodając jedynie marginalny narzut CPU.
// Żadne zmiany w kodzie nie są wymagane; flagi JVM włączają optymalizację: // -XX:+UseG1GC -XX:+UseStringDeduplication -XX:StringDeduplicationAgeThreshold=3 public class DeduplicationExample { public static void main(String[] args) { // Te dwa łańcuchy dzielą tę samą tablicę zaplecza po deduplikacji String a = new String("FinancialInstrument".toCharArray()); String b = new String("FinancialInstrument".toCharArray()); // Po odpowiedniej liczbie cykli GC i przerwach ewakuacyjnych, // a.value == b.value (wewnętrzna równość referencji tablicy) } }
Platforma handlu wysokiej częstotliwości przetwarzająca komunikaty protokołu FIX doświadczyła poważnych czasów przerwy G1 przekraczających 200 ms. Profilowanie ujawniło, że 30% 64 GB sterty było konsumowane przez obiekty String reprezentujące standardowe tagi (np. "55", "150", "EUR/USD") oraz wartości podobne do enumów analizowane z przychodzących strumieni bajtowych. Każda instancja wiadomości tworzyła nowe instancje String za pomocą new String(byte[], Charset), co skutkowało milionami zduplikowanych tablic zapleczy na minutę.
Rozważono kilka rozwiązań. String.intern() zostało odrzucone, ponieważ wymagało inwazyjnych zmian w ponad 50 typach wiadomości i ryzykowało nasycenie Metaspace trwałymi referencjami, które nigdy nie zostałyby zebrane. Prototypowano własną pamięć podręczną opartą na WeakHashMap, ale wprowadziło to złożony narzut konkurencyjny i logikę czyszczenia starych wpisów, która paradoksalnie zwiększała presję GC z powodu dodatkowego przetwarzania WeakReference.
Zespół ostatecznie włączył G1 String Deduplication z domyślnym progiem wieku 3. To przezroczyste podejście nie wymagało żadnych zmian w kodzie i działało podczas istniejących przerw ewakuacyjnych, unikając wszelkich nowych faz zatrzymania w świecie.
Rezultatem było 22% zmniejszenie użycia sterty i spadek czasów przerwy 95. percentyla do poniżej 50 ms. Narzut CPU zmierzono na około 1,5% podczas godzin szczytu na rynku, co było akceptowalnym kompromisem dla oszczędności pamięci i poprawy latencji.
Jak deduplikacja łańcuchów wpływa na Compact Strings w Javie 9, które przechowują tekst Latin-1 jako byte[] zamiast char[]?
Odpowiedź. String Deduplication został zaktualizowany, aby działać na tablicach byte[] gdy Compact Strings są włączone (domyślnie od Java 9). Logika deduplikacji inspekcjonuje pole coder (LATIN1 lub UTF16) i odpowiednio haszuje odpowiadającą tablicę byte[] lub char[]. Tabela deduplikacji przechowuje wpisy indeksowane zarówno przez hasz, jak i typ tablicy, zapewniając, że łańcuchy Latin-1 są deduplikowane w stosunku do innych łańcuchów Latin-1, a pełnozakresowe łańcuchy UTF-16 w stosunku do ich odpowiedników. Kandydaci często błędnie sądzą, że funkcja została usunięta z Compact Strings, ale pozostaje w pełni kompatybilna.
Dlaczego JVM narzuca próg wieku (domyślnie 3 cykle GC), zanim łańcuch stanie się kwalifikowalny do deduplikacji?
Odpowiedź. Próg wieku zapobiega marnowaniu cykli CPU na deduplikację krótkotrwałych, efemerycznych łańcuchów, które prawdopodobnie znikną w następnej młodej kolekcji. Wymagając, aby String przetrwał kilka cykli ewakuacji G1 (promując go z Eden do regionów Survivor i w końcu do Tenured), heurystyka zapewnia, że przetwarzane są tylko „dojrzałe” łańcuchy - te z wysokim prawdopodobieństwem długoterminowego przetrwania. Amortyzuje to koszty obliczenia haszu i wyszukiwania w tabeli w oczekiwanym czasie życia obiektu.
Czy deduplikacja łańcuchów wpływa na niemutowalność lub stabilność hashCode instancji String?
Odpowiedź. Nie. Proces deduplikacji jest ściśle szczegółem implementacyjnym mutacji referencji pola value. Ponieważ wymieniana tablica zawiera identyczne bajty lub znaki, logiczny stan String oraz jego hashCode pozostają niezmienione. hashCode jest pamiętany w jednorazowym polu w obrębie obiektu String, a ponieważ treść jest identyczna, pamiętana wartość pozostaje ważna. Umowa equals jest zachowana, ponieważ równość treści implikuje, że równość referencji pamięci zapleczowej nie ma znaczenia dla umowy API. Operacja jest atomowa z perspektywy aplikacji, zachowując gwarancję niemutowalności String.