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

Autor: jgardo

Programista Java od 2013 roku. Interesuje się niskopoziomową Javą, ekosystemem Jvm i jego wydajnością. Co jednak nie przeszkadza w przywiązywaniu uwagi do czystości kodu w życiu codziennym ;) Pracuje w PayU.

3 myśli w temacie “Final – klasy i metody”

Dodaj komentarz

Twój adres email nie zostanie opublikowany. Pola, których wypełnienie jest wymagane, są oznaczone symbolem *