Konkatenacja stringów – benchmark

Dostałem reklamacje od kumpla, który spodziewał się jakichś benchmarków porównujących wydajność konkatenacji w kolejnych wersjach JVMów. Oczywiście chodzi o dopełnienie poprzedniego wpisu.

Co sprawdzamy i jak?

Założyłem, że porównam działanie kodu skompilowane różnymi wersjami kompilatorów javac z JDK 1.4, 1.8 i 15. Następnie te skompiowane kody wrzucę do plików Jar, które będę uruchamiał na kolejnych wersjach JVMa.

Nie wiem, ilu z Was kompilowało kod z poziomu terminala. Osobiście preferuję skorzystanie z Mavena, ewentualnie z poziomu IDE. Jednak w ramach ćwiczenia stwierdziłem, że skompiluję i spakuję do Jar z użyciem linii komend. Dla jednego pliku nie okazało się to zbyt trudne 😉

javac src/main/java/dev/jgardo/jvm/miscellaneous/string/StringConcatenation.java

jar cf ./string.jar dev/jgardo/jvm/miscellaneous/string/StringConcatenation.class

Następnie uruchamiałem następujące benchmarki w JVMek różnych wersji (1.7,1.8,11,15 – wszystkie z rodziny OpenJDK), które korzystają z bibliotek skompilowanych różnymi wersjami javac.

private static final StringConcatenation CONCATENATION = new StringConcatenation();

@Benchmark
public String concatenation() {
    return CONCATENATION.helloWorldFromOldMen(12);
}

Gdzie StringConcatenation to:

public class StringConcatenation {
    public String helloWorldFromOldMen(long age) {
        return "Hello " + " world from " + age + " years old men";
    }
}

Wyniki

JVM \ jar1.4 [ns/op]1.7/1.8 [ns/op]>9 [ns/op]
OpenJDK 1.7101,435 ± 2,74666,310 ± 1,106
OpenJDK 1.898,539 ± 1,75768,302 ± 1,228
OpenJDK 1196,123 ± 1,13754,094 ± 2,11723,195 ± 0,172
OpenJDK 1583,235 ± 1,83755,243 ± 2,06723,516 ± 0,301
Wyniki w zależności od wersji maszyny wirtualnej oraz wersji kompilatora javac. Wyniki wyrażone w ns/op.

Z tych wyników można wyciągnąć kilka wniosków:

  1. StringBuffer jest wolniejszy od StringBuildera
    Mało odkrywcze – dodatkowa synchronizacja zawsze coś będzie kosztować. Jakkolwiek wydaje się, że w tych nowszych JVMkach StringBuffer przyspieszył zawsze jest przynajmniej 1/3 wolniejszy niż StringBuilder
  2. Uruchomienie konkatenacji skompilowanej javac w wersji 1.8 jest szybsze na OpenJDK 11 o około 20% niż na OpenJDK 1.8.
    To w prawdopodobnie wynika z tego, że w Java 9 zaczęto używać 1 bajtowego byte zamiast 2 bajtowego char. Więcej o tym choćby tutaj – JEP-254.
  3. Uruchomienie konkatenacji skompilowanej javac w wersji 9 wzwyż powoduje skrócenie czasu o ok. 55%.
    O tym efekcie wspominałem już w poprzednim wpisie. Notatka eksperymentalna zawierała prawdę 😉

Pamięć

Zmierzyłem również ilość potrzebnej pamięci do wykonania konkatenacji. Również nie było to trudne – wystarczyło do benchmarku dodać jedną linijkę podpinającą GCProfiler. Wyniki w poniższej tabelce.

JVM \ jar1.4 [B/op]1.7/1.8[B/op]>9[B/op]
OpenJDK 1.7272,000 ± 0,001272,000 ± 0,001
OpenJDK 1.8272,000 ± 0,001272,000 ± 0,001
OpenJDK 11200,000 ± 0,001168,000 ± 0,00180,000 ± 0,001
OpenJDK 15200,028 ± 0,002168,019 ± 0,00180,009 ± 0,001
Wyniki w zależności od wersji maszyny wirtualnej oraz wersji kompilatora javac. Wyniki wyrażone w B/op.

Również i tutaj jestem winien kilku słów komentarza:

  1. StringBuilder i StringBuffer uruchomione na OpenJDK w wersji 9 wzwyż korzystają z wspomnianego wcześniej ulepszenia – JEP-254. Stąd redukcja o 25% zużycia pamięci względem uruchomienia na wersji 1.7 lub 1.8.
  2. Użycie konkatenacji skompilowanej javac w wersji 9 wzwyż pozwala na redukcję zużycia pamięci o 50% w porównaniu do konkatenacji skompilowanej javac w wersji 1.8 i o 67% w porównaniu do wersji 1.4.

Podsumowanie

Warto używać nowszej wersji Javy niż owiana sławą 1.8. Wbrew pozorom w nowych wersjach Javy wchodzą nie tylko nowe feature’y, lecz i usprawnienia wydajności.

O ile konkatenacja w Javach 9+ jest znacznie bardziej wydajna, to rzadko kiedy jest to na tyle kluczowe, by zastąpić czytelne String.format, Logger.info itd. Czytelność jest ważna, a wydajność konkatenacji stringów może mieć marginalne znaczenie, jeśli macie znacznie cięższe operacje do wykonania – operacje na bazie danych lub uderzenie HTTP na zewnętrzny serwis.

Warto też spojrzeć na minimalną wersję Javy wymaganą przez biblioteki jako na potencjalną możliwość przyspieszenia działania, zamiast wyłącznie na ograniczenie.

Pax

Oceń wpis

„Hello ” + name + „.”, czyli konkatenacja Stringów

Stringi są na ciekawym tworem – zbyt rozbudowane, aby dało się je zamknąć w typ prymitywny, a jednocześnie operacje na nich muszą być bardzo wydajne.

Jedną z podstawowych operacji na nich jest tworzenie nowych łańcuchów znaków poprzez łączenie różnych zmiennych. Jednym słowem – konkatenacja. Można ją uzyskać na wiele różnych sposobów m. in. operator +, StringBuilder, StringBuffer lub String.format().

W tym artykule opowiem nieco o operatorze +.

Implementacja

Załóżmy, że interesuje nas następująca operacja.

    public String helloWorldFromOldMen(long age) {
        return "Hello  world from " + age + " years old men";
    }

Warto na wstępie zajrzeć, co mówi oficjalna dokumentacja Java Language Specification o konkatenacji.

An implementation may choose to perform conversion and concatenation in one step to avoid creating and then discarding an intermediate String object. To increase the performance of repeated string concatenation, a Java compiler may use the StringBuffer class or a similar technique to reduce the number of intermediate String objects that are created by evaluation of an expression.

The Java® Language Specification, Java SE 15 Edition

Co ciekawe, ta część jest niezmienna od specyfikacji dla Javy 1.0.

I rzeczywiście, jeśli skompilujemy kod kompilatorem do Javy 1.4, to rezultat (po skompilowaniu i dekompilacji) będzie następujący:

    Code:
      stack=3, locals=3, args_size=2
         0: new           #2   // class java/lang/StringBuffer
         3: dup
         4: invokespecial #3   // Method java/lang/StringBuffer."<init>":()V
         7: ldc           #4    // String Hello  world from
         9: invokevirtual #5   // Method java/lang/StringBuffer.append:(Ljava/lang/String;)Ljava/lang/StringBuffer;
        12: lload_1
        13: invokevirtual #6  // Method java/lang/StringBuffer.append:(J)Ljava/lang/StringBuffer;
        16: ldc           #7   // String  years old men
        18: invokevirtual #5  // Method java/lang/StringBuffer.append:(Ljava/lang/String;)Ljava/lang/StringBuffer;
        21: invokevirtual #8  // Method java/lang/StringBuffer.toString:()Ljava/lang/String;
        24: areturn

Jednak StringBuffer ma tę właściwość, że jego metody są synchronizowane. W przypadku konkatenacji stringów, wykonywanych w tym samym wątku, jest to zbędne. Z tego powodu w Javie 1.5 zdecydowano zamienić StringBuffer na StringBuilder, który tej synchronizacji jest pozbawiony.

Kolejnym krok nastąpił w Javie 1.6 – wprowadzono usprawnienie, pozwalające JITowi C2 zamienić użycia StringBuilder na tworzenie Stringa bez konieczności tworzenia obiektu StringBuilder. W Javie 1.7 włączono ten mechanizm domyślnie. Okazało się, jednak, że ta opcja (OptimizeStringConcat) jest „krucha” (ang. fragile) i sprawia problemy przy dalszej optymalizacji Stringa.

JEP-280

Postanowiono zastosować ten sam mechanizm, co przy implementacji Lambd. Zamiast na etapie kompilacji ustalać, jak jest wykonywana konkatenacja Stringów, pozwólmy na wygenerowanie tego kodu przez JVMa przed pierwszym uruchomieniem.

