Sygnatura metody, jej parametry i kwestia wydajności

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

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

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

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

W tym artykule opowiem nieco o operatorze +.

Implementacja

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

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

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

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

The Java® Language Specification, Java SE 15 Edition

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

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

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

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

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

JEP-280

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

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

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

A jak to będzie w wydajności?

Generalnie – szybciej.

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

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

Podsumowanie

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

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

Jeśli chodzi o linki do poczytania, to:

Pax et bonum 🙂

 

Var

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

var może wpływać na bytecode

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

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

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

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

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

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

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

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

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

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

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

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

Czytelność

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

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

// zamiast:
Map
 

ThreadLocal

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]

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

Jackson – wstęp

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

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

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

Trochę kodu

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

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

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

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

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

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

I to wszystko?

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

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

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

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

Performance

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

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

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

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

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

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

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

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

....[Hottest Methods (after inlining)]..............................................................

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


 21,68%         C2, level 4  com.fasterxml.jackson.databind.deser.impl.MethodProperty::deserializeAndSet, version 915

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

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

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

Podsumowanie

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

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

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

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

Pax et bonum.

 

Odczyt finalnych pól instancyjnych

Zaraz koniec roku, trzeba zamknąć pewne tematy.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

-XX:+TrustFinalNonStaticFields

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

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

  0x00007f95c4440bdc: mov     $0xc,%eax

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

@jdk.internal.vm.annotation.Stable

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

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

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

Ludzie listy piszą…

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

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

Co robić, jak żyć?

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

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

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

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

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

A co z polami instancyjnymi?

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

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

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

 

Tworzenie obiektów z finalnymi polami instancyjnymi

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

Tworzenie obiektów

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

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

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

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

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

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

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

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

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

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

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

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

Poprawę sytuacji potwierdza wynik testu JCStress:

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

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

Nic za darmo

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

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

Constructor Injection with finals 5,3
Constructor Injection without finals 4,72
Field Injection 4,46
Setter Injection 6,1

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

To znaczy, że final tylko spowalnia?

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

 

Final – pola statyczne

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

Typy prymitywne i Stringi

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

Obiekty

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

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

Aby się tego dowiedzieć, wykonajmy prosty test:

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

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

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

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

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

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

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

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

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

Podsumowanie

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

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

 

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

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

Zmienne lokalne

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

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

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

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

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

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

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

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

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

Constant Folding

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

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

Argumenty metod

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

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

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

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

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

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

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

a następnie zdekompilowaniu, otrzymujemy podany fragment kodu:

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

    int result1 = a1 * b1 + 20 * b1;

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

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

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

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

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

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

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

Podsumowanie

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

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

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