Konkatenacja stringów – benchmark

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

Co sprawdzamy i jak?

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

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

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

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

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

private static final StringConcatenation CONCATENATION = new StringConcatenation();

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

Gdzie StringConcatenation to:

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

Wyniki

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

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

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

Pamięć

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

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

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

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

Podsumowanie

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

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

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

Pax

Oceń wpis

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

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

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

W tym artykule opowiem nieco o operatorze +.

Implementacja

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

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

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

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

The Java® Language Specification, Java SE 15 Edition

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

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

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

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

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

JEP-280

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

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

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

A jak to będzie w wydajności?

Generalnie – szybciej.

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

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

Podsumowanie

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

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

Jeśli chodzi o linki do poczytania, to:

Pax et bonum 🙂

Oceń wpis

Kompilacja kodu w Javie – JIT teoretycznie

W poprzednim wpisie pisałem ogólnie o kompilacji kodu w Javie. W tym głównym bohaterem jest JIT, a dokładniej techniki optymalizacji w nim stosowane.

Uwagi wstępne

Na początku zarysować sytuację niskopoziomową.

Otóż mamy procesor. Dla ułatwienia załóżmy, że jeden. Ten procesor służy do wykonywania różnych prostych operacji. Operacje przeważnie mają dodatkowe argumenty i jakiś rezultat.

Skąd procesor ma wiedzieć jakie instrukcje wykonać i skąd te argumenty operacji?

Otóż wszystkie dane zapisane są w pamięci operacyjnej. Jednak każdorazowe sięgnięcie do pamięci ram byłoby mało optymalne, dlatego dane pobierane są w większej ilości. Pamięć – podobnie do instrukcji – jest ułożona sekwencyjnie. Zatem pobierane są fragmenty pamięci najbliżej pobieranej instrukcji. Potrzebujemy w danym momencie jedną instrukcję (i tylko jedną), zatem reszta pobranych danych trafia do cache‚ów procesora.

Kod operacji oraz argumenty są ładowane do rejestrów. Jednak nie wszystkie rejestry są wykorzystywane w przetwarzaniu operacji – niektóre służą za „najszybszą pamięć” szybszą od cache‚ów procesora.

Jak działa procesor?

Z wykonywaniem instrukcji procesora jest trochę, jak czytaniem książki. Ale tylko trochę…

Standardowo, ludzie czytają książki od początku do końca, słowo po słowie. Czytają z pewną stałą prędkością (stała ilość słów w czasie). Gdy skończymy czytać stronę (skończą się instrukcje w cache‚ach procesora), to musimy przerzucić kartkę (wczytać z pamięci do cache‚ów nową stronę). To zajmuje jakiś czas i zmniejsza ilość przeczytanych słów w czasie.

Najlepiej się czyta słowa będące obok siebie – w linii. Gdy zaczynamy czytać kolejną linię, również czasem to wybija z rytmu. Podobnie i procesor najchętniej wykonuje wszystkie operacje po kolei, znając już następną instrukcję. To porównanie jest nieco naciągane, jednakże potokowość bazuje właśnie na najbliższych operacjach. Gdy linia się kończy, musimy (nawet w obrębie widzianej strony) spojrzeć na następną linię. Analogiczną sytuacją jest wykonanie instrukcji skoku w obrębie instrukcji w cache‚u – gubimy wówczas kontekst następnych operacji.

Najgorzej (biorąc pod uwagę wydajność czytania) za to czyta się powieści interaktywne, gdzie co chwilę trzeba skakać z jednej strony na inną. Tutaj alegorią może być małe procedurki (rozumiane jako pewne ciągi instrukcji), które są wywoływane co chwila i w ten sposób procesor „skacze” po skompilowanym kodzie.

Generalnie wszelkiego rodzaju instrukcje skoku (wysokopoziomowo mówiąc wywołania metod, if-else‚y, switch-case‚y itp) są trudne.

A co to ma do kompilacji?

Wiedząc o tym, w jak działa procesor możemy w taki sposób skompilować aplikację, aby procesor dział jak najszybciej.

W JVMie JIT kompiluje poszczególne metody. Na tej metodzie są wykonywane optymalizacje, które usprawniają działanie metody. Musimy zatem przeanalizować cały kod metody.