Takie podejście pozwala na eliminowanie wstecznej kompatybilności zastosowanych optymalizacji, gdzie zmiany w starszej Javie musiały również działać w nowszej. Jednocześnie kod skompilowany w starszej Javie po uruchomieniu na nowszym JVMie automatycznie działał szybciej, gdyż optymalizacje robione są przy pierwszym uruchomieniu.

Wspomniany JEP-280 został wdrożony w Javie 9.

A jak to będzie w wydajności?

Generalnie – szybciej.

Przy generowaniu kodu konkatenacji aktualnie jest dostępnych 6 strategii, przy czym domyślnie włączona jest najefektywniejsza. Pozwala ona na konkatenowanie 3-4 krotnie szybciej, jednocześnie wykorzystując 3-4 razy mniej pamięci (w ekstremalnych przypadkach 6.36x szybciej i 6.36x mniej pamięci). Tworzenie Stringów w tej strategii odbywa się praktycznie bez tworzenia dodatkowych obiektów, po których GC musiałby sprzątać.

Jednokrotny narzut wynikający z konieczności wygenerowania kodu w Runtime’ie jest stosunkowo mały – do 30ms.

Podsumowanie

Szczerze mówiąc, tkwiło we mnie przekonanie, że jak konkatenacja Stringów to tylko i wyłącznie StringBuilder, bo inaczej jest „nieefektywnie”. Okazuje się jednak, że operator + może być bardziej efektywny w prostych przypadkach.

Kolejny raz można powiedzieć, że jeśli chcesz pomóc JVMowi w optymalizacji, to pisz porządny, czytelny, rzemieślniczy kod.

Jeśli chodzi o linki do poczytania, to:

Pax et bonum 🙂

Oceń wpis

Wpływ Garbage Collectorów na działanie JVMa – odczyty z pamięci

Poprzedni wpis był o tworzeniu obiektów w JVMie w zależności od wybranego garbage collectora. W tym wpisie skupię się na przedstawieniu wpływie wybranego gc na odczyt istniejących obiektów.

Benchmark

Przed uruchomieniem Benchmarku tworzona jest statyczna tablica. Następnie w bloku statycznym jest ona wypełniana przykładowymi danymi. Tworzone obiekty to wrappery na int.
Benchmark uruchamiany jest dla młodej oraz starej generacji (o ile istnieje taki podział). Aby wymusić przynależność do starej generacji JVM jest proszony o wykonanie GC kilkakrotnie.

    private static final IntWrapper[] WRAPPERS = new IntWrapper[100000];

    static {
        for (int i = 0; i < WRAPPERS.length; i++) {
            WRAPPERS[i] = new IntWrapper(i);
        }
        for (int i = 0; i < 20; i++) {
//            Runtime.getRuntime().gc();
        }
    }

Następnie w benchmarku zliczana jest suma pierwszych 2000 wartości we wspomnianych obiektach.

    @Benchmark
    public long method() {
        long sum = 0;
        for (int i = 0; i < 2000; i++){
            sum += WRAPPERS[i].getI();
        }
        return sum;
    }

UWAGA! Jak widać w powyższym benchmarku bazujemy jedynie na zmiennych lokalnych (czyt. trzymanych na stosie wątku). Jednocześnie w trakcie benchmarku nie tworzymy ani jednego nowego obiektu. Badamy jedynie wpływ gc na odczyty, a nie wydajność samego gc.

UWAGA 2! We wpisie zakładam, że czytelnik przeczytał poprzedni wpis, a także, że jego treść była w miarę zrozumiała. 😉

Epsilon GC

Na początek EpsilonGC, czyli brak działań. Zarówno wynik dla wariantu bez gc oraz z wymuszonym gc jest taki sam – około 1708 ns/op (wymuszanie gc nie ma w tym przypadku sensu i jest ignorowane, stąd te same wyniki).

W tym wpisie uznaję ten wynik uznać za „wzorcowy” i do niego będę porównywał inne rezultatu.

ParallelGC

W przypadku ParallelGC program działa nieprzerwanie aż do momentu braku miejsca na nowe obiekty (MinorGC) lub braku miejsca na promocję obiektów do starej generacji (FullGC). Wszystkie metadane niezbędne do gc wyliczane są w czasie STW bez dodatkowego narzutu na „runtime”. Zatem można spodziewać się wyniku analogicznego do EpsilonGC.

Tak też się dzieje w przypadku obiektów w młodej generacji – w tym przypadku wynik to 1703 ns/op .

W przypadku starej generacji mamy niespodziankę – wynik 1579 ns/op (szybciej o 8%). Z czego wynika szybsze wykonanie benchmarku?

Otóż przy kopiowaniu danych z młodej do starej generacji wykonywana jest analiza, która pozwala na uporządkowanie danych w bardziej logiczny sposób niż przy tworzeniu obiektów. O ile wcześniej elementy tablicy mogły być rozdzielone innymi obiektami (szczególnie, że statyczna inicjalizacja działa się przy ładowaniu klas), o tyle po wykonaniu gc elementy tablicy prawdopodobnie trzymane są razem.

ConcMarkSweepGC

W tym przypadku również młoda generacja wypada podobnie do EpsilonGc – 1694 ns/op. W przypadku starej generacji wynik to 1837 ns/op, czyli 7% wolniej. Jednak podobnie jak w poprzednim wpisie zostawię tę analizę historykom, skoro w OpenJDK 14 CMSa już nie uświadczymy 😉

G1GC

W przypadku G1Gc nie widać różnicy w porównaniu do EpsilonGc – 1701 ns/op oraz 1698 op/s. Prawdopodobnie wynika to z tego, że zarówno młoda generacja, jak i stara znajdują się w takiej samej liczbie segmentów, które niekoniecznie muszą być zlokalizowane blisko siebie. Aczkolwiek, to tylko domysł, który trudno zweryfikować…

O ile G1Gc korzysta z barier zapisu, o tyle z barier odczytu nie korzysta, stąd też brak dodatkowego narzutu na odczyt.

ZGC

Jak już wspominałem w poprzednim wpisie ZGC korzysta z barier odczytu. Wspomniałem również, że aktualnie nie ma podziału na młodą i starą generację.

Oba te fakty widoczne są w wynikach benchmarku – 1996 ns/op w wersji z gc oraz 2025 ns/op w wersji bez gc. Zatem w tym benchmarku narzut spowodowany korzystaniem z ZGC to 16%. Dość dużo, biorąc pod uwagę, że ten gc nie został ani razu uruchomiony w czasie benchmarkowania…

Shenandoah

O ile Shenandoah w OpenJDK 13 również nie posiada podziału o tyle wyniki dwóch benchmarków się znacząco różnią.

W przypadku braku wcześniejszych gc, wynik jest porównywalny do EpsilonGC (~1700 ns/op).

W drugim przypadku – gdy został wcześniej wymuszony gc – wynik był 4041 ns/op. To jest bardzo duży narzut – 136% więcej w porównaniu do EpsilonGC.

Taka różnica może wynikać z głównego założenia działania Shenandoah. Przed każdym obiektem zawsze znajduje się dodatkowy wskaźnik na ten obiekt, lub na nowe miejsce w pamięci, gdzie został ten obiekt przeniesiony. W pesymistycznym przypadku najpierw odczytujemy wartość wskaźnika (jednocześnie ładując do Cache’u procesora obiekt), po czym okazuje się, że załadowany obiekt nie jest tym, którego szukamy. Wówczas musimy pobrać obiekt z innej lokalizacji. Jednak wydaje się dość nieprawdopodobne, że taka sytuacja by była cały czas.

Trzeba pamiętać, że ten gc jest w wersji 13 eksperymentalny. Plany są takie, by ten gc mógł działać produkcyjnie w wersji 15. Zatem jest szansa, że ten przypadek będzie usprawniony.

Podsumowanie

Wybór Garbage Collectora może mieć wpływ nie tylko na samo czyszczenie pamięci, lecz również na działanie aplikacji. Garbage Collector może przyspieszyć wykonywanie kodu (przypadek ParallelGC) lub spowolnić (Shenandoah, ZGC).

Warto wspomnieć, że Shenandoah i ZGC wejdą produkcyjnie dopiero w OpenJDK 15 zatem można oczekiwać pewnych poprawek.

Standardowo ludziom z nadmiarem czasu polecam prezentacje na Youtube. Bardzo dobre podsumowanie wydajności wykorzystania procesora oraz pamięci – Sergey Kuksenko z Oracle. Ponadto, dla ludzi chcących zaznajomić się z Shenandoah polecam prezentację Alexey’a Shipilev’a.

Przy okazji życzę wszystkiego dobrego na święta Wielkanocne 😉

