Java HotSpot - Dessa você não sabia

Contextualização

Eu estava com um problema de performance em um dos endpoints de uma API Java lá do meu trabalho. O tempo de resposta era sempre imprevisível, variava de 300ms a 4 segundos. Nas primeiras execuções a demora era sempre maior, e ia reduzindo com o tempo, mas depois de algums instantes sem executar nenhum teste, o tempo de resposta voltava a ser 4 segundos. E eu ficava tipo "QUE???".

(É importante lembrar que esses testes eram feitos em ambiente de homologação)

Tentei resolver o problema

Eu li e reli o código várias vezes tentando encontrar possíveis problemas de performance, e nada! Tudo parecia perfeito. Eu até coloquei aqueles cálculos de timestamp nos métodos pra tentar encontrar um gargalo e adivinha? Nada! Estava tudo normal.

Debati esse problema com meu amigo

Estava conversando com ele sobre esse problema, tentando descobrir o que podia ser. Até que ele falou a seguinte frase: — Pode ser o Java "esquentando" Na hora eu não entendi, até porque o Java não é um motor que esquenta e melhora o desempenho com o tempo... Foi ai que ele me explicou que a JVM tem um recurso de otimização automática. Ela entende as partes do código mais usadas e já deixa elas "prontas" para a próxima execução, ou seja, quanto mais vezes o código é executado, mais performático ele fica, em outras palavras, o Java "esquenta".

O HotSpot

O nome dessa funcionalidade é Java HotSpot. Ela é uma implementação da JVM projetada pra ganho de desempenho nas aplicações. Pra conseguir fazer isso, ela usa algumas artemanhas como: Compilação Just-in-Time (JIT); Garbage Collection; Profiling; Entre outras. E isso explica direitinho o motivo do endpoint ficar mais rápido sozinho ao longo do tempo.

Eu achei isso simplismente incrível!!!

E você, o que achou?

Seu amigo está certo, o java realmente "esquenta" e o nome disso é JIT e Tiered Compilation. O que ocorre é que o Java realiza varias otimizações no codigo em tempo de execução (ex: alinhamento de método, ele transforma seu getMethod-setMethod em um acesso direto ao atributo da classe). Com o tempo e com o decorrer de várias execuções essas otimizações vão se tornando cada mais "violentas" ao ponto que no ultimo nível restará apenas codigo binario (como c++). O contrário também pode ocorrer, que é o evento de "desotimização", mas o seu impacto é minimo.

A melhor forma de acompanhar esse comportamento de otimizações é utilizar o JMC (Java Mission Control) com o JFR (Java Flight Recorder), dentro da ferramenta há uma aba focada especialmente nisso.

No geral você não precisa se preocupar com o ajuste desses internos da JVM, mas caso queira experimentar, você pode utilizar o paramêtro: -XX:CompileThreshold=NUMERO_DE_EXECUCOES, para controlar o numero de execuções necessárias para a jvm considerar um método apto a ser otimizado.

Outro ponto que pode impactar a velocidade de sua aplicação de forma agressiva é o coletor de lixo e sua versão Java, quanto mais recente for sua versão java, mais performático ele tende a ser.

Por ultimo deixo minha recomendação de paramêtros (que sempre utilizo nos sistemas que trabalho) que você pode testar na sua aplicação:

-XX:MaxRAMPercentage=75 - define o máximo de uso da memoria para 75%, util para ser utilizado em containers. Pq não mais? O container também vai precisar de memoria. Pq não define o minimo como a maioria do pessoal faz? Não é necessário, é um antipattern que pode inclusive prejudicar sua performance. Definir o máximo já é o suficiente, a JVM é inteligente, burro é o dev.

-XX:+UseG1GC - habilita o coletor de lixo G1GC, apesar de não ser recomendado para containers pequenos, ainda sim pode apresentar um desempenho superior em aplicações REST por conta da natureza do seu algoritmo (focado em zonas de objetos jovens).

-XX:MaxGcPauseMillis=100 - quantidade de tempo em milisegundos que você deseja que o coletor de lixo aplique no seu processo.

