Esse artigo faz parte da revista Java Magazine edição 30. Clique aqui para ler todos os artigos desta edição

OR: windowtext; FONT-FAMILY: Verdana">m 0cm 12pt">Saiba em detalhes como os compiladores JIT aceleram seu código, e fique por dentro das últimas novidades de desempenho nas novas JVMs

Osvaldo Pinali Doederlein

O Java evoluiu muito desde a sua introdução, crescendo continuamente tanto em funcionalidades quanto em desempenho. As antigas percepções sobre mau desempenho do Java já são algo do passado. Hoje, somente desenvolvedores muito desinformados – ou de má-fé – afirmariam que o Java é “lento por ser interpretado”, ou que compiladores JIT não podem competir com compiladores estáticos (o que se tem visto ultimamente é com freqüência o oposto).

Por outro lado, a tecnologia das JVMs não está "completa", no sentido que não reste nada a melhorar. Também não faz mágica – toda plataforma e toda linguagem faz algumas opções, e certos aspectos do Java ainda dificultam a obtenção de um desempenho máximo em casos específicos.

Este artigo possui dois objetivos gerais. O primeiro é discutir, de maneira franca, as questões de desempenho do Java, os princípios da otimização de código, e as capacidades e limitações das JVMs atuais. O segundo é atualizar o leitor sobre as últimas novidades de desempenho do Java – especificamente as novas otimizações que estão vindo com o Mustang e o IBM JDK 5.0.

Desempenho e a evolução das linguagens

No estudo de linguagens de programação e compiladores, vemos que o trabalho dos otimizadores vai no sentido contrário do realizado pelos projetistas de linguagens. Tudo o que é construído por uma linguagem de programação tem que ser “desconstruído” pelos otimizadores.

Linguagens de mais alto nível têm um desempenho intrínseco pior[1], pois as primitivas de programação utilizadas no código-fonte vão se distanciando cada vez mais das operações fundamentais das CPUs. Essa distância exige um mapeamento a ser feito pelo compilador. Quanto mais complexo o mapeamento, mais inteligência será necessária para fazê-lo da forma mais eficiente possível.

Por exemplo, para fazer um loop, um programador poderia escrever o seguinte código em Assembly x86:

LOOP:

  MOV EDI, 1000 # executar 1000 iterações

  ... corpo do loop ...

  DEC EDI

  JNZ EDI, LOOP # decrementa, e repete até chegar a 0

Este código é "de baixo nível", não só por utilizar as instruções nativas de uma CPU, mas também por explorar suas idiossincrasias. Por exemplo, usamos o registrador EDI como variável de controle do loop, porque este registrador é muito útil para indexar arrays de dados. Também programamos o loop ao contrário, contando de 1000 a 0 (ao invés de contar de 0 a 1000, como seria mais intuitivo). Isso é feito para explorar o comportamento de instruções como a DEC, a qual além de diminuir um valor em 1, também compara o resultado com zero – o que economiza uma instrução extra que teríamos que usar (a CMP) caso o valor de parada fosse qualquer outro.

Em suma, uma linguagem de baixo nível não só aumenta a quantidade de código que tem de ser escrito, mas também induz o programador a contorcer seus algoritmos para adaptar-se ao comportamento da linguagem[2]. E o Assembly, se por um lado facilita obter o melhor desempenho possível, tem desvantagens sérias e conhecidas. É extremamente difícil, pouco produtivo e perigoso (sendo difícil evitar bugs catastróficos) – e a portabilidade é zero.

Na medida em que as linguagens sobem de nível, resolvem-se essas desvantagens, mas sempre com algum custo em desempenho. Por exemplo, em Java 5.0 podemos escrever um loop assim:

for (Cliente c: clientes)

  // utiliza c

 As melhorias são evidentes. Facilidade de programação e manutenção; portabilidade, inclusive binária; segurança – qualquer erro gera no máximo uma exceção, por exemplo se clientes==null teremos uma NullPointerException, mas não uma pane ou corrupção do heap.

Os custos, por outro lado, também são grandes. Código nativo deve ser gerado em tempo de execução, pois inclui otimizações nem sempre triviais – como alocar variáveis para registradores ou selecionar as instruções de CPU ideais para cada operação. A JVM é obrigada a verificar indexações de arrays e usos de referências nulas. Se clientes for uma coleção, sua iteração exigirá objetos Iterator, que exigem alocação no heap, invocação a métodos polimórficos como next(), código adicional para iteração fail-fast[3], e ao final do loop viram lixo, dando trabalho ao Garbage Collector.

Os problemas trazidos pelas linguagens de alto nível criam desafios cada vez maiores para os compiladores. O exemplo anterior em Java nem é a última palavra em “alto nível”. Poderíamos complicar ainda mais, mesmo em um caso simples como aquele. Numa linguagem com tipos dinâmicos (como JavaScript ou Smalltalk), o programador não precisa declarar o tipo de cada variável – o que cria muito mais dificuldades para a compilação eficiente. E o que dizer de linguagens declarativas como SQL, CLIPS ou Prolog, que nem sequer possuem loops explícitos?

Os programadores têm sempre a expectativa de utilizar linguagens mais avançadas, mas sem pagar por isso, considerando que uma nova linguagem só é perfeita se gerar programas tão eficientes quanto a anterior. Boa parte da propaganda sobre novas linguagens, veiculada por fornecedores ou aficionados, é centrada em benchmarks de desempenho, que são sempre focados na comparação com linguagens de baixo nível como C – ou pelo menos, com suas competidoras no mercado (ex.: C# versus Java).

Essa expectativa de ganhar algo em troca de nada não é, claro, realista, pois os compiladores – por mais avançados que sejam – não têm inteligência para competir com um bom programador humano trabalhando com uma linguagem de mais baixo nível. No entanto, os compiladores têm uma vantagem: força bruta. O Deep Blue, um supercomputador de 256 CPUs, só derrotou Garry Kasparov por ser capaz de calcular 200 bilhões de lances de Xadrez em três minutos[4]. Da mesma forma, um compilador Java (e seu otimizador) só derrota um humano trabalhando com Assembly devido à sua capacidade de executar milhões de cálculos e decisões simples por segundo.

Um compilador Java só derrota um humano trabalhando com Assembly, devido à sua capacidade de executar milhões de cálculos e decisões simples por segundo

Com um prazo indefinido, um humano programando em Assembly sempre venceria a disputa. Mas com a necessidade de desenvolver aplicações cada vez mais complexas, em cada vez menos tempo, as técnicas de baixo nível se inviabilizam para a grande maioria das tarefas. Atualmente, até mesmo em softwares de desempenho extremamente crítico, como sistemas operacionais de tempo real e jogos de ação, só uma fração minúscula do sistema – os “hot spots” com desempenho mais crítico – são escritos em Assembly. Mesmo linguagens de nível intermediário, como C/C++, têm dado lugar ao Java e outras opções de alto nível como linguagens dinâmicas e de scripting.

Hoje até mesmo softwares de desempenho muito crítico como SOs de tempo real e jogos de ação têm uma fração minúscula escrita em Assembly

Antes de continuar, vale consultar o quadro "Execução de Código na JVM" para conceitos fundamentais sobre interpretação, compilação e otimização de código nas JVMs. Esses conceitos serão usados generosamente nas seções a seguir.

O desempenho do Java

Qualquer linguagem ou plataforma possui algumas características que às vezes podem entrar em conflito com o desempenho, mas que são "negociáveis". Por exemplo, sabemos que Java é uma linguagem orientada a objetos e suporta recursos típicos de OO que dificultam uma implementação eficiente, como métodos polimórficos. Mas nessa área o Java pode fazer alguns sacrifícios em nome do desempenho – como suportar tipos primitivos (ex. int) ou métodos não-polimórficos (os private, final e static), sem comprometer muito seu objetivo de ser uma boa linguagem OO de alta produtividade.

Por outro lado, o Java também possui certos requisitos fundamentais "não-negociáveis", como portabilidade, funcionalidades dinâmicas (carregamento dinâmico de classes e reflection), além de robustez e segurança. Em qualquer disputa com um destes requisitos, o desempenho irá perder: não há meio-termo possível. O problema é que esses requisitos têm pouco valor se não forem satisfeitos de forma estrita. Por exemplo, imagine uma linguagem quase totalmente robusta, que ofereça uma única "porta" para corrupção de memória – por exemplo, uma sintaxe que permita aos programadores desativar a verificação de índices de arrays em algoritmos críticos. Seria como uma residência fortificada, com cerca elétrica e vigilância 24h, mas cujo dono deixasse a chave da porta debaixo do capacho.

É comum encontrarmos críticas ao Java por ser uma linguagem que não dá ao programador a liberdade de escrever código do jeito mais "certinho" (seguro, portável etc.) ou de forma mais eficiente – optando por trabalhar mais e correr mais riscos. Mas essas críticas parecem não levar em conta o fator que acabamos de expor: alguns requisitos fundamentais do Java (que são razões pelas quais Java faz tanto sucesso) só são satisfeitos se implementados de forma radical[5].

Esses requisitos do Java dificultam uma implementação eficiente, mas não a impossibilitam. Os compiladores podem ser inteligentes o bastante para desfazer o nó de desempenho gerado pelas exigências da linguagem. Continuando nosso exemplo de indexação de arrays, veja este código:

for (int i = 0; i < size; ++i)

  arr[i] *= k;

Aqui, o problema é que o Java exige que cada indexação (arr[i]) seja verificada e que, se estiver fora de faixa, seja lançada uma ArrayIndexOutOfBoundsException. Assim, o código nativo gerado pelo compilador será algo como:

for (int i = 0; i < size; ++i) {

  if (i <= 0 || i > size) throw new IllegalArgumentException(i);

  arr[i] *= k;

}

Este código é muito mais lento que o equivalente de uma linguagem que não exige a verificação de índices, como C ou C++. O prejuízo vem não somente do if(...), mas também da eliminação de oportunidades de otimização (como logo veremos). E todo esse custo existe de fato, em interpretadores ou em compiladores muito primitivos, como os que tínhamos com o Java 1.1.x. Mas não se preocupe. Qualquer compilador decente poderia produzir pelo menos o seguinte código:

if (i > arr.length) throw new IllegalArgumentException(arr.length);

for (int i = 0; i < size; ++i)

  arr[i] *= k;

A otimização é simples de entender. Primeiro, eliminamos o teste i <= 0, pois i é inicializado com a constante 0 e só é incrementado[6]. Mas a principal melhoria consiste em mover para fora do loop, condições que sabemos ser invariantes em relação ao loop. Se tivermos, por exemplo, size==10 e arr.length==5, isto sempre irá gerar uma exceção, pois o loop não altera size nem arr.length. Compiladores de outras linguagens que exigem verificação de índices têm feito esse tipo de otimização desde tempos imemoriais (bem, pelo menos desde o Fortran nos anos 60).

O problema é que o Java, como de costume, é mais exigente que as outras linguagens. Ele requer que as exceções de runtime sejam geradas de forma precisa, sem efeitos colaterais sobre o comportamento do programa. Então, se arr tem cinco elementos, mas a variável size manda contar até 10, o único comportamento aceito é executar as cinco primeiras iterações do loop, que modificam os elementos arr[0]..arr[4], e só então gerar um ArrayIndexOutOfBoundsException(5).

Isso significa que a otimização acima é ilegal em Java: não podemos gerar a exceção logo no começo do loop, mesmo tendo certeza que o loop contém um erro de índice, e até sabendo o valor de índice que causará o problema. Observe que a alteração dos elementos válidos do loop (antes da exceção) é um "efeito colateral" irreversível, pois o lançamento da exceção não desfaz as alterações no array. Seria também possível escrever um programa que captura a exceção e utiliza os resultados parciais (aqueles primeiros cinco elementos modificados de arr).

Pessoas ainda não completamente sintonizadas com o espírito do Java reclamam destas regras, que parecem floreios com pouco uso prático: de que adianta executar metade de um loop bichado, só para suportar técnicas de programação "sujas", como algoritmos que dependem da captura de exceções em tempo de execução? Mas é preciso se ter uma visão mais ampla, considerando novamente os princípios do Java. Um deles é a portabilidade: não adianta termos um bytecode portável e bibliotecas portáveis, se nosso código puder exibir variações sutis de comportamento (até na mesma JVM e mesma plataforma!) devido à ação fortuita e arbitrária do compilador.

A solução é continuar tentando, trabalhando cada vez mais:

if (size >= arr.length) { // versão lenta:

  for (int i = 0; i < arr.length; ++i) {

    if (i > size) throw new IllegalArgumentException();

    arr[i] *= k;

}

else { // versão rápida:

    for (int i = 0; i < arr.length; ++i) {

      arr[i] *= k;

}

Esta otimização é conhecida como versionamento. O otimizador gera duas cópias do loop: uma "versão rápida" que não faz verificação de índice, mas que só pode ser usada quando se tem a certeza que nenhuma exceção será gerada; e uma "versão lenta", executada no caso de exceção. Uma condição inserida pelo compilador determina, em tempo de execução, qual versão será usada.

...

Quer ler esse conteúdo completo? Tenha acesso completo