Oceń wpis

Ile kosztuje Garbage Collection – tworzenie obiektów

Garbage collector to taki stwór, który automagicznie przeczesując pamięć znajduje śmieci i odzyskuje miejsce przez nie zajmowane. Oczywiście za posprzątanie trzeba mu zapłacić.

Ile?

Odpowiedź standardowa – to zależy. Istnieje bowiem bardzo wiele garbage collectorów. Nie są one ustandaryzowane, więc w zależności od implementacji JVMa są dostępne różne implementacje garbage collectorów.

W tym wpisie skupię się na gckach dostępnych w AdoptOpenJDK (HotSpot) w wersji 13. Dostępne w tym JVMie są między innymi:

  • Epsilon GC (mój faworyt 🙂 )
  • Parallel GC
  • CMS (Concurrent Mark Sweep)
  • G1
  • ShenandoahGC
  • ZGC

Większość z nich to skomplikowane mechanizmy, jednak postaram się pokrótce opisać jak działają.

Hipoteza generacyjna

Bardzo ważnym pojęciem dla gcków jest tzw. hipoteza generacyjna. Zakłada ona, że obiekty bardzo często mają bardzo krótki cykl życia (śmiertelność niemowląt), zatem pamięć po takich obiektach można szybko odzyskać. Jednocześnie zakłada ona, że jeśli obiekt przeżył kilka cykli odśmiecania, to można założyć, że będzie żył bardzo długo.

Ta teoria przekłada się na praktykę: w wielu implementacjach gc jest podział na generację młodą oraz starą.

Minor GC

Młoda generacja składa się z dwóch fragmentów pamięci – Eden i Survivor. W Edenie umieszczane są zawsze nowo utworzone obiekty. Jeśli obiekt jest żywy i przetrwał cykl GC, to jest przenoszony do Survivor. Jeśli obiekt przetrwa kilka cykli GC, wówczas jest promowany do starszyzny (starej generacji).

Czyszczenie młodej generacji jest bardzo wydajne, dlatego robi się je oddzielnie od starej generacji. Takie czyszczenie nazywamy Minor Garbage Collection.

MinorGC Najczęściej korzysta z algorytmu Mark-Copy. W pierwszej fazie musimy się dowiedzieć, jakie obiekty są żyjące.
W pierwszym kroku oznaczamy wszystkie obiekty jako nieżywe. Następnie znajdujemy wszystkie obiekty, które z pewnością są żywe tzw. GCRooty (zmienne ze stosów wątków, zmienne statyczne i wiele wiele innych). Oznaczamy je jako żywe, a następnie wszystkie jego pola oznaczamy jako żywe oraz rekurencyjnie oznaczamy pola pól jako żywe.
W drugim kroku, gdy już wiemy, jakie obiekty są żywe, kopiujemy je do bezpiecznych przestrzeni Survivor. Jako, że wszystkie obiekty z Edenu są bezpiecznie przeniesione, to możemy wyczyścić cały Eden.

Oczywiście cały proces jest bardziej skomplikowany i zależy od implementacji, ale na potrzeby tego artykułu mam nadzieję, że ta teoria wystarczy. A teraz do kodu!

Benchmark

W tym benchmarku będziemy sprawdzać, ile obiektów zdążymy zaalokować w czasie sekundy:

    private static class IntWrapper {
        int i = 12;
    }

    @Benchmark
    public void method() {
        new IntWrapper();
    }

Trzeba oczywiście pamiętać o wyłączniu EscapeAnalysis, żeby być pewnym, że te obiekty się rzeczywiście tworzą. Stąd potrzebna flaga -XX:-DoEscapeAnalysis.

Kolejnym potrzebnym argumentem jest odpowiednia wielkość sterty. W tym przypadku użyłem 8GB pamięci.

Ostatnią sprawą o której trzeba pamiętać jest upewnienie się, że ta pamięć jest zarezerwowana dla JVMa. Normalnie, gdy JVM prosi Linuxa o pamięć, to Linux tę pamięć da tak tylko teoretycznie tzn. czasem Linux da pamięć dopiero w momencie zapisu, przez co czasem musi jej poszukać.
Opcja -XX:+AlwaysPreTouch zapewnia, że pamięć będzie od razu gotowa do użycia.

Epsilon GC

Jest to mój faworyt. Ten Garbage Collector nie robi nic do czasu, gdy zacznie kończyć się pamięć. Jeśli pamięć się skończy, to mówi: Nie ma więcej pamięci. I co mi pan zrobi? Czyli rzuca błędemOutOfMemoryError.

Pytanie: na co komu taki nieużyteczny gc? W zamyśle twórców (JEP 318) odpowiedzi jest wiele,a jedna z nich to – do testów wydajnościowych. I z tej cechy tego gc skorzystamy.

W czasie całego Benchmarku prawie całkowicie zapełnia się cały heap.

Wynik benchmarku: 332 942 227 ops/s (przepustowość)

Parallel GC

Dość prosty garbage collector, który jest zrównolegloną wersją Serial GC. Działanie jest następujące: kończy się miejsce, więc zatrzymujemy cały JVM i szukamy pamięci. Najpierw szukamy obiektów żywych – wszystkie żywe przenosimy w bezpieczne miejsce, a całą resztę pamięci oramy (i możemy potem z niej korzystać). To bardzo duże uproszczenie.

W tym GC pamięć dzielimy na stałe części – young to domyślnie 1/2 old. Survivor to 20% Eden. Zatem pamięci w Edenie zostaje jakieś 2GB. Zatem kilka razy Minor GC się odpali. Jednak charakterystyka tych danych – wszystkie obiekty od razu są nieużytkami – powoduje, że nie ma żadnych obiektów do przeszukania, więc Minor GC trwa po 2ms i bardzo nieznacznie wpływa całość przetwarzania.

Wynik: 329 558 528 ops/s (99% Epsilion GC)

Concurrent Mark Sweep

Parallel GC posiada jedną szczególną wadę – faza Stop The World przy dużych Heapach i pełnych kolekcjach (Young + Tenured) potrafi trwać co najmniej kilkaset milisekund. Przy zastosowaniach serwerowych taka pauza potrafi spowodować timeout żądania. Poszukiwano zatem rozwiązania, które skróci pauzy. Najstarszym jest właśnie Concurrent Mark Sweep.

Jednak w przypadku młodej generacji również jest używany analogiczny algorytm jak w przypadku ParallelGC.

Wyniki są zastanawiające, gdyż jest to średnio 183 989 107 operacji na sekundę (55% Epsilion GC), czyli znacznie mniej niż w poprzednich przypadkach. Czasy pauz dla MinorGC to 45ms, są też znacznie częściej.
I bardzo by mnie to zastanawiało, skąd ten stan rzeczy, jednak ten GC szczęśliwie przechodzi do historii począwszy od Javy 14 (JEP-363). Zostawmy historię historykom.

G1

Ten gc wprowadzony w Java 6, a domyślny od Javy 9 był rewolucyjny. Koncepcja zupełnie inna – pamięć jest podzielona na około 2048 fragmentów, z których każdy może być przypisany do jednego z typów (free, eden, survivor, old oraz humongous). Dzięki temu również gc może swobodnie zmieniać proporcje między young a old.

W tym gc głównym założeniem jest zmniejszanie czasu zatrzymania aplikacji. Osiągnięte jest to poprzez przesunięcie wykonywania niektórych operacji na czas działania systemu równolegle do działania aplikacji. Oczywiście to wymaga pewnego narzutu przy wykonywaniu operacji, dodatkowej pamięci i działania osobnych wątków GC.

Wspomniane dodatkowe operacje są wygenerowane przez JIT dla tego GC i zatem skompilowany kod jest większy.

Do rzeczy – tutaj ten prosty benchmark pozwala wykonanie 311 283 463 ops/s (93% Epsilion GC). Należy pamiętać, że oprócz tego wątku równolegle działają również wątki GC, więc rzeczywista przepustowość jest jeszcze mniejsza.

Shenandoah

Ten garbage collector mocno bazuje na G1, próbując usprawnić niektóre etapy tak, by nie wymagały pauzy. Założenia, są takie, że długość pauzy to maksymalnie 10 ms niezależnie od wielkości stosu. Maksymalna strata przepustowości to 15%. Trzeba również nadmienić, że wymaga nieco więcej pamięci, gdyż każdy obiekt musi mieć dłuższy nagłówek. O ile możliwy jest podział na młodą i starą generację, o tyle aktualnie nie jest on zaimplementowany.

Shenandoah został zaprojektowany i jest rozwijany od 2014 roku przez Red Hat, jednak możemy go używać dopiero od Javy 12. Niestety wersje JDK wypuszczane przez Oracle (OracleJDK) nie zawierają Shenandoah, dlatego zaznaczałem na wstępie, że korzystam z AdoptOpenJDK opartego na Hotspot w wersji 13.

