No obligue a los oyentes a reflexionar

Introduccion



Durante el proceso de desarrollo, a menudo es necesario crear una instancia de una clase cuyo nombre se almacena en el archivo de configuración XML, o llamar a un método cuyo nombre se escribe como una cadena como el valor del atributo de anotación. En tales casos, la respuesta es una: "¡Use la reflexión!".


En la nueva versión de CUBA Platform, una de las tareas para mejorar el marco era deshacerse de la creación explícita de controladores de eventos en las clases de controlador de las pantallas de la interfaz de usuario. En versiones anteriores, las declaraciones del controlador en el método de inicialización del controlador estaban muy desordenadas con el código, por lo que en la séptima versión decidimos limpiar todo.


Un detector de eventos es solo una referencia al método que debe llamarse en el momento adecuado (consulte la plantilla del Observador ). Dicha plantilla es bastante simple de implementar usando la clase java.lang.reflect.Method . Al inicio, solo necesita escanear las clases, extraer los métodos anotados de ellas, guardar las referencias a ellas y usar los enlaces para llamar al método (o métodos) cuando ocurre el evento, como se hace en la mayor parte de los marcos. Lo único que nos detuvo fue que muchos eventos se generan tradicionalmente en la interfaz de usuario, y cuando se usa la API de reflexión, debe pagar algún precio en la forma de la llamada al método. Por lo tanto, decidimos analizar de qué otra manera puede hacer manejadores de eventos sin usar la reflexión.


Ya publicamos materiales sobre MethodHandles y LambdaMetafactory en un habr , y este material es una especie de continuación. Examinaremos los pros y los contras del uso de la API de reflexión, así como las alternativas: generar código con la compilación AOT y LambdaMetafactory, y cómo se usó en el marco CUBA.


Reflexión: Viejo. Bueno Confiable


En informática, reflexión o reflexión (el holónimo de introspección, reflexión en inglés) significa un proceso durante el cual un programa puede rastrear y modificar su propia estructura y comportamiento en tiempo de ejecución. (c) Wikipedia.


Para la mayoría de los desarrolladores de Java, la reflexión nunca es algo nuevo. Me parece que sin este mecanismo, Java no se habría convertido en ese Java, que ahora ocupa una gran cuota de mercado en el desarrollo de software de aplicaciones. Solo piense: proxying, vinculando métodos a eventos a través de anotaciones, inyección de dependencias, aspectos e incluso instanciando el controlador JDBC en las primeras versiones de JDK. La reflexión en todas partes es la piedra angular de todos los marcos modernos.


¿Hay algún problema con Reflection aplicado a nuestra tarea? Hemos identificado tres:


Velocidad : una llamada de método a través de la API de Reflection es más lenta que una llamada directa. En cada nueva versión de JVM, los desarrolladores aceleran constantemente las llamadas a través de la reflexión, el compilador JIT intenta optimizar aún más el código, pero de todos modos, la diferencia en comparación con la llamada al método directo es notable.


Escribir : si usa java.lang.reflect.Method en el código, entonces esto es solo una referencia a algún método. Y en ninguna parte está escrito cuántos parámetros se pasan y de qué tipo son. Una llamada con los parámetros incorrectos generará un error en tiempo de ejecución y no en la etapa de compilación o descarga de la aplicación.


Transparencia : si el método llamado a través de la reflexión falla, tendremos que pasar por varias llamadas invoke() antes de llegar al fondo de la causa real del error.


Pero si miramos el código de los controladores de eventos Spring o JPA en Hibernate, entonces el viejo java.lang.reflect.Method estará dentro. Y en el futuro cercano, creo que es poco probable que esto cambie. Estos marcos son demasiado grandes y están demasiado vinculados a ellos, y parece que el rendimiento de los controladores de eventos en el lado del servidor es suficiente para pensar en lo que puede reemplazar las llamadas a través de la reflexión.


¿Y qué otras opciones hay?


Compilación AOT y generación de código: ¡devuelva la velocidad a las aplicaciones!


El primer candidato para reemplazar la API de reflexión es la generación de código. Ahora han comenzado a aparecer marcos como Micronaut o Quarkus , que intentan resolver dos problemas: reducir la velocidad de inicio de la aplicación y reducir el consumo de memoria. Estas dos métricas son vitales en nuestra era de contenedores, microservicios y arquitecturas sin servidor, y los nuevos marcos están tratando de resolver esto mediante la compilación AOT. Usando diferentes técnicas (puede leer aquí , por ejemplo), el código de la aplicación se modifica de tal manera que todas las llamadas reflexivas a métodos, constructores, etc. reemplazado por llamadas directas. Por lo tanto, no necesita escanear clases y crear beans en el momento del inicio de la aplicación, y JIT optimiza el código de manera más eficiente en tiempo de ejecución, lo que proporciona un aumento significativo en el rendimiento de las aplicaciones creadas en dichos marcos. ¿Este enfoque tiene desventajas? Respuesta: por supuesto que la hay.


Primero, no ejecuta el código que escribió. El código fuente cambia durante la compilación, por lo que si algo sale mal, a veces es difícil entender dónde está el error: en su código o en el algoritmo de generación (generalmente en el suyo, por supuesto ) Y a partir de aquí surge el problema de depuración: debe depurar su propio código.


El segundo: para ejecutar una aplicación escrita en el marco con la compilación AOT, necesita una herramienta especial. No puede simplemente obtener y ejecutar una aplicación escrita en Quarkus, por ejemplo. Necesitamos un complemento especial para maven / gradle, que preprocesará su código. Y ahora, en caso de errores en el marco, debe actualizar no solo las bibliotecas, sino también el complemento.


En verdad, la generación de código tampoco es nueva en el mundo de Java; no apareció con Micronaut o Quarkus . De una forma u otra, algunos marcos lo usan. Aquí podemos recordar lombok, aspectoj con su generación preliminar de código para aspectos o eclipselink, que agrega código a las clases de entidad para una deserialización más eficiente. En CUBA, utilizamos la generación de código para generar eventos sobre cambios en el estado de una entidad e incluir mensajes de validación en el código de clase para simplificar el trabajo con entidades en la IU.


Para los desarrolladores de CUBA, implementar la generación de código estático para los controladores de eventos sería un paso extremo porque se tuvieron que hacer muchos cambios en la arquitectura interna y en el complemento para la generación de código. ¿Hay algo que parezca un reflejo pero más rápido?


LambdaMetafactory: llamadas al mismo método, pero más rápido


Java 7 introdujo una nueva instrucción para la JVM: invokedynamic . Sobre ella hay un excelente informe de Vladimir Ivanov en jug.ru aquí . Originalmente concebida para su uso en lenguajes dinámicos como Groovy, esta instrucción fue un gran candidato para invocar métodos en Java sin usar la reflexión. Al mismo tiempo que la nueva instrucción, apareció una API asociada en el JDK:


  • Class MethodHandle : apareció en Java 7, pero todavía no se usa con mucha frecuencia
  • LambdaMetafactory : esta clase ya es de Java 8, se convirtió en un desarrollo adicional de la API para llamadas dinámicas, utiliza MethodHandle en MethodHandle interior.

Parecía que MethodHandle , siendo esencialmente un puntero escrito a un método (constructor, etc.), podría cumplir el rol de java.lang.reflect.Method . Y las llamadas serán más rápidas, porque todas las comprobaciones de tipo que se realizan en la API de Reflection con cada llamada, en este caso, se realizan solo una vez, cuando MethodHandle .


Pero, por desgracia, el MethodHandle puro resultó ser incluso más lento que las llamadas a través de la API de reflexión. Se pueden lograr ganancias de rendimiento haciendo que MethodHandle estático, pero no en todos los casos. Hay una excelente discusión sobre la velocidad de MethodHandle llamadas a MethodHandle en la lista de correo de OpenJDK .


Pero cuando LambdaMetafactory clase LambdaMetafactory , hubo una posibilidad real de acelerar las llamadas a métodos. LambdaMetafactory permite crear un objeto lambda y envolver una llamada de método directo en él, que se puede obtener a través de MethodHandle . Y luego, usando el objeto generado, puede llamar al método deseado. Aquí hay un ejemplo de la generación que envuelve el método getter pasado como parámetro a BiFunction:


 private BiFunction createGetHandlerLambda(Object bean, Method method) throws Throwable { MethodHandles.Lookup caller = MethodHandles.lookup(); CallSite site = LambdaMetafactory.metafactory(caller, "apply", MethodType.methodType(BiFunction.class), MethodType.methodType(Object.class, Object.class, Object.class), caller.findVirtual(bean.getClass(), method.getName(), MethodType.methodType(method.getReturnType(), method.getParameterTypes()[0])), MethodType.methodType(method.getReturnType(), bean.getClass(), method.getParameterTypes()[0])); MethodHandle factory = site.getTarget(); BiFunction listenerMethod = (BiFunction) factory.invoke(); return listenerMethod; } 

Como resultado, obtenemos una instancia de BiFunction en lugar de Method. Y ahora, incluso si usamos Método en nuestro código, reemplazarlo con BiFunction no es difícil. Tome el código real (ligeramente simplificado, verdadero) para llamar al manejador de métodos, marcado @EventListener desde Spring Framework:


 public class ApplicationListenerMethodAdapter implements GenericApplicationListener { private final Method method; public void onApplicationEvent(ApplicationEvent event) { Object bean = getTargetBean(); Object result = this.method.invoke(bean, event); handleResult(result); } } 

Y aquí está el mismo código, pero que utiliza una llamada de método a través de una lambda:


 public class ApplicationListenerLambdaAdapter extends ApplicationListenerMethodAdapter { private final BiFunction funHandler; public void onApplicationEvent(ApplicationEvent event) { Object bean = getTargetBean(); Object result = funHandler.apply(bean, event); handleResult(result); } } 

Cambios mínimos, la funcionalidad es la misma, pero hay ventajas:


Una lambda tiene un tipo : se especifica en la creación, por lo que fallará llamar "solo un método".


La pila de rastreo es más corta : al llamar a un método a través de una lambda, solo se agrega una llamada adicional: apply() . Y eso es todo. A continuación, se llama al método en sí.


Pero la velocidad debe ser medida.


Medir la velocidad


Para probar la hipótesis, creamos un microbenchmark usando JMH para comparar el tiempo de ejecución y el rendimiento al llamar al mismo método de diferentes maneras: a través de la API de reflexión, a través de LambdaMetafactory, y también agregamos una llamada directa al método para comparar. Se crearon enlaces a Method y lambdas antes de que comenzara la prueba.


Parámetros de prueba:


 @BenchmarkMode({Mode.Throughput, Mode.AverageTime}) @Warmup(iterations = 5, time = 1000, timeUnit = TimeUnit.MILLISECONDS) @Measurement(iterations = 10, time = 1000, timeUnit = TimeUnit.MILLISECONDS) 

La prueba en sí se puede descargar desde GitHub y ejecutarla usted mismo, si está interesado.


Resultados de la prueba para Oracle JDK 11.0.2 y JMH 1.21 (los números pueden variar, pero la diferencia sigue siendo notable y casi la misma):


Prueba - Obtenga valorRendimiento (ops / us)Tiempo de ejecución (us / op)
LambdaGetTest720,0118
ReflectionGetTest650,0177
DirectMethodGetTest2600.0048
Prueba - Establecer valorRendimiento (ops / us)Tiempo de ejecución (us / op
LambdaSetTest960.0092
ReflectionSetTest580,0173
DirectMethodSetTest4150.0031

En promedio, resultó que llamar a un método a través de una lambda es aproximadamente un 30% más rápido que a través de una API de reflexión. Hay otra gran discusión sobre el rendimiento de la invocación de métodos aquí si alguien está interesado en los detalles. En resumen: la ganancia en velocidad se obtiene, entre otras cosas, debido al hecho de que las lambdas generadas se pueden incluir en el código del programa, y ​​los controles de tipo aún no se realizan, a diferencia de lo que ocurre con la reflexión.


Por supuesto, este punto de referencia es bastante simple, no incluye métodos de llamada en una jerarquía de clases o medición de la velocidad de llamada a los métodos finales. Pero hicimos mediciones más complejas, y los resultados siempre estuvieron a favor del uso de LambdaMetafactory.


Uso


En el marco de trabajo de CUBA versión 7, en los controladores de IU, puede usar la anotación @Subscribe para "firmar" un método para ciertos eventos de la interfaz de usuario. Internamente, esto se implementa en LambdaMetafactory , los enlaces a los métodos de escucha se crean y almacenan en caché en la primera llamada.


Esta innovación permitió borrar en gran medida el código, especialmente en el caso de formularios con una gran cantidad de elementos, interacción compleja y, en consecuencia, con una gran cantidad de controladores de eventos. Un ejemplo simple de QuickStart de CUBA: imagine que necesita volver a calcular el monto del pedido al agregar o eliminar artículos del producto. Debe escribir código que ejecute el método calculateAmount() cuando la colección cambie en la entidad. Cómo se veía antes:


 public class OrderEdit extends AbstractEditor<Order> { @Inject private CollectionDatasource<OrderLine, UUID> linesDs; @Override public void init( Map<String, Object> params) { linesDs.addCollectionChangeListener(e -> calculateAmount()); } ... } 

Y en CUBA 7, el código se ve así:


 public class OrderEdit extends StandardEditor<Order> { @Subscribe(id = "linesDc", target = Target.DATA_CONTAINER) protected void onOrderLinesDcCollectionChange (CollectionChangeEvent<OrderLine> event) { calculateAmount(); } ... } 

En pocas palabras: el código es más limpio y no existe un método mágico init() , que tiende a crecer y llenarse de controladores de eventos con una complejidad creciente del formulario. Y, sin embargo, ni siquiera necesitamos hacer un campo con el componente al que nos estamos suscribiendo, CUBA encontrará este componente por ID.


Conclusiones


A pesar de la aparición de una nueva generación de marcos con compilación AOT ( Micronaut , Quarkus ), que tienen ventajas innegables sobre los marcos "tradicionales" (principalmente, se comparan con Spring ), todavía hay una gran cantidad de código escrito utilizando la API de reflexión (y gracias por la misma primavera). Y parece que Spring Framework sigue siendo el líder entre los marcos de desarrollo de aplicaciones y trabajaremos con código basado en la reflexión durante mucho tiempo.


Y si está pensando en usar la API de Reflection en su código, ya sea una aplicación o un marco, piense dos veces. Primero, sobre la generación de código, y luego sobre MethodHandles / LambdaMetafactory. El segundo método puede resultar más rápido, y los esfuerzos de desarrollo no se gastarán más que en el caso de usar la API Reflection.


Algunos enlaces más útiles:
Una alternativa más rápida a Java Reflection
Hackear expresiones lambda en Java
Método maneja en Java
Reflexión de Java, pero mucho más rápido
¿Por qué es LambdaMetafactory un 10% más lento que un MethodHandle estático pero un 80% más rápido que un MethodHandle no estático?
Demasiado rápido, demasiado megamórfico: ¿qué influye en el rendimiento de las llamadas a métodos en Java?

Source: https://habr.com/ru/post/447022/


All Articles