EnumSet vs Set.of() 5 (2)

Niegdyś musiałem stworzyć zbiór Enumów i umieścić go w polu static final. Pełen optymizmu skorzystałem z Set.of() z JDK9. Zdziwiłem się jednak, gdy SonarQube zgłosił, że jeśli używam zbiór Enumów, to powinienem użyć EnumSet. Wewnętrzne wzburzenie nie dawało mi spokoju, bo przecież Set.of() jest niemutowalne i korzysta ze @Stable wewnątrz, więc lepsze powinno być preferowane w tym względzie…

Po ponad 1,5 roku postanowiłem zweryfikować, co o moim przekonaniu mówią benchmarki.

EnumSet

Najpierw warto zajrzeć w szczegóły implementacji klasy EnumSet.

Zacznijmy od tego, że jest to klasa mutowalna, której wartości muszą dziedziczyć po klasie Enum.

Każda wartość wyliczeniowy posiada przyporządkowaną wartość ordinal(), która jest intem. Zatem zbiór Enumów może być reprezentowany poprzez pojedyncza liczbę, której poszczególne bity będą zapalone, gdy dany Enum (z odpowiednim ordinal()) będzie w zbiorze.

Zatem, aby przechować Enum o max 64 wartościach, wystarczy liczba typowana longiem. Jednak jak przechowywać Enumy o większej dziedzinie? Wówczas zamiast jednej liczby można użyć tablicy liczb.

Powyższe stwierdzenia przekładają się na implementację – EnumSet jest tak na prawdę klasa abstrakcyjną (udostępniającą metody fabryczne) i posiada dwie podklasy – RegularEnumSet reprezentujący jednym longiem dla Enumów o max 64 wartościach oraz JumboEnumSet ze stanem trzymanym w tablicy longów.

Set.of()

Set.of() zwraca zawsze klasę niemutowalną. Jeśliby zajrzeć w implementację metody Set.of(), to można zobaczyć, że zwracana klasa zależy od liczby elementów. Dla pustej listy zwracana jest instancja SetN, dla jednego i dwóch elementów – Set12, a dla trzech elementów wzwyż – ponownie SetN.

Set12 jest implementacją obsługująca wyłącznie jednoelementowe i dwuelementowe zbiory. Wewnątrz klasy są zawarte dwa pola, w których umieszczany jest pierwszy i opcjonalnie drugi element. Dzięki temu, że korzystamy z pól, a nie z tablicy oszczędzamy jeden odczyt z pamięci. Dodatkowo implementację metod w przypadku max dwóch elementów są trywialne – nie trzeba nawet iterować po tablicy (wszak jej nie ma ;)).

Implementację zwracane przez Set.of() są niezmienne, zatem można pokusić się o optymalizację odczytów. Takie pole, które na pewno jest niezmienne można oprócz oznaczenia finalem (a które niewiele daje), można oznaczyć adnotacją @Stable. Ta adnotacją dostępna dla modułów JDK upewnia kompilator, że wartość oznaczona NA PEWNO nie zostanie zmieniona. Użycie tej adnotacji jest wykorzystywane co najmniej w przypadku pól static final.

Zarówno na polach z elementami (dla Set12), jak i tablicy z elementami (SetN) jest użyte @Stable.

Zajętość pamięci

Pierwszym aspektem jest zmierzenie ile pojedynczy zbiór Enumów zajmuje pamięci. Zmierzone zostały zarówno same obiekty zbioru, jak i tworzone przez nie obiekty. Nie zostały jednak wzięte pod uwagę obiekty współdzielone między wiele instancji (np.EnumSet.universe lub dane klasy czy refleksji).

Wielkości są badane dla zbioru pełnego danych Enumów t.j. zbiór Enumów o N wartościach zawierający wszystkie możliwe wartości tego Enuma.

Otóż o ile dla jednego i dwóch elementów mniej waży Set.of(), o tyle w pozostałych przypadku lepiej wypada EnumSet, który rośnie tylko o 8 bajtów na każde 64 elementów dziedziny. W przypadku Set.of() każdy dodatkowy element w zbiorze zajmuje 8 bajtów.

Benchmark

Z braku czasu nieco poszedłem na skróty i badałem jednocześnie zarówno wpływ wielkości domeny (liczba dostępnych wartości danego Enuma), jak i wielkość zbioru.

Wielkość domeny powinna zmniejszać wydajność EnumSeta co każde 64 dostępnych wartości, niezależnie od zawartości zbioru.

Z drugiej strony wielkość zbioru powinna zmniejszać bardzo szybko wydajność Set.of() oraz znacznie wolniej wydajność EnumSeta.

Set.contains()