Rezultat dla tego GC to 323 042 410 ops/s (97% Epsilion GC), co oznacza, że sama alokacja nowych obiektów jest naprawdę sprawna. Oczywiście trzeba pamiętać o większym narzucie na pamięć oraz na wątki gc pracujące w tle.

ZGC

Założenia ZGC są bardzo podobne do Shenandoah – 10ms, 15% uszczerbku w wydajności. Implementacja jednak znacznie się różni i podobno bliżej jej do C4 Azul Zing. ZGC pojawił się eksperymentalnie w Javie 11 dla Linux i jest aktywnie rozwijany przez zespół Oracle.

Korzysta przede wszystkim z kolorowych wskaźników oraz barier. Kolorowe wskaźniki to koncepcja ukrycia pewnych informacji wewnątrz wskaźnika. Chodzi o to, że 64bitami możemy zaadresować w pamięci jakiś milion terabajtów danych, gdy aktualnie adresujemy maksymalnie kilka terabajtów. Zatem najwyższe bity zawsze będą zerami; można by je zagospodarować i przechowywać w nich jakieś informacje. Tak też się dzieje. Kosztem możliwości obsługi maksymalnie heapów o wielkości zaledwie 4TB nie mamy dodatkowego narzutu w każdym nagłówku obiektu.

W planach jest zwiększenie obsługi do 16TB, zmniejszenie maksymalnej pauzy do 1ms oraz wprowadzenie generacyjności.

A wracając do benchmarku – 306 055 939 ops/s (92% Epsilion GC).

Podsumowanie

Garbage Collectory to temat rzeka. Algorytmy składają się z wielu faz, istnieje wiele struktur danych wykorzystywanych przez gc. Nie sposób ich opisać w jednym wpisie na blogu…

Jeśli ktoś chciałby bardziej zgłębić temat gc, to na początek proponuję Handbook od Plumbr. Czytanie o tym może być trudne, więc warto poszukać prezentacji na youtube o gckach. Po polsku na WJugu Jakub Kubryński, po angielsku na Devoxxie.

Wniosek jest prosty – nie ma nic za darmo… krótkie pauzy, to duży narzut na wydajność. Długie pauzy – brak narzutu na wydajność.

Pytanie na czym nam bardziej zależy…

Oceń wpis

ThreadLocal

ThreadLocal jest trochę jak świnka morska…

Słowem wstępu

Bohaterem tego wpisu jest java.lang.ThreadLocal. Jak sama nazwa wskazuje klasa umożliwia trzymanie pewnej zmiennej w kontekście jednego wątku. Taką klasę można wykorzystać w różnych sytuacjach, a najbardziej typową jest tworzenie obiektów, które nie są thread-safe i przechowywanie takich obiektów osobno dla każdego wątku. Wówczas pozbywamy się wymaganej kosztownej synchronizacji.
Kanonicznym przykładem jest klasa SimpleDateFormatter, która nie jest thread-safe.

Istnieje jeszcze inna klasa zastosowań ThreadLocal. Polega ona na inicjalizacji na początku przetwarzania, następnie w czasie przetwarzania na pobraniu danej wartości (bądź – co gorsza – modyfikacji) a na końcu przetwarzania na usunięciu tej wartości. Przykładowo – Filtr Servletowy:

public class UserNameFilter implements Filter {
    public static final ThreadLocal<String> USER_NAME = new ThreadLocal<>();
    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        try {
            USER_NAME.set("Dobromir");
            filterChain.doFilter(servletRequest, servletResponse);
        } finally {
            USER_NAME.remove();
        }
    }
}

Takie zastosowanie to tak na prawdę taki lokalny dla wątku singleton. Taki mechanizm obrazuje mniej więcej:

Asdf movie

Kto takiego kodu nie popełnił, niech pierwszy rzuci kamień 😉

Czasem po prostu nie ma innej opcji, by przekazać coś z jednego miejsca w drugie, bo przykładowo ogranicza nas interfejs/zewnętrzna biblioteka. Jednak, gdy mamy możliwość przekazania czegoś w parametrze metody zamiast w ThreadLocal, warto z tej możliwości skorzystać. Nawet, gdy to będzie przepchanie przez 20 ramek głębiej w stacktrace’ie.

Escape analysis

Warto kontekst przekazywać w parametrach z wielu powodów. Najważniejszym jest jawne ukazanie zależności potem, testowalność itp. Ale gdzieś na sam końcu jest też wydajność.

Dla każdej metody skompilowanej C2 jest uruchamiane Escape Analysis, która pozwala na unikanie fizycznego tworzenia obiektów. Jeśli jednak taki obiekt jest udostępniony w jakimś polu, to automatycznie uniemożliwiamy ominięcie tworzenia obiektu.

Implementacja ThreadLocal

Najprostsza implementacja tej idei to zwykła mapa HashMap<Long, Object>, która w kluczu przyjmuje Thread.getId(). To rozwiązanie jest jednak zasadniczą wadę – jeśli wątek by zakończył swoje działanie, a wpis nie zostałby usunięty, wówczas mielibyśmy klasyczny przykład wycieku pamięci w Javie. Trzymanie jakiegoś rodzaju uchwytu do tych wpisów dla ThreadLocal może i rozwiązało problem, ale mogłoby być kosztowne pamięciowo.

Dlatego OpenJDK robi to inaczej. W każdym obiekcie java.lang.Thread istnieje pole threadLocals będące instancją klasy ThreadLocal.ThreadLocalMap. W tym polu przetrzymywane są wartości dla wszystkich ThreadLocal. Jest to mapa, którą można określić jako HashMap<ThreadLocal,Object>.

Gdy wołamy o ThreadLocal.get() wywoływany jest następujący kawałek kodu:

    public T get() {
        Thread t = Thread.currentThread();
        ThreadLocal.ThreadLocalMap map = this.getMap(t);
        if (map != null) {
            ThreadLocal.ThreadLocalMap.Entry e = map.getEntry(this);
            if (e != null) {
                T result = e.value;
                return result;
            }
        }

        return this.setInitialValue();
    }

    ThreadLocal.ThreadLocalMap getMap(Thread t) {
        return t.threadLocals;
    }

Ta nieco zawiła implementacja trzyma wszystkie zmienne specyficzne dla wątku blisko reprezentacji tego wątku w Javie. Dzięki temu w czasie kończenia działania wątku, łatwo je udostępnić dla gc’ka (this.threadLocals = null).

Czy ThreadLocal ma jakieś super moce?

Pojęcie Thread-local storage jest pojęciem znanym i powszechnym w różnych językach programowania. Ponadto jest tak często nazywana pewna część pamięci natywnej wyłączna dla wątku systemu operacyjnego. Jednak w przypadku OpenJDK taka pamięć jest wykorzystywana co najwyżej przy jakichś metadanych GCka (wystarczy wyszukać w kodzie źródłowym OpenJDK terminu ThreadLocalStorage). Całość implementacji ThreadLocal bazuje na Heapie.

Co więcej, okazuje się, że ten ThreadLocal nie jest aż tak przywiązany do samego wątku, gdyż można go z poziomu innego wątku zmienić. Można to łatwo sprawdzić wykonując refleksyjną magię:

public class ThreadLocalExperiment {

    private static boolean work = true;
    private static final ThreadLocal<Integer> THREAD_LOCAL = new ThreadLocal<>();

    public static void main(String[] args) throws Exception {
        var thread1 = new Thread(() -> {
            THREAD_LOCAL.set(12);
            while (work) { }
            System.out.println(THREAD_LOCAL.get());
        });

        thread1.start();

        var clazz = Thread.class;
        var field = clazz.getDeclaredField("threadLocals");
        field.setAccessible(true);
        var threadLocals = field.get(thread1);
        var method = threadLocals.getClass().getDeclaredMethod("set", ThreadLocal.class, Object.class);
        method.setAccessible(true);
        method.invoke(threadLocals, THREAD_LOCAL, 24);

        work = false;
    }
}

ThreadLocal vs local variable

Generalnie warto też porównać, jaka jest różnica w wydajności między zmiennymi lokalnymi, a ThreadLocal. Prosty benchmark ukazujący skalę różnicy wydajności:

    @Benchmark
    @CompilerControl(CompilerControl.Mode.PRINT)
    public Integer local() {
        Integer i = 1;

        return i;
    }

    @Benchmark
    @CompilerControl(CompilerControl.Mode.PRINT)
    public Integer threadLocal() {
        THREAD_LOCAL.set(1);
        Integer i = THREAD_LOCAL.get();
        THREAD_LOCAL.remove();
        return i;
    }

