Przed Java 9, kompilator javac mechanicznie tłumaczył każde wyrażenie konkatenacji łańcuchów na sekwencję alokacji StringBuilder i wywołań append, kończąc na wywołaniu toString(). Takie podejście generowało obszerne, monomorficzne bajtokody w każdym miejscu konkatenacji, wiążąc strategię implementacji nieodwracalnie z decyzjami podejmowanymi w czasie kompilacji. Podstawowym problemem z tym statycznym tłumaczeniem było to, że powiększało ono wielkość metod ponad progi inliningowe HotSpot i uniemożliwiało JVM wybór lepszych strategii wykonawczych w czasie, takich jak scalanie kopii tablic lub operacje wektoryzowane, ponieważ logika była zamarznięta w strumieniu bajtokodów zamiast znajdować się w optymalizowanych bibliotekach uruchomieniowych. Java 9 (JEP 280) wprowadziła konkatenację opartą na invokedynamic, gdzie kompilator emituje instrukcję invokedynamic odnoszącą się do StringConcatFactory; ta fabryka zwraca ConstantCallSite, która jest niezmienna po początkowym powiązaniu, sygnalizując JVM, że docelowy MethodHandle nigdy się nie zmieni i może być traktowany jako bezpośrednie, devirtualizowane wywołanie podlegające agresywnemu inliningowi i analizie ucieczki.
Platforma handlu wysokiej częstotliwości wymagała generowania milionów wiadomości protokołu FIX na sekundę, wykorzystując rozbudowaną konkatenację łańcuchów do par tag-wartość. Profilowanie na Java 8 ujawniło, że alokacje StringBuilder na ścieżce krytycznej pochłaniały 18% całkowitej pamięci sterty, powodując częste przerwy GC, podczas gdy wygenerowany bajtokod dla złożonych wiadomości przekraczał 325-bajtowy próg inliningowy kompilatora C2, uniemożliwiając kluczowe optymalizacje pętli i powodując nieprzewidywalne skoki opóźnień.
Rozwiązanie 1: Ręczne zarządzanie pulą ThreadLocal. Podejście to polegało na przechowywaniu instancji StringBuilder na wątek, aby wyeliminować koszty alokacji. Zalety: Zmniejszyło to presję GC dla krótkożyjących obiektów i zredukowało rotację obiektów. Wady: Wprowadziło to skomplikowane zarządzanie cyklem życia, wymagało starannego czyszczenia, aby uniknąć wycieków pamięci w mapach ThreadLocal, i zaciemniało logikę biznesową za pomocą szablonów puli.
Rozwiązanie 2: Budowa ByteBuffer poza stertą. Strategia ta wykorzystała ByteBuffer.allocateDirect do budowy wiadomości poza zarządzaną stertą. Zalety: Osiągnięto zerową presję GC przy budowie wiadomości i pozwolono na bezpośrednie pisanie do gniazd za pomocą NIO. Wady: Wprowadziło to ekstremalną złożoność, poświęciło gwarancje niezmienności String oraz wprowadziło ryzyko manualnej bezpieczeństwa pamięci, a także skomplikowało debugowanie z powodu manipulacji surowymi bajtami.
Rozwiązanie 3: Aktualizacja do Java 11 z konkatenacją invokedynamic. Obejmowało to migrację czasu wykonania do wykorzystania StringConcatFactory bez zmiany kodu aplikacji. Zalety: Zredukowano wielkość bajtokodu na każdą konkatenację z ~200 bajtów do ~5 bajtów, a niezmienność ConstantCallSite pozwoliła HotSpot włączyć logikę konkatenacji bezpośrednio do pętli handlowych. Wady: Wymagało to kompleksowych testów regresyjnych i tymczasowej niekompatybilności z agentami manipulacji bajtokodami.
Wybrane rozwiązanie i rezultat. Rozwiązanie 3 zostało wybrane po wykazaniu w kanarkowym wdrożeniu 35% redukcji wskaźnika alokacji oraz eliminacji skoków opóźnień spowodowanych przez GC. System teraz utrzymuje dwa razy większą przepustowość przy sub-milisekundowym opóźnieniu p99, ponieważ kompilator JIT traktuje konkatenację jako operację wewnętrzną, skutecznie eliminując wszelkie koszty wywołań metod.
Dlaczego StringConcatFactory wykorzystuje ConstantCallSite zamiast MutableCallSite, a jaka optymalizacja zostałaby utracona, gdyby pozwolono na mutowalność?
Mechanizm rozruchowy zwraca ConstantCallSite, ponieważ strategia konkatenacji jest określona wyłącznie przez statyczne typy argumentów i stały przepis w miejscu wywołania, nie wymagając dynamicznego zmieniania celu po powiązaniu. Gdyby użyto MutableCallSite, JVM byłby zmuszony dodatkowo wstawiać bariery pamięciowe lub kontrole wywołań wirtualnych przy każdym wywołaniu, aby obsłużyć potencjalne zmiany celu, co uniemożliwiłoby JIT stosowanie inlining oraz propagację stałych, a także ponownie wprowadziłoby dokładne koszty wywołania, które invokedynamic miał na celu wyeliminować.
W jaki sposób metoda rozruchowa makeConcatWithConstants różni się od makeConcat w obsłudze literałów łańcuchów i dlaczego ta różnica ma znaczenie dla wydajności miejsca wywołania?
Metoda makeConcatWithConstants przyjmuje ciąg "przepisu", w którym literały są osadzone za pomocą znaczników, co pozwala mechanizmowi rozruchowemu wchłonąć stałe do generowanego MethodHandle, zamiast przekazywać je jako dynamiczne argumenty stosu. To redukuje liczbę dynamicznych argumentów w miejscu wywołania, zmniejszając ruch stosu i presję rejestrów, podczas gdy makeConcat traktuje wszystkie operandy jako dynamiczne. Podejście oparte na przepisie umożliwia JVM przeprowadzenie częściowego składania stałych podczas wiązania, potencjalnie wstępnie obliczając stałe prefiksy w generowanym kodzie.
W jakim konkretnym warunku JVM może całkowicie wyeliminować koszty wywołania invokedynamic dla konkatenacji łańcuchów, traktując to jako operację bez działania lub czystą stałą?
Jeśli wszystkie operandy w wyrażeniu konkatenacji są wyrażeniami stałymi w czasie kompilacji, takimi jak literały łańcuchów lub stałe static final, javac może całkowicie wykonać składanie stałych w czasie kompilacji, zastępując wyrażenie pojedynczym literałem String w puli stałych i całkowicie eliminując instrukcję invokedynamic. Jeśli nawet jeden operand jest dynamiczny, wywołanie indy pozostaje, jednak JIT może wciąż złożyć stałą wynikową podczas optymalizacji, jeśli uda mu się dowieść niezmienności wejścia za pomocą zaawansowanej analizy ucieczki, chociaż to jest różne od składania w czasie kompilacji.