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 🙂

Lambda dla ubogich 0 (0)

Z mojego doświadczenia wynika, że większość programistów posiada jakieś swoje własne projekciki na boku. A to jakiś analizator giełdy, a to jakaś apka do ogarniania smogu, a to parsowanie rozkładów jazdy pociągów, względnie sterowanie żarówkami we własnym smart domu.

Takie projekciki charakteryzują się myślą przewodnią: byle szybko zakodzić i byle działało. Z założenia mogą działać tylko w optymistycznych flow i nie posiadać żadnych testów. Mają być radosnym programowanie i sprawdzeniem się, czy jeszcze potrafimy coś zaprogramować w innych „niepracowych” technologiach.

Takimi też przesłankami kierowałem się tworząc swój mini projekcik o roboczej nazwie:

Potrzeba była następująca: posiadam wirtualkę w cloudzie i chciałbym posiadać na niej dużo swoich aplikacji, jednak mam tylko 1GB pamięci i 8GB miejsca na dysku… Bardziej problematyczne jest pierwsze ograniczenie… A Java – jak to Java – zjada tyle pamięci, ile się jej przydzieli.

Można oczywiście skorzystać z Graala, skorzystać z Micronauta/Quarkusa i skompilować projekt do kodu natywnego. Problem pojawia się, gdy dziergamy nasze tajne projekty w Spring Boot od kilku lat i jest za dużo kodu do przepisania.

Zatem gdyby tylko istniało jakieś rozwiązanie, które pozwala na uruchamianie naszych serwisów, gdy są potrzebne, a gdy nie są potrzebne, to je ubija. I gdyby jeszcze było w miarę lekkie, nieobciążające zbytnio pamięci ani procesora. Takie Heroku tylko na VPSie.

Trochę poszukałem w internecie takie rozwiązania i oczywiście nie znalazłem 😛

No dobra, gdybym szukał trochę dłużej, to bym znalazł. Jednak taki własny projekt zawsze czegoś uczy, domena jest inna niż ta w pracy, więc może warto spróbować samemu coś takiego napisać…

Development

Z założenia rozwiązanie miało być przenaszalne, crossplatformowe oraz w miarę proste, zatem wybór padł na Dockera, jako sposób zarządzania aplikacjami. Serwerem proxy jest Nginx (wcześniej już chciałem go nieco poznać). Jednak jak połączyć te dwa światy, żeby za proxy była warstwa zarządzająca kontenerami Dockera?

Okazuje się, że istnieje takie rozwiązanie jak OpenResty, który rozszerza Nginx o możliwość wykonywania skryptów (w języku Pascalo-podobnym o nazwie Lua). Zawsze nowy język do CV 😀 Warto mieć jakieś Api dockerowe i panel administracyjny zatem pożeniłem to wszystko z Portainer.

Zatem poświęciłem kilka wieczorów i oto jest: lambda-for-vps. Jeżli ktoś chciałby spróbować, jak działa, wystarczy zklonować repo i uruchomić:

docker-compose -f docker-compose.yml up

Domyślnie uruchamia się projekt z przykładowym hello-world, który jest dostępny na http://localhost/hello-world.

Działanie

Co nieco o działaniu jest opisane na githubie, jednak po angielsku. Po polsku to w skrócie:

  1. Po uderzeniu na endpoint /hello-world za pomocą Portainer sprawdzany jest stan aplikacji (określonej za pomocą docker-compose.yml).
  2. Jeśli kompozycja nie istnieje, to wszystkie kontenery są automagicznie zaciągane.
  3. Jeśli kompozycja jest nieżywa, wówczas uruchamia całą kompozycję. Po uruchomieniu kontenerów istnieje opcja czekania określonego czasu od wstania kompozycji.
  4. Skoro wszystko działa, to request jest obsługiwany.
  5. Następnie zlecamy ubicie kompozycji o ile nie będzie w między czasie nowych żądań. Innymi słowy kompozycja jest ubijana po podanej liczbie sekund od ostatniego requesta.

Prawdopodobnie projekt nie jest doskonały. Jednak posiada kilka fajnych cech:

  • wg docker images całość waży 181 MB,
  • wg docker stats obydwa kontenery zużywają 12 MB pamięci i szczątkową część procesora,
  • jest w nowym nieznanym mi języku Lua,
  • konfiguracja w popularnym Nginx,
  • zarządza apkami trzymanymi w Dockerze.

zatem jak na mały projekt jest przynajmniej Good enough.

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

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 😉

Ile kosztuje Garbage Collection – tworzenie obiektów 0 (0)

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…

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.