Czy buforowanie odwołań do metod to dobry pomysł w Javie 8?

81

Rozważ, że mam kod podobny do następującego:

class Foo {

   Y func(X x) {...} 

   void doSomethingWithAFunc(Function<X,Y> f){...}

   void hotFunction(){
        doSomethingWithAFunc(this::func);
   }

}

Załóżmy, że hotFunctionjest to wywoływane bardzo często. Czy byłoby wskazane buforowanie this::func, może w ten sposób:

class Foo {
     Function<X,Y> f = this::func;
     ...
     void hotFunction(){
        doSomethingWithAFunc(f);
     }
}

O ile rozumiem odwołania do metod w języku Java, maszyna wirtualna tworzy obiekt anonimowej klasy, gdy używane jest odwołanie do metody. Zatem buforowanie odwołania stworzyłoby ten obiekt tylko raz, podczas gdy pierwsze podejście tworzy go przy każdym wywołaniu funkcji. Czy to jest poprawne?

Czy odwołania do metod, które pojawiają się w gorących pozycjach w kodzie, powinny być buforowane, czy też maszyna wirtualna może to zoptymalizować i uczynić buforowanie zbędnym? Czy istnieje ogólna najlepsza praktyka w tym zakresie, czy też to wysoce implementacja maszyn wirtualnych jest specyficzna, czy takie buforowanie jest przydatne?

geksycyd
źródło
Powiedziałbym, że używasz tej funkcji tak często, że ten poziom strojenia jest potrzebny / pożądany, może lepiej byłoby odrzucić lambdy i wdrożyć funkcję bezpośrednio, co daje więcej miejsca na inne optymalizacje.
SJuan76
@ SJuan76: Nie jestem tego pewien! Jeśli odwołanie do metody jest kompilowane do anonimowej klasy, jest tak szybkie, jak zwykłe wywołanie interfejsu. Dlatego uważam, że nie należy unikać stylu funkcjonalnego w przypadku kodu aktywnego.
gexicide
4
Odwołania do metod są implementowane z invokedynamic. Wątpię, czy zauważysz poprawę wydajności poprzez buforowanie obiektu funkcji. Wręcz przeciwnie: może to uniemożliwić optymalizację kompilatora. Czy porównałeś wydajność obu wariantów?
nosid
@nosid: Nie, nie zrobiłem porównania. Ale używam bardzo wczesnej wersji OpenJDK, więc moje liczby i tak mogą nie mieć znaczenia, ponieważ sądzę, że pierwsza wersja implementuje tylko nowe funkcje szybko i brudno i nie można tego porównać z wydajnością, gdy funkcje dojrzeją z biegiem czasu. Czy specyfikacja naprawdę upoważnia do invokedynamicużycia? Nie widzę tutaj powodu!
gexicide
4
Powinien być automatycznie zapisywany w pamięci podręcznej (nie jest to równoznaczne z tworzeniem nowej klasy anonimowej za każdym razem), więc nie powinieneś się martwić o tę optymalizację.
assylias

Odpowiedzi:

83

Musisz dokonać rozróżnienia między częstymi wykonaniami tej samej strony wywołań , dla bezstanowych lambda lub stanowych lambd, a częstym użyciem odwołania do metody do tej samej metody (przez różne strony wywołań).

Spójrz na następujące przykłady:

    Runnable r1=null;
    for(int i=0; i<2; i++) {
        Runnable r2=System::gc;
        if(r1==null) r1=r2;
        else System.out.println(r1==r2? "shared": "unshared");
    }

Tutaj ta sama strona wywołania jest wykonywana dwa razy, tworząc bezstanową lambdę, a bieżąca implementacja zostanie wydrukowana "shared".

Runnable r1=null;
for(int i=0; i<2; i++) {
  Runnable r2=Runtime.getRuntime()::gc;
  if(r1==null) r1=r2;
  else {
    System.out.println(r1==r2? "shared": "unshared");
    System.out.println(
        r1.getClass()==r2.getClass()? "shared class": "unshared class");
  }
}

W tym drugim przykładzie ta sama witryna wywołania jest wykonywana dwa razy, tworząc lambdę zawierającą odwołanie do Runtimeinstancji, a bieżąca implementacja zostanie wydrukowana "unshared"ale "shared class".