-XX:PauseTimeIntervalMillis=200 - espaço de tempo entre uma coleta e outra.

links (recomendo ver o link da microsoft):

Achei muito interessante aprender sobre isso. Geralmente pensamos que essa lentidão é devido a API estar adormecida, mas isso é em ambientes de produção em que os recursos de cloud como Azure, desligam a VM para economizar recursos (até onde eu sei).

Como é ambiente homologação, não deveria ocorrer isso, e saber que o próprio java aplica uma otimização na execução do código após a primeira execução é algo bem legal.

Eita, não sabia que podia variar tanto esse cold start do java. Na segunda execução já caia significativamente o tempo execução? Ou precisa de algumas para ensinar bem o JIT como se comportar??

Não sei exatamente te dizer quantos vezes precisa, mas depois de umas 5 requisições ia de 4 segundos pra 300ms. Como o ambiente era homologação, tinha poucas requisições em outros serviços, o que deve ter ajudado o HotSpot a otimizar ainda mais esse que eu testei.
Não é necessariamente o _hotspot_, ainda mais pra poucas requisições. Tem outras coisas que podem ter contribuído para este resultado. Quando a JVM começa a executar, tem uma série de coisas que ela precisa fazer para iniciar. Por exemplo, tem [várias estruturas internas que ela precisa inicializar](https://howtodoinjava.com/java/garbage-collection/java-memory-model/), o *classloader* tem que carregar várias classes, etc. Por isso até hoje Java é conhecida por ter um *startup* mais lento que as outras linguagens. Isso já explicaria a primeira requisição ser mais lenta, porque apesar de muitas classes nativas já terem sido carregadas, várias outras (incluindo as classes envolvidas no teste) só o são sob demanda. Por isso que muitas libs de benchmarking costumam ter uma fase de *warmup* para que esta demora inicial não interfira nos resultados. **Aliás, fica a dica de testar usando alguma dessas libs**, [em vez de comparar os timestamps](https://stackoverflow.com/q/504103). Para testes simples eu uso o [JMH](https://github.com/openjdk/jmh), mas existem várias outras (busque por "_java benchmark tools_" e escolha a sua). Também pode ser que o Garbage Collector tenha rodado no meio do teste, interferindo no tempo de resposta. Apesar dos algoritmos de GC terem melhorado, o impacto deste nunca é zero. Pra isso, precisaria analisar com mais detalhes (pesquise por "ferramentas de *profiling*"). E vale notar que até aqui, não temos nada de _hotspot_. --- O mecanismo de *hotspot* (que é parte de algumas JVM's, entenda melhor [aqui](https://stackoverflow.com/q/16568253)) é o que faz com que um código que tenha executado "muitas vezes" seja otimizado. Neste caso o JIT (_just in time compiler_) entra em ação e re-compila aquele trecho para código de máquina (na verdade é um pouco mais complicado que isso, veja [aqui](https://stackoverflow.com/a/4908698)). Mas este mecanismo só entra em ação depois de **muitas** execuções. O valor exato é difícil de determinar, conforme explicado [aqui](https://stackoverflow.com/q/35601841), mas garanto que somente 5 execuções não é nem perto do suficiente para ativar o _hotspot_.

Incrível mesmo!! Estou trabalhando em um projeto com Java 21 e Spring Boot 3, e a cada dia tenho me impressionado com o quanto o Java vem evoluindo e facilitando nosso trabalho.

O que eu acho mais fantástico, é o fato de você não ser pego desprevinido. Se te pedirem para rodar em outro S.O. ou se pedirem para trocar de banco de dados (levando em conta que JPA é uma prática comum e básica), não vai ter a fala: "Então, você teria que ter avisado antes, aí eu usaria a biblioteca X ou Y, que é cross plataforma e blá, blá, blá". A linguagem já é nativamente portável. Realmente segue o velho slogan: WORA, Write once, run anywhere ou "Escreva uma vez, execute em qualquer lugar"