W metodach bardzo często są wywoływane inne metody. Aby dowiedzieć się, co się w nich dzieje, należy do nich zajrzeć i – jeśli to możliwe – przepisać do analizowanej metody treści wywoływanych metod. W ten sposób nie tylko rozszerzamy poznany kontekst metody, ale również ograniczamy ilość skoków do wywoływanych metod.
O inliningu jednak zrobię osobny wpis, zatem nie będę się o tym rozpisywał.

Jeśli już kontekst wywoływanej metody jest wystarczająco szeroki, można pokusić się o poszukanie usunięcie martwego kodu. Przykładowo, gdy w jednej metodzie przekazujemy do drugiej zawsze nienullowy parametr, a w wywoływanej metodzie na początku mamy „nullchecka”, to można spokojnie go usunąć (to tylko przykład, bo nullchecki są inaczej obsługiwane).

Profilowanie

Wspomniane wcześniej optymalizacje można wykonać zarówno AoT jak i JIT. Bardzo dużą wartość daje profilowanie wykonania metody, które jest specyficzne dla JIT.

Dzięki temu uzyskujemy specyficzny kontekst działania naszej aplikacji. Przykłady:

  • Załóżmy, że mamy jakąś flagę, która po uruchomieniu nie zmienia swojej wartości oraz ifka, który od tejże zależy. Jeśli 15 000 razy wartość się nie zmieniła, to możemy założyć, że i 15 001 raz wartość się nie zmieni. O ile samo sprawdzenie musimy wykonać, to domyślną ścieżką powinno być wykonanie treści if, a ewentualne else przerzucone gdzieś możliwie daleko w skompilowanym kodzie. Tak, żeby nie tracić miejsca na „stronie” naszej książki, skoro i tak się to prawie na pewno nie zdarzy.
  • Załóżmy, że zarówno if, jak i else są wykonywane, jednak blok wewnątrz if jest 3 krotnie częściej niż blok else. Zatem, aby zaoszczędzić na skokach, treść częstszego bloku powinna być zaraz po sprawdzeniu warunku. Wówczas ograniczamy rozmiar straty potokowości.
  • Podobnie dla switcha kolejność case można uszeregować wg częstości występowania.

Profilowanie dotyczy jednak również typu. Pozwala ono – analogicznie do profilowania ifów – na umiejscowienie najczęściej wywoływanego kodu najbliżej wywołania metody. W optymistycznym przypadku możemy stwierdzić, że obiekt tylko jednego typu pojawia się w wywołaniu wirtualnym, a w pozostałych przypadkach zostawić pułapkę, która spowoduje deoptymalizację metody do pierwotnej postaci.

Inne optymalizacje

W szczególny sposób optymalizowane są pętle.

Pod koniec każdej iteracji pętli domyślnie wstawiana jes instrukcja skoku do początku pętli. Zakładając, że pętla wykona się wielokrotnie, możemy ograniczyć ilość skoków poprzez wielokrotny „copy-paste” bloku pętli po każdym sprawdzając warunek pętli.

Dodatkowo, jeśli pętla wykonywana jest tysiącami razy, to o ile nie wykonuje żadnej specyficznej metody, którą można by skompilować, to kod wewnątrz pętli nie miał by okazji do kompilacji. Jednak istnieje mechanizm (On Stack Replacement), który pozwala skompilować kod samej pętli oraz przełączyć się na wykonywanie skompilowanej treści pętli bez zatrzymania programu.

Inną ciekawostką jest, że nie wszystkie metody są kompilowane „od zera” w czasie JIT. Istnieją takie metody zwane intrinsics, które są gotowe do inline’owania. Przykłady to Math.max(), Object.hashCode(), System.copyArray() czy bardziej używane StringBuilder.append() lub Integer.toLongValue().

O ile niektóre „zmienne” możemy przechowywać w rejestrach procesora, to rejestrów tych jest niewiele. Na tyle niewiele, aby w czasie kompilacji metody decydować, które zmienne powinny tam trafić. Ten problem nie należy do banalnych. A bardziej szczegółowo jest to problem NP trudny – problem kolorowania grafu. Jednak C2 taką analizę wykonuje.

Podsumowanie

