Artigo Java Magazine 30 - Mais Performance com Java
Saiba em detalhes como os compiladores JIT aceleram seu código, e fique por dentro das últimas novidades de desempenho nas novas JVMs.
Clique aqui para ler esse artigo em PDF.
Mais Performance com Java
Explorando Novas Técnicas de Otimização do HotSpot
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 (" [...] continue lendo...
Artigos relacionados
-
Artigo
-
Artigo
-
Artigo
-
Artigo
-
Artigo