Performance — JMH e microbenchmarks
TL;DR
Medir performance num
@TestcomSystem.nanoTime()mente: a JVM aquece, o JIT compila durante a medição, e código sem efeito colateral é simplesmente eliminado. O JMH (Java Microbenchmark Harness) é o harness oficial do OpenJDK que lida com warmup, fork de JVM e dead-code elimination por você. É assim que se mede microbenchmark a sério — não com um cronômetro num teste de unidade.
O que é
JMH é o Java Microbenchmark Harness, mantido pelo próprio projeto OpenJDK. Ele existe porque medir a velocidade de um trecho de código Java é surpreendentemente difícil: a JVM é uma plataforma adaptativa, e quase toda tentativa ingênua de cronometrar uma operação acaba medindo o harness, o aquecimento ou o nada — em vez do código de interesse.
Um microbenchmark mede um pedaço pequeno e isolado de código — a diferença entre concatenar com + e com StringBuilder, o custo de um HashMap vs. TreeMap, o overhead de um Stream vs. um for. JMH dá a infraestrutura para que essas medidas sejam reprodutíveis e confiáveis, em vez de números que mudam de execução para execução.
O escopo aqui é microbenchmark de código: medir uma operação isolada num ambiente controlado. Profiling de produção e observabilidade — onde você mede a aplicação rodando sob carga real — são outro tema (profiling de produção, Galho 17), e não usam JMH.
Por que importa
Decisões de performance baseadas em “achismo” custam caro. Otimizar o trecho errado, ou trocar uma estrutura de dados por outra “porque parece mais rápida”, desperdiça tempo e às vezes piora o desempenho. Um microbenchmark honesto transforma a discussão de opinião em medida.
Mas a única coisa pior que não medir é medir errado. Um benchmark ingênuo que diz “StringBuilder é 1000x mais rápido” porque o JIT eliminou o loop de concatenação produz uma conclusão falsa com aparência de rigor. JMH importa justamente porque desarma essas armadilhas: ele força o aquecimento, isola a JVM e impede que o compilador apague o que você queria medir.
Como funciona
Por que medir performance num @Test mente (warmup, JIT, dead-code elimination, constant folding)
Cronometrar com System.nanoTime() dentro de um @Test falha por quatro razões, todas vindas da natureza adaptativa da JVM (o mecanismo está no Galho 3):
- Warmup: nas primeiras execuções o método roda interpretado ou só parcialmente compilado. Medir antes do aquecimento mede a JVM fria, não o código otimizado que rodará em produção.
- JIT compilando durante a medição: o compilador C2 entra em ação no meio do seu loop de medida. Você cronometra uma mistura de código interpretado e compilado — um número sem significado.
- Dead-code elimination: se o resultado do cálculo não é usado, o JIT prova que o cálculo não tem efeito observável e o apaga. Você cronometra um loop vazio.
- Constant folding: se as entradas são constantes conhecidas em tempo de compilação, o JIT pré-computa o resultado. Você cronometra uma atribuição de constante, não o algoritmo.
O JIT que torna tudo isso traiçoeiro é exatamente o assunto do Galho 3 — aqui interessa só a consequência: um @Test não tem como controlar nenhum desses fatores.
JMH: @Benchmark / @Warmup / @Measurement / @Fork / @State
JMH resolve cada problema com uma anotação. O método a medir é marcado com @Benchmark, e o harness o executa em fases controladas:
@BenchmarkMode(Mode.AverageTime)— escolhe o que medir.AverageTimereporta tempo médio por operação; há tambémThroughput(operações por unidade de tempo),SampleTimeeSingleShotTime.@Warmup(iterations = N, time = ...)— roda N iterações descartadas antes de medir, deixando o JIT aquecer e compilar. Só depois do warmup as medidas começam.@Measurement(iterations = N, time = ...)— as iterações que realmente contam para o resultado.@Fork(value = N)— roda o benchmark em uma JVM separada (ou N JVMs). Isolar o processo evita que o estado de uma medida contamine a próxima (perfis de compilação, alocação prévia) e reduz a variância entre execuções.@State(Scope.Thread)— encapsula o estado mutável do benchmark num objeto gerenciado pelo JMH. O escopoThreaddá uma instância por thread; há tambémBenchmark(compartilhado) eGroup.
@Param: variar o tamanho do input
A anotação @Param declara valores que o JMH vai injetar num campo do estado, rodando o benchmark uma vez para cada valor. É como você varia o tamanho do input sem duplicar o método:
@Param({"10", "100", "1000"})
private int size;O JMH executa o benchmark três vezes — size=10, size=100, size=1000 — e reporta cada um separadamente. Isso revela como o custo escala com a entrada, em vez de dar um único número opaco.
Blackhole: consumir o resultado pra o JIT não eliminar
Para impedir a dead-code elimination, o resultado do cálculo precisa ser consumido de um jeito que o JIT não consiga otimizar. Há duas formas:
- Retornar o valor do método
@Benchmark— o JMH trata o retorno como consumido. Blackhole— um parâmetro injetado pelo JMH cujo métodoconsume(...)engole qualquer valor sem que o compilador prove que ele é inútil. Indispensável quando o benchmark produz vários resultados num loop (você não pode retornar todos).
@Benchmark
public void medirComBlackhole(Blackhole bh) {
bh.consume(calcular());
}Sem o Blackhole (ou o retorno), o JIT apagaria calcular() e você cronometraria um loop vazio.
Na prática
Um benchmark comparando concatenação com + versus StringBuilder, variando o número de junções:
import org.openjdk.jmh.annotations.*;
import org.openjdk.jmh.infra.Blackhole;
import java.util.concurrent.TimeUnit;
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MICROSECONDS)
@Warmup(iterations = 5, time = 1)
@Measurement(iterations = 5, time = 1)
@Fork(value = 2)
@State(Scope.Thread)
public class StringConcatBenchmark {
@Param({"10", "100", "1000"})
private int repeticoes;
@Benchmark
public String concatComPlus() {
String resultado = "";
for (int i = 0; i < repeticoes; i++) {
resultado += i; // cria uma nova String a cada volta
}
return resultado; // retornado => consumido, sem dead-code elimination
}
@Benchmark
public void concatComStringBuilder(Blackhole bh) {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < repeticoes; i++) {
sb.append(i);
}
bh.consume(sb.toString()); // Blackhole consome o resultado
}
}O @Warmup garante que o JIT (Galho 3: JIT — C1, C2 e tiered compilation) já tenha compilado os dois métodos antes da medição; o @Fork(2) roda tudo em duas JVMs separadas para checar a estabilidade do número; o @Param mostra como a vantagem do StringBuilder cresce com o tamanho da entrada. Sem essas anotações, o mesmo código num @Test daria um resultado quase aleatório — é o JIT que torna o microbenchmark traiçoeiro.
Armadilhas
(1) Medir com System.nanoTime() dentro de um @Test
Cronometrar manualmente num teste de unidade dá um número que parece preciso (nanossegundos!) mas não tem significado: o JIT compila no meio da medição e o warmup falseia o começo.
@Test
void quaoRapidoEh() {
long inicio = System.nanoTime();
for (int i = 0; i < 1_000_000; i++) {
calcular(); // medido frio, depois compilando, depois quente — tudo misturado
}
long fim = System.nanoTime();
System.out.println((fim - inicio) + " ns"); // número sem valor
}Fix: tire a medição do @Test. Use JMH com @Warmup e @Measurement, que separam o aquecimento da medida e isolam a JVM com @Fork.
(2) Sem warmup
Mesmo dentro do JMH, configurar @Warmup(iterations = 0) (ou cronometrar à mão sem aquecer) mede a JVM fria e interpretada, não o código que o JIT compilou. O resultado superestima o custo real em produção, às vezes por ordens de magnitude.
@Warmup(iterations = 0) // erro: sem aquecimento
@Measurement(iterations = 5)
@Benchmark
public int semWarmup() { ... } // mede código interpretadoFix: dê iterações de warmup suficientes (@Warmup(iterations = 5) é um ponto de partida razoável) para o C2 compilar o método antes da fase de medição.
(3) Resultado não consumido
Se o método @Benchmark calcula algo e não retorna nem entrega o valor ao Blackhole, o JIT prova que o cálculo não tem efeito observável e o elimina. Você cronometra um loop vazio e conclui, falsamente, que a operação é “instantânea”.
@Benchmark
public void resultadoIgnorado() {
calcular(); // retorno descartado => dead-code elimination => loop apagado
}Fix: retorne o valor do método, ou injete um Blackhole e chame bh.consume(resultado). Assim o compilador é obrigado a manter o cálculo vivo.
Em entrevista
Frase pronta (inglês)
Timing code with
System.nanoTime()inside a unit test is misleading on the JVM: the method runs interpreted until the JIT warms up, the C2 compiler kicks in during the measurement, and any result you don’t consume gets removed by dead-code elimination. That’s why I reach for JMH, the OpenJDK microbenchmark harness — it controls warmup, forks a separate JVM, and usesBlackholeto keep the compiler from optimizing away the very code I’m measuring. I treat JMH as code-level microbenchmarking, kept separate from production profiling, which answers a different question under real load.
Vocabulário
| Termo (EN) | Tradução / sentido |
|---|---|
| microbenchmark | medida isolada de um trecho pequeno de código |
| warmup | iterações descartadas para o JIT compilar antes de medir |
| dead-code elimination | o JIT apaga cálculos sem efeito observável |
| constant folding | o JIT pré-computa resultados de entradas constantes |
| fork (JVM) | rodar o benchmark numa JVM separada para isolar e reduzir variância |
| harness | a infraestrutura que orquestra a medição (o próprio JMH) |
| throughput | operações por unidade de tempo (modo de benchmark) |
Veja também
- Mutation testing — PIT
- JIT — C1, C2 e tiered compilation
- Testes (MOC do galho)
- Trilha Java
- Dicionário de Java (verbete JMH (microbenchmark))
Referências
- OpenJDK JMH (Java Microbenchmark Harness): https://github.com/openjdk/jmh