Wyniki to 4,358 ± 0,039 ns/op dla zmiennej lokalnej oraz 41,359 ± 2,797 ns/op dla ThreadLocal (1 ns to jedna milionowa milisekundy zatem niewiele 😉 ). Jednak samo sięganie na stertę zamiast na stos wątku jest już pewnym minusem. Ponadto te różnice w pewien sposób zależą od GC, którym wartości ThreadLocal podlegają.

JITowi również nie jest łatwo zinterpretować wartości ThreadLocal jako niezmienialne przez inne wątki. Chociaż swoją drogą mogą być zmienione jak wcześniej zostało wykazane. Brak możliwości zastosowania Escape Analysis również nie pomaga…

Ale o co chodzi z tą świnką morską?

ThreadLocal to taka świnka morska, bo ani świnka, ani morska…

Ani nie są tą jakoś szczególnie wyłączne dane wątku, gdyż są na Heapie, współdzielone, a takie stricte dane wątku są na Off-Heapie. Ani też nie są to szczególnie lokalne dane – przeważnie to są singletony w kontekście wątku.

Niby blisko tego wątku to jest, ale jednak nie za bardzo…

Oceń wpis

Przepisujemy Jacksona z refleksji na LambdaMetafactory [ZOBACZ JAK]

We wpisie o takiej szybszej refleksji porównywałem różne podejścia do wywoływania setterów. Czas na to by tę teorię zastosować w Jacksonie oraz jaki jest wpływ na wydajność tego rozwiązania.

Jackson – wstęp

Jackson Databind to jedna z najpopularniejszych bibliotek do mapowania obiektów na tekst w formacie JSON oraz JSONów na obiekty. Używana domyślnie przez Spring Boota, lecz również przez zyskującego na popularności Micronauta. Aktualnie wersją stabilną jest wersja 2.10 wymagająca do działania Javę 7. Nadchodząca wersja 3.0.0 będzie wymagała minimum Javy 8.

Ciąg znaków w postaci JSON może być zmapowany na obiekt w Javie poprzez wywołanie konstruktora, ustawienie (refleksyjnie) pól lub (refleksyjne) wywołanie metod setterów. Ten ostatni sposób wykorzystujący publiczne settery jest sposobem domyślnym.

I to właśnie wywoływanie setterów jest mechanizmem do podrasowania. Wybranym sposobem usprawnienia setterów jest użycie LambdaMetafactory.metafactory(), który wypadł najlepiej w testach opisanych we wspomnianym wpisie.

Trochę kodu

Klasą bezpośrednio odpowiadająca za ustawienie pól z użyciem setterów jest MethodProperty. Oprócz zapięcia się tam debuggerem można to wywnioskować również z komentarza dotyczącego klasy:

/**
 * This concrete sub-class implements property that is set
 * using regular "setter" method.
 */
public final class MethodProperty
    extends SettableBeanProperty

Działanie Jacksona w obrębie tej klasy (oraz mu podobnych ustawiających pola) można podzielić na przygotowanie metadanych wywoływanego settera (wykonywane jednokrotnie dla danej klasy) oraz wywołanie settera (przy każdym deserializowanym obiekcie klasy).

Zatem w fazie inicjalizacji musimy nie tylko zapisać obiekt Method, lecz również wygenerować implementację interfejsu BiConsumer. Ta implementacja dla podanego obiektu ustawi podaną wartość wywołując odpowiedni setter.

Generowanie takiego obiektu jest nietrywialne. Najpierw trzeba zamienić Method w MethodHandle, wyczarować skądś Lookup, a następnie dopasować odpowiednią sygnaturę BiConsumer wrzucić wszystko do LambdaMetafactory.metafactory() a potem tylko magiczne getTarget() i invoke().
Dużo trudnego kodu, o który nie pytają na rozmowach rekrutacyjnych, więc nie trzeba go znać, ni rozumieć. Jednak jeśli was jeszcze nie zniechęciłem, to można spojrzeć na plik, gdzie umieściłem tę całą magię.

Po zainicjalizowaniu implementacji BiConsumera i zapisaniu jej obiektu w nowym polu klasy MethodProperty można wziąć się za drugą fazę – wywoływanie. W tym przypadku zmiany ograniczyły się do zamiany _method.invoke(object, value) na consumer.accept(instance, value).

I to wszystko?

Oczywiście, że nie 😉 obsłużyliśmy zaledwie ustawianie pól obiektowych (Stringów). Zostało jeszcze 8 typów prymitywnych (czy wymienisz je wszystkie?) tzn. stworzenie 8 interfejsów odpowiadających BiConsumer oraz ich obsługi.

Dodatkowo MethodProperty odpowiada też za settery zwracające ustawione wartości (nie void), które zatem całą pracę trzeba też wykonać dla BiFunction.
I dla 8 typów prymitywnych również.

Na koniec mvn clean install oraz sprawienie, by testy się zazieleniły.

Ostatecznie można przejść do sprawdzania wpływu na wydajność 🙂
Dla ciekawych tych wszystkich zmian – draft pull requesta.

Performance

Zrobiłem zatem porządne testy – dla OpenJDK w wersjach 8 oraz 11 uruchomiłem prosty benchmark – deserializację z użyciem wcześniej stworzonego ObjectMappera (czyli inicjalizacja już poczyniona). Do benchmarku zaprzęgnięty JMH – najpierw porządne rozgrzanie JVMa i benchmarkowanej metody, potem 100 iteracji po 1s każda. Wszystko wykonywane na Ubuntu 18.04 bez trybu graficznego, bez dostępu do internetu, bez innych nadmiarowych procesów.

Zestawy testowe składały się z 3 podzestawów – obiektów z polami obiektowymi (Stringami), obiektów z polami prymitywnymi oraz miks po połowie (String/primitive). Każdy z podzestawów posiadał klasy o 2,6, 10 lub 20 polach.

Wyniki są następujące (wyniki podane w ns/op):

Nazwa testuOpenJDK 8 z refleksjąOpenJDK 8 z LambdaOpenJDK 11 z refleksjąOpenJDK 11 z Lambda
primitive 2375,162371,571420,594424,329
primitive 6883,396833,530888,789833,256
primitive 101423,6831219,3351407,7131540,637
primitive 203294,1293263,1963598,2303708,698
objects 2369,348371,997430,879429,898
objects 6866,949897,4461045,449984,428
objects 101340,5021333,7121562,4671519,283
objects 202874,2112723,3563282,2163286,685
mixed 2383,846382,690454,834447,254
mixed 6865,195818,739975,578970,954
mixed 101370,8341359,1501620,9321598,931
mixed 203106,1883056,0293834,5733573,692

Krótko mówiąc, może czasem jest coś szybciej, ale to niewiele szybciej (średnio 1-3%), jednak czasem nawet bywa wolniej.

Gdzie się podziała ta całą wydajność?

Z najprostszych obliczeń (oraz poprzedniego artykułu) dla „objects-20” czysta refleksja powinna zajmować 60,494ns * 20 pól = 1209,88ns. Wywołanie z LambdaMetafactory powinno kosztować 18,037 * 20 pól = 360,74 ns.
Czyli walczyliśmy o 849,14ns/2874,211ns = 29,5%.

Uruchamiając ponownie benchmark JMH z dodatkowym profilowaniem .addProfiler(LinuxPerfAsmProfiler.class) zobaczyć można, że rzeczywiście procentowo nieco odciążyliśmy metodę odpowiedzialną za przypisania wartości polu.

....[Hottest Methods (after inlining)]..............................................................
<wartość dla wersji z refleksją>
 23,38%         C2, level 4  com.fasterxml.jackson.databind.deser.impl.MethodProperty::deserializeAndSet, version 869 

<wartość dla wersji z lambda>
 21,68%         C2, level 4  com.fasterxml.jackson.databind.deser.impl.MethodProperty::deserializeAndSet, version 915

Gdzie jest reszta? Trzeba zweryfikować założenia.
W poprzednim wpisie podawałem takie zalety MethodHandle / LambdaMetafactory:

  • przy każdym wywołaniu Method.invoke sprawdzamy, czy dostępy się zgadzają
    • Tutaj rzeczywiście oszczędzamy – patrząc głęboko w kod C2 można zauważyć brak sprawdzania dostępów;
  • gdzieś wewnątrz wywołania Method.invoke jest wywoływana metoda natywna – plotki głoszą, że to jest powolne…
    • W trybie interpretowanym rzeczywiście tak jest, jednak C2 potrafi owinąć w klasę (podobnie do LambdaMetafactory), zatem tutaj zysku brak
  • sama treść metody wywoływanej jest za każdym razem zagadką – takiej metody nie da się zinline‚ować treści tej metody do nadrzędnych metod. Wywoływana metoda nie jest rozumiana przez JITa.
    • W tym przypadku rzeczywiście C2 mógłby próbować ziniline’ować treść metody. Niestety kontekst wywoływania metody jest zbyt wąski, a profilowanie typu settera prowadzi do wywnioskowania, że jest wywoływany jeden z 20 setterów o interfejsie BiConsumer. Takiego wywołania „megamorficznego” nie można zinline’ować, przez co musimy wpierw sprawdzić typ, a nastęnie wykonać instrukcję skoku do treści metody.
      Dokładnie to samo dzieje się przy refleksji – skaczemy do treści metody w owiniętej przez refleksję w klasę metodzie. Stąd i tutaj przyspieszenia brak.