W tym przypadku badany był przypadek, gdzie sprawdzana była obecność ostatniego elementu domeny Enuma. Wielkość zbioru jest równocześnie wielkością dziedziny. Weryfikowane były dwa warianty – gdy trzymamy taki zbiór w polu statycznym finalnym (s.f.) i po prostu w finalnym.

Wyniki przedstawiane są poniżej (liczba operacji na sekundę – im więcej tym lepiej ;))

W przypadku pola static final operacja .contains() działa szybciej dla Set.of() zawierający 0, 1 lub 2 elementy (o 10%-50%). W pozostałych przypadkach EnumSet działa szybciej.

Co ciekawe, acz logiczne dla EnumSet dla dziedziny do 64 Enumów działa tak samo szybko niezależnie od zawartości zbioru (operacje na bitach są… szybkie ;)).

Zastanawiającą rzeczą do dalszego rozważania we własnym zakresie zostawiam „losowość” wydajności Set.of() dla większych niż 2. Dla pól static final wydajność „skacze”.

W przypadku pól finalnych przewaga Set.of() jest widoczna tylko dla pustego i jednoelementowego zbioru. Dla większych zbiorów EnumSet wygrywa.

Porównywalna byłaby wydajność dla 2 elementowego zbioru o dziedzinie większej niż 64 elementy, zatem zawsze przy wyborze EnumSet vs Set.of() należy brać pod uwagę dwa czynniki – dziedzinę i wielkość zbioru.

Set.containsAll()

W drugim badanym benchmarku brałem pod uwagę operacje .containsAll() w argumencie podając zbiór pełny. Tutaj również brałem pod uwagę dwa przypadki: gdy argumentem jest zbiór tego samego typu i gdy zbiór jest innego typu.

Wyniki przedstawiane są poniżej (liczba operacji na sekundę – im więcej tym lepiej ;))

W przypadku .containsAll() użycie Set.of() jest uzasadnione w zasadzie tylko w przypadku pustego zbioru. Wyniki również jasno pokazują, że w przypadku korzystania z EnumSeta, warto zadbać o to, by argumentem .containsAll() był również był EnumSet – wówczas dla dowolnej zawartości zbioru wydajność jest taka sama i zależyna wyłącznie od dziedziny.

Podsumowanie

Mając do dyspozycji Set.of() i EnumSet wybór należy uzależnić od dziedziny Enuma oraz wielkości zbioru. Dla 0,1,2 elementów lepiej wypada Set.of(), szczególnie, gdy dziedzina ma więcej niż 64 elementy. W pozostałych przypadkach lepiej wybrać EnumSeta.

Zasadniczo, mając do dyspozycji jedno i drugie, to warto rozważyć trzecie 😉 Jest kilka bibliotek, które implementują interfejsów standardowych Javowych kolekcji – chociażby Eclipse Collections, Apache Commons Collections lub choćby stara dobra Guava. Z ciekawostek to istnieje implementacja Mapy trzymająca elementy poza heapem – Chronicle Map.

Wszelkie źródła, benchmarki itd pod tym adresem.

Jednak ten SonarQube miał rację… 😉

Pax et bonum!

P. S. Ten tekst powstał w połowie w komunikacji miejskiej i pociągu. Dziękuję MPK i PKP 😉

Taka refleksja, ale szybsza… JDK18 update 0 (0)

Implementacja wywoływania metod z użyciem refleksji ma całkiem bogatą historię. Dostępna od początku w Javie (początkowo bardzo niewydajna), gruntownie przepisana w Javie 1.4, następnie zyskała konkurencję w postaci MethodHandle. Ostatecznie konkurenci będą musieli się pogodzić, gdyż w Jdk 18 w ramach JEP-416 wywołanie metod z użyciem refleksji tj. Method::invoke zostanie przepisane na używające pod spodem MethodHandle.

Co aktualnie nie domaga w refleksji?

Zacznijmy od tego, jak aktualnie działa wywołanie Method::invoke.

  1. Sprawdzany jest dostęp do tej metody – czy w ogóle możemy ją wywołać.
  2. Jeśli ta metoda była wywoływana często, to ostatecznie treść wywoływanej metody jest kompilowana JITem. Trzeba zatem sprawdzić, czy taki kod istnieje i go wywołać.
  3. Jeśli metoda nie jest skompilowana JITem, to wywoływana jest metoda natywna uruchamiająca kod metody.

Warto zauważyć, że dostęp jest sprawdzany przy każdym wywołaniu. Jest to nadmiarowe, gdyż wystarczyłoby sprawdzić kontekst na poziomie klasy tylko raz i taki kontekst wywoływania zcache’ować w klasie.

Drugą niedogodnością jest brak możliwości wniknięcia JITa do treści wywoływanej metody. JIT po prostu nie wie, jakie operacje są wywoływane w tej metodzie – traktuje wywołaną metodę jako „black box”.

Method Handle

