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:”<ścieżka do jar>”, 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… 😉

Oceń wpis

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 finals5,3
Constructor Injection without finals4,72
Field Injection4,46
Setter Injection6,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 😉

Oceń wpis

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.

Oceń wpis

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.

Oceń wpis

Final – klasy i metody

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.

Zatem porównując listingi Final i NonFinal można zauważyć jedynie różnicę w używaniu pamięci – 8 bajtów różnicy w metadanych (użycie finala oszczędza tych 8 bajtów).

Spoglądając w kod źródłowy OpenJDK można zauważyć, że często w miejscach gdzie jest sprawdzany final dla metod/klas, znaleźć można również odniesienia do CHA (przykładowo klasa ciMethod.cpp). Z tego względu trudno znaleźć jakieś optymalizacje dające przewagę na korzyść finala.

Podsumowanie

Jak widać, nie ma prócz oszczędności 8 bajtów dla C2 nie ma zalet używania finala na metodach/klasach pod kątem wydajności. Wynika to jednak nie z ułomności JVMa, a raczej z możliwości wnioskowania, czy dana metoda/klasa jest finalna.

Ten artykuł rozpoczyna „kodyfikację” mojej prezentacji „Final – w poszukiwaniu wydajności” na DevCrowd/Confitura 2019/4Developers Poznań.
Będę wrzucał kolejne fragmenty prezentacji.

Jeśli ktoś bardzo chciałby zobaczyć wspomnianą prezentację, to można ją obejrzeć na youtube. Jakkolwiek, aż sam się stresuję, gdy oglądam siebie, jak się stresuję, więc może lepiej poczekać na kolejne artykuły 😉

Następny artykuł z serii final: zmienne lokalne i argumenty metod.

Oceń wpis