Temat jest na tyle obszerny, że trudno omówić w jednym wpisie wszystkie optymalizacje. Mogę tylko zostawić kilka ciekawych linków:

Kolejny wpis będzie prawdopodobnie o inliningu, gdyż najważniejszy temat, jeśli chodzi kompilację kodu.
Standardowo prośba o ocenę wpisu, żebym wiedział, że ktoś to przeczytał 😉

Pax et bonum
(und gesundheit, bo korona)

Oceń wpis

Kompilacja kodu w Javie

Ile razy kod Javy trzeba kompilować, aby optymalny…

Wpis ten jest wstępem do kolejnych wpisów, więc może być dosyć ogólny, względnie nudny dla przeciętnego Senior Tech JVM Performance Leada 😉

OpenJDK/OracleJDK

Największą częścią rynku (91%) są dystrybucje JVMa z rodziny OpenJDK (z OracleJDK włącznie). Dystrybucje te bazują na tym samym kodzie źródłowym, zatem można je spokojnie omawiać wspólnie.

Kompilacja plików .java

Chronologicznie należałoby zacząć od kodu źródłowego zapisanego w plikach.java.

Kod kompilowany jest do standardowo bytecode‚u i ta kompilacja jest wykonywana przed uruchomieniem programu. Spokojnie tę kompilację można nazwać pierwszą kompilacją i – bynajmniej – nie jest ona optymalna 😉 Wszelkie metody optymalizacji są tutaj bardzo ograniczone. Można zrobić jakieś Constant Folding (o którym pisałem już chociażby we wpisach o finalach), jednak nie jest to szczyt technik optymalizacyjnych.

Kod kompilowany do bytecode‚u kompiluje się jednak znacznie szybciej, niż kompilowany do kodu natywnego. Jest też bardziej elastyczny. Czyli do developmentu „good enough”.

JIT

Kompilacja just-in-time (swobodne tłumaczenie to „rychło w czas”) polega na kompilowaniu kodu w czasie działania aplikacji.
Jakby się głębiej zastanowić, to widać podobieństwa do samolotu w tejże reklamie.

Założenie jest takie, że większość kodu jest wykonywana na tyle rzadko, że można ją po prostu wykonać w trybie interpretowanym. Szkoda zatem cykli procesora na szalone optymalizacje fragmentów kodu, wykonywanych jednokrotnie. Warto się jednak skupić na metodach wykonywanych tysiące razy.

Co więcej, skoro mamy już pewien narzut na interpretację kodu bajtowego, to do czasu porządnej optymalizacji warto poświęcić procesor i zbierać dodatkowe statystyki. Mogą one być bardzo przydatne – jeśli w ciągu przykładowo 1000 wykonań program ani razu nie wszedł w jakiegoś ifka, to prawdopodobnie przy 1001 wywołaniu również nie wejdzie. Można przykładowo pominąć treść sekcji, którego ten ifek dotyczy (zaoszczędzimy bajtów i nie tylko, acz o tym kiedy indziej). W razie czego, gdyby przez przypadek ifek stał się prawdziwy, można przykładowo odesłać do wykonywania źródeł w bajtkodzie (to tylko przykładowy przykład; trochę inaczej się to robi w realu).

C1, C2

Dawno temi, w zamierzchłych czasach Javy 1.6 istniały dwa osobne kompilatory C1 (szybki, acz niedokładny – kliencki) oraz C2 (wolny, ale zaawansowany – serwerowy/opto). Na starcie wybierało się pożądany kompilator flagą lub JVM sam nam go wybierał bazując na parametrach naszego sprzętu.

W Javie 1.7 została wprowadzona tzn. kompilacja warstwowa. Według niej interpretujemy kod. Po przekroczeniu progu 2 000 wywołań kompilujemy metodę z użyciem C1. Jednak życie toczy się dalej, a metoda dalej jest wywoływana. Po wywoływaniu metody 15 000 razy jest ona kompilowana z użyciem C2.

Jest jednak kilka „ale”.

Wspomniana kompilacja warstwowa ma ponumerowane warstwy. Poziom 0 to nieskompilowana metoda w trybie interpretowanym. Poziom 4 to C2. Za to za warstwy 1-3 odpowiada C1, która ma różne „warianty”. Istnieje wariant z pełnym profilowaniem (3), ale też istnieje lżejsza wersja ze ograniczonym profilowaniem(2) i najlżejsza – bez profilowania (1).