Te dwie wady Method::invoke adresuje mechanizm MethodHandle wprowadzony w Jdk 1.7. Po pierwsze kontekst (Lookup) jest wymagany do znalezienia uchwytu na metodę, zatem dostęp sprawdzany jest jednokrotnie.

Po drugie MethodHandle rozumie kod wykonywany, dzięki czemu JIT ma możliwość zinline’owania wykonywanego kodu.

O ile ten mechanizm pozwala na wykonanie optymalnego kodu, o tyle problemem jest poziom trudności. Znacznie trudniej stworzyć kod wywołujący MethodHandle i łatwiej w nim o pomyłkę. Zrozumienie takiego kodu również jest trudniejsze.

Dodatkowo, o ile JIT ma możliwość rozumienia treści MethodHandle, o tyle ta informacja jest wykorzystywana praktycznie tylko w przypadku MethodHandle przetrzymywanych w polach statycznych finalnych (constant). W innych sytuacjach JIT nie wykorzystuje informacji o wykonywanym kodzie (a przynajmniej OpenJdk tak nie robi).

JEP-416

Próbę pożenienia Method::invoke z MethodHandle podejmuje wspomniany JEP-416. Dostarczony zostanie wraz z Jdk 18 w najbliższych dniach.

Zgodnie z oczekiwaniami otrzymujemy usprawnienia działania z MethodHandle wraz z prostotą wykonania Method::invoke. Dodatkowo autorzy JEPa wspominają jeszcze o zalecie „mniejszej ilości StackFrame’ów” oraz ułatwieniu dalszego rozwoju platformy poprzez usunięciu specjalnego traktowania refleksji.

Wydajność

Normalnie w tym akapicie przeszedłbym do benchmarkowania rozwiązania, gdyby nie to, że wyniki benchmarków są już zawarte w JEPie. O ile znacząca poprawa wydajności w przypadku Method, Constructor i Field trzymanych w polach static final (43–57% poprawa) zadowala, o tyle zawieść mogą benchmarki innych wywołań (51–77% pogorszenie).

Autorzy zapewniają, że w rzeczywistych aplikacjach zmiany nie mają znaczenia (a sprawdzili w Jacksonie, XStream i Kryo). Obiecują również poprawę na polach na których wystąpiło pogorszenie.

Podsumowanie

Wprowadzone zmiany należy mimo wszystko uznać za pozytywne. O ile zmniejszenie wydajności ma negatywny wydźwięk, o tyle zwiększenie utrzymywalności, potencjał na zwiększenie wydajności oraz uzasadnienie przygotowaniem pod Valhallę i Loom kompensują ten negatywny efekt.

Żebyśmy jeszcze dożyli tychże…

Jeśli ten wpis Cię zainteresował, polecam moje dwa poprzednie wpisy o refleksji – Taka refleksja tylko szybsza… i Przepisujemy Jacksona z refleksji na LambdaMetafactory [ZOBACZ JAK].

Pax et bonum!

Kilka słów o Recordach w Javie 0 (0)

Jako preview feature pojawiły się w JDK 14, by po dwóch odsłonach Javy wejść do standardu w wersji 16. Pół roku później została wydana Java 17, która będąc wydaniem Long Term Support prawdopodobnie będzie się cieszyła dużym zainteresowaniem.

Czas zatem przyjrzeć się, co w tych rekordach siedzi…

Krótkie wprowadzenie

Czym są rekordy w Javie? – to pytanie iście filozoficzne. Sami Twórcy musieli się nagłowić, co to właściwie jest i jak ten twór nazwać. Wszystkie te rozterki ładnie słychać w wywiadzie z Brianem Goetzem, który polecam. Dużo teorii o można wyczytać również ze źródłowego JEPa. Tam w podsumowaniu można między innymi przeczytać:

(…)records, which are classes that act as transparent carriers for immutable data. Records can be thought of as nominal tuples.”

JEP 395

Czyli krótko: rekord to pojemnik na dane, przy czym dane są określone nazwą (w przeciwieństwie do Tuple).

Wielu mądrzejszych ode mnie ludzi pisało już o Rekordach, zatem nie będę pisał o rzeczach już opisanych, a podrzucę kilka linków (po angielsku i po polsku – pierwsze z brzegu znalezione na jvm-bloggers).

Implementacja Recordów

Warto zacząć od tego, rekordy są (nie)zwykłą klasą Javową. Zwięzły zapis:

public record Record(int i, String j)

kompilowany jest do:

public final class Record extends java.lang.Record {
    private final int i;
    private final String j;

    public Record(int i, String j) {
        this.i = i;
        this.j = j;
    }

    public int i() {
        return i;
    }

    public String j() {
        return j;
    }

    public final java.lang.String toString() {
        // (implementacja)
    }

