Não force os ouvintes a refletir

1. Introdução



Durante o processo de desenvolvimento, muitas vezes é necessário criar uma instância de uma classe cujo nome esteja armazenado no arquivo de configuração XML ou chamar um método cujo nome seja gravado como uma seqüência de caracteres como o valor do atributo de anotação. Nesses casos, a resposta é uma: "Use reflexão!".


Na nova versão da plataforma CUBA, uma das tarefas para melhorar a estrutura era livrar-se da criação explícita de manipuladores de eventos nas classes de controladores das telas da interface do usuário. Nas versões anteriores, as declarações do manipulador no método de inicialização do controlador eram muito confusas com o código, portanto, na sétima versão, decidimos limpar tudo.


Um ouvinte de evento é apenas uma referência ao método que precisa ser chamado no momento certo (consulte o modelo do Observer ). Esse modelo é bastante simples de implementar usando a classe java.lang.reflect.Method . Na inicialização, você só precisa varrer as classes, retirar os métodos anotados, salvar as referências e usar os links para chamar o método (ou métodos) quando o evento ocorrer, como é feito na maioria das estruturas. A única coisa que nos impediu foi que muitos eventos são gerados tradicionalmente na interface do usuário e, ao usar a API de reflexão, você deve pagar algum preço na forma da hora da chamada do método. Portanto, decidimos analisar de que outra maneira você pode criar manipuladores de eventos sem usar reflexão.


Já publicamos materiais sobre MethodHandles e LambdaMetafactory em um habr , e esse material é um tipo de continuação. Examinaremos os prós e os contras do uso da API de reflexão, bem como alternativas - geração de código com compilação AOT e LambdaMetafactory, e como ela foi usada na estrutura CUBA.


Reflexão: Antigo. Bom Confiável


Em ciência da computação, reflexão ou reflexão (o holônimo de introspecção, reflexão em inglês) significa um processo durante o qual um programa pode rastrear e modificar sua própria estrutura e comportamento em tempo de execução. (c) Wikipedia.


Para a maioria dos desenvolvedores Java, a reflexão nunca é uma coisa nova. Parece-me que sem esse mecanismo, o Java não se tornaria aquele Java, que agora ocupa uma grande participação de mercado no desenvolvimento de aplicativos. Pense: proxying, vinculando métodos a eventos por meio de anotações, injeção de dependência, aspectos e até instanciando o driver JDBC nas primeiras versões do JDK! A reflexão em todos os lugares é a pedra angular de todos os quadros modernos.


Há algum problema com o Reflection aplicado à nossa tarefa? Identificamos três:


Velocidade - uma chamada de método através da API do Reflection é mais lenta que uma chamada direta. Em cada nova versão da JVM, os desenvolvedores aceleram constantemente as chamadas através da reflexão, o compilador JIT tenta otimizar ainda mais o código, mas, de qualquer maneira, a diferença em comparação com a chamada direta ao método é perceptível.


Digitação - se você usar java.lang.reflect.Method no código, isso será apenas uma referência a algum método. E em nenhum lugar está escrito quantos parâmetros são passados ​​e que tipo são. Uma chamada com parâmetros errados gerará um erro no tempo de execução, e não no estágio de compilação ou download do aplicativo.


Transparência - se o método chamado através da reflexão falhar, teremos que percorrer várias chamadas invoke() antes de chegarmos ao fundo da causa real do erro.


Mas se observarmos o código dos manipuladores de eventos Spring ou JPA no Hibernate, o bom e velho java.lang.reflect.Method estará dentro. E em um futuro próximo, acho improvável que isso mude. Essas estruturas são muito grandes e muito vinculadas a elas, e parece que o desempenho dos manipuladores de eventos no lado do servidor é suficiente para pensar no que você pode substituir as chamadas através da reflexão.


E que outras opções existem?


Compilação AOT e geração de código - devolva aos aplicativos velocidade!


O primeiro candidato a substituir a API de reflexão é a geração de código. Agora, estruturas como Micronaut ou Quarkus começaram a aparecer, tentando resolver dois problemas: reduzindo a velocidade de inicialização do aplicativo e reduzindo o consumo de memória. Essas duas métricas são vitais na nossa era de contêineres, microsserviços e arquiteturas sem servidor, e novas estruturas estão tentando resolver isso através da compilação AOT. Usando técnicas diferentes (você pode ler aqui , por exemplo), o código do aplicativo é modificado de tal maneira que todas as chamadas reflexivas para métodos, construtores etc. substituído por chamadas diretas. Portanto, você não precisa varrer classes e criar beans no momento da inicialização do aplicativo, e o JIT otimiza o código com mais eficiência no tempo de execução, o que proporciona um aumento significativo no desempenho dos aplicativos criados nessas estruturas. Essa abordagem tem desvantagens? Resposta: claro que existe.


Primeiro, você não executa o código que escreveu: o código-fonte muda durante a compilação; portanto, se algo der errado, às vezes é difícil entender onde está o erro: no seu código ou no algoritmo de geração (geralmente no seu, é claro) ) E a partir daqui surge o problema de depuração - você precisa depurar seu próprio código.


O segundo - para executar um aplicativo escrito na estrutura com a compilação AOT, você precisa de uma ferramenta especial. Você não pode simplesmente obter e executar um aplicativo escrito em Quarkus, por exemplo. Precisamos de um plugin especial para o maven / gradle, que pré-processará seu código. E agora, no caso de erros na estrutura, você precisa atualizar não apenas as bibliotecas, mas também o plug-in.


Na verdade, a geração de código também não é nova no mundo Java; ela não apareceu no Micronaut ou no Quarkus . De uma forma ou de outra, algumas estruturas o usam. Aqui podemos lembrar o lombok, aspectj, com sua geração preliminar de código para aspectos ou eclipselink, que adiciona código às classes de entidade para desserialização mais eficiente. No CUBA, usamos a geração de código para gerar eventos sobre alterações no estado de uma entidade e incluir mensagens de validador no código de classe para simplificar o trabalho com entidades na interface do usuário.


Para os desenvolvedores do CUBA, implementar a geração de código estático para manipuladores de eventos seria um passo extremo, pois muitas mudanças precisavam ser feitas na arquitetura interna e no plug-in para geração de código. Existe algo que parece reflexão, mas mais rápido?


LambdaMetafactory - o mesmo método chama, mas mais rápido


O Java 7 introduziu uma nova instrução para a JVM - invokedynamic . Sobre ela, há um excelente relatório de Vladimir Ivanov no jug.ru aqui . Originalmente concebida para uso em linguagens dinâmicas como Groovy, esta instrução era uma excelente candidata para invocar métodos em Java sem usar reflexão. Ao mesmo tempo que a nova instrução, uma API associada apareceu no JDK:


  • Classe MethodHandle - apareceu em Java 7, mas ainda não é usado com muita frequência
  • LambdaMetafactory - essa classe já é do Java 8, tornou-se um desenvolvimento adicional da API para chamadas dinâmicas, usa MethodHandle dentro.

Parecia que MethodHandle , sendo essencialmente um ponteiro digitado para um método (construtor, etc.), seria capaz de cumprir o papel de java.lang.reflect.Method . E as chamadas serão mais rápidas, porque todas as verificações de tipo que são realizadas na API do Reflection com cada chamada, nesse caso, são executadas apenas uma vez, quando o MethodHandle .


Mas, infelizmente, o MethodHandle puro acabou sendo ainda mais lento do que as chamadas pela API de reflexão. É possível obter ganhos de desempenho tornando o MethodHandle estático, mas não em todos os casos. Há uma excelente discussão sobre a velocidade das chamadas do MethodHandle na lista de discussão do OpenJDK .


Mas quando a classe LambdaMetafactory , havia uma chance real de acelerar as chamadas de método. LambdaMetafactory permite criar um objeto lambda e LambdaMetafactory uma chamada de método direta, que pode ser obtida através do MethodHandle . E então, usando o objeto gerado, você pode chamar o método desejado. Aqui está um exemplo da geração que envolve o método getter passado como um parâmetro para o 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, obtemos uma instância de BiFunction em vez de Method. E agora, mesmo que tenhamos usado Method em nosso código, substituí-lo por BiFunction não é difícil. Pegue o código real (um pouco simplificado, verdadeiro) para chamar o manipulador de métodos, marcado @EventListener no 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); } } 

E aqui está o mesmo código, mas que usa uma chamada de método via 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); } } 

Mudanças mínimas, a funcionalidade é a mesma, mas há vantagens:


Um lambda tem um tipo - ele é especificado na criação, portanto, chamar "apenas um método" falhará.


A pilha de rastreamento é mais curta - ao chamar um método por meio de uma lambda, apenas uma chamada adicional é adicionada - apply() . E isso é tudo. Em seguida, o próprio método é chamado.


Mas a velocidade deve ser medida.


Meça a velocidade


Para testar a hipótese, fizemos uma marca de microbench usando JMH para comparar o tempo de execução e a taxa de transferência ao chamar o mesmo método de maneiras diferentes: através da API de reflexão, através do LambdaMetafactory, e também adicionamos uma chamada de método direta para comparação. Os links para Method e lambdas foram criados e armazenados em cache antes do início do teste.


Parâmetros de teste:


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

O teste em si pode ser baixado do GitHub e executado por você mesmo, se estiver interessado.


Resultados do teste para Oracle JDK 11.0.2 e JMH 1.21 (os números podem variar, mas a diferença permanece perceptível e aproximadamente a mesma):


Teste - Obtenha ValorTaxa de transferência (ops / us)Tempo de execução (us / op)
LambdaGetTest720,0118
ReflectionGetTest650,0177
DirectMethodGetTest2600,0048
Teste - Definir valorTaxa de transferência (ops / us)Tempo de execução (us / op
LambdaSetTest960,0092
ReflectionSetTest58.0,0173
DirectMethodSetTest4150,0031

Em média, descobriu-se que chamar um método por meio de um lambda é cerca de 30% mais rápido do que por meio de uma API de reflexão. Há outra grande discussão sobre desempenho de chamada de método aqui, se alguém estiver interessado nos detalhes. Resumidamente - o ganho de velocidade é obtido também devido ao fato de que as lambdas geradas podem ser incorporadas no código do programa e as verificações de tipo ainda não são realizadas, diferentemente da reflexão.


Obviamente, essa referência é bastante simples, não inclui métodos de chamada em uma hierarquia de classes nem mede a velocidade de chamada de métodos finais. Mas fizemos medições mais complexas e os resultados sempre foram a favor do uso do LambdaMetafactory.


Use


Na estrutura CUBA versão 7, nos controladores da interface do usuário, você pode usar a anotação @Subscribe para "assinar" um método para determinados eventos da interface do usuário. Internamente, isso é implementado no LambdaMetafactory , links para métodos do ouvinte são criados e armazenados em cache na primeira chamada.


Essa inovação tornou possível limpar bastante o código, especialmente no caso de formulários com um grande número de elementos, interação complexa e, consequentemente, com um grande número de manipuladores de eventos. Um exemplo simples do CUBA QuickStart: Imagine que você precisa recalcular o valor do pedido ao adicionar ou remover itens do produto. Você precisa escrever um código que execute o método calculateAmount() quando a coleção for alterada na entidade. Como parecia 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()); } ... } 

E no CUBA 7, o código fica assim:


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

Conclusão: o código é mais limpo e não existe um método mágico init() , que tende a crescer e a ser preenchido com manipuladores de eventos com crescente complexidade do formulário. E, no entanto - nem precisamos criar um campo com o componente em que estamos assinando, o CUBA encontrará esse componente por ID.


Conclusões


Apesar do surgimento de uma nova geração de estruturas com compilação AOT ( Micronaut , Quarkus ), que têm vantagens inegáveis ​​sobre estruturas "tradicionais" (principalmente, elas são comparadas ao Spring ), ainda existe uma enorme quantidade de código escrito usando a API de reflexão (e obrigado pela mesma primavera). E parece que o Spring Framework ainda é o líder entre os frameworks de desenvolvimento de aplicativos e trabalharemos com o código baseado em reflexão por muito tempo.


E se você estiver pensando em usar a API de reflexão no seu código - seja um aplicativo ou uma estrutura - pense duas vezes. Primeiro, sobre geração de código e, em seguida, sobre MethodHandles / LambdaMetafactory. O segundo método pode se tornar mais rápido, e os esforços de desenvolvimento serão gastos não mais do que no caso de usar a API de reflexão.


Alguns links mais úteis:
Uma alternativa mais rápida ao Java Reflection
Hackeando expressões lambda em Java
Manipuladores de método em Java
Reflexão Java, mas muito mais rápido
Por que o LambdaMetafactory é 10% mais lento que um MethodHandle estático, mas 80% mais rápido que um MethodHandle não estático?
Muito rápido, muito megamórfico: o que influencia o desempenho da chamada de método em Java?

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


All Articles