Este artigo será o segundo artigo da série de otimização de desempenho JVM (o primeiro artigo: Portal), e o compilador Java será o conteúdo principal discutido neste artigo.
Neste artigo, a autora (Eva Andreasson) apresenta pela primeira vez diferentes tipos de compiladores e compara o desempenho de execução da compilação do lado do cliente, do compilador do lado do servidor e da compilação multicamadas. Em seguida, no final do artigo, são apresentados vários métodos comuns de otimização de JVM, como eliminação de código morto, incorporação de código e otimização do corpo do loop.
O recurso de maior orgulho do Java, a “independência de plataforma”, origina-se do compilador Java. Os desenvolvedores de software fazem o possível para escrever os melhores aplicativos Java possíveis, e um compilador é executado nos bastidores para produzir código executável eficiente com base na plataforma de destino. Diferentes compiladores são adequados para diferentes requisitos de aplicação, produzindo assim diferentes resultados de otimização. Portanto, se você puder entender melhor como os compiladores funcionam e conhecer mais tipos de compiladores, poderá otimizar melhor seu programa Java.
Este artigo destaca e explica as diferenças entre os vários compiladores de máquinas virtuais Java. Ao mesmo tempo, também discutirei algumas soluções de otimização comumente usadas por compiladores just-in-time (JIT).
O que é um compilador?
Simplificando, um compilador recebe um programa de linguagem de programação como entrada e outro programa de linguagem executável como saída. Javac é o compilador mais comum. Existe em todos os JDKs. Javac pega o código java como saída e o converte em código executável JVM - bytecode. Esses bytecodes são armazenados em arquivos que terminam com .class e carregados no ambiente de tempo de execução java quando o programa java é iniciado.
O bytecode não pode ser lido diretamente pela CPU. Ele também precisa ser traduzido para uma linguagem de instrução de máquina que a plataforma atual possa entender. Existe outro compilador na JVM responsável por traduzir o bytecode em instruções executáveis pela plataforma de destino. Alguns compiladores JVM requerem vários níveis de estágios de código de bytecode. Por exemplo, um compilador pode precisar passar por diversas formas diferentes de estágios intermediários antes de traduzir o bytecode em instruções de máquina.
De uma perspectiva independente de plataforma, queremos que nosso código seja o mais independente possível de plataforma.
Para conseguir isso, trabalhamos no último nível de tradução – desde a representação de bytecode mais baixa até o código de máquina real – que realmente vincula o código executável à arquitetura de uma plataforma específica. Do nível mais alto, podemos dividir os compiladores em compiladores estáticos e compiladores dinâmicos. Podemos escolher o compilador apropriado com base em nosso ambiente de execução alvo, nos resultados de otimização que desejamos e nas restrições de recursos que precisamos atender. No artigo anterior discutimos brevemente compiladores estáticos e compiladores dinâmicos e nas seções seguintes iremos explicá-los com mais detalhes.
Compilação estática VS compilação dinâmica
O javac que mencionamos anteriormente é um exemplo de compilação estática. Com um compilador estático, o código de entrada é interpretado uma vez e a saída é a forma na qual o programa será executado no futuro. A menos que você atualize o código-fonte e recompile (por meio do compilador), o resultado da execução do programa nunca mudará: isso ocorre porque a entrada é uma entrada estática e o compilador é um compilador estático.
Com compilação estática, o seguinte programa:
Copie o código do código da seguinte forma:
staticint add7(int x ){ return x+7;}
será convertido em bytecode semelhante ao seguinte:
Copie o código do código da seguinte forma:
iload0 bipush 7 iadd ireturn
Um compilador dinâmico compila dinamicamente uma linguagem em outra linguagem. A chamada dinâmica refere-se à compilação enquanto o programa está em execução - compilar durante a execução! A vantagem da compilação e otimização dinâmica é que ela pode lidar com algumas alterações quando o aplicativo é carregado. O tempo de execução Java geralmente é executado em ambientes imprevisíveis ou mesmo mutáveis, portanto, a compilação dinâmica é muito adequada para o tempo de execução Java. A maioria das JVMs usa compiladores dinâmicos, como compiladores JIT. É importante notar que a compilação dinâmica e a otimização de código requerem o uso de algumas estruturas de dados, threads e recursos de CPU adicionais. Quanto mais avançado for o otimizador ou analisador de contexto de bytecode, mais recursos ele consome. Mas estes custos são insignificantes em comparação com as melhorias significativas de desempenho.
Tipos de JVM e independência de plataforma de Java
Um recurso comum de todas as implementações de JVM é compilar bytecode em instruções de máquina. Algumas JVMs interpretam o código quando o aplicativo é carregado e usam contadores de desempenho para localizar código "quente", outras fazem isso por meio de compilação; O principal problema da compilação é que a centralização requer muitos recursos, mas também leva a melhores otimizações de desempenho.
Se você é novo em Java, as complexidades da JVM certamente o deixarão confuso. Mas a boa notícia é que você não precisa descobrir! A JVM gerenciará a compilação e otimização do código, e você não precisa se preocupar com instruções de máquina e como escrever o código para melhor corresponder à arquitetura da plataforma em que o programa está sendo executado.
Do bytecode java ao executável
Depois que seu código Java for compilado em bytecode, a próxima etapa é traduzir as instruções do bytecode em código de máquina. Esta etapa pode ser implementada através de um interpretador ou de um compilador.
explicar
A interpretação é a maneira mais simples de compilar bytecode. O intérprete encontra a instrução de hardware correspondente a cada instrução de bytecode na forma de uma tabela de consulta e a envia à CPU para execução.
Você pode pensar no intérprete como um dicionário: para cada palavra específica (instrução de bytecode), existe uma tradução específica (instrução de código de máquina) correspondente a ela. Como o intérprete executa imediatamente uma instrução toda vez que a lê, esse método não pode otimizar um conjunto de instruções. Ao mesmo tempo, toda vez que um bytecode é chamado, ele deve ser interpretado imediatamente, de modo que o interpretador seja executado muito lentamente. O interpretador executa o código de maneira muito precisa, mas como o conjunto de instruções de saída não é otimizado, ele pode não produzir resultados ideais para o processador da plataforma alvo.
compilar
O compilador carrega todo o código a ser executado no tempo de execução. Dessa forma, ele pode se referir a todo ou parte do contexto de tempo de execução ao traduzir o bytecode. As decisões tomadas são baseadas nos resultados da análise do gráfico de código. Como comparar diferentes ramificações de execução e fazer referência a dados de contexto de tempo de execução.
Após a sequência de bytecode ser traduzida em um conjunto de instruções de código de máquina, a otimização pode ser realizada com base neste conjunto de instruções de código de máquina. O conjunto de instruções otimizado é armazenado em uma estrutura chamada buffer de código. Quando esses bytecodes são executados novamente, o código otimizado pode ser obtido diretamente deste buffer de código e executado. Em alguns casos, o compilador não usa o otimizador para otimizar o código, mas usa uma nova sequência de otimização - “contagem de desempenho”.
A vantagem de usar um cache de código é que as instruções do conjunto de resultados podem ser executadas imediatamente, sem a necessidade de reinterpretação ou compilação!
Isso pode reduzir bastante o tempo de execução, especialmente para aplicativos Java onde um método é chamado diversas vezes.
otimização
Com a introdução da compilação dinâmica, temos a oportunidade de inserir contadores de desempenho. Por exemplo, o compilador insere um contador de desempenho que é incrementado toda vez que um bloco de bytecode (correspondente a um método específico) é chamado. O compilador usa esses contadores para localizar "blocos ativos" para determinar quais blocos de código podem ser otimizados para trazer a maior melhoria de desempenho ao aplicativo. Os dados de análise de desempenho em tempo de execução podem ajudar o compilador a tomar mais decisões de otimização no estado online, melhorando ainda mais a eficiência de execução do código. Como obtemos dados de análise de desempenho de código cada vez mais precisos, podemos encontrar mais pontos de otimização e tomar melhores decisões de otimização, como: como sequenciar melhor as instruções e se devemos usar um conjunto de instruções mais eficiente. se deve eliminar operações redundantes, etc.
Por exemplo
Considere o seguinte código java Código de cópia O código é o seguinte:
staticint add7(int x ){ return x+7;}
Javac irá traduzi-lo estaticamente no seguinte bytecode:
Copie o código do código da seguinte forma:
iload0
bipush 7
eu adicionei
retornarei
Quando este método é chamado, o bytecode será compilado dinamicamente em instruções de máquina. O método pode ser otimizado quando o contador de desempenho (se existir) atingir um limite especificado. Os resultados otimizados podem ser semelhantes ao seguinte conjunto de instruções de máquina:
Copie o código do código da seguinte forma:
lea rax,[rdx+7] ret
Diferentes compiladores são adequados para diferentes aplicações
Diferentes aplicações têm necessidades diferentes. Os aplicativos corporativos do lado do servidor geralmente precisam ser executados por um longo tempo, portanto, geralmente desejam mais otimização de desempenho, enquanto os miniaplicativos do lado do cliente podem desejar tempos de resposta mais rápidos e menos consumo de recursos; Vamos discutir três compiladores diferentes e seus prós e contras.
Compiladores do lado do cliente
C1 é um compilador de otimização bem conhecido. Ao iniciar a JVM, adicione o parâmetro -client para iniciar o compilador. Pelo seu nome podemos descobrir que C1 é um compilador cliente. É ideal para aplicativos clientes que possuem poucos recursos de sistema disponíveis ou exigem inicialização rápida. C1 realiza otimização de código usando contadores de desempenho. Este é um método de otimização simples com menos intervenção no código-fonte.
Compiladores do lado do servidor
Para aplicativos de longa execução (como aplicativos corporativos do lado do servidor), usar um compilador do lado do cliente pode não ser suficiente. Neste momento devemos escolher um compilador do lado do servidor como C2. O otimizador pode ser iniciado adicionando o servidor à linha de inicialização da JVM. Como a maioria dos aplicativos do lado do servidor normalmente são de longa execução, você poderá coletar mais dados de otimização de desempenho usando o compilador C2 do que aplicativos leves e de curta duração do lado do cliente. Portanto, você também poderá aplicar técnicas e algoritmos de otimização mais avançados.
Dica: aqueça seu compilador do lado do servidor
Para implantações no lado do servidor, o compilador pode levar algum tempo para otimizar esses códigos "quentes". Portanto, a implantação no lado do servidor geralmente requer uma fase de “aquecimento”. Portanto, ao realizar medições de desempenho em implantações no lado do servidor, certifique-se sempre de que seu aplicativo atingiu um estado estável! Dar ao compilador tempo suficiente para compilar trará muitos benefícios para sua aplicação.
O compilador do lado do servidor pode obter mais dados de ajuste de desempenho do que o compilador do lado do cliente, para que possa realizar análises de ramificação mais complexas e encontrar caminhos de otimização com melhor desempenho. Quanto mais dados de análise de desempenho você tiver, melhores serão os resultados da análise de seu aplicativo. É claro que realizar análises extensas de desempenho requer mais recursos do compilador. Por exemplo, se a JVM usar o compilador C2, ela precisará usar mais ciclos de CPU, um cache de código maior, etc.
Compilação multinível
A compilação multicamadas mistura compilação do lado do cliente e compilação do lado do servidor. Azul foi o primeiro a implementar a compilação multicamadas em sua JVM Zing. Recentemente, esta tecnologia foi adotada pelo Oracle Java Hotspot JVM (após Java SE7). A compilação multinível combina as vantagens dos compiladores do lado do cliente e do lado do servidor. O compilador cliente está ativo em duas situações: quando o aplicativo é iniciado e quando os contadores de desempenho atingem limites de nível inferior para realizar otimizações de desempenho. O compilador cliente também insere contadores de desempenho e prepara o conjunto de instruções para uso posterior pelo compilador do lado do servidor para otimização avançada. A compilação multicamadas é um método de análise de desempenho com alta utilização de recursos. Como coleta dados durante a atividade do compilador de baixo impacto, esses dados podem ser usados posteriormente em otimizações mais avançadas. Essa abordagem fornece mais informações do que analisar contadores usando código interpretativo.
A Figura 1 descreve a comparação de desempenho de intérpretes, compilação do lado do cliente, compilação do lado do servidor e compilação multicamadas. O eixo X é o tempo de execução (unidade de tempo) e o eixo Y é o desempenho (número de operações por unidade de tempo)
Figura 1. Comparação de desempenho do compilador
Em relação ao código puramente interpretado, o uso de um compilador do lado do cliente pode trazer melhorias de desempenho de 5 a 10 vezes. A quantidade de ganho de desempenho que você obtém depende da eficiência do compilador, dos tipos de otimizadores disponíveis e de quão bem o design do aplicativo corresponde à plataforma de destino. Mas para os desenvolvedores de programas, o último pode muitas vezes ser ignorado.
Comparados aos compiladores do lado do cliente, os compiladores do lado do servidor geralmente podem trazer melhorias de desempenho de 30% a 50%. Na maioria dos casos, as melhorias de desempenho geralmente ocorrem às custas do consumo de recursos.
A compilação multinível combina as vantagens de ambos os compiladores. A compilação do lado do cliente tem um tempo de inicialização mais curto e pode realizar uma otimização rápida. A compilação do lado do servidor pode realizar operações de otimização mais avançadas durante o processo de execução subsequente;
Algumas otimizações comuns do compilador
Até agora, discutimos o que significa otimizar código e como e quando a JVM realiza a otimização de código. A seguir, terminarei este artigo apresentando alguns métodos de otimização realmente usados pelos compiladores. A otimização da JVM realmente ocorre no estágio de bytecode (ou estágio de representação da linguagem de nível inferior), mas a linguagem Java será usada aqui para ilustrar esses métodos de otimização. É impossível cobrir todos os métodos de otimização de JVM nesta seção, é claro. Espero que essas introduções inspirem você a aprender centenas de métodos de otimização mais avançados e a inovar na tecnologia de compiladores.
Eliminação de código morto
A eliminação de código morto, como o nome sugere, consiste em eliminar código que nunca será executado – ou seja, código “morto”.
Se o compilador encontrar algumas instruções redundantes durante a operação, ele removerá essas instruções do conjunto de instruções de execução. Por exemplo, na Listagem 1, uma das variáveis nunca será usada após uma atribuição a ela, portanto a instrução de atribuição pode ser completamente ignorada durante a execução. Correspondendo à operação no nível do bytecode, o valor da variável nunca precisa ser carregado no registrador. Não ter que carregar significa que menos tempo de CPU é consumido, acelerando assim a execução do código, resultando em um aplicativo mais rápido - se o código de carregamento for chamado muitas vezes por segundo, o efeito de otimização será mais óbvio.
A Listagem 1 usa código Java para ilustrar um exemplo de atribuição de um valor a uma variável que nunca será usada.
Listagem 1. O código de cópia do código morto é o seguinte:
int timeToScaleMyApp(boolean infinitoOfResources){
int reArquiteto =24;
int patchByClustering =15;
int useZing=2;
if(endlessOfResources)
retornar reArchitect + useZing;
outro
retornar useZing;
}
Durante a fase de bytecode, se uma variável for carregada, mas nunca usada, o compilador poderá detectar e eliminar o código morto, conforme mostrado na Listagem 2. Se você nunca realizar esta operação de carregamento, poderá economizar tempo de CPU e melhorar a velocidade de execução do programa.
Listagem 2. O código de cópia de código otimizado é o seguinte:
int timeToScaleMyApp(boolean infinitoOfResources){
int reArchitect =24; //operação desnecessária removida aqui…
int useZing=2;
if(endlessOfResources)
retornar reArchitect + useZing;
outro
retornar useZing;
}
A eliminação de redundância é um método de otimização que melhora o desempenho do aplicativo removendo instruções duplicadas.
Muitas otimizações tentam eliminar as instruções de salto no nível da instrução da máquina (como JMP na arquitetura x86, as instruções de salto alteram o registro do ponteiro da instrução, desviando assim o fluxo de execução do programa). Esta instrução de salto é um comando que consome muitos recursos em comparação com outras instruções ASSEMBLY. É por isso que queremos reduzir ou eliminar este tipo de instrução. A incorporação de código é um método de otimização muito prático e conhecido para eliminar instruções de transferência. Como a execução de instruções de salto é cara, incorporar alguns métodos pequenos frequentemente chamados no corpo da função trará muitos benefícios. A Listagem 3-5 demonstra os benefícios da incorporação.
Listagem 3. Chamando o código de cópia do método O código é o seguinte:
int whenToEvaluateZing(int y){ return diasLeft(y)+ diasLeft(0)+ diasLeft(y+1);}
Listagem 4. O código de cópia do método chamado é o seguinte:
int diasLeft(int x){ if(x ==0) return0; else return x -1;}
Listagem 5. O código de cópia do método embutido é o seguinte:
int quandoToEvaluateZing(int y){
temperatura interna =0;
se(y==0)
temperatura +=0;
outro
temperatura += y -1;
se(0==0)
temperatura +=0;
outro
temperatura +=0-1;
se(y+1==0)
temperatura +=0;
outro
temperatura +=(y +1)-1;
temperatura de retorno;
}
Na Listagem 3-5 podemos ver que um método pequeno é chamado três vezes em outro corpo de método, e o que queremos ilustrar é: o custo de incorporar o método chamado diretamente no código será menor do que executar três saltos. transferência de instruções.
Incorporar um método que não é chamado com frequência pode não fazer muita diferença, mas incorporar um método chamado "quente" (um método que é chamado com frequência) pode trazer muitas melhorias de desempenho. O código incorporado muitas vezes pode ser otimizado ainda mais, conforme mostrado na Listagem 6.
Listagem 6. Após a incorporação do código, é possível obter otimização adicional copiando o código da seguinte maneira:
int whenToEvaluateZing(int y){ if(y ==0)return y; elseif(y ==-1)return y -1;
Otimização de loop
A otimização do loop desempenha um papel importante na redução do custo adicional de execução do corpo do loop. O custo extra aqui se refere a saltos caros, muitas verificações de condições e pipelines não otimizados (ou seja, uma série de conjuntos de instruções que não realizam operações reais e consomem ciclos extras de CPU). Existem muitos tipos de otimizações de loop. Aqui estão algumas das otimizações de loop mais populares:
Mesclagem do corpo do loop: quando dois corpos de loop adjacentes executam o mesmo número de loops, o compilador tentará mesclar os dois corpos do loop. Se dois corpos de loop forem completamente independentes um do outro, eles também poderão ser executados simultaneamente (em paralelo).
Loop de inversão: basicamente, você substitui um loop while por um loop do-while. Este loop do-while é colocado dentro de uma instrução if. Esta substituição reduzirá duas operações de salto, mas aumentará o julgamento condicional, aumentando assim a quantidade de código; Esse tipo de otimização é um ótimo exemplo de troca de mais recursos por código mais eficiente – o compilador pesa os custos e benefícios e toma decisões dinamicamente em tempo de execução.
Reorganizar o corpo do loop: Reorganize o corpo do loop para que todo o corpo do loop possa ser armazenado no cache.
Expanda o corpo do loop: reduza o número de verificações e saltos de condições do loop. Você pode pensar nisso como a execução de várias iterações "inline" sem precisar fazer verificação condicional. Desenrolar o corpo do loop também traz certos riscos, pois pode reduzir o desempenho ao afetar o pipeline e um grande número de buscas de instruções redundantes. Mais uma vez, cabe ao compilador decidir se deseja desenrolar o corpo do loop em tempo de execução, e vale a pena desenrolar se isso trará uma melhoria maior no desempenho.
A descrição acima é uma visão geral de como os compiladores no nível do bytecode (ou nível inferior) podem melhorar o desempenho dos aplicativos na plataforma de destino. O que discutimos são alguns métodos de otimização comuns e populares. Devido ao espaço limitado, damos apenas alguns exemplos simples. Nosso objetivo é despertar seu interesse no estudo aprofundado da otimização através da simples discussão acima.
Conclusão: pontos de reflexão e pontos-chave
Escolha diferentes compiladores de acordo com diferentes propósitos.
1. Um intérprete é a forma mais simples de traduzir bytecode em instruções de máquina. Sua implementação é baseada em uma tabela de consulta de instruções.
2. O compilador pode otimizar com base em contadores de desempenho, mas requer o consumo de alguns recursos adicionais (cache de código, thread de otimização, etc.).
3. O compilador cliente pode trazer uma melhoria de desempenho de 5 a 10 vezes em comparação com o intérprete.
4. O compilador do lado do servidor pode trazer uma melhoria de desempenho de 30% a 50% em comparação com o compilador do lado do cliente, mas requer mais recursos.
5. A compilação multicamadas combina as vantagens de ambos. Use a compilação do lado do cliente para tempos de resposta mais rápidos e, em seguida, use o compilador do lado do servidor para otimizar o código chamado com frequência.
Existem muitas maneiras possíveis de otimizar o código aqui. Uma tarefa importante do compilador é analisar todos os métodos de otimização possíveis e, em seguida, pesar os custos de vários métodos de otimização em relação à melhoria de desempenho trazida pelas instruções finais da máquina.