    public final int hashCode() {
        // (implementacja)
    }

    public final boolean equals(java.lang.Object) {
        // (implementacja)
    }
}

Jakby porównać te dwa zapisy, to ewidentnie łatwiej i szybciej utworzyć rekord niż tę drugą implementację.

Dodatkowo stworzone rekordy posiadają pewne feature’y, których normalnie zaimplementować się nie da. Przede wszystkim implementacje toString, hashCode i equals nie są generowane w czasie kompilacji do bytecode’u, a przy pierwszym użyciu danej metody. Podobny mechanizm można znaleźć w konkatenacji stringów lub w lambdach. Dodatkowo, do wygenerowania każdej z nich jest używana jedna metoda, chociaż w parametrach jest przekazywany pewien znacznik określający, którą metodę wygenerować. Wszystko to pozwala nieco odchudzić sam bytecode – nawet o 10%. Jednakże polemizowałbym, czy jest sensowne w czasach terabajtowych dysków twardych oszczędzać 100KB na bajtkodzie.

Mechanizm refleksji również został wzbogacony o informacje typowe dla rekordów. Można nie tylko wyciągnąć jakie pola istnieją w tym rekordzie (getRecordComponents), ale również jest informacja, jaka metoda pozwala na pobranie wartości tego pola. Warto tutaj nadmienić, że rekordy nie spełniają standardu JavaBean – nie mają getterów. Jednak można to uznać za zaletę, gdyż nie musimy się zastanawiać, czy akcesor zaczyna się o „get” czy „is”.

Kolejność pól oraz argumentów konstruktora jest taka sama, da się ją pobrać refleksją i daje dobre podstawy do PatternMatchingu na rekordach. Istnieje na to już JEP i aktualnie jest w stanie „kandydat do JDK 18”.

Wsparcie dla serializacji jest również dostarczane automagicznie. Wystarczy dodać interfejs Serializable, a JVM zapewni, że nikt niczego nikczemnego nie poczyni z obiektem. Dodatkowo deserializacja wywołuje domyślnie wygenerowany konstruktor, co jest niestandardowym zachowaniem w serializacji Javowej.

„Wyzwania”

Rekordy mają też swoje niedogodności.

Pierwszą z nich dodawanie adnotacji. Jeśli chcemy dodać adnotację to robimy to następująco public record Record(@SomeAnno int i, String j) . Niestety w ten sam sposób adnotujemy zarówno pole, jak i metodę dostępu do pola, ale również parametr konstruktora. Adnotacja zostanie dodana we wszystkich miejscach, do których pasuje – jeśli adnotacja może być zastosowana do pola, wówczas pole zostanie zaadnotowane; jeśli adnotacja przeznaczona jest do metod, wówczas akcesor zostanie zaadnotowany. Mimo wszystko może to tworzyć pewne konflikty.

Drugim problemem, na który się natknąłem, jest stawianie breakpointa przy debugowaniu. Aktualnie wszystkie akcesory, pola i konstruktor mają w bytecodzie przypisaną tę samą linię początku konstruktora. Zatem zastawienie się na „getterze”, a właściwie to akcesorze może być mało komfortowe.

Pewną niedogodnością może być brak prostego stworzenia nowego rekordu na podstawie starego bez przepisywania wszystkich wartości. Jednak tutaj z pomocą przychodzi Lombok, który aktualnie wspiera rekordy zarówno @Builderem, jaki i @Withem.

Wsparcie bibliotek

Rekordy są aktualnie wspierane przez Jacksona (od wersji 2.12) w serializacji i deserializacji bez dodatkowych akcji.

Hibernate również wspiera rekordy, ale jedynie w odczycie tj. rezultat zapytania można zmapować do rekordu/ów. O ile nie mogą one pełnić funkcji encji, o tyle można je wykorzystać jako obiekt wartości i w tej roli wykorzystać je w polach encji.

W Springu konfigurację można zmapować do rekordów. Można je również wykorzystać jako DTO do przekazywania wartości do silników template’ów takich jak Thymeleaf.

Garść linków

Wpisów o rekordach w internecie jest wiele, postaram się zalinkować najciekawsze:

  • Na początek tekst o rekordach + porównanie z case klasami ze Scali i data klasami z Kotlina – link,
  • Użycie rekordów w JPA/Hibernate – link oraz link,
  • Nieco teorii, czyli JEP-395,
  • Zapiski Briana Goetza o rekordach – link,
  • Gdzie można zastosować dokładniej rekordy w Springu – link,
  • Obszerne omówienie rekordów (po polsku!) w serii wpisów Piotra Przybył – intro, możliwości rekordów, refleksja, lombok; wpisów jest więcej, ale po angielsku 😉

Pax!

Sygnatura metody, jej parametry i kwestia wydajności 0 (0)