No cóż… „Bo tutaj jest jak jest… Po prostu…”.

Podsumowanie

Pomysł na usprawnienie był całkiem dobry, jednak bardziej skomplikowana rzeczywistość rozmyła złudzenia o znacznie wydajniejszym Jacksonie.

Podane rozwiązanie ma jednak pewną wadę – dla każdego settera generujemy klasę. Przeważnie tych setterów jest dużo, co oznacza, że zaśmiecamy dość mocno Metaspace bez brania pod uwagę, czy ten setter jest często wywoływany, czy rzadko. Warto tu zatem użyć zamiast tego MethodHandle – przynajmniej przedwcześnie nie generuje klasy, a wydajność może być niegorsza niż podanego rozwiązania.

Czy da się szybciej?
Prawdopodobnie tak, jednak nie używając setterów, a konstruktorów i pól. Ale to temat na inny wpis 😉

Na koniec w noworocznym prezencie link do artykułu Shipileva o megamorphic calls. Bo to mądry człowiek jest 😉

Pax et bonum.

Oceń wpis

Odczyt finalnych pól instancyjnych

Zaraz koniec roku, trzeba zamknąć pewne tematy.

Ostatni wpis w tej serii jest o optymalizacjach związanych z finalnymi polami instancyjnymi. Zacznę od obiecanego powrotu do tematu z pól statycznych finalnych, czyli obiektu trzymanego w polu statycznym finalnym oraz jego pól.

Gdy właściciel pola finalnego sam jest polem static final

Weźmy pod uwagę hipotetyczną sytuację: hipotetyczny obiekt Owner, który jest przetrzymywany w polu static final posiada pole finalne int value. W jakiejś innej klasie odwołujemy się przez to pole static final do klasy Owner, a następnie do tego int value.
W czasie kompilacji JIT mamy informację dokładnie o ustalonej wartości tego pola value, zatem można by oczekiwać, że w ramach optymalizacji zostanie wykonany Constant Folding. Jaka jest rzeczywistość? Wykonajmy benchmark:

@State(Scope.Benchmark)
public class InstanceFinalFieldBenchmark {
    public static final ConstantClass CONSTANT_CLASS = new ConstantClass(12);

    public static class ConstantClass {
        private final int value;
        public ConstantClass(int value) { this.value = value; }
        public int getValue() { return value; }
    }

    @Benchmark
    @CompilerControl(CompilerControl.Mode.PRINT)
    public int local() {
        return CONSTANT_CLASS.getValue();
    }
}

Adnotacja @CompilerControl(CompilerControl.Mode.PRINT) powoduje wypisanie kodu maszynowego dla tej metody. Spoglądając w jej treść odnajdziemy fragment:

  0x00007f487be1f05c: movabs  $0x716202b90,%r10  ;   {oop(a 'pl/jgardo/field/instance/InstanceFinalFieldBenchmark$ConstantClass'{0x0000000716202b90})}
  0x00007f487be1f066: mov     0xc(%r10),%eax    ;*getfield value {reexecute=0 rethrow=0 return_oop=0}
                                                ; - pl.jgardo.field.instance.InstanceFinalFieldBenchmark$ConstantClass::getValue@1 (line 23)
                                                ; - pl.jgardo.field.instance.InstanceFinalFieldBenchmark::local@3 (line 58)

Te dwie instrukcje odpowiadają za pobranie wartości z pola klasy, co jest też objaśnione komentarzem getfield value.
Innymi słowy brakuje tutaj tej optymalizacji, której byśmy oczekiwali. Dlaczego tak się dzieje?

OpenJDK ma problem z zaufaniem do instancyjnych pól finalnych.

Dzieje się tak z powodu, że istnieje kilka sposobów na popsucie pola final. Te sposoby to:

  • metody natywne,
  • refleksja,
  • method handles,
  • sun.misc.Unsafe.

(m. in. o tych sposobach jest prezentacja Volkera Simonisa „How final is final”, którą polecam 😉 ).

Niestety póki co nie jesteśmy w stanie za dużo zrobić, by final domyślnie odzyskał pełną wiarygodność. Jakkolwiek są pewne dość brudne sposoby, by zmusić JVMa do zaufania finalom.

-XX:+TrustFinalNonStaticFields

Pierwszy to eksperymentalna flaga -XX:+TrustFinalNonStaticFields. Niestety istnieje ona w OpenJDK w wersji 8 i późniejszych, lecz w OracleJDK była w wersji 8, a w 11 już nie…

Jeśli chodzi o skuteczność tej flagi, to w OpenJDK działa ona zgodnie z przewidywaniem, tzn zwraca od razu żądaną wartość:

  0x00007f95c4440bdc: mov     $0xc,%eax

Jeśli ktoś chciałby co nieco poczytać na temat tej flagi, to polecam spojrzeć na tę korespondencję mailową.

@jdk.internal.vm.annotation.Stable

Drugim sposobem na zmuszenie JVMa do zaufania final jest użycie adnotacji @Stable na danym polu finalnym. Taka adnotacja istnieje od OpenJDK w wersji 9, jednak została ona zaprojektowana tylko i wyłącznie do użytku wewnętrznego JVM i nie jest zbyt łatwo dostępna dla zwykłych śmiertelników.

Nie oznacza to jednak, że się nie da jej użyć… 😉
Istnieją dwa ograniczenia zniechęcające do użycia jej:

  • Adnotacja jest dostępna tylko dla modułów: java.base, jdk.internal.vm.ci, jdk.unsupported
    • Ale jeśli dodamy przy kompilacji obiektu korzystającego ze @Stable parametry --add-exports java.base/jdk.internal.vm.annotation=ALL-UNNAMED to się skompiluje,
  • Obiekt korzystający ze @Stable musi być załadowany przez bootclassloader
    • Zatem jeśli dodamy przy uruchomieniu parametr -Xbootclasspath/a:”<ścieżka do jar>”, to też zadziała 😉

Ludzie listy piszą…

Na temat @Stable również istnieje korespondencja mailowa, na którą warto spojrzeć. Dotyczyła ona pytania, dlaczego by nie udostępnić takiej adnotacji dla użytkowników. W tej korespondencji jest wiele ciekawych wątków i linków.

W jednej z odpowiedzi można znaleźć trzeci sposób na zasymulowanie @Stable. Jednak nie testowałem, więc się nie wypowiem.

Co robić, jak żyć?

Jest pewna nadzieja – na samym końcu wspomnianej wyżej korespondencji jest taka wypowiedź:

For optimizing final fields there are much more promising approaches:
   (1) optimistic optimizations in JITs: treat finals as constants and 
track updates invalidating code which relies on that (there were some 
experiments, e.g. [1]);
   (2) forbid final field updates at runtime after initialization is 
over and freely optimize them in JITs.

Both approaches still have some roadblocks on their way (deserialization relies on modifying final fields, hard to track final field values of individual objects in JVM, etc), but the platform steadily moves in the direction of treating final fields as truly final by default.

Zatem trzeba to przyjąć z pokorą i cierpliwością, bo pisanie JVMów do łatwych nie należy…

Chyba, że się jest bogatym, to zamiast cierpliwie czekać, można zainwestować w Azul Zing – tam jest wiele ciekawych flag do użycia z „final” w treści (na stronie chriswhocodes.com można podejrzeć, jakie są dostępne opcje/flagi dla różnych JVMów; można wyszukać po nazwie opcji).
Chociaż osobiście jeszcze nie zgłębiałem możliwości tej JVMki.

A co z polami instancyjnymi?

Jak się okazuje, śledzenie finalnych pól obiektu static final jest nietrywialne, a jeszcze trudniejsze jest śledzenie wartości w polach niestatycznych… Nie znalazłem niestety żadnych optymalizacji dla pola finalnego…

Tym smutnym faktem kończę całą tę sagę o final. Ostatecznie Frodo nie dotarł do Góry Przeznaczenia, Golum odebrał mu pierścień, a słowo kluczowe final dla pól instancyjnych nie ma de facto pozytywnego wpływu na wydajność…

Ale głowy do góry, nadchodzi nowy rok 2020, a z nim nowe, czternaste wydanie OpenJDK, gdzie ma zostać pokazany światu po raz pierwszy nowy sposób dostępu do Off-Heapa. Jednocześnie to może być kolejny krok w stronę zmniejszenia znaczenia sun.misc.Unsafe. A to może w skończonym czasie doprowadzić do wzrostu znaczenia finala.
Cytując klasyka -„Make final final again”. Czy coś podobnego… 😉

