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

Taka refleksja tylko szybsza…

„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

Oceń wpis