Ile parametrów powinna mieć metoda?

Wujek Bob w „Czystym kodzie” twierdzi, że najlepiej zero. Dopuszcza też metody jedno i maksymalnie dwuparametrowe. Trzy i więcej parametry to już tłok, który przy przekazywaniu należy zgrupować w obiekt.

Pod kątem czystości kodu mamy zatem pewne wytyczne. Jak to wygląda od strony wydajności kodu? Czy opłaca się grupować parametry w obiekty, które można przekazać, czy wręcz przeciwnie?

Benchmark przekazywania parametrów

Załóżmy, że mamy dwie nieskomplikowane metody – w jednej przekazujemy parametry, w drugiej wrapper z tymi parametrami:

private int func(int i1, int i2, int counter) {
    counter--;
    if (counter == 0) {
        return counter;
    } else {
        return func(i1, i2, counter);
    }
}

private int func(Wrapper2 i1, int counter) {
    counter--;
    if (counter == 0) {
        return counter;
    } else {
        return func(i1, counter);
    }
}
private static class Wrapper2 {
    private final int i1;
    private final int i2;

    public Wrapper2(int i1, int i2) {
        this.i1 = i1;
        this.i2 = i2;
    }
}

Metody te pozwalają na wykonanie nieskomplikowanej czynności dekrementacji oraz rekurencyjnie wywołują te metody, aż licznik się wyzeruje. Badać będziemy przypadki z 1,2,4,8 i 16 argumentami/polami wrapperów.

Uruchamiając benchmark z licznikiem o wartości 1 zauważymy te same wyniki (u mnie ok 3,327 ± 0,013 ns/op). Jest to efekt inline’owania, dzięki któremu można zaoszczędzić wywołań metod. Kod maszynowy skompilowany przez c2 dla wszystkich metod jest taki sam.

Zatem, jeśli chcielibyśmy zbadać, czy wywołania metod z różnymi sygnaturami się różnią, to… trzeba wywołać te metody 😉 Można to zrobić na dwa sposoby – albo zwiększyć counter do wartości uniemożliwiającej inline’owanie albo zakazać inline’owania.

Po zmianie wartości counter na 10 wyniki już się znacząco różnią:

Benchmark                                Mode  Cnt   Score   Error  Units
WriteParamsBenchmark.primitiveParams_01  avgt   10  12,498 ± 0,123  ns/op
WriteParamsBenchmark.primitiveParams_02  avgt   10  12,817 ± 0,059  ns/op
WriteParamsBenchmark.primitiveParams_04  avgt   10  12,960 ± 0,146  ns/op
WriteParamsBenchmark.primitiveParams_08  avgt   10  23,064 ± 0,145  ns/op
WriteParamsBenchmark.primitiveParams_16  avgt   10  55,409 ± 0,750  ns/op
WriteParamsBenchmark.wrappedParams_01    avgt   10  12,529 ± 0,088  ns/op
WriteParamsBenchmark.wrappedParams_02    avgt   10  12,519 ± 0,285  ns/op
WriteParamsBenchmark.wrappedParams_04    avgt   10  12,481 ± 0,164  ns/op
WriteParamsBenchmark.wrappedParams_08    avgt   10  12,521 ± 0,130  ns/op
WriteParamsBenchmark.wrappedParams_16    avgt   10  12,455 ± 0,144  ns/op

Jeśli natomiast zakażemy inline’owania, to wyniki różnią się, choć mniej znacząco:

Benchmark                                Mode  Cnt  Score   Error  Units
WriteParamsBenchmark.primitiveParams_01  avgt   10  5,657 ± 0,100  ns/op
WriteParamsBenchmark.primitiveParams_02  avgt   10  5,630 ± 0,044  ns/op
WriteParamsBenchmark.primitiveParams_04  avgt   10  5,964 ± 0,059  ns/op
WriteParamsBenchmark.primitiveParams_08  avgt   10  6,395 ± 0,061  ns/op
WriteParamsBenchmark.primitiveParams_16  avgt   10  8,766 ± 0,143  ns/op
WriteParamsBenchmark.wrappedParams_01    avgt   10  5,624 ± 0,036  ns/op
WriteParamsBenchmark.wrappedParams_02    avgt   10  5,650 ± 0,052  ns/op
WriteParamsBenchmark.wrappedParams_04    avgt   10  5,630 ± 0,097  ns/op
WriteParamsBenchmark.wrappedParams_08    avgt   10  5,673 ± 0,077  ns/op
WriteParamsBenchmark.wrappedParams_16    avgt   10  5,624 ± 0,056  ns/op

Z obu benchmarków wynika jednoznacznie, że przekazywanie większej ilości parametrów kosztuje (porównując 1 parametr z 16 parametrami widać różnicę od 50% do 440%). Jednocześnie zgrupowanie parametrów w obiekt pozwala ten koszt zredukować – przekazujemy wówczas tak naprawdę tylko jeden parametr – wskaźnik na wrapper.