Runnable r1=System::gc, r2=System::gc;
System.out.println(r1==r2? "shared": "unshared");
System.out.println(
    r1.getClass()==r2.getClass()? "shared class": "unshared class");

W przeciwieństwie do tego w ostatnim przykładzie są dwie różne witryny wywołań generujące równoważne odwołanie do metody, ale od tego 1.8.0_05momentu zostaną wydrukowane "unshared"i "unshared class".


Dla każdego wyrażenia lambda lub odwołania do metody kompilator wyemituje invokedynamicinstrukcję, która odwołuje się do metody ładowania początkowego dostarczonej przez środowisko JRE w klasie LambdaMetafactoryoraz statycznych argumentów niezbędnych do utworzenia żądanej klasy implementacji lambda. To, co produkuje fabryka meta, pozostawia faktycznemu środowisku JRE, ale jest to określone zachowanie invokedynamicinstrukcji, aby zapamiętać i ponownie użyć CallSiteinstancji utworzonej przy pierwszym wywołaniu.

Bieżące środowisko JRE tworzy obiekt stały ConstantCallSitezawierający a MethodHandledla bezstanowych lambd (i nie ma żadnego powodu, aby robić to inaczej). A odwołania do staticmetod są zawsze bezstanowe. Tak więc w przypadku lambd bezstanowych i witryn z pojedynczym wywołaniem odpowiedź musi brzmieć: nie buforuj, JVM zrobi, a jeśli nie, musi mieć mocne powody, których nie powinieneś przeciwdziałać.

W przypadku lambd mających parametry i this::funcjest to lambda, które ma odniesienie do thisinstancji, sytuacja wygląda nieco inaczej. Środowisko JRE może je buforować, ale oznaczałoby to utrzymywanie pewnego rodzaju Mapmiędzy rzeczywistymi wartościami parametrów a wynikową lambdą, co mogłoby być bardziej kosztowne niż tylko ponowne utworzenie tej prostej strukturalnej instancji lambda. Bieżące środowisko JRE nie buforuje wystąpień lambda mających stan.

Ale to nie znaczy, że klasa lambda jest tworzona za każdym razem. Oznacza to po prostu, że rozwiązana witryna wywołania będzie zachowywać się jak zwykła konstrukcja obiektu tworząca instancję klasy lambda, która została wygenerowana przy pierwszym wywołaniu.

Podobne rzeczy dotyczą odwołań do metod do tej samej metody docelowej utworzonej przez różne witryny wywołań. JRE może współdzielić między sobą jedną instancję lambda, ale w obecnej wersji nie, najprawdopodobniej dlatego, że nie jest jasne, czy utrzymanie pamięci podręcznej się opłaci. Tutaj nawet wygenerowane klasy mogą się różnić.


Zatem buforowanie, jak w twoim przykładzie, może sprawić, że twój program będzie robił inne rzeczy niż bez. Ale niekoniecznie bardziej wydajne. Obiekt buforowany nie zawsze jest bardziej wydajny niż obiekt tymczasowy. O ile naprawdę nie mierzysz wpływu na wydajność spowodowanego przez tworzenie lambda, nie powinieneś dodawać żadnego buforowania.

Myślę, że istnieją tylko specjalne przypadki, w których buforowanie może być przydatne:

  • mówimy o wielu różnych witrynach z połączeniami odnoszących się do tej samej metody
  • lambda jest tworzona w konstruktorze / klasie inicjalizacji, ponieważ później strona użycia będzie
    • być wywoływane przez wiele wątków jednocześnie
    • cierpią z powodu słabszej wydajności pierwszej inwokacji
Holger
źródło
5
Wyjaśnienie: termin „call-site” odnosi się do wykonania invokedynamicinstrukcji, która utworzy lambdę. Nie jest to miejsce, w którym zostanie wykonana metoda interfejsu funkcjonalnego.
Holger
1
Pomyślałem, że this-capturing lambdy były singletonami o zasięgu instancji (syntetyczna zmienna instancji w obiekcie). Tak nie jest?
Marko Topolnik
2
@Marko Topolnik: to byłaby zgodna strategia kompilacji, ale nie, ponieważ jdk Oracle do 1.8.0_40tego nie jest. Te lambdy nie są zapamiętywane, więc mogą być zbierane jako śmieci. Pamiętaj jednak, że po połączeniu invokedynamiccallsite może zostać zoptymalizowany jak zwykły kod, tj. Escape Analysis działa dla takich instancji lambda.
Holger,
2
Wydaje się, że nie ma standardowej klasy biblioteki o nazwie MethodReference. Masz na myśli MethodHandletutaj?
Lii
2
@Lii: masz rację, to literówka. Co ciekawe, nikt wcześniej tego nie zauważył.
Holger
11