W idealnej sytuacji kompilujemy metodę do warstwy 3 (po 2 000 wykonaniach), a następnie do warstwy 4 (po 15 000 wykonaniach). Jednak nie zawsze tak jest. Trzeba mieć świadomość, że po przekroczeniu tych progów metoda jest wrzucana do kolejki metod do kompilacji. Przykładowo czasem kolejka do kompilatora C2 jest na tyle długa, że może do czasu zwolnienia C2 można ograniczyć profilowanie (przejść z 3 warstwy na 2). Jak głoszą slajdy Oracle’owe, trzeci poziom jest 30% wolniejszy niż drugi.

AoT?

Jak wiemy istnieje też GraalVM i kompilator Graal. Jest to jednak rozwiązanie typowo Oracle’owe, więc w tym wpisie nie będę rozwijał tego tematu.

Są jednak dwie ciekawostki.
Pierwsza jest taka, że jeśli macie ochotę napisać własny kompilator, to nie ma sprawy, OpenJDK (od Javy 9) wesprze Cię w tym wystawiając interfejs,który trzeba zaimplementować pisząc kompilator. Proste 5 metodek 😉

Druga ciekawostka jest mniej znana – również w Javie 9 powstał eksperymentalnie kompilator AoT. Pozwala on kompilować program do kodu natywnego. Istnieje jednak jeszcze drugi tryb kompilacji – kompilowanie do kodu natywnego z profilowaniem. Taka opcja pozwala na rekompilację z użyciem C2 i dodatkowych metadanych zbieranych w czasie działania programu. W założeniu ten tryb miał przyspieszyć włączanie projektu, jednak benchmarki powiadają, że tak się nie dzieje…

Podsumowanie

O ile trochę wiemy co się dzieje, to nie wiemy w jaki sposób. Zatem w kolejnych wpisach napiszę nieco o tym co się kompiluje, kiedy, jak oraz skąd to wszystko mamy wiedzieć…

Z ciekawych linków zostawiam tylko wpis na blogu Microsoftu o AoT i nie tylko.

Gwiazdkujcie, komentujcie i bywajcie zdrów 😉

Pax et Bonum

Oceń wpis

Prywatny projekt w tydzień na produkcji

Czy da się w tydzień wypuścić produkcję MVP własnego prostego projektu robionego po godzinach pracy? Challenge accepted!

Btw. nie martwcie się, że to drugi wpis z rzędu o projektach, zamiast o low level Javie. Nie mam zamiaru zmieniać profilu bloga. Po prostu poświęciłem dużo czasu na ten projekt i szkoda byłoby tego też nie opisać. A w moim mniemaniu wyszło całkiem spoko.

Prolog

Do indeksowania wybranego fragmentu internetu już się wcześniej zabierałem. Moim targetem były audycje radiowe – linki do nich oraz metadane, żeby mieć po czym szukać audycji.

Podobny potencjał dostrzegłem i w zupełnie innej sferze. Pojawiało się na bieżąco mnóstwo contentu, który warto by zaindeksować, aby łatwiej sobie tego pożądanego fragmentu poszukać. Toteż spytałem na jednej grupie Facebookowej, czy taka koncepcja jest sensowna i potencjalnie przydatna. Pomysł spotkał się z dużym entuzjazmem, zatem nic tylko wziąć się do roboty.

Charakterystyka projektu jest mocno nacelowana na czas – im szybciej tym szybciej. Zatem nie ma czasu na rzeźbienie, trzeba korzystać z gotowców, dowozić największy efekt najmniejszym kosztem. Bugi? Na później. Pracochłonny feature? Na koniec backlogu. Słowem – czyste naparzanie kodu 🙂

Technologie

Czas nagli, więc nie będę tworzył CRUDów od zera, stawiał serwera, zbędnie konfigurował itd. Zdarzyło mi się wcześniej korzystać z JHipstera, zatem stwierdziłem, że i w tym przypadku będzie to najlepsze narzędzie.