Korzystanie z parametrów

W powyższym benchmarku sprawdziliśmy jak tylko jak przekazywanie parametru wpływa na wydajność, ale nie sprawdziliśmy wpływu na korzystanie przekazanych danych. Potencjalnie te dane mogą trafić do jakichś cache’ów, więc może większy koszt przekazania danych przez parametr zostanie jakoś zrekompensowany.

Sprawdza to drugi benchmark będący modyfikacją pierwszego. W tym przypadku zamiast zwracać counter zwracamy sumę parametrów metody.

    private int func(int i1, int i2, int counter) {
        counter--;
        if (counter == 0) {
            return i1 + i2 + i1 + i2 + i1 + i2 + i1 + i2 + i1 + i2 + i1 + i2 + i1 + i2 + i1 + i2;
        } else {
            return func(i1, i2, counter);
        }
    }

    private int func(Wrapper2 i1, int counter) {
        counter--;
        if (counter == 0) {
            return i1.i1 + i1.i2 + i1.i1 + i1.i2 + i1.i1 + i1.i2 + i1.i1 + i1.i2
                    + i1.i1 + i1.i2 + i1.i1 + i1.i2 + i1.i1 + i1.i2 + i1.i1 + i1.i2;
        } else {
            return func(i1, counter);
        }
    }

Gdy inline’owanie pozostało włączone, a licznik wynosił 1 wyniki były następujące:

Benchmark                               Mode  Cnt  Score   Error  Units
ReadParamsBenchmark.primitiveParams_01  avgt   10  3,356 ± 0,023  ns/op
ReadParamsBenchmark.primitiveParams_02  avgt   10  3,343 ± 0,013  ns/op
ReadParamsBenchmark.primitiveParams_04  avgt   10  3,429 ± 0,019  ns/op
ReadParamsBenchmark.primitiveParams_08  avgt   10  3,340 ± 0,014  ns/op
ReadParamsBenchmark.primitiveParams_16  avgt   10  3,358 ± 0,017  ns/op
ReadParamsBenchmark.wrappedParams_01    avgt   10  5,639 ± 0,096  ns/op
ReadParamsBenchmark.wrappedParams_02    avgt   10  5,652 ± 0,051  ns/op
ReadParamsBenchmark.wrappedParams_04    avgt   10  5,995 ± 0,049  ns/op
ReadParamsBenchmark.wrappedParams_08    avgt   10  6,240 ± 0,268  ns/op
ReadParamsBenchmark.wrappedParams_16    avgt   10  6,621 ± 0,026  ns/op

Widać zatem pewien zysk na przekazywaniu parametrów zamiast obiektu. Wynika on poniekąd z faktu, że przekazywany obiekt już był utworzony jeden raz i udostępniony na Heapie, zatem odczyty w wrappedParams dotyczą obiektu z Heapa. Sytuacja wygląda inaczej, jeśli będziemy tworzyć nowe obiekty, które po escape analysis zostaną zamienione na zmienne lokalne – wówczas nie ma różnicy w czasie wykonywania metod zarówno dla parametrów, jak i wrapperów (jest to około 3,358 ± 0,017 ns/op).

Jeśli jednak zakażemy inline’owania, to rezultaty są następujące:

Benchmark                               Mode  Cnt   Score   Error  Units
ReadParamsBenchmark.primitiveParams_01  avgt   10   7,561 ± 0,126  ns/op
ReadParamsBenchmark.primitiveParams_02  avgt   10   7,651 ± 0,038  ns/op
ReadParamsBenchmark.primitiveParams_04  avgt   10   7,847 ± 0,031  ns/op
ReadParamsBenchmark.primitiveParams_08  avgt   10   8,800 ± 0,075  ns/op
ReadParamsBenchmark.primitiveParams_16  avgt   10  10,405 ± 0,060  ns/op
ReadParamsBenchmark.wrappedParams_01    avgt   10   8,044 ± 0,104  ns/op
ReadParamsBenchmark.wrappedParams_02    avgt   10   8,084 ± 0,068  ns/op
ReadParamsBenchmark.wrappedParams_04    avgt   10   8,356 ± 0,076  ns/op
ReadParamsBenchmark.wrappedParams_08    avgt   10   8,573 ± 0,065  ns/op
ReadParamsBenchmark.wrappedParams_16    avgt   10   9,073 ± 0,042  ns/op

W tym wypadku korzystanie z przekazywania parametrów, bez umieszczania ich w obiekcie jest korzystniejsze. Wynika to (podobnie jak wcześniej) z tego, że przekazujemy wartości, a nie wskaźnik na obiekt z wartościami. Zatem oszczędzamy na dostępie do pamięci.