Oceń wpis

Tworzenie obiektów z finalnymi polami instancyjnymi

Ten przedostatni wpis na temat final jest o tworzeniu obiektów z finalnymi polami instancyjnymi (poprzednie są klasach/metodach finalnych, argumentach metod lub zmiennych lokalnych oraz o polach statycznych).

Tworzenie obiektów

Jest pewna cecha dość kluczowa final dla pól instancyjnych.

Weźmy na przykład sytuację, w której jeden wątek tworzy obiekt, wypełnia jego pola, a następnie publikuje do „przestrzeni między wątkowej” (przypisuje do innego pola). Drugi wątek cały czas na bieżąco korzysta z nowotworzonych obiektów oraz jego „zainicjalizowanych” pól.

Okazuje się, że ze względu na możliwość zmiany kolejności wykonywania instrukcji (zarówno przez procesor jak i przez JVM) domyślnie nie ma gwarancji, że przy publikacji nie zawsze wszystkie pola są zainicjalizowane.

Daną sytuację można sprawdzić następującym kodem:

@JCStressTest
@Outcome(id = "-1", expect = ACCEPTABLE, desc = "Object is not seen yet.")
@Outcome(id = {"0", "1", "2", "3", "4", "5", "6", "7"}, expect = ACCEPTABLE_INTERESTING, desc = "Seeing partially constructed object.")
@Outcome(id = "8", expect = ACCEPTABLE,  desc = "Seen the complete object.")
@State
public class NonFinalStressTest {
    int v = 1;
    MyObject o;

    @Actor
    public void actor1() {
        o = new MyObject(v);
    }

    @Actor
    public void actor2(IntResult1 r) {
        MyObject o = this.o;
        if (o != null) {
            r.r1 = o.x8 + o.x7 + o.x6 + o.x5 + o.x4 + o.x3 + o.x2 + o.x1;
        } else {
            r.r1 = -1;
        }
    }

    public static class MyObject {
        int x1, x2, x3, x4;
        int x5, x6, x7, x8;
        public MyObject(int v) {
            x1 = v;
            x2 = v;
            x3 = v;
            x4 = v;
            x5 = v;
            x6 = v;
            x7 = v;
            x8 = v;
        }
    }
}

Kod ten jest tak naprawdę test napisanym w frameworku JCStress. Framework ten przede wszystkim ułatwia znajdywanie błędów w programowaniu wielowątkowym. Po uruchomieniu tego testu zauważymy następujące wyniki:

  1 matching test results. 
      [OK] com.vmlens.stressTest.tests.NonFinalStressTest
    (JVM args: [-Dfile.encoding=UTF-8, -server, -XX:+UnlockDiagnosticVMOptions, -XX:+StressLCM, -XX:+StressGCM])
  Observed state   Occurrences              Expectation  Interpretation                                              
              -1    43 381 257               ACCEPTABLE  Object is not seen yet.                                     
               0             0   ACCEPTABLE_INTERESTING  Seeing partially constructed object.                        
               1             0   ACCEPTABLE_INTERESTING  Seeing partially constructed object.                        
               2             0   ACCEPTABLE_INTERESTING  Seeing partially constructed object.                        
               3             0   ACCEPTABLE_INTERESTING  Seeing partially constructed object.                        
               4            62   ACCEPTABLE_INTERESTING  Seeing partially constructed object.                        
               5            42   ACCEPTABLE_INTERESTING  Seeing partially constructed object.                        
               6           360   ACCEPTABLE_INTERESTING  Seeing partially constructed object.                        
               7           437   ACCEPTABLE_INTERESTING  Seeing partially constructed object.                        
               8     5 015 292               ACCEPTABLE  Seen the complete object.                                   

Jak widać na listingu takie sytuacje, choć rzadko, jednak się zdarzają.

I wtedy wkracza final cały na biało. Jeśli go dodamy przy polach klasy MyObject, wówczas Java Memory Model zapewnia, że taki obiekt będzie opublikowany tylko z zainicjalizowanymi polami finalnymi.

Poprawę sytuacji potwierdza wynik testu JCStress:

(ETA:        now) (Rate: 1,65E+07 samples/sec) (Tests: 1 of 1) (Forks:  8 of 8) (Iterations: 40 of 40; 40 passed, 0 failed, 0 soft errs, 0 hard errs) 

Opisana sytuacja jest dość znana i opisana w różnych zakątkach internetu. Zatem jeśli ktoś pragnie zgłębić ten temat, polecam artykuł z DZone.
Kod testu JCStress, który potwierdza istnienie tego zjawiska, również nie jest moją inwencją twórczą. Jest to jeden z przykładów tego użycia frameworku.

Nic za darmo

Jeśli dostajemy gwarancję publikacji zainicjalizowanego obiektu, to jednocześnie zabraniamy wykonywania pewnych optymalizacji lub – co gorsza – czekamy. Zatem coś musi się działać wolniej.

Osobiście zauważyłem to przy pewnym eksperymencie – porównywałem czas inicjalizacji kontekstu Springa w zależności od sposobu Dependency Injection. Początkowo porównywałem wstrzykiwanie zależności przez settery, pola oraz przez konstruktor. Na samym końcu sprawdziłem, jaki wpływ ma dodanie/usunięcie finala przy wstrzykiwaniu przez konstruktor. Eksperyment dotyczył 800 beanów z łącznie 10 000 polami. Wyniki są następujące:

Constructor Injection with finals5,3
Constructor Injection without finals4,72
Field Injection4,46
Setter Injection6,1

Zatem widać wspomniany narzut zwiększający czas tworzenia obiektu. Wątpię, żeby Spring – jako framework DI – przy inicjalizacji kontekstu wymagał finali na polach aby zapewnić, że kontekst jest kompletny. Jeśli więc komuś bardzo zależy na czasie podnoszenia aplikacji, to jest to jakiś pomysł na skrócenie tego czasu. Jakkolwiek, dla mnie to klasyczny przykład przedwczesnej optymalizacji.

To znaczy, że final tylko spowalnia?

Nie.
Ale o tym będzie kolejny wpis – ostatni z tej serii 😉

Oceń wpis

Final – pola statyczne

Po omówieniu słowa kluczowego final dla klas, metod, zmiennych lokalnych oraz argumentów funkcji można przejść do final w kontekście pól. Pola obiektów można podzielić na statyczne (czyli takie, które są związane z daną klasą) oraz instancyjne (związane bezpośrednio z danym obiektem). W obu przypadkach final określa, że dane pole może mieć tylko jedno przypisanie, które z resztą musi być wykonane w czasie tworzenia obiektu/ładowania klasy. Cóż interesującego można powiedzieć o polach statycznych finalnych?

Typy prymitywne i Stringi

Jak głosi Oficjalny tutorial do Javy 8 autorstwa Oracle, pola statyczne finalne zwane są compile-time constant (albo były, bo aktualnie trudno znaleźć tę nazwę w nowszych źródłach). Jakkolwiek, każde użycie takiego pola jest zamieniane w czasie kompilacji do bytecode’u na jego wartość.
Zatem optymalizacja dla final, którą zauważyliśmy również dla finalnych zmiennych lokalnych, tzn. Constant Folding ma zastosowanie również i w tym przypadku.

Obiekty

O ile przy typach prymitywnych można zrobić Constant Folding, o tyle w przypadku samych obiektów raczej nie ma takiej możliwości (ciężko sobie to wyobrazić). Warto jednak sprawdzić optymalizację odwołań do danego pola takiego obiektu umieszczonego w polu static final. Jednak wówczas mówimy tak na prawdę o finalu w kontekście niestatycznym, zatem opiszę to przy innej okazji.

Jaki wpływ ma dodanie final do pola statycznego w kontekście wywoływania jego metody?

Aby się tego dowiedzieć, wykonajmy prosty test:

private static final Super F_SUPER = new Super();
private static final Super F_SUB_AS_SUPER = new Sub();
private static final Sub F_SUB = new Sub();

private static Super N_SUPER = new Super();
private static Super N_SUB_AS_SUPER = new Sub();
private static Sub N_SUB = new Sub();

// FOR EACH
public int benchmark() {
    return SOME_CASE.someMethodInvocation();
}

W tym benchmarku sprawdzamy wywoływanie metody, której treść zawiera zwrócenie stałej wartości. Sprawdzamy wywołanie polimorficzne, bezpośrednie nadklasy oraz bezpośrednio podklasy. W przypadku każdego zastosowania słowa kluczowego final mamy (na moim laptopie) 250 milionów operacji na sekudnę. Jeśli spojrzeć w kod wygenerowany przez C2, to zobaczymy tam wyłącznie zwrócenie tej stałej wartości. Ten brak dodatkowych akcji zarówno dla wywołań polimorficznych jak i bezpośrednich wynika z tego, że zarówno po pierwszym, jak i po 15 000 wywołaniu metody znamy obiekt, którego metodę wywołujemy. Jest w polu finalnym, więc nie może się zmienić. Po zainicjalizowaniu nie da się go również zamienić na null. Stąd prosty kod maszynowy:

mov     $0x5,%eax
add     $0x10,%rsp
pop     %rbp
mov     0x108(%r15),%r10
test    %eax,(%r10)       ;   {poll_return}


Taką samą przepustowość otrzymałem również dla private static Sub N_SUB. Stało się tak pomimo, iż jeśli spojrzymy w kod C2, zobaczymy tam dodatkowego nullchecka (4 dodatkowe instrukcje kodu maszynowego). Jednak nie musimy sprawdzać typu obiektu w polu dzięki wspomnianemu wcześniej mechanizmowi CHA. Stąd kod maczynowy wygląda następująco:

movabs  $0x716320790,%r10
mov     0x84(%r10),%r11d
test    %r11d,%r11d
je      0x7f3968742977
mov     $0x5,%eax
add     $0x10,%rsp
pop     %rbp
mov     0x108(%r15),%r10
test    %eax,(%r10)       ;   {poll_return}

Nieco więcej instrukcji trzeba wykonać w przypadku polimorficznego wywołania metody pola statycznego niefinalnego. Oprócz wspomnianego wcześniej nullchecka musimy dodatkowo sprawdzić typ obiektu – jest to dodatkowy odczyt z pamięci, co skutkuje zmniejszeniem przepustowości z 250 do 243 milionów operacji na sekundę. Wspomniane zmiany są widoczne na zrzucie instrukcji kodu maszynowego wygenerowanego przez C2.

movabs  $0x7164c8a90,%r10  ;   {oop()}
mov 	0x88(%r10),%r11d  ;*getstatic N_SUB_AS_SUPER {reexecute=0 rethrow=0 return_oop=0}
mov 	0x8(%r12,%r11,8),%r10d  ; implicit exception: dispatches to 0x00007f84ceff0822
cmp 	$0x80126b8,%r10d  ;   {metadata('pl/jgardo/classes/hierarchy/with/FinalClass')}
jne 	0x7f84ceff0810
movabs  $0x716320790,%r10
mov     0x84(%r10),%r11d
test    %r11d,%r11d
je      0x7f3968742977
mov     $0x5,%eax
add     $0x10,%rsp
pop     %rbp
mov     0x108(%r15),%r10
test    %eax,(%r10)       ;   {poll_return}

Podsumowanie

To chyba najkrótszy z dotychczasowych wpisów.
Podsumować go można stwierdzeniem, że dla pól statycznych słowo kluczowe final ma znaczenie – dla typów prymitywnych, stringów, lecz również dla obiektów.

Następny artykuł z serii final: tworzenie obiektów z polami finalnymi.

Oceń wpis

Final – zmienne lokalne i argumenty metod, a wydajność

W poprzednim wpisie pisałem o final pod kątem klas i metod. W tym skupie się na zastosowaniu final przy zmiennych lokalnych oraz argumentach metod.

Zmienne lokalne

Pierwszym miejscem, gdzie moglibyśmy szukać optymalizacji jest kompilacja do bytecode’u. Jak już pisałem we wcześniejszych wpisach, na tym etapie zbyt dużo optymalizacji się nie dzieje.

Jednak jeśli skompilujemy następujący fragment kodu zauważymy pewne ciekawe fakty.

    public void finalVariablePresentation() {
        final String final1 = "a";
        final String final2 = "b";
        final String finalConcatenation = final1 + final2;

        String nonFinal1 = "a";
        String nonFinal2 = "b";
        String nonFinalConcatenation = nonFinal1 + nonFinal2;
    }

Po kompilacji powyższego kodu, a następnym zdekompilowaniu (z użyciem Bytecode Viewer oraz widoku JD-GUI Decompiler) możemy zobaczyć następujący kod:

    public void finalVariablePresentation() {
        String final1 = "a";
        String final2 = "b";
        String finalConcatenation = "ab";
    
        String nonFinal1 = "a";
        String nonFinal2 = "b";
        String nonFinalConcatenation = nonFinal1 + nonFinal2;
    }

W tym listingu widzimy 2 ciekawe rzeczy.
Pierwszą jest to, że jeśli mamy w kodzie dwie finalne zmienne lokalne (takie stałe lokalne), które chcemy ze sobą konkatenować, to ta konkatenacja jest robiona na etapie kompilacji do bytecodu. Dzięki temu nie musimy robić konkatenacji przy każdym wywołaniu metody. Skutkuje zmniejszeniem czasu potrzebnego do uzyskania danej wartości z 10,8 ns do 6,5 ns (na moim komputerze, po rozgrzaniu i kompilacji C2).
Zysk może być jeszcze większy w przypadku wcześniejszych wersji Javy niż 8. Dopiero w tej wersji Javy tworzenie nowych Stringów przy użyciu operatora + jest w czasie kompilacji zamieniana na new StringBuilder().append().append().toString().

Drugim ciekawym faktem, który widzimy we wspomnianych listingach jest utrata informacji o final. Zatem poza wspomnianym wcześniej mechanizmem ewaluacji wyrażeń, nie ma żadnych dodatkowych wydajnościowych zalet stosowania słowa final, ponieważ… tej informacji nie ma w bytecodzie.

Constant Folding

Technika obliczania wyrażeń w czasie kompilacji, jeśli znamy składowe tego wyrażenia nazywa się Constant Folding.

W czasie kompilacji do bytecode’u oprócz Stringów jest ona używana do ewaluacji wyrażeń typu prymitywnego. Jednak w przeciwieństwie do Stringów nie powoduje przyspieszenia działania programu. Jest tak, ponieważ C2potrafi sam „wywnioskować”, które zmienne są stałe (nawet bez final) oraz C2 również wykorzystuje Constant Folding dla zmiennych prymitywnych (dla Stringów nie), zatem dla zminnych prymitywnych, nie ma znaczenia, czy jakieś wyrażenie zostanie wyliczone w czasie kompilacji do bytecode’u, czy w czasie kompilacji C2.

Argumenty metod

Również dla argumentów metod warto sprawdzić, co można wyczytać z bytecode’u. Zatem po skompilowaniu danego fragmentu kodu:

    public void countSomeHash() {
        final int a1 = countHashPrivate(2);
        final int b1 = countHashPrivate(4);
        final int n1 = 20;

        final int result1 =  a1 * b1 + n1 * b1;

        int a2 = countHashPrivateWithoutFinals(2);
        int b2 = countHashPrivateWithoutFinals(4);
        int n2 = 20;

        final int result2 =  a2 * b2 + n2 *b2;
    }

    private int countHashPrivate(final int n) {
        final int a = 3;
        final int b = 2;
        return a * b + n *b;
    }

    private int countHashPrivateWithoutFinals(int n) {
        int a = 3;
        int b = 2;
        return a * b + n *b;
    }

a następnie zdekompilowaniu, otrzymujemy podany fragment kodu:

  public void countSomeHash() {
    int a1 = countHashPrivate(2);
    int b1 = countHashPrivate(4);
    int n1 = 20;
    
    int result1 = a1 * b1 + 20 * b1;
    
    int a2 = countHashPrivateWithoutFinals(2);
    int b2 = countHashPrivateWithoutFinals(4);
    int n2 = 20;
    
    int result2 = a2 * b2 + n2 * b2;
  }
  
  private int countHashPrivate(int n) {
    int a = 3;
    int b = 2;
    return 6 + n * 2;
  }

  private int countHashPrivateWithoutFinals(int n) {
    int a = 3;
    int b = 2;
    return a * b + n * b;
  }

W czasie kompilacji do bytecode’u nie widać żadnych rezultatów optymalizacji. Ponadto, nie widać też informacji, że dany argument metody jest finalny.

Okazuje się, że generalnie o argumentach metod mało wiemy. Nie znamy żadnych modyfikatorów argumentów (final), nie znamy również ich nazw. Jednak to domyślne zachowanie można od Javy 8 zmienić przez dodanie do javac argumentu -parameters.

Niestety dodanie wspomnianego parametru nie wpływa na wydajność…

Podsumowanie

Niestety utrata informacji o final w czasie kompilacji do bytecode’u zamyka ewentualne możliwości optymalizacji kodu.

Jedyną sensowną optymalizacją jest wspomniane Constant Folding w celu wyliczenia String. Dla wartości prymitywnych ta technika może pozytywnie wpłynąć na czas wykonywania tylko w trybie interpretowanym lub po kompilacji przez C1.

Następny artykuł z serii final: pola statyczne.

Oceń wpis