Hotspot client e server
Antes de falar especificamente sobre o Hotspot, é preciso saber que existem na verdade dois Hotspot's, chamados de client e server.
O client compiler é o compilador presente na JRE de 32 bits. Ele tem como objetivos gerar código nativo rapidamente e, preferencialmente, sem afetar a responsividade do código sendo executado. Seu nome vem do fato de que é preferivelmente utilizado em aplicações clientes que normalmente interagem com usuários. Já o server compiler está presente apenas no JDK (e nas JRE de 64bits) e exige mais tempo para gerar código nativo. Contudo, várias otimizações só estão presentes no server compiler. Seu principal uso é para aplicações que tipicamente executam em servidores, com um tempo de vida maior. Para ativar o server, basta invocar o executável java com a opção -server.
E como funciona o Hotspot?
O Hotspot (nome do compilador jit da máquina virtual da Sun) observa a execução dos programas e, a partir do perfil de execução e da plataforma em que se encontra, gera código nativo para o programa sendo executado. Isso significa que ele pode (e o faz) gerar código específico para o processador em que o programa está sendo executado, se aproveitando de registradores específicos e instruções especiais.
Exemplo prático
Para ilustrar o comportamento do JIT, vamos mostrar um pequeno exemplo. O código abaixo (que também se encontra no repositório de códigos deste blog) é bem simples e mostra bem o que o Hotspot é capaz de fazer:
long bits = 0L;
if(args.length > 0) {
bits = Long.parseLong(args[0]);
}
long start = System.nanoTime();
int n = 2000000001;
boolean shift = bits > 0;
while (n > 0) {
if(shift) {
bits ^= 1 << 5;
}
n--;
}
System.out.println("bits: "+ bits);
long end = System.nanoTime();
System.out.println(TimeUnit.NANOSECONDS.toMillis(end-start) + "ms");
Executando o código acima nos três modos (puramente interpretado, compilado com o client compiler e com o server compiler) passando o número 1 como argumento, temos os seguintes resultados (em um Solaris usando java6u14):
Interpretado (invocado com -Xint): infinito!
Client compiler: 2903ms
Server compiler: 11ms
O resultado do server compiler é impressionante. Para comparar, o código C equivalente (compilado no GCC 3.4.6), no mesmo computador mas com o tempo medido com o utilitário 'time' do UNIX:
gcc: 7422ms
gcc -O1: 2541ms
gcc -O5: 1447ms
A versão server é inclusive muito mais rápida que a versão nativa compilada com -O5 (o 5o nível de otimização do GCC - mais do que isso não trouxe melhorias para este exemplo). Mas mesmo a versão client tem desempenho parecido com o GCC -O1.
Java mais rápido que código nativo?
Para entender o que ocorreu com o programa anterior, serão explicadas algumas das técnicas de otimização feitas pelo Hotspot. Para começar, o Hotspot detecta que é necessário otimizar o corpo do loop. Para isso, ele aplica uma técnica conhecida como loop unrolling com fator de 2. O loop resultante fica parecido com:
Porém, dentro do loop, o valor de shift não é alterado e o 'n--' não tem efeito sobre a computação sendo feita. O compilador, dessa vez, reordena as instruções para ficar parecido com:
Para que a variável 'bits' não seja carregada mais de uma vez e porque a operação XOR (^) é associativa, o compilador resolve colapsar as duas instruções em uma só:
Nesse momento, o Hotspot percebe que a expressão:
(1 << 5) ^ (1 << 5)
na verdade é a constante 0! Assim, a parte interna do loop pode ser eliminada, bastando adicionar as instruções do seu efeito colateral (decrementar o n até 0). Dessa forma, o código final executado é parecido com:
É claro que esta é uma simplificação do que acontece realmente (o loop na verdade é dividido em três seções, mas estas explicações detalhadas ficam para outro post). A versão client compiler pára antes de fazer todas as otimizações para que o código nativo possa ser executado mais rapidamente. O código gerado, porém, não é o mais otimizado possível - que é um quase noop.if(n % 2 == 1) {
if(shift) bits ^= 1 << 5;
n--;
}
while (n > 0) {
if(shift) {
bits ^= 1 << 5;
}
n--;if(shift) {
bits ^= 1 << 5;
}
n--;}
Porém, dentro do loop, o valor de shift não é alterado e o 'n--' não tem efeito sobre a computação sendo feita. O compilador, dessa vez, reordena as instruções para ficar parecido com:
while (n > 0) {
if(shift) {
bits ^= 1 << 5;
bits ^= 1 << 5;
}
n--;
n--;
}
Para que a variável 'bits' não seja carregada mais de uma vez e porque a operação XOR (^) é associativa, o compilador resolve colapsar as duas instruções em uma só:
while (n > 0) {
if(shift) {
bits ^= (1 << 5) ^ (1 << 5);
}
n -= 2;
}
(1 << 5) ^ (1 << 5)
na verdade é a constante 0! Assim, a parte interna do loop pode ser eliminada, bastando adicionar as instruções do seu efeito colateral (decrementar o n até 0). Dessa forma, o código final executado é parecido com:
if(n % 2 == 1) {
if(shift) bits ^= 1 << 5;
n--;
}
n = 0;
Conclusão
Não se preocupe em escrever o código java mais otimizado possível. É preferível escrever um código mais legível e deixar as otimizações mais estranhas para o Hotspot.
Embora não se possa afirmar que a JVM consiga gerar código tão eficiente quanto os compiladores nativos, é seguro afirmar, pelo menos, que eles tem desempenhos equivalentes.
PS: O exemplo aqui presente e muitas outras discussões bastante interessantes podem ser acompanhadas no google groups de linguagens da jvm.
update: muitos erros de português...
update2: Os tempos da versão java medidos com o time foram omitidos por serem virtualmente identicos ao tempo acima. Em todo caso, o user time é, em média, 100ms superior ao tempo medido de dentro da aplicação.
update2: Os tempos da versão java medidos com o time foram omitidos por serem virtualmente identicos ao tempo acima. Em todo caso, o user time é, em média, 100ms superior ao tempo medido de dentro da aplicação.
Muito bom.
ResponderExcluirfiz um programa equivalente em c# mas não consegui achar nenhum lugar para pedir maior nível de otimização do jit do .NET. O máximo que se tem é pedir um /o+ na compilação.
de qqer forma o tempo ficou em torno de 2350 ms.
Muito bacana o artigo. Mas um pouco corner case. Ia ser legal se existissem estudos de programas 'reais'.
ResponderExcluirO bacana foi também saber que no modo server e client são utilizados hotspot's diferentes.
(eu cheguei neste post pelo google reader do wagão)
ResponderExcluirDiminui dois zeros desse n e refaz o teste.
para mim deu isso:
(GCC)
[rafaellg@memphis]$ time fast 1
33
real 0m0.062s
user 0m0.052s
sys 0m0.004s
(SUN JAVA)
time java Fast 1
bits: 33
6ms
real 0m0.121s
user 0m0.068s
sys 0m0.016s
Parece que você encontrou alguma picuinha da implementação do gcc.
olá rafel,
ResponderExcluirdiminuindo dois 0 do loop faz com que o teste seja pequeno demais - o tempo de analisar e compilar o código fica grande demais
de qualquer maneira, com ou sem os 0's, o GCC gera o mesmo codigo assembly (pelo menos no gcc 3.4.6 do openSolaris intel) - se voce puder verificar com GCC -S, mas deve ser igual no linux tb
Wagner, o código original fazia parte de um programa que esta com um comportamento estranho em produção, onde era usada uma JVM de 64 bits. O comportamento estranho era que o loop era executado instantaneamente. O loop interno era idêntico ao postado.
ResponderExcluir