Jednak często się zdarza, że wykorzystujemy wartości dopiero po kilkukrotnym przekazaniu (przepchaniu) ich przez inne metody – przekazujemy parametry tylko po to, aby wykorzystać je w kolejnych wywołaniach metody. Warto zatem się przyjrzeć sytuacji, gdzie zanim skorzystamy z parametrów, musimy je przekazać w kolejnych wywołaniach metod.

Taką sytuację symuluje wywołanie benchmarku z licznikiem równym 10. Wyniki są następujące:

ReadParamsBenchmark.primitiveParams_01  avgt   10   27,279 ± 0,407  ns/op
ReadParamsBenchmark.primitiveParams_02  avgt   10   27,115 ± 0,803  ns/op
ReadParamsBenchmark.primitiveParams_04  avgt   10   26,793 ± 0,100  ns/op
ReadParamsBenchmark.primitiveParams_08  avgt   10   63,575 ± 0,380  ns/op
ReadParamsBenchmark.primitiveParams_16  avgt   10  203,437 ± 1,001  ns/op
ReadParamsBenchmark.wrappedParams_01    avgt   10   27,635 ± 0,194  ns/op
ReadParamsBenchmark.wrappedParams_02    avgt   10   27,311 ± 0,193  ns/op
ReadParamsBenchmark.wrappedParams_04    avgt   10   27,731 ± 0,219  ns/op
ReadParamsBenchmark.wrappedParams_08    avgt   10   27,744 ± 0,181  ns/op
ReadParamsBenchmark.wrappedParams_16    avgt   10   29,646 ± 0,106  ns/op

W tym wypadku wyniki są mniej korzystne dla przekazywania parametrów. Zysk z odczytu parametrów został został praktycznie zniwelowany przez koszt przekazania tych parametrów.

Przekazanie parametrów według C2

Warto zwrócić uwagę na jeden fakt – czasy wykonania benchmarków znacznie się zwiększają w przypadku przekazania 8 lub 16 parametrów. Czasy się zwiększają nieliniowo w stosunku do ilości parametrów.

Warto spojrzeć w kod maszynowy wygenerowany przez C2. Tam przekazywanie parametrów jest wykonywane w następujący sposób:

  0x00007efdb9222d8c: mov     $0x1,%edx
  0x00007efdb9222d91: mov     $0x1,%ecx
  0x00007efdb9222d96: mov     $0x1,%r8d
  0x00007efdb9222d9c: mov     $0x1,%r9d
  0x00007efdb9222da2: mov     $0x1,%edi
  0x00007efdb9222da7: mov     $0x1,%r10d
  0x00007efdb9222dad: mov     %r10d,(%rsp)
  0x00007efdb9222db1: mov     $0x1,%r11d
  0x00007efdb9222db7: mov     %r11d,0x8(%rsp)
  0x00007efdb9222dbc: mov     %r10d,0x10(%rsp)
  0x00007efdb9222dc1: mov     %r11d,0x18(%rsp)
  0x00007efdb9222dc6: mov     %r10d,0x20(%rsp)
  0x00007efdb9222dcb: mov     %r11d,0x28(%rsp)
  0x00007efdb9222dd0: mov     %r10d,0x30(%rsp)
  0x00007efdb9222dd5: mov     %r11d,0x38(%rsp)
  0x00007efdb9222dda: mov     %r10d,0x40(%rsp)
  0x00007efdb9222ddf: mov     %r11d,0x48(%rsp)
  0x00007efdb9222de4: mov     %r10d,0x50(%rsp)
  0x00007efdb9222de9: mov     $0xa,%r11d
  0x00007efdb9222def: mov     %r11d,0x58(%rsp)

Z powyższego listingu wynik jeden wniosek – pierwsze parametry przekazywane są bezpośrednio w rejestrze, gdy kolejne poza rejestrami procesora. To jest prawdopodobnie przyczyna zwiększonych czasów przy większej ilości parametrów. Oczywiście możliwe, że taka sytuacja występuje na moim przedpotopowym laptopie, a dla maszyn serwerowych o lepszych procesorach C2 wygeneruje kod wykorzystujący większą ilość rejestrów.

Podsumowanie

Wydaje się, że mniejsza ilość parametrów metod może wpływać nie tylko na czytelność kodu, ale również na wydajność. Oczywiście różnice są nieznaczne, ale jednak istnieją. Zależy również, jak często korzystamy z tych parametrów. Jeśli parametry przekazujemy częściej, niż z nich korzystamy, to lepiej użyć obiektu opakowującego parametry.

Pax!