JHipster to w skrócie generator aplikacji opartych na Spring Boot. Tworzy on CRUDy razem z widokami. Tworzy kontrolery, opcjonalne serwisy, mappery oraz repozytoria Spring Data. Utrwalanie danych możliwe przez wykorzystanie relacyjnej bazy danych (u mnie Postgres, na dev jest H2), choć dostępne są również różne cache oraz NoSQLe, jednak z nich nie korzystałem.

Dostępne technologie front-endowe to Angular, Vue oraz React. Wygląd oparty na Bootstrap 4.

Czas zacząć robić tę robotę…

Na początku trzeba zdefiniować model – encje oraz relacje między nimi. Istnieje do tego ładny edytor webowy JDL-Studio. Można też skorzystać z innych narzędzi do definicji modelu, jednak wówczas nie miałem dostępu do kompa, a na tablecie ładnie dało radę wszystko wyklikać. W godzinę model był gotowy.

I tutaj pierwsze zaskoczenie – po zapisaniu projektu gdzieś w chmurze w tym JDL-Studio można go ładnie poprosić, żeby zaaplikował wygenerowany projekt do wybranego repozytorium na githubie poprzez stworzenie Pull Requesta. Zatem jak uzyskałem dostęp do kompa, można było zrobić git pull i zacząć naparzać kod!

Budowanie projektu to wykonanie ./mvnw. Rzecz jasna trzeba odczekać, aż Maven zaciągnie się sam, a potem zaciągnie pół internetu. Zależności front-endowe również są zaciągane automagicznie. Po jakimś czasie widzimy uruchomiony projekt w wersji developerskiej.

Development

Wygenerowane CRUDy wyglądają dość topornie i trzeba je znacznie podrasować (szczególnie formularz new/edit). Jakkolwiek style, layouty już istnieją predefiniowane i wyglądają nie najgorzej, zatem przynajmniej coś.

Fajne jest to, że do developerki JHipster generuje wstępnie dane użytkowników oraz losowe dane dla naszych encji – nie trzeba tyle klikać. Za to mvn clean czyści również całą bazę H2.

Gdyby ten projekt nie był „na szybkości”, to pewnie spodobało by mi się, że w ramach generowania kodu generowane są testy. I to nie tylko jednostkowe, ale też integracyjne, end to end, a nawet (co mnie zdziwiło) testy architektury aplikacji.

JHipster jednak nie tylko generuje model i widoki. Generuje również całą przydatną otoczkę aplikacyjną. Można wymienić rejestrację, autoryzację, uwierzytelnianie, metryki, zarządzanie loggerami, użytkownikami. Audyt – kto kiedy się logował, healthchecki, podgląd konfiguracji to również przyjemna sprawa. Wszystko to dostępne od ręki dla admina. Zwykły użytkownik może domyślnie tylko zarządzać encjami biznesowymi. Niezalogowany może się co najwyżej zarejestrować lub zalogować (zabezpieczone bodajże przez Spring Security).

Na produkcję z tym!

Wymagana konfiguracja produkcyjna projektu jest minimalna – u mnie ograniczyło się to do konfiguracji skrzynki pocztowej, żeby projekt mógł wysyłać maile do rejestracji. Zatem projekt jest prawie od razu gotowy na produkcje.

Na produkcję, czyli gdzie? Otóż JHipster również i tutaj ma wsparcie dla różnych rozwiązań: AWS, Azure, GCP, Kubernetes, Heroku oraz Openshift.

Osobiście wybrałem Heroku, bo tanio – domyślnie za free możesz testowo postawić Proof of Concept i postawić Postgresa do 10 000 wierszy.

Jednak z darmowym planem jest o tyle problem, że gdy aplikacja nie jest używana to ją ubijają. Za to za 7 $ miesięcznie będzie raz dziennie restartowana, lecz będzie działać cały czas. Limit pamięci – 512MB; metryki (nieco inne niż w JHipster) też ograniczone – do ostatniej doby. Deployment oczywiście z downtime (w przypadku jednej instancji serwisu).

Wrzucenie tego na produkcję z JHipsterem to bajka! Wystarczyło zainstalować integrację do heroku, wpisać jhipster heroku, wybrać wersję Javy i po kilku minutach nowa wersja jest na prodzie! A byłoby jeszcze szybciej, ale testy się „muszą” odpalić 😉

Ostatnie szlify