Sytuacja, w której jest to dobry ideał, niestety, to sytuacja, gdy lambda jest przekazywana jako słuchacz, którego chcesz usunąć w pewnym momencie w przyszłości. Odwołanie z pamięci podręcznej będzie potrzebne, ponieważ przekazanie innego odwołania this :: do metody nie będzie postrzegane jako ten sam obiekt podczas usuwania, a oryginał nie zostanie usunięty. Na przykład:

public class Example
{
    public void main( String[] args )
    {
        new SingleChangeListenerFail().listenForASingleChange();
        SingleChangeListenerFail.observableValue.set( "Here be a change." );
        SingleChangeListenerFail.observableValue.set( "Here be another change that you probably don't want." );

        new SingleChangeListenerCorrect().listenForASingleChange();
        SingleChangeListenerCorrect.observableValue.set( "Here be a change." );
        SingleChangeListenerCorrect.observableValue.set( "Here be another change but you'll never know." );
    }

    static class SingleChangeListenerFail
    {
        static SimpleStringProperty observableValue = new SimpleStringProperty();

        public void listenForASingleChange()
        {
            observableValue.addListener(this::changed);
        }

        private<T> void changed( ObservableValue<? extends T> observable, T oldValue, T newValue )
        {
            System.out.println( "New Value: " + newValue );
            observableValue.removeListener(this::changed);
        }
    }

    static class SingleChangeListenerCorrect
    {
        static SimpleStringProperty observableValue = new SimpleStringProperty();
        ChangeListener<String> lambdaRef = this::changed;

        public void listenForASingleChange()
        {
            observableValue.addListener(lambdaRef);
        }

        private<T> void changed( ObservableValue<? extends T> observable, T oldValue, T newValue )
        {
            System.out.println( "New Value: " + newValue );
            observableValue.removeListener(lambdaRef);
        }
    }
}

Byłoby miło nie potrzebować lambdaRef w tym przypadku.

user2219808
źródło
Ach, teraz rozumiem, o co chodzi. Brzmi rozsądnie, choć prawdopodobnie nie jest to scenariusz, o którym mówi PO. Niemniej jednak głosowano.
Tagir Valeev
9

O ile rozumiem specyfikację języka, pozwala na tego rodzaju optymalizację, nawet jeśli zmienia obserwowalne zachowanie. Zobacz następujące cytaty z sekcji JSL8 §15.13.3 :

§15.13.3 Ocena odniesień do metod w czasie wykonywania

W czasie wykonywania ocena wyrażenia odwołania do metody jest podobna do oceny wyrażenia tworzenia instancji klasy, o ile normalne zakończenie tworzy odniesienie do obiektu. [..]

[..] Albo zostanie przydzielone i zainicjowane nowe wystąpienie klasy z poniższymi właściwościami lub istnieje odwołanie do istniejącego wystąpienia klasy z poniższymi właściwościami.

Prosty test pokazuje, że odwołania do metod dla metod statycznych (mogą) skutkować tym samym odniesieniem dla każdej oceny. Poniższy program wypisuje trzy wiersze, z których pierwsze dwa są identyczne:

public class Demo {
    public static void main(String... args) {
        foobar();
        foobar();
        System.out.println((Runnable) Demo::foobar);
    }
    public static void foobar() {
        System.out.println((Runnable) Demo::foobar);
    }
}

Nie mogę odtworzyć tego samego efektu dla funkcji niestatycznych. Jednak w specyfikacji języka nie znalazłem nic, co mogłoby utrudniać tę optymalizację.

Tak więc, o ile nie ma analizy wydajności w celu określenia wartości tej ręcznej optymalizacji, zdecydowanie odradzam to. Buforowanie wpływa na czytelność kodu i nie jest jasne, czy ma on jakąkolwiek wartość. Przedwczesna optymalizacja jest źródłem wszelkiego zła.

nosid
źródło