Konkatenacja stringów – benchmark 0 (0)

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 \ jar 1.4 [ns/op] 1.7/1.8 [ns/op] >9 [ns/op]
OpenJDK 1.7 101,435 ± 2,746 66,310 ± 1,106
OpenJDK 1.8 98,539 ± 1,757 68,302 ± 1,228
OpenJDK 11 96,123 ± 1,137 54,094 ± 2,117 23,195 ± 0,172
OpenJDK 15 83,235 ± 1,837 55,243 ± 2,067 23,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 \ jar 1.4 [B/op] 1.7/1.8[B/op] >9[B/op]
OpenJDK 1.7 272,000 ± 0,001 272,000 ± 0,001
OpenJDK 1.8 272,000 ± 0,001 272,000 ± 0,001
OpenJDK 11 200,000 ± 0,001 168,000 ± 0,001 80,000 ± 0,001
OpenJDK 15 200,028 ± 0,002 168,019 ± 0,001 80,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

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

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."":()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 🙂

Var 0 (0)

Zarezerwowany typ o nazwie var wprowadzony w Javie 10 jako jeden z rezultatów projektu Amber. Został już dość dobrze opisany na innych mądrych stronach, zatem przytoczę tylko nieoczywiste fakty na jego temat.

var może wpływać na bytecode

Całe wnioskowanie typu dzieje się na etapie kompilacji do bytecode’u. Wtedy również dzieje się podmiana typu zmiennej na typ wywnioskowany.

Czasem przy definicji zmiennej użylibyśmy interfejsu. Jednak gdy użyjemy słowa var, wówczas w pewnych użyciach w bytecodzie pojawi się typ konkretny. Przykładowo w takim kawałku kodu:

        List list = new ArrayList();
        list.size();

        var varList = new ArrayList();
        varList.size();

Dekompilowanie poleceniem javap -v -p potwierdza tę tezę:

      LocalVariableTable:
        Start  Length  Slot  Name   Signature
(...)
           16     113     2  list   Ljava/util/List;
           31      98     3 varList   Ljava/util/ArrayList;
(...)

Z tej różnicy wynika konieczność użycia instrukcji invokeinterface zamiast invokevirtual. W trybie interpretowanym invokeinterface ma potencjalnie niezerowy (ale i niezbyt duży) negatywny wpływ na wydajność. Ale o tym już pisałem, więc nie będę rozwijał tego wątku…

var umożliwia wywoływanie metod wcześniej niewidocznych…

… i nie tylko chodzi o to, że w poprzednim przykładzie można wywoływać metody typowe dla konkretnej klasy. Chociaż poniekąd jest to powiązane…

Otóż wyobraźmy sobie klasę anonimową. Standardowo nie mamy informacji o tym, jaka to jest klasa, ponieważ jest… anonimowa… Jednak używając słowa var można wywołać metody specyficzne dla tej klasy.

        var anonymous = new Object() {
            int anInt;
            public int ret() {
                return anInt;
            }
        };
        anonymous.anInt = 4;
        System.out.println(anonymous.ret());

Pachnie Javascriptem, co nie? 😉
Co by nie mówić, wcześniej wywoływanie metod klas anonimowych było możliwe tylko z użyciem refleksji lub methodHandle, więc jest to jakieś usprawnienie.

Czytelność

Dyskusyjną sprawą jest czytelność. Osobiście w jednym projekcie pisanym w Javie 11 doświadczyłem, że var może tę czytelność poprawić. Przykładowo, osobiście taki kod łatwiej się czyta:

var stringsByIds = getStringsById();
var stringList = stringsByIds.getOrDefault(1, List.of());

// zamiast:
Map

ThreadLocal 0 (0)

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 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, 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.

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 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…

Przepisujemy Jacksona z refleksji na LambdaMetafactory [ZOBACZ JAK] 0 (0)

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 testu OpenJDK 8 z refleksją OpenJDK 8 z Lambda OpenJDK 11 z refleksją OpenJDK 11 z Lambda
primitive 2 375,162 371,571 420,594 424,329
primitive 6 883,396 833,530 888,789 833,256
primitive 10 1423,683 1219,335 1407,713 1540,637
primitive 20 3294,129 3263,196 3598,230 3708,698
objects 2 369,348 371,997 430,879 429,898
objects 6 866,949 897,446 1045,449 984,428
objects 10 1340,502 1333,712 1562,467 1519,283
objects 20 2874,211 2723,356 3282,216 3286,685
mixed 2 383,846 382,690 454,834 447,254
mixed 6 865,195 818,739 975,578 970,954
mixed 10 1370,834 1359,150 1620,932 1598,931
mixed 20 3106,188 3056,029 3834,573 3573,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)]..............................................................

 23,38%         C2, level 4  com.fasterxml.jackson.databind.deser.impl.MethodProperty::deserializeAndSet, version 869


 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.

Odczyt finalnych pól instancyjnych 0 (0)

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:””, 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… 😉