Nową apkę warto postawić pod jakąś bardziej ambitną domeną niż domyślna: https://<nazwaAplikacji>.herokuapp.com/. Osobiście kupiłem na OVH domenę za 7 zł na rok, więc to też nie jest jakiś super koszt. Domena gotowa do użycia w jeden dzień – tylko skonfigurować.

Warto wspomnieć, że Heroku samo podpina certyfikat od Let’s Encrypt za free i konfiguruje, więc HTTPSa mamy również zerowym kosztem.

Przyda się również pamiętać, żeby zmienić hasło z admin:admin i user:user na jakieś trudniejsze 🙂

Na szybkości zrobiłem też testy wydajnościowe i aplikacja klęka przy mniej więcej 20 req/s.

Epilog

Warto by powiedzieć co tak naprawdę było przedmiotem projektu 😉

Otóż jest taki kandydat na prezydenta, który bardzo mocno poszedł w internety. Codziennie przynajmniej jeden live z odpowiedziami na pytania ludzi. Jednak trudno się odnajduje potem te fragmenty, kiedy jaki temat był na tapecie.

Zatem poindeksowałem, co usłyszałem na 30 filmikach na Youtube, przeczytałem na 42 stronach pdfu z programem/wizją oraz na 8 z 13 stron www z programem w internetach.

Wyszło jakieś 200-300 przeróżnych tematów.
Co by nie mówić, to contentu jest naprawdę sporo… U innych kandydatów roboty byłoby mniej (a na jeszcze innych na program trzeba czekać do 4 dnia przed wyborami, więc bym nie zdążył).

Cały projekt, jeśli chcecie zobaczyć, dostępny na tej oto podlinkowanej stronie.

To by było na tyle. Mam nadzieję, że apka podziała jeszcze 3 tygodnie, a potem będzie można zaorać 😉

A potem wreszcie wrócić do pisania o JVMie!

Oceń wpis

Lambda dla ubogich

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:

Lambda dla ubogich

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.

Oceń wpis

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

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

Benchmark

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

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

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

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

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

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

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

Epsilon GC

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

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

ParallelGC

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

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

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

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

ConcMarkSweepGC

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

G1GC

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

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

ZGC

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

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

Shenandoah

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

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

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

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

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

Podsumowanie

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

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

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

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

Oceń wpis

Ile kosztuje Garbage Collection – tworzenie obiektów

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

Ile?

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

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

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

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

Hipoteza generacyjna

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

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

Minor GC

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

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

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

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

Benchmark

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

    private static class IntWrapper {
        int i = 12;
    }

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

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

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

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

Epsilon GC

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

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

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

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

Parallel GC

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

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

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

Concurrent Mark Sweep

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

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

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

G1

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

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

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

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

Shenandoah

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

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

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

ZGC

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

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

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

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

Podsumowanie

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

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

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

Pytanie na czym nam bardziej zależy…

Oceń wpis

Quarkus – co ciekawego siedzi w środku.

Nie wiem, czy śledzicie wpisy na JvmBloggers, jednak w poprzednim tygodniu były 4 wpisy CRUDowe o Quarkusie. Jeżeli wszyscy o tym piszą, to przecież nie będę gorszy 😉

Jeśli chcecie się dowiedzieć, jak napisać CRUDa to tutaj po polsku, a tu po angielsku. Ja osobiście zajmę się bezużyteczną wiedzą – dlaczego to jest fajne i jaki to ma potencjał. Ale od początku.

GraalVM

GraalVM jest to Oracle’owa maszyna wirtualna bazowana na OpenJDK. Jej celem było stworzenie uniwersalnej maszyny wirtualnej, która obsługuje wiele języków programowania – zarówno JVMowe (Java, Scala, Groovy), jak również skryptowe (Js, Ruby, Python, R) i natywne (C++). Pozwala również na bardzo wydajne wywoływania funkcji zdefiniowanych w różnych językach (takie cross-językowe wywołania).

Jednak stworzenie takiej VMki to była długa droga… Najwięcej działo się przy releasie JDK 9. Wtedy położono fundamenty pod GraalVM – wydzielono w źródłach JDK interfejs kompilatora, poukładano projekt OpenJDK modularnie oraz wprowadzono kompilator Ahead-of-time. Skoro już był wydzielony interfejs kompilatora, to można było wprowadzić nowy kompilator – modularny, piękny i pachnący, o wdzięcznej nazwie Graal. W dodatku cały napisany w Javie (bo kto umie w C++, gdzie nawet GCka nie ma). Uczyniono to w JDK 10 w JEP 317. Tenże kompilator Graal jest w niektórych względach lepszy od C2 – z pewnością lepiej radzi sobie ze różnego rodzaju streamami. Swego czasu popularne było stwierdzenie, że Twitter przeszedł z C2 na Graala i zjadało mu 5% procesora mniej. Jakkolwiek mowa była o Scali, nie o Javie.

W końcu GraalVM wyszedł w maju zeszłego roku. W założeniu miał pozwalać na łatwe wywoływanie międzyjęzykowych funkcji. Standardowym use case’m miało być wydajne wywoływanie funkcji Machne Learningowych napisanych w R lub Pythonie (jak wiadomo Java w tej dziedzinie nie jest zbyt popularna). Innym przykładem może być jednokrotne tworzenie walidacji po stronie frontendu oraz wywoływanie tej walidacji także na backendzie (pozdro Apzumi! 😉 ). Jednak osobiście moim faworytem jest narzędzie do generowania kodu natywnego.

GraalVM Native Image

Narzędzie to pozwala na stworzenie łatwo uruchamianej aplikacji napisanej w Javie skompilowanej do wersji natywnej dla systemu operacyjnego. W pierwszej wersji (19) wspierano tylko Linuxa i MacOSa, w wersji 20 wpierany jest również Windows. GraalVM jest dostępny z JDK w wersji 8 jak i 11. Kompilacja do kodu natywnego jest wykonywana z użyciem wspomnianego wcześniej kompilatora Graal.

W czasie kompilacji do kodu natywnego ma miejsce stworzenie grafu zależności między klasami, polami, metodami, dzięki którego analizie wszystkie nieużywane pola, klasy, metody są usuwane. Wycięcie 90% platformy powoduje, że aplikacja wstaje w milisekundy zamiast sekund. Dodatkowo zużywa znacznie mniej pamięci (mierzone w dziesiątkach megabajtów zamiast setek). Choć na kompilację trzeba poświęcić kilka minut…

Co więcej – GraalVM Native Image nie wspiera wszystkich opcji dostępnych dla standardowego JDK. Brakuje między innymi MethodHandles, InvokeDynamic, serializacji, security managera. W przypadku użyciu refleksji, DynamicProxy, JNI lub dynamicznego ładowania klas wymagana jest rejestracja używanych klas w osobnym pliku konfiguracyjnym. Więcej o ograniczeniach można poczytać na tej podstronie.

Jest kilka rzeczy, które są robione inaczej. Najlepszym przykładem jest wykonywanie inicjalizacji klas na etapie kompilacji źródeł. Dzięki temu GraalVM uruchamia się szybciej. Czasem może jednak to prowadzić do dziwnych błędów. Inną różnicą jest to, że przekazywane propertiesty przy kompilacji są dostępne dla klas inicjalizowanych na etapie kompilacji, a na etapie runtime już nie.

GraalVM umożliwia również wydawanie natywnych bibliotek do systemów operacyjnych, co wcześniej w Javie nie było możliwe.

Jak to w środku działa?

W miarę normalnie – mamy normalny Heap, który co jakiś czas jest odśmiecany. Z tego gdzieś mi się obiło o oczy, a czego znaleźć aktualnie nie potrafię są dostępne 2 GCki. Jeden jest pewną odmianą Parallel GC, drugi jest z rodziny Lowlatency.
Przy uruchamianiu normalnie definiujemy parametry -Xmx oraz -Xms (gdyż normalnie bierze 1/4 całej pamięci 😉 ). Debugować się nie da, chyba, że z użyciem debuggerów natywnych takich jak GDB. VisualVM bodajże jest, choć dla Native Image jest ograniczony. Heap Dump można wykonać, lecz tylko w wersji Enterprise Edition, gdyż z czegoś Oracle musi żyć.

Ciekawe jest to, że na użytek inicjalizacji klas w czasie budowania i obiektów w nim stworzonych jest osobny tzw. Image Heap.

A co na tym można uruchomić?

Z pewnością nie Springa. Bazuje on bowiem z założenia na refleksji, generowaniu bytecode’u, proxy i innych. Nawet gdyby się dało uruchomić, byłoby to wciąż wolne.

Dlatego specjaliści od Red Hata postanowili stworzyć Quarkusa. Sam Quarkus jest nakładką na Vert.x zapewniającą odpowiednie działanie dla GraalVM Native Image. Dodatkowo istnieje wiele „wtyczek” do Quarkusa, w których głównym założeniem jest wykorzystanie pełni potencjału GraalVMa.

Przykładowo mechanizm Dependency Injection ogarnięte jest w czasie kompilacji zamiast w runtime. Inicjalizacja „kontrolerów” webowych również w czasie inicjalizacji.

Nawet Hibernate jest porządnie zoptymalizowany dzięki wtyczce do Quarkusa. A gdy zmigrują Hibernate na Jandexa, wówczas i część inicjalizacji metadanych możnaby przenieść na czas kompilacji.

Podobne pole do optymalizacji widzę również w Jacksonie. Już teraz można cache’ować metadane obiektów w zewnętrznych implementacjach. Nie sprawdzałem jednak, czy w Quarkusie tak się aktualnie dzieje.

Nawet jeśli jeszcze istniejące rozwiązania nie są optymalne, to:
1. Projekt ma mniej niż rok, więc spokojnie 😉
2. Już teraz aplikacje uruchamiają się w czasie mierzonym w milisekundach, więc raczej będzie tylko lepiej.

Czy to działa?

Osobiście w 2 wieczoro/noce zmigrowałem prywatny projekcik z Tomcata, Spring MVC, Hibernate (Criteria API), SpringFilter, Quartz na Quarkusa z Hibernate (JPA Criteria Api) oraz ichniejszego Crona.
Trochę brakuje Spring Data, ale da się żyć.
Działa, nie padło, działa szybciej, wstaje szybciej, zjada znacznie mniej pamięci. Czyli dla prywatnych projekcików postawionych na VPSie działa idealnie 😉

Czy działa gdzieś produkcyjnie? Nie wiem… Ale pewnie internet wie… Spytajcie Googla, może coś powie na ten temat!

Mowa końcowa

Quarkus ma potencjał. Jak nie do małych projekcików, to do lambd wstających w milisekundy.
Migracja ze Springa na Quarkusa nie jest trudna, lecz nieco uprzykrzająca życie ze względu na inne adnotacje. Micronaut ma bardziej zbliżone Api do Springa.

Dla chętnych prezentacja po polsku o Quarkusie z Warszawskiego JUGa przedstawiana przez jednego z jego twórców.

Zostawcie gwiazdki, komcie i inne takie.

Pax!

Oceń wpis

Var

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<String> list = new ArrayList<>();
        list.size();

        var varList = new ArrayList<String>();
        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<? extends Serializable, List<? extends String>> stringsByIds = getStringsById();
List<? extends String> stringList = stringsByIds.getOrDefault(1, List.of());

var w tym przypadku ułatwia nam zrozumienie „co się tu dzieje”. Analizy „jak się to dzieje” raczej nie zaciemnia, bo i tak aby zrozumieć trzeba ponawigować trochę po klasach.

Unikałbym jednak słowa var przy typach prymitywnych z zainicjalizowaną wartością.

        var l = 1; // czy to long?
        var f = 1.0; // czy to float?

Final?

Jak sprawić by nasza zmienna była zmienną ostateczną? Używając final var.

Były proponowane różne podejścia do tego zagadnienia – między innymi była propozycja słowa val lub let. Niestety ostatecznie nie zdecydowano się na wprowadzenie do Javy żadnego z tych słów…

Podsumowanie

var może być przydatny 😉
Podrzucam też bezpośredni JEP odnośnie var oraz podsumowanie dyskusji na temat obecnego kształtu tego słówka.

P.S. Wiem, że czyta ten blog niezerowa liczba osób, ale jeszcze nie dorobiłem mechanizmu, sprawdzającego, ilu się udaje doczytać do końca… Zatem taka prośba: jeżeli doczytaliście aż dotąd, zostawcie po sobie ocenę lub komentarz. Będę wtedy wiedział, że da się te wypociny doczytać do końca 😉

Pax

Oceń wpis