¿Es una buena idea el almacenamiento en caché de referencias de métodos en Java 8?

81

Considere que tengo un código como el siguiente:

class Foo {

   Y func(X x) {...} 

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

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

}

Supongamos que hotFunctionse llama muy a menudo. Entonces, ¿sería aconsejable almacenar en caché this::func, tal vez así:

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

En lo que respecta a mi comprensión de las referencias al método Java, la máquina virtual crea un objeto de una clase anónima cuando se utiliza una referencia al método. Por lo tanto, almacenar en caché la referencia crearía ese objeto solo una vez, mientras que el primer enfoque lo crea en cada llamada de función. ¿Es esto correcto?

¿Deben almacenarse en caché las referencias a métodos que aparecen en posiciones activas en el código o la máquina virtual puede optimizar esto y hacer que el almacenamiento en caché sea superfluo? ¿Existe una práctica recomendada general al respecto o esta implementación de VM es muy específica, ya sea que dicho almacenamiento en caché sea de alguna utilidad?

gexicida
fuente
Yo diría que usa esa función tanto que este nivel de ajuste es necesario / deseable, tal vez sería mejor eliminar lambdas e implementar la función directamente, lo que da más espacio para otras optimizaciones.
SJuan76
@ SJuan76: ¡No estoy seguro de esto! Si una referencia de método se compila en una clase anónima, es tan rápido como una llamada de interfaz habitual. Por lo tanto, no creo que deba evitarse el estilo funcional para el código activo.
gexicida
4
Las referencias a métodos se implementan con invokedynamic. Dudo que vea una mejora en el rendimiento almacenando en caché el objeto de función. Al contrario: podría inhibir las optimizaciones del compilador. ¿Comparaste el rendimiento de las dos variantes?
nosid
@nosid: No, no hice una comparación. Pero estoy usando una versión muy temprana de OpenJDK, por lo que mis números podrían no tener importancia de todos modos, ya que supongo que la primera versión solo implementa las nuevas funciones quick 'n' dirty y esto no se puede comparar con el rendimiento cuando las funciones han madurado. tiempo extraordinario. ¿La especificación realmente invokedynamicexige que se deba usar? ¡No veo ninguna razón para esto aquí!
gexicida
4
Debería almacenarse en caché automáticamente (no es equivalente a crear una nueva clase anónima cada vez), por lo que no debería tener que preocuparse por esa optimización.
Assylias

Respuestas:

82

Debe hacer una distinción entre ejecuciones frecuentes del mismo sitio de llamada , para lambda sin estado o lambdas con estado, y usos frecuentes de una referencia de método al mismo método (por diferentes sitios de llamada).

Mira los siguientes ejemplos:

    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");
    }

Aquí, el mismo sitio de llamada se ejecuta dos veces, produciendo una lambda sin estado y se imprimirá la implementación actual "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");
  }
}

En este segundo ejemplo, el mismo sitio de llamada se ejecuta dos veces, produciendo una lambda que contiene una referencia a un Runtime instancia y la implementación actual imprimirá "unshared"pero "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");

Por el contrario, en el último ejemplo hay dos sitios de llamadas diferentes que producen una referencia de método equivalente, pero a partir de 1.8.0_05este se imprimirán "unshared"y "unshared class".


Para cada expresión lambda o referencia de método, el compilador emitirá una invokedynamicinstrucción que se refiere a un método bootstrap proporcionado por JRE en la clase LambdaMetafactoryy los argumentos estáticos necesarios para producir la clase de implementación lambda deseada. Se deja al JRE real lo que produce la metafábrica, pero es un comportamiento específico de la invokedynamicinstrucción recordar y reutilizar la CallSiteinstancia creada en la primera invocación.

El JRE actual produce un ConstantCallSite contiene una MethodHandlea una constante para lambdas sin estado (y no hay ninguna razón imaginable para hacerlo de manera diferente). Y referencias de métodos astatic método método siempre son apátridas. Entonces, para lambdas sin estado y sitios de llamada única, la respuesta debe ser: no almacenar en caché, la JVM funcionará y, si no lo hace, debe tener fuertes razones por las que no debe contrarrestar.

Para lambdas que tienen parámetros, y this::func es una lambda que tiene una referencia a la thisinstancia, las cosas son un poco diferentes. El JRE puede almacenarlos en caché, pero esto implicaría mantener algún tipo de valor Mapentre los valores reales de los parámetros y la lambda resultante, lo que podría ser más costoso que simplemente crear de nuevo esa instancia lambda estructurada simple. El JRE actual no almacena en caché las instancias lambda que tienen un estado.

Pero esto no significa que la clase lambda se cree siempre. Simplemente significa que el sitio de llamada resuelto se comportará como una construcción de objeto ordinaria que instancia la clase lambda que se ha generado en la primera invocación.

Se aplican cosas similares a las referencias de métodos al mismo método de destino creado por diferentes sitios de llamada. El JRE puede compartir una única instancia lambda entre ellos, pero en la versión actual no lo hace, probablemente porque no está claro si el mantenimiento de la caché dará sus frutos. Aquí, incluso las clases generadas pueden diferir.


Por lo tanto, el almacenamiento en caché como en su ejemplo podría hacer que su programa haga cosas diferentes que sin él. Pero no necesariamente más eficiente. Un objeto en caché no siempre es más eficiente que un objeto temporal. A menos que realmente mida un impacto en el rendimiento causado por una creación de lambda, no debe agregar ningún almacenamiento en caché.

Creo que solo hay algunos casos especiales en los que el almacenamiento en caché podría ser útil:

  • estamos hablando de muchos sitios de llamadas diferentes que se refieren al mismo método
  • la lambda se crea en el constructor / class initialize porque más tarde el use-site
    • ser llamado por varios subprocesos al mismo tiempo
    • sufre el menor rendimiento de la primera invocación
Holger
fuente
5
Aclaración: el término "call-site" se refiere a la ejecución de la invokedynamicinstrucción que creará el lambda. No es el lugar donde se ejecutará el método de interfaz funcional.
Holger
1
Pensé que this-capturar lambdas eran singletons de ámbito de instancia (una variable de instancia sintética en el objeto). ¿No es así?
Marko Topolnik
2
@Marko Topolnik: esa sería una estrategia de compilación compatible, pero no, ya que el jdk de Oracle 1.8.0_40no es así. Estas lambdas no se recuerdan, por lo que se pueden recolectar como basura. Pero recuerde que una vez que invokedynamicse ha vinculado un sitio de llamadas, puede optimizarse como el código normal, es decir, el Análisis de escape funciona para tales instancias lambda.
Holger
2
No parece haber una clase de biblioteca estándar llamada MethodReference. ¿Quieres decir MethodHandleaquí?
Lii
2
@Lii: tienes razón, eso es un error tipográfico. Curiosamente, nadie parece haberse dado cuenta antes.
Holger
11

Una situación en la que es un buen ideal, desafortunadamente, es si la lambda se pasa como un oyente que desea eliminar en algún momento en el futuro. Se necesitará la referencia en caché, ya que pasar otra referencia de este :: método no se verá como el mismo objeto en la eliminación y el original no se eliminará. Por ejemplo:

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);
        }
    }
}

Hubiera sido bueno no necesitar lambdaRef en este caso.

usuario2219808
fuente
Ah, ahora veo el punto. Suena razonable, aunque probablemente no sea el escenario del que habla el OP. Sin embargo, votó a favor.
Tagir Valeev
8

Por lo que entiendo la especificación del lenguaje, permite este tipo de optimización incluso si cambia el comportamiento observable. Consulte las siguientes citas de la sección JSL8 §15.13.3 :

§15.13.3 Evaluación en tiempo de ejecución de referencias de métodos

En tiempo de ejecución, la evaluación de una expresión de referencia de método es similar a la evaluación de una expresión de creación de instancia de clase, en la medida en que la finalización normal produce una referencia a un objeto. [..]

[..] Se asigna e inicializa una nueva instancia de una clase con las propiedades a continuación, o se hace referencia a una instancia existente de una clase con las propiedades a continuación.

Una prueba simple muestra que las referencias de métodos para métodos estáticos (pueden) dar como resultado la misma referencia para cada evaluación. El siguiente programa imprime tres líneas, de las cuales las dos primeras son idénticas:

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);
    }
}

No puedo reproducir el mismo efecto para funciones no estáticas. Sin embargo, no he encontrado nada en la especificación del lenguaje que inhiba esta optimización.

Por lo tanto, mientras no haya un análisis de rendimiento para determinar el valor de esta optimización manual, lo desaconsejo. El almacenamiento en caché afecta la legibilidad del código y no está claro si tiene algún valor. La optimización prematura es la fuente de todos los males.

nosid
fuente