Pattern matching w Javie – niskopoziomowo 0 (0)

Pierwsza propozycja Pattern Matchingu dla instrukcji switch w Javie pojawiła się w JDK 17. Po kilku iteracjach usprawnień w najświeższym LTS’ie – JDK 21 (19 września 2023)- wyszła w wersji ostatecznej. Warto zatem spojrzeć pod maskę jak to wygląda pod maską i zweryfikować pod kątem wydajności.

Czym jest pattern matching?

Zasadniczo ten blog nie dostarcza informacji o definicjach, a o niskopoziomowych działaniach, zatem po opis Pattern Matchingu zapraszam choćby tu. Jedno co mogę napisać, to że to znany koncept w wielu językach programowania od wielu lat.

W Javie pierwsza wersja preview Pattern Matchingu dla instrukcji pojawiła się w JDK 17. O ile w międzyczasie została zmieniona składnia tego feature, ostatecznie w JDK 21 przykład jego wykorzystania może wyglądać tak:

private Object iterator;

public void patternMatching(Blackhole bh) {
    switch (iterator) {
        case Boolean b when b -> {
            bh.consume(3);
            iterator = false;
        }
        case String s -> {
            bh.consume(s);
        }
        case Boolean b -> {
            bh.consume(5);
            iterator = true;
        }
        default -> throw new IllegalStateException("Unexpected value: " + iterator);
    }
}

W przeciwieństwie do standardowego switcha na enumie lub liczbach, w Pattern Matchingu ważna jest kolejność. W skrajnym przypadku gdy będzie wiele pasujących wyrażeń, zostanie dopasowane to prawdziwe wyrażenie, które występuje jako pierwsze. Wszystkie te dylematy są opisane w JEP-441.

Pattern matching – wgląd w bytecode

Jeślibyśmy skompilowali powyższy fragment, a następnie go zdekompilowali do czytelnej formy z użyciem dekompilatora udostępnionego przez Jetbrains w IntelliJ rezultat (nieco okrojony) wyglądałby tak:

public void patternMatching(Blackhole bh) {
    Object var10000 = this.iterator;
    Objects.requireNonNull(var10000);
    Object var2 = var10000;
    byte var3 = 0;

    while(true) {
        switch (((Class)var2).typeSwitch<invokedynamic>(var2, var3)) {
            case 0:
                Boolean b = (Boolean)var2;
                if (!b) {
                    var3 = 1;
                    continue;
                }

                bh.consume(3);
                this.iterator = false;
                break;
            case 1:
                String s = (String)var2;
                bh.consume(s);
                break;
            case 2:
                Boolean b = (Boolean)var2;
                bh.consume(5);
                this.iterator = true;
                break;
            default:
                throw new IllegalStateException("Unexpected value: " + String.valueOf(this.iterator));
        }

        return;
    }
}

Powyższy listing obrazuje działanie dopasowywania wzorca. Dopasowanie bazuje na mechanizmie invokedynamic, który twórcy JDK używają również w lambdach bądź w konkatenacji Stringów. Pokrótce daje to możliwość wprowadzenia usprawnień bez wymogu rekompilacji źródeł, gdyż wykonywany kod jest generowany w runtime przy pierwszym odwołaniu do klasy zawierającej go.

Jak widać dopasowanie odbywa się na podstawie dwóch argumentów – dopasowywanego obiektu oraz pomocniczej zmiennej pozwalającej określić, jakie było poprzednie dopasowanie, które się nie udało. Dopasowywanie odbywa się w pętli – jeśli warunek (guard) nie zostanie spełniony, wartość zmiennej tymczasowej jest zmieniona, a następnie cała pętla jest powtarzana. Dzięki zmianie tej zmiennej trafimy do innego case’a niż poprzednio.

Przy okazji widać również, że w powyższym przypadku tzw. Switch Expression jest tłumaczone na standardowego switcha.

Jednak podglądając ten bytecode można mieć wątpliwości, czy lepiej pod kątem wydajności będzie użyć Pattern Matchingu, czy zwykłego ciągu If-Else’ów. Warto to sprawdzić benchmarkiem JMH. Wszak jeśli mamy do czynienia z pętlą while (true) (czyt. skok bezwarunkowy), to mogą istnieć różnice.

Benchmark

Zasadniczo porównania dotyczą powyższej sytuacji, jednak najpierw w wersji okrojonej do samego Booleana z guardem, Booleana oraz domyślnej ścieżki. Zatem kod testujący wyglądał następująco:

public class PatternMatchingBenchmark {
(...)
    private Object iterator;
    @Benchmark
//    @CompilerControl(CompilerControl.Mode.EXCLUDE)
    public void ifElse(Blackhole bh) {
        Objects.requireNonNull(iterator);
        if (iterator instanceof Boolean b) {
            if  (b) {
                bh.consume(3);
                iterator = false;
            } else {
                bh.consume(5);
                iterator = true;
            }
        }
    }

    @Benchmark
//    @CompilerControl(CompilerControl.Mode.EXCLUDE)
    public void patternMatching(Blackhole bh) {
        switch (iterator) {
            case Boolean b when b -> {
                bh.consume(3);
                iterator = false;
            }
            case Boolean b -> {
                bh.consume(5);
                iterator = true;
            }
            default -> throw new IllegalStateException("Unexpected value: " + iterator);
        }
    }
}

W pierwszej wersji, gdzie sprawdzałem tylko tryb interpretowany wyniki były następujące (na MacBooki Pro z 2018 roku z procesorem 2,2 GHz 6-Core Intel Core i7). Dystrybucja to OpenJDK (build Oracle’owski – build 21+35-2513):

Benchmark                                  Mode  Cnt         Score       Error  Units
PatternMatchingBenchmark.ifElse           thrpt   10  15506516.529 ± 54518.117  ops/s
PatternMatchingBenchmark.patternMatching  thrpt   10   4125353.810 ± 24199.732  ops/s

Od razu widać, że PatternMatching ma pewien dodatkowy narzut sprawiający, że w trybie interpretowanym działa prawie 4 razy wolniej niż stary if. Jaka jednak jest wydajność dopasowywania wzorca z użyciem JITa (C2)?

Benchmark                                  Mode  Cnt          Score         Error  Units
PatternMatchingBenchmark.ifElse           thrpt   10  555514980.934 ± 2758608.077  ops/s
PatternMatchingBenchmark.patternMatching  thrpt   10  554135386.372 ± 2915232.272  ops/s

Jak widać tym razem ani dodatkowa pętla, ani dodatkowe zmienne nie stanowiły problemu dla kompilatora C2 – rozwijanie pętli i inline’ing to jedne z najprostszych optymalizacji.

Gdzie PatternMatching może być bardziej wydajny?

Wydaje się, że analogicznie do switcha na Enumach pewien zysk mógłby się pojawić, gdybyśmy chcieli wykonać skok bezpośrednio do case odpowiadającego dopasowanej klasie. Względnie łatwiejsze mogłoby być uszeregowanie bloków case według prawdopodobieństwa wystąpienia, żeby skoki do odpowiedniego bloku były najmniejsze, dzięki czemu możliwe, że blok kodu będzie w cache’u. Chociaż, możliwe, że bloki if-else’ow również mogą taka optymalizację uczynić po dokładniejszej analizie kodu. Aczkolwiek to optymalizacje w skali tak małej, że nie są warte pracy programistów.

Jest jedna różnica, gdzie ciąg if-else’ow mógłby być mniej wydajny – jeśli robilibyśmy switcha na polu klasy. W przypadku if-else’ow za każdym razem zczytywalibysmy wartość ze sterty, a w switchu wartość byłaby zczytana ze sterty jednokrotnie. Oczywiście przypisanie wartości pola do zmiennej lokalnej wystarczyłoby, aby tę przewagę switcha zniwelować…

Podsumowanie

Patttern Matching jest mechanizmem, który ma za zadanie zwiększyć czytelność, zmniejszyć szum informacyjny wokół kodu, a także zwiększyć ekspresywność języka.

Potencjalne zyski z korzystania z tego mechanizmu o ile byłyby możliwe, to są raczej wątpliwe – nie taki był cel wprowadzenia tego mechanizmu.

Jeśli szukacie dobrego dokładnego niskopoziomowego opisu pattern matchingu zapraszam na bloga Natalii Dziubenko.

Źródła wykorzystane przy pisaniu tego wpisu znajdują się na moim Githubie – dokładnie w podprojekcie pattern-matching.

Pax & Bonum

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

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

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

Krótkie wprowadzenie

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

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

JEP 395

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

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

Implementacja Recordów

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

public record Record(int i, String j)

kompilowany jest do:

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

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

    public int i() {
        return i;
    }

    public String j() {
        return j;
    }

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

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

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

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

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

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

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

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

„Wyzwania”

Rekordy mają też swoje niedogodności.

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

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

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

Wsparcie bibliotek

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

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

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

Garść linków

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

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

Pax!

Odczyt finalnych pól instancyjnych 0 (0)

Zaraz koniec roku, trzeba zamknąć pewne tematy.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

-XX:+TrustFinalNonStaticFields

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

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

  0x00007f95c4440bdc: mov     $0xc,%eax

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

@jdk.internal.vm.annotation.Stable

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

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

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

Ludzie listy piszą…

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

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

Co robić, jak żyć?

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

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

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

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

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

A co z polami instancyjnymi?

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

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

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

Tworzenie obiektów z finalnymi polami instancyjnymi 0 (0)

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 0 (0)

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ść 0 (0)

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.

Final – klasy i metody 0 (0)

Czy oznaczenie klasy jako finalną może skutkować optymalizacjami? Jakkolwiek słowa kluczowego final powinniśmy używać przede wszystkim dla zabiegów projektowych, pewne podejrzenia o optymalizacje zdają się być uzasadnione…

Metody finalne są łatwiej inline’owalne

Pierwszą potencjalną optymalizacją jest prostsze wywoływanie metod finalnych. Prosty przykład:

public class Super {
    public void method() { (...) }
}

public class Sub extends Super {
    public void final method() { (...) }
}

public class InvokerClass {
    private Sub sub;
    void invokingMethod() {
        sub.method();
    }
}

Normalnie każde wywołanie metody publicznej modelowane jest instrukcją kodu bajtowego invokevirtual. Ta instrukcja odpowiada za wywołanie – nomen omen – metod wirtualnych. Oznacza to, że przeważnie musimy szukać, czy istnieje jakiś podtyp, który daną metodę nadpisuje, aby ją wykonać.

Jednak w przypadku klasy/metody finalnej wiemy, że żadnej podklasy nadpisującej tej metody nie ma. Zatem w wyżej ukazanym przypadku można by się pokusić o zamianę tej instrukcji na invokespecial, w której wykonujemy określoną metodę. Wówczas uniknęlibyśmy sprawdzania typu.

Mimo wszystko, nie zastosowano tej optymalizacji.

Po pierwsze mogłaby zaistnieć taka sytuacja, w której kompilujemy źródła z classpath posiadającym jakąś bibliotekę w jednej wersji. Następnie w runtime‚ie używamy drugiej wersji.
Załóżmy, że w pierwszej wersji był final, gdy w drugiej nie tylko nie ma final, lecz istnieje również podklasa. Gdybyśmy wówczas zastosowali tę optymalizację, wówczas w przypadku podklasy wykonywalibyśmy nie tę metodę, co prowadziłoby do błędów. Brak podmiany invokevirtual na invokespecial nas chroni od takiego błędu.

Po drugie nie użyto tej optymalizacji, ponieważ… nie. Po prostu tak napisano w specyfikacji i tyle 😉
„Changing a method that is declared final to no longer be declared final does not break compatibility with pre-existing binaries.” – tak twierdzi Java Language Specification.

JIT to the rescue

W trybie interpretowanym nie udało się znaleźć optymalizacji. Może jednak choć JIT jakoś wykorzysta tego finala do na klasie/metodzie do optymalizacji.

Spójrzmy na następujące przypadki (podobne do poprzednich):

public class Super {
    public void method() { (...) }
}

public class Final extends Super {
    public void final method() { (...) }
}

public class NonFinal extends Super {
    public void method() { (...) }
}

public class BenchmarkClass {
    private Super super = new Super();
    private Final final = new Final();
    private NonFinal nonfinal = new NonFinal();

    @Benchmark
    @CompilerControl(CompilerControl.Mode.PRINT)
    void benchmarkSuper() {
        super.method();
    }

    @Benchmark
    @CompilerControl(CompilerControl.Mode.PRINT)
    void benchmarkFinal() {
        final.method();
    }

    @Benchmark
    @CompilerControl(CompilerControl.Mode.PRINT)
    void benchmarkNonfinal() {
        nonfinal.method();
    }
}

W pierwszym przypadku mamy do czynienia z nadklasą. W przypadku metody skompilowanej przez C2, możemy się domyślać, jakiego typu będzie wartość (na podstawie profilowania wywołania metody), jednak nie mamy żadnej pewności, czy typ wartości pola nie zostanie kiedyś zmieniony na jakąś z podklas (Final lub NonFinal). To też jest widoczne w kodzie maszynowym wygenerowanym przez C2.

W przypadku pola typu Final w runtimie jesteśmy pewni, że nie będzie żadnej podklasy, więc jedynym możliwym typem jest właśnie Final. Zatem możemy zaoszczędzić na odczycie z pamięci samego typu, co widać w listingu kodu maszynowego.

Jeśli jednak spojrzymy, że na listing NonFinal, to zauważymy, że jest on prawie identyczny z listingiem Final. Również w nim jest założenie, że obiekt w polu nonFinal będzie właśnie typu NonFinal. Skąd to założenie?

Otóż OpenJDK przy każdym ładowaniu klasy pielęgnuje informacje o hierarchii klas – jaka klasie klasy są liśćmi w hierarchii klas, jakie są nadklasami itd. Taka analiza hierarchii klas nazywa się Class Hierarchy Analysis (CHA) i jest dość starym mechanizmem w Javie.Jeśli dana klasa jest liściem w hierarchii klas, to zakładamy, że nie ma podklas.

Wówczas po kompilacji C2 – podobnie jak przy final – mamy jeden odczyt pamięci mniej.

Jeśliby po kompilacji C2 okazało się, że jest ładowana jakaś nowa podklasa NonFinal, wtedy trzeba unieważnić skompilowane fragmenty kodu maszynowego i wrócić w nich do trybu interpretowanego.

Taka refleksja tylko szybsza… 0 (0)

„Refleksja jest wolna…”

Ile razy o tym słyszeliśmy… „Nie używaj refleksji, bo to mało wydajne, lepiej użyć bezpośrednio metody, jeśli możesz”.

Wiecie, że Jackson korzysta z refleksji na setterach do ustawiania wartości pól? A jak już wspomniałem „refleksja jest wolna”… Może dałoby się ją zamienić na coś innego?

Cel

Naszym celem będzie wykonanie nieznanego nam settera w nieznanym obiekcie w sposób najbardziej wydajny. Warto na początek stwierdzić, co będzie naszym celem, do którego będziemy dążyć.

Załóżmy, że mamy taki zwykły obiekt, w którym chcemy ustawić 4 pola różnych typów: int, long, Object, String. Każde pole posiada setter, który nic nie zwraca (void). Załóżmy, że nie chcemy się bawić Garbage Collection, więc będziemy używać wciąż jednego obiektu, któremu będziemy ustawiać wciąż te same pola jednego obiektu.

Zatem najszybszy sposób ustawiania to będzie proste wywołanie setterów:

    @Benchmark
    public Pojo directSetters(PojoHolder pojoHolder) {
        Pojo pojo = pojoHolder.pojo;
        pojo.setI1(i1);
        pojo.setL1(l1);
        pojo.setO1(o1);
        pojo.setS1(s1);
        return pojo;
    }

Wynik na moim laptopie: 7,993 ± 0,182 ns/op.

Jak już wspomniałem, potencjalnie największe zło to refleksja:

    @Benchmark
    public Pojo reflectionSetters(PojoHolder pojoHolder) throws InvocationTargetException, IllegalAccessException {
        Pojo pojo = pojoHolder.pojo;
        methods[0].invoke(pojo, i1);
        methods[1].invoke(pojo, l1);
        methods[2].invoke(pojo, o1);
        methods[3].invoke(pojo, s1);
        return pojo;
    }

Wynik na moim laptopie: 60,494 ± 4,026 ns/op. Czyli 7 razy więcej… To dość dużo… Dlaczego tak dużo? Przyczyn jest wiele:

  • przy każdym wywołaniu Method.invoke:
    • sprawdzamy, czy dostępy się zgadzają… (lub czy setAccessable było wywołane),
    • sprawdzamy, czy obiekt na którym wykonujemy operację jest odpowiedniego typu,
    • czy ilość parametrów się zgadza i czy są odpowiednich typów,
    • jeśli parametry są typu prymitywnego, to trzeba wykonać autoboxing,
  • gdzieś wewnątrz wywołania Method.invoke jest wywoływana metoda natywna – plotki głoszą, że to jest powolne…
  • 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.
    Jedynie można wykonać skok do skompilowanej przez C2 treści metody…

Gdyby tylko istniała taka lepsza wersja refleksji…

Generalnie refleksja się sprawdza, do czasu, gdy musimy przeiterować po klasie, żeby dowiedzieć się, jakie pola w niej istnieją. W założeniu nie miała być podstawą działania frameworków…

Postanowiono jednak stworzyć mechanizm, który jest jest bardziej odpowiedni dla opisu oraz wywoływania metod. Dzięki temu do Javy 7 wprowadzono MethodHandle. Sprawdźmy zatem jak działa.

    private static MethodHandles.Lookup lookup = MethodHandles.lookup();
    (...)
        methodHandles[0] = lookup.unreflect(methods[0]);
        methodHandles[1] = lookup.unreflect(methods[1]);
        methodHandles[2] = lookup.unreflect(methods[2]);
        methodHandles[3] = lookup.unreflect(methods[3]);

    @Benchmark
    @CompilerControl(CompilerControl.Mode.PRINT)
    public Pojo methodHandles(PojoHolder pojoHolder) throws Throwable {
        Pojo pojo = pojoHolder.pojo;
        methodHandles[0].invokeExact(pojo, i1);
        methodHandles[1].invokeExact(pojo, l1);
        methodHandles[2].invokeExact(pojo, o1);
        methodHandles[3].invokeExact(pojo, s1);;
        return pojo;
    }

Wynik na moim laptopie: 33,540 ± 0,410 ns/op.Jest lepiej, choć szału nie ma… 4x wolniejsze niż bezpośrednie wywołanie, ale 2 razy szybsze niż refleksja.

Dlaczego to działa szybciej? Przede wszystkim sprawdzenie dostępów odbywa się na etapie tworzenia MethodHandle (do tego służy wspomniane Lookup).

Czy można jeszcze bardziej przyspieszyć wywoływanie setterów?

W poście o Lambdach wspomniałem o LambdaMetafactory. Pozwala on wygenerować implementację danego interfejsu podając uchwyt do metody, którą chcemy wywołać. W przypadku settera ustawiającego pole nieprymitywne można by użyć istniejącego interfejsu java.util.function.BiConsumer, gdzie pierwszym parametrem jest obiekt, któremu ustawiamy pole, a drugim – wartość, którą ustawiamy. Dla typów prymitywnych należałoby użyć dedytkowanych analogicznych interfejsów, tzn. dla pól typu intjava.util.function.ObjIntConsumer, a pól typu longjava.util.function.ObjLongConsumer.
I tak dla każdego pola wygenerowalibyśmy implementację odpowiedniego interfejsu, który wywołuje setter na podanym obiekcie.

Jak widać stworzenie takich implementacji już nie jest tak trywialne jak method handle, ale może warto ze względu na performance się o to pokusić…

    private static ObjIntConsumer getIntSetter(MethodHandle methodHandle) throws Throwable {
        final Class functionKlaz = ObjIntConsumer.class;
        Object o = getSetter(methodHandle, functionKlaz);
        return (ObjIntConsumer) o;
    }

    private static ObjLongConsumer getLongSetter(MethodHandle methodHandle) throws Throwable {
        final Class functionKlaz = ObjLongConsumer.class;
        Object o = getSetter(methodHandle, functionKlaz);
        return (ObjLongConsumer) o;
    }

    private static BiConsumer getObjectSetter(MethodHandle methodHandle) throws Throwable {
        final Class functionKlaz = BiConsumer.class;
        Object o = getSetter(methodHandle, functionKlaz);
        return (BiConsumer) o;
    }

    private static Object getSetter(MethodHandle methodHandle, Class functionKlaz) throws Throwable {
        final String functionName = "accept";
        final Class functionReturn = void.class;
        Class aClass = !methodHandle.type().parameterType(1).isPrimitive()
                ? Object.class
                : methodHandle.type().parameterType(1);
        final Class[] functionParams = new Class[] { Object.class,
                aClass};

        final MethodType factoryMethodType = MethodType
                .methodType(functionKlaz);
        final MethodType functionMethodType = MethodType.methodType(
                functionReturn, functionParams);

        final CallSite setterFactory = LambdaMetafactory.metafactory( //
                lookup, // Represents a lookup context.
                functionName, // The name of the method to implement.
                factoryMethodType, // Signature of the factory method.
                functionMethodType, // Signature of function implementation.
                methodHandle, // Function method implementation.
                methodHandle.type() // Function method type signature.
        );

        final MethodHandle setterInvoker = setterFactory.getTarget();
        return setterInvoker.invoke();
    }
    @Benchmark
    @CompilerControl(CompilerControl.Mode.PRINT)
    public Pojo generatedSetter(PojoHolder pojoHolder) throws Throwable {
        Pojo pojo = pojoHolder.pojo;
        ((ObjIntConsumer)setters[0]).accept(pojo, i1);
        ((ObjLongConsumer)setters[1]).accept(pojo, l1);
        ((BiConsumer)setters[2]).accept(pojo, o1);
        ((BiConsumer)setters[3]).accept(pojo, s1);
        return pojo;
    }

Wynik na moim laptopie: 10,839 ± 0,077 ns/op.
Narzut na takie wywoływanie setterów, to zaledwie 0.3x, zatem w porównaniu zarówno do refleksji jak i method handle jest to doskonały wynik.

Wywołujemy jednak różne interfejsy, co może być nieco problematyczne… Chcielibyśmy wywoływać jeden interfejs, żeby się przypadkami skrajnymi nie zajmować… Dlatego warto też sprawdzić jakie będą rezultaty dla jednego interfejsu – BiConsumer.

Tutaj muszę zrobić pewną dygresję. Byłoby naiwnością twierdzenie, że jako jedyny w internetach zgłębiam temat „wydajnej refleksji”. Istnieje niejeden artykuł na ten temat.
Przy szukaniu rozwiązania problemu unifikacji wywołań do wywołań interfejsu BiConsumer natrafiłem na ten artykuł, który pozwolił na proste rozwiązanie problemu castowania typów prymitywnych na Obiekt. Jest to zastosowanie zastosowanie zwykłej lambdy.

            biConsumerSetters[0] = (a, b) -> ((ObjIntConsumer) setters[0]).accept(a, (int) b);
            biConsumerSetters[1] = (a, b) -> ((ObjLongConsumer) setters[1]).accept(a, (long) b);
            biConsumerSetters[2] = getBiConsumerObjectSetter(lookup.unreflect(methods[2]));
            biConsumerSetters[3] = getBiConsumerObjectSetter(lookup.unreflect(methods[3]));
    @Benchmark
    @CompilerControl(CompilerControl.Mode.PRINT)
    public Pojo biConsumerOnly(PojoHolder pojoHolder) throws Throwable {
        Pojo pojo = pojoHolder.pojo;
        biConsumerSetters[0].accept(pojo, i1);
        biConsumerSetters[1].accept(pojo, l1);
        biConsumerSetters[2].accept(pojo, o1);
        biConsumerSetters[3].accept(pojo, s1);
        return pojo;
    }

Jak można się spodziewać taka warstwa pośrednia nieco spowalnia. Jednak wynik jest wciąż zadawalający – 18,037 ± 0,132 ns/op.

To be continued…

Wstępne rozpoznanie możliwości zamiany refleksji jest zrobione. Jednak przeprowadzone badania były w dosyć „sterylnym” środowisku – wszystkie settery były wywoływane „po indeksie”. Można się spodziewać gorszych wyników w przypadku wywołań setterów „w pętli”.

Dodatkowo, zastosowanie tych metod na kodzie biblioteki ma znacznie szerszy kontekst, który może być trudniejszy do analizy dla C2. Może się też okazać, że refleksja jest tak małą częścią całej deserializacji, że refleksja jest „wystarczająco dobra”.

Zanim jednak opiszę swoje doświadczenia musi trochę czasu upłynąć. Aktualnie muszę skupić się przygotowaniu do prezentacji na 4developers w Poznaniu.

Zatem cierpliwości 😉

// Edit: Pojawił się wpis z zastosowaniem treści tego artykułu dla Jacksona

Jak wydajnie rzucać wyjątki? 0 (0)

Cóż… Najlepiej nie rzucać 😉

Generalnie mógłbym podlinkować tylko Blog Alexey’a Shipileva i zakończyć wpis… Alexey dokładnie zbadał temat wydajności wyjątków.
Jednak dla osób wolących artykuły w języku polskim również i ja pokrótce temat opiszę.

Na samym końcu dla odmiany napiszę coś życiowego 😉

Uwaga! Dla odmiany w tym wpisie nie będzie listingu Bytecode’u 😛

Ile kosztuje nas rzut wyjątkiem?

Na początek warto sprawdzić, ile kosztuje w ogóle rzucenie wyjątkiem.
Stworzyłem zatem benchmark (oczywiście używając frameworku JMH), który w bloku try-catch rzuca lub nie rzuca wyjątkiem. Sprawdziłem również, czy jakiś wpływ ma to, jaki wyjątek łapiemy.

Trzeba jednak wziąć pod uwagę, że taki kod w którym w jednej metodzie jest rzucany wyjątek, a ramkę niżej (wyżej?) go łapiemy byłby skompilowany przez C2 i z wysokim prawdopodobieństwem zinline’owany. To by doprowadziło do sytuacji, w której ten wyjątek nie byłby w kodzie maszynowym explicite rzucany – byłaby skompilowana wydajna symulacja rzucania wyjątku. Taka sytuacja nas jednak nie interesuje – wszak szukamy kosztu rzucenia wyjątku, a nie kosztu symulacji rzucania wyjątku.
Aby uniemożliwić inline’owanie, należało dodać dodatkową adnotacją @CompilerControl(CompilerControl.Mode.DONT_INLINE) (udostępnioną przez JMH – normalnie tę akcję da się wymusić parametrem wywołania java, co jest jednak mało wygodne).
Nie chcemy również badać, jak długo tworzony jest obiekt wyjątku, dlatego za każdym razem rzucamy tym samym wyjątkiem, który jest umieszczony w polu statycznym finalnym.

    @Benchmark
    @CompilerControl(CompilerControl.Mode.PRINT)
    public int throwBenchmark() {
        try {
            throwRuntimeException();
        } catch (RuntimeException e) {
            return 5;
        }
        return 1;
    }

    @Benchmark
    @CompilerControl(CompilerControl.Mode.PRINT)
    public int throwThrowableBenchmark() {
        try {
            throwRuntimeException();
        } catch (Throwable e) {
            return 5;
        }
        return 1;
    }

    @CompilerControl(CompilerControl.Mode.DONT_INLINE)
    private void throwRuntimeException() {
        throw RUNTIME_EXCEPTION;
    }

    @Benchmark
    @CompilerControl(CompilerControl.Mode.PRINT)
    public int withoutthrowBenchmark() {
        try {
            dontThrowRuntimeException();
        } catch (RuntimeException e) {
            return 5;
        }
        return 1;
    }

    @CompilerControl(CompilerControl.Mode.DONT_INLINE)
    private void dontThrowRuntimeException() {

    }

Wyniki tego benchmarku są następujące (dla OpenJDK w wersji 11):

Benchmark                                     Mode  Cnt    Score   Error  Units
SimpleThrowBenchmark.throwBenchmark           avgt   10  144,234 ± 1,407  ns/op
SimpleThrowBenchmark.throwThrowableBenchmark  avgt   10  146,276 ± 1,083  ns/op
SimpleThrowBenchmark.withoutthrowBenchmark    avgt   10    5,684 ± 0,055  ns/op

Czyli takie rzucenie wyjątku spowalnia nam tę bardzo prostą metodę jakieś 30 razy…
Oczywiście ta metoda i tak nic nie robi, jednak różnica w czasie wykonania jest bardzo duża…

Zarzucenie wyjątkiem głęboko w stacktrace

Warto również sprawdzić sytuację, w której wyjątek musi przewędrować przez wiele ramek zanim trafi na odpowiedni blok try-catch. To zachowanie jest sprawdzane przez kolejny benchmark. Polega on na rekurencyjnym odliczaniu do 10, 50 lub 100, aby ostatecznie rzucić wyjątkiem (albo go nie rzucać i zakończyć rekurencję).

    @Benchmark
    @CompilerControl(CompilerControl.Mode.PRINT)
    public int throw100Benchmark() {
        try {
            throwRuntimeException(100);
        } catch (RuntimeException e) {
            return 5;
        }
        return 1;
    }

    @Benchmark
    @CompilerControl(CompilerControl.Mode.PRINT)
    public int throw50Benchmark() {
        try {
            throwRuntimeException(50);
        } catch (RuntimeException e) {
            return 5;
        }
        return 1;
    }

    @Benchmark
    @CompilerControl(CompilerControl.Mode.PRINT)
    public int throw10Benchmark() {
        try {
            throwRuntimeException(10);
        } catch (RuntimeException e) {
            return 5;
        }
        return 1;
    }

    private void throwRuntimeException(int i) {
        if (i == 0) {
            throwThrow();
            return;
        } else {
            throwRuntimeException(i - 1);
        }
    }

    @CompilerControl(CompilerControl.Mode.DONT_INLINE)
    private void throwThrow() {
        throw RUNTIME_EXCEPTION;
    }

    @Benchmark
    @CompilerControl(CompilerControl.Mode.PRINT)
    public int without100throwBenchmark() {
        try {
            dontThrowRuntimeException(100);
        } catch (RuntimeException e) {
            return 5;
        }
        return 1;
    }

    @Benchmark
    @CompilerControl(CompilerControl.Mode.PRINT)
    public int without50throwBenchmark() {
        try {
            dontThrowRuntimeException(50);
        } catch (RuntimeException e) {
            return 5;
        }
        return 1;
    }

    @Benchmark
    @CompilerControl(CompilerControl.Mode.PRINT)
    public int without10throwBenchmark() {
        try {
            dontThrowRuntimeException(10);
        } catch (RuntimeException e) {
            return 5;
        }
        return 1;
    }

    private void dontThrowRuntimeException(int i) {
        if (i == 0) {
            dontThrowThrow(i);
        } else {
            dontThrowRuntimeException(i - 1);
        }
    }

    @CompilerControl(CompilerControl.Mode.DONT_INLINE)
    private void dontThrowThrow(int i) {
        i++;
    }

Wyniki benchmarku są następujące:

Benchmark                                          Mode  Cnt      Score      Error  Units
StacktraceThrowBenchmark.throw100Benchmark         avgt   10  10047,087 ± 3463,884  ns/op
StacktraceThrowBenchmark.throw10Benchmark          avgt   10    922,528 ±   15,990  ns/op
StacktraceThrowBenchmark.throw50Benchmark          avgt   10   3846,247 ±   64,145  ns/op
StacktraceThrowBenchmark.without100throwBenchmark  avgt   10    152,059 ±    1,656  ns/op
StacktraceThrowBenchmark.without10throwBenchmark   avgt   10     17,163 ±    0,184  ns/op
StacktraceThrowBenchmark.without50throwBenchmark   avgt   10     92,268 ±   32,105  ns/op

Jak widać rzucenie wyjątkiem głęboko w stacktrace działa (w miarę) proporcjonalnie dłużej do odległości do najbliższego bloku try-catch. Najprostszy wniosek z tego jest taki – jeśli już musimy rzucać wyjątek, to nie daleko od łapiącego go bloku try-catch 😉
Mimo wszystko to nie są jakieś kosmiczne wartości – rzucenie wyjątku na głębokości 100 ramek to „koszt” 10 000 nanosekund, czyli 10 mikrosekund, czyli 0,01 milisekundy. Nie ma dramatu…

Skąd te różnice?

Algorytm działania rzucania wyjątku jest następujący (za grupą Hotspot projektu OpenJDK).
1. Sprawdzamy, czy w danej ramce na stosie istnieje odpowiedni blok try-catch. Informacja o tym znajduje się gdzieś na Off-Heapie.
2. Wśród znalezionych bloków try-catch sprawdzamy, czy któryś pasuje do naszego miejsca, gdzie wyjątek został rzucony/propagowany w dół.Jeśli jest takie miejsce, trzeba sprawdzić, czy blok łapie odpowiedni typ wyjątku.

3. Jeśli nie znaleźliśmy w tej ramce obsługi błędu, wówczas zdejmujemy ramkę ze stackframe i wracamy do punktu 1.

Niby nie brzmi to skomplikowanie, a jednak z 35 instrukcji procesora robi się 835, gdy rzucimy wyjątkiem (a przynajmniej tak twierdzi .addProfiler(LinuxPerfNormProfiler.class)). I to dla C2. W trybie interpretowanym ten stosunek to 550 do 2090 dla wykonania pojedynczej metody.
Jakby się dobrze zastanowić, to można by próbować jakoś optymalizować to rzucanie wyjątków. Można by utrzymywać jakąś tablicę, która dla danego typu wyjątku przechowuje adres skoku do którego ma trafić w przypadku rzucenia wyjątku… Może miałoby to sens, gdyby nie to, że wystąpienie wyjątku powinno być… wyjątkiem 😉 A utrzymywanie takiej tablicy tylko zajmowałoby czas i bezsensownie psuło wydajność…

A gdzie jest to coś życiowego?

Dość powszechną (i słuszną) praktyką jest walidacja parametrów wywołania. Często przy okazji walidacji można spotkać się z rzucaniem wyjątków jak choćby javax.validation.ValidationException. Jeśli tych wyjątków występowałoby dużo mogłoby w jakiś sposób ograniczyć wydajność. Być może warto by to zbadać.

Z tą motywacją stworzyłem prosty Restowy serwis w Spring Boot. A w nim 3 endpointy:
1. Rzucenie wyjątku na głębokości 100 ramek i „łapanie” z @ExceptionHandler,
2. Zagłębienie się na 100 ramek, powrót, a następnie rzucenie wyjątku na głębokości 1 ramki i „łapanie” z @ExceptionHandler
3. Zagłębienie się na 100 ramek, powrót i zwrócenie rezultatu walidacji w ResponseEntity.badRequest()

@SpringBootApplication
@RestController
public class ThrowExceptionSpringApplication {

    private static final String SORRY_NOT_VALID = "Sorry, not valid";

    @GetMapping("/response/deep/exception")
    public String throwDeepException() {
        Supplier supplier = () -> {
            throw new ValidationException(SORRY_NOT_VALID);
        };
        return deep(100, supplier);
    }

    @GetMapping("/response/shallow/exception")
    public String throwShallowException() {
        deep(100, () -> SORRY_NOT_VALID);
        throw new ValidationException(SORRY_NOT_VALID);
    }

    @GetMapping("/response/no-exception")
    public ResponseEntity dontThrowDeepException() {
        return ResponseEntity.badRequest()
                .body(deep(100, () -> SORRY_NOT_VALID));
    }

    private String deep(int i, Supplier producer) {
        if (i != 0) {
            return deep(i-1, producer);
        }
        return producer.get();
    }

    @ResponseStatus(code = HttpStatus.BAD_REQUEST)
    @ExceptionHandler(ValidationException.class)
    public String handleException(ValidationException e) {
        return SORRY_NOT_VALID;
    }

    public static void main(String[] args) {
        SpringApplication.run(ThrowExceptionSpringApplication.class);
    }
}

Następnie wszystkie endpointy benchmarkowałem z użyciem komendy ab -n 10000 -c 100 . Sprawdzałem w jakim najkrótszym czasie skończy się test (sprawdzałem wielokrotnie po uprzednim rozgrzaniu serwisu). Rezultaty były następujące:

1.Time taken for tests:   1.573 seconds
2.Time taken for tests:   1.515 seconds
3.Time taken for tests:   1.365 seconds

Zatem rzucenie wyjątku na głębokości 100 ramek jest o około 4% mniej wydajne, niż rzucenie na głębokości 1 ramki. Niby bez szału, ale jeżeli jedyną różnicą jest tylko miejsce wykonania throw to w sumie jest to różnica…
Jeszcze większą różnicę widać, jeśli zamiast rzucać wyjątek po prostu zwrócimy ResponseEntity.badRequest() – tutaj różnica to około 13%.

Podsumowanie

Wracając do myśli z początku tekstu – najlepiej nie rzucać wyjątków. Takie podejście jest stosowane chociażby w Scali, gdzie zamiast rzucania wyjątku można zwracać Either.

Drugą myślą jest to, że wyjątki powinny być rzucane „wyjątkowo” 😉 Exception Driven Development raczej nie jest dobrym pomysłem.

Ostatnią myślą, którą chciałem się to ponowne polecenie Artykułu Alexey’a Shipileva. Można tam przeczytać, to o czym nawet nie wspomniałem, czyli:

  • Inline’owanie na odpowiednim poziomie może polepszyć wydajność rzucania wyjątków,
  • Tworzenie Stacktrace’a jest wolne i można z niego zrezygnować,
  • Jak rzadkie rzucanie wyjątków jest wg Alexey’a dostatecznie rzadkie

Interfejs w JVMie – niskopoziomowo 0 (0)

Standardowym pytaniem rekrutacyjnym jest: czym się różni interfejs od klasy abstrakcyjnej w Javie. O ile większość odpowiedzi zaczyna się od strony specyfikacji, o tyle w tym wpisie podejdę od strony implementacji.

Bytecode

Standardowo zaczniemy od spojrzenia w bytecode. Najpierw podejrzymy co siedzi w abstrakcyjnej klasie:

public abstract class AbstractClass {
    public abstract int doSth(int a);
    public int defaultDoSth(int a) {
        return doSth(a);
    }
}

Następnie podejrzymy analogiczny do niej interfejs:

public interface Interface {
    int doSth(int a);
    default int defaultDoSth(int a) {
        return doSth(a);
    }
}

Porównując obydwa twory zaczniemy od stwierdzenia banału, że po skompilowaniu otrzymujemy pliki z rozszerzeniem .class Gdzie zatem jest trzymana informacja o tym, że jedno jest interfejsem a drugie klasą? Na to pytanie odpowiada listing wywołania na tych plikachjavap -v -p.

A w nim zauważymy, że klasa abstrakcyjna posiada flagi flags: (0x0421) ACC_PUBLIC, ACC_SUPER, ACC_ABSTRACT, gdy interfejs posiada flagi flags: (0x0601) ACC_PUBLIC, ACC_INTERFACE, ACC_ABSTRACT.
Zatem o byciu interfejsem świadczy dodatkowa flaga ACC_INTERFACE.
Flaga ACC_SUPERw klasie abstrakcyjnej jest aktualnie nadmiarowa i istnieje tylko w celu wstecznej kompatybilności.

Drugą różnicą, którą zauważyć porównując bytecode obu plików .class jest obecność konstruktora w klasie abstrakcyjnej. Jak wiemy, jeśli jawnie nie stworzymy konstruktora w kodzie źródłowym, kompilator wygeneruje nam domyślny konstruktor danej klasy.

Trzecią różnicą dość oczywistą, aczkolwiek niewidoczną w tym porównaniu jest to, że klasa w przeciwieństwie do interfejsu może posiadać pola.

Ostatnią różnicę, którą dokładniej opiszę jest różnica w implementacji metody defaultDoSth(int).

Dla klasy abstrakcyjnej bytecode wygląda następująco:

 public int defaultDoSth(int);
    descriptor: (I)I
    flags: (0x0001) ACC_PUBLIC
    Code:
      stack=2, locals=2, args_size=2
         0: aload_0
         1: iload_1
         2: invokevirtual #2                  // Method doSth:(I)I
         5: ireturn
      LineNumberTable:
        line 7: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       6     0  this   Ldev/jgardo/jvm/miscellaneous/interfaces/AbstractClass;
            0       6     1     a   I
}

Domyślna metoda interfejsu Interface defaultDoSth(int) zdekomilowana wygląda tak:

  public int defaultDoSth(int);
    descriptor: (I)I
    flags: (0x0001) ACC_PUBLIC
    Code:
      stack=2, locals=2, args_size=2
         0: aload_0
         1: iload_1
         2: invokeinterface #1,  2            // InterfaceMethod doSth:(I)I
         7: ireturn
      LineNumberTable:
        line 7: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       8     0  this   Ldev/jgardo/jvm/miscellaneous/interfaces/Interface;
            0       8     1     a   I
}

W implementacji metody defaultDoSth(int) w przypadku klasy abstrakcyjnej widzimy instrukcję kodu bajtowego invokevirtual, a za nią parametr, w którym jest wskazywana metoda, którą chcemy wywołać.
W przypadku interfejsu zauważymy invokeinterface z dwoma parametrami.
A jakie są różnice między nimi? Czy były konieczne dwa różne kody operacji?

Invoke(…)

Ogólnie mówiąc, wywoływanie metod można wykonać za pomocą jednego z 5 różnych instrukcji kodu bajtowego (w zależności od kontekstu):

  • invokestatic – używamy go za każdym razem, kiedy wywołujemy metodę statyczną, czyli taką, w której nie potrzebujemy przekazywać wskaźnika na obiekt z którego jest ona wywoływana (nie ma przykazywanego w żaden sposób obiektu this).
  • invokespecial – tej instrukcji używamy, kiedy chcemy wywołać konkretną metodę danego obiektu. Nie zastanawiamy się, czy ta metoda jest nadpisana w klasie dziedziczącej, lecz wskazujemy dokładną metodę. Przykładem jest wywoływanie metody prywatnej, metody z nadklasy lub konstruktora. Przekazujemy niejawnie this jako pierwszy parametr metody.
  • invokedynamic – ta instrukcja jest raczej niedostępna w Javie i rzadko spotkana (oprócz lambd). Kluczowe jest w niej to, że generalnie na etapie kompilacji nie wiemy, co chcemy wywołać 😉 Na etapie kompilacji jedynie wskazujemy, kto może wiedzieć, co chcemy wywołać.
    Trochę poruszam temat we wpisie o lambdach.
  • invokevirtual – w tym przypadku mamy do czynienia z wywołaniami metod publicznych, chronionych i prywatnych w ramach pakiety (generalnie nieprywatnych). Na etapie kompilacji przeważnie nie wiemy, czy wywołujemy metodę z danej klasy, czy klasy po niej dziedziczącej (ze względu na polimorfizm). W runtime‚ie JVM ewaluuje, jaką metodę wykonać.
    W tej operacji równierz przemycamy this. Argumentem tego kodu bajtowego jest indeks metody, którą wywołujemy.
  • invokeinteface – bardzo podobna instrukcja do invokevirtual z tą różnicą, że nie znamy nawet klasy, której metodę wywołujemy. W tym przypadku jednak mamy dwa argumenty tego kodu bajtowego – pierwszym jest jawne przekazanie uchwytu („wskaźnika”) do interfejsu, którego metodę chcemy wywołać, a drugim – indeks tej metody w danym interfejsie.

Różnice między invokevirtual a invokeinterface

Jak wywnioskować, którą metodę należy wykonać?
Najprościej byłoby sprawdzić klasę (wskaźnik na klasę jest w każdym obiekcie w jego nagłówku), której metodę chcemy wywołać i poszukać, czy istnieje implementacja poszukiwanej metody. Jeśli nie ma jej w tej klasie, szukamy jej w nadklasie. Jeśli i w niej nie ma, szukamy dalej w zwyż w hierarchii.

Jednak takie podejście jest mało efektywne. Mogłoby się zdarzyć, że wielokrotnie obliczamy to samo wywołując wciąż tę samą metodę. Zatem lepiej by było na etapie ładowania klasy obliczyć od razu, jaką dokładnie metodę trzeba wykonać dla każdej dostępnej metody. Wynik takich obliczeń można składować w metadanych danej klasy.
I właśnie taka tablica, która przechowuje dla każdej metody wskaźnik do kodu, jest nazywana vtable. Zatem za każdym razem, gdy używamy invokevirtual, najpierw sprawdzamy klasę obiektu, następnie odczytujemy z klasy adres do pola z tablicą vtable, a następnie uruchamiamy kod znaleziony pod indeksem danej metody (indeks ten jest podany w argumencie invokevirtual).

Warto tutaj nadmienić, że indeksy metod dla danej hierarchii klas są takie same, tzn. jeśli jakaś klasa miała tablicę vtable, to każda jej podklasa będzie miała wszystkie jej metody z nadklasy w dokładnie tej samej kolejności, a ewentualne dodatkowe metody specyficzne dla podklasy będą znajdować się na końcu tabeli. Zatem jeden argument z indeksem metody wystarczy do precyzyjnego określenia metody, gdyż indeks metody w danej hierarchii klas jest stały.

Nieco inna sytuacja jest w przypadku invokeinterface. Jeśli klasa implementuje interfejs, to metody go implementujące mogą być w vtable w różnej kolejności. Zatem dla interfejsu potrzebujemy analogicznej struktury zwanej itable. Kolejność metod jest specyficzna dla danego interfejsu.
„Niestety” klasy mogą implementować wiele interfejsów, dlatego każda klasa posiada tablicę tablic itable. Skoro jest wiele itable, to trzeba je rozróżniać, stąd konieczność przekazywania wraz z kodem invokeinterface argumentu w postaci odniesienia do interfejsu, jak i metody.
Pewną konsekwencją możliwości implementacji wielu interfejsów jest to, że żeby znaleźć odpowiednią itable trzeba przeiterować po tablicy tablic itable, co jest mało wydajne.

Jeśli ten opis jest zbyt mglisty, warto zajrzeć pod ten pomocny w zrozumieniu link.

A to tylko początek

Można na ten temat jeszcze wiele pisać. Póki co jednak zostawiam garść linków omawiających tematy wokół. Dotyczą one przede wszystkim:

  • optymalizacji invokeinterface/invokevirtual. Należy wspomnieć, że znaczna większość interfejsów/klas posiada tylko jedną klasę implementującą lub jedną podklasę. Można się wówczas pokusić o optymalizację i skuteczne inline’owanie. Słowa klucze dla tego tematu to: monomorphic, bimorphic, megamorphic.

    Osobiście polecam wpis Aleksey’a Shipilev’a oraz Richarda Warburtona

  • opisu wywołań wirtualnych/interfejsu prosto ze strony OpenJDK
  • porównania wywołań wirtualnych w różnych językach
  • wydajności obu wywołań oraz o bugu, który nie pozwala inline’ować jedynej implementacji interfejsu

A na razie to by było ode mnie na tyle. Jeśli chcecie sami coś po kombinować z interfejsami źródła moich eksperymentów są na githubie. Dajcie znać w komentarzach, co o tym wszystkim myślicie 😉