Os aplicativos Java são executados em JVM, mas você conhece a tecnologia JVM? Este artigo (a primeira parte desta série) conta como funciona a máquina virtual Java clássica, como: os prós e contras do Java write-once, mecanismos de plataforma cruzada, noções básicas de coleta de lixo, algoritmos clássicos de GC e otimização de compilação. Os artigos subsequentes falarão sobre a otimização do desempenho da JVM, incluindo o design mais recente da JVM – suportando o desempenho e a escalabilidade dos aplicativos Java altamente simultâneos de hoje.
Se você é um desenvolvedor, deve ter encontrado esse sentimento especial, de repente você tem um lampejo de inspiração, todas as suas ideias estão conectadas e você pode relembrar suas ideias anteriores de uma nova perspectiva. Eu pessoalmente adoro a sensação de aprender novos conhecimentos. Tive essa experiência muitas vezes enquanto trabalhava com tecnologia JVM, especialmente com coleta de lixo e otimização de desempenho de JVM. Neste novo mundo do Java, espero compartilhar essas inspirações com vocês. Espero que você esteja tão animado para aprender sobre o desempenho da JVM quanto estou escrevendo este artigo.
Esta série de artigos foi escrita para todos os desenvolvedores Java interessados em aprender mais sobre o conhecimento subjacente da JVM e o que a JVM realmente faz. Em alto nível, discutirei a coleta de lixo e a busca incessante por segurança e velocidade de memória livre sem afetar a operação do aplicativo. Você aprenderá as partes principais da JVM: coleta de lixo e algoritmos de GC, otimização de compilação e algumas otimizações comumente usadas. Também discutirei por que a marcação Java é tão difícil e fornecerei conselhos sobre quando você deve considerar testar o desempenho. Por fim, falarei sobre algumas inovações em JVM e GC, incluindo Zing JVM da Azul, IBM JVM e foco na coleta de lixo Garbage First (G1) da Oracle.
Espero que você termine de ler esta série com uma compreensão mais profunda da natureza das restrições de escalabilidade do Java e como essas restrições nos forçam a criar uma implantação Java de maneira ideal. Esperamos que você tenha uma sensação de iluminação e uma boa inspiração em Java: pare de aceitar essas limitações e mude-as! Se você ainda não é um trabalhador de código aberto, esta série pode incentivá-lo a se desenvolver nesta área.
Desempenho da JVM e o desafio “compilar uma vez, executar em qualquer lugar”
Tenho novas notícias para aqueles que acreditam teimosamente que a plataforma Java é inerentemente lenta. Quando o Java se tornou um aplicativo de nível empresarial, os problemas de desempenho do Java pelos quais a JVM foi criticada já existiam há mais de dez anos, mas essa conclusão agora está desatualizada. É verdade que, se você executar tarefas estáticas e determinísticas simples em diferentes plataformas de desenvolvimento hoje, provavelmente descobrirá que o uso de código otimizado para máquina terá um desempenho melhor do que o uso de qualquer ambiente virtual, na mesma JVM. No entanto, o desempenho do Java melhorou muito nos últimos 10 anos. A demanda do mercado e o crescimento da indústria Java resultaram em vários algoritmos de coleta de lixo, novas inovações de compilação e uma série de heurísticas e otimizações que possuem tecnologia JVM avançada. Abordarei alguns deles em capítulos futuros.
A beleza técnica da JVM é também o seu maior desafio: nada pode ser considerado uma aplicação do tipo “compilar uma vez, executar em qualquer lugar”. Em vez de otimizar para um caso de uso, um aplicativo ou uma carga de usuário específica, a JVM rastreia continuamente o que o aplicativo Java está fazendo no momento e otimiza de acordo. Esta operação dinâmica leva a uma série de problemas dinâmicos. Os desenvolvedores que trabalham na JVM não dependem de compilação estática e taxas de alocação previsíveis ao projetar inovações (pelo menos não quando exigimos desempenho em ambientes de produção).
A causa do desempenho da JVM
Nos meus primeiros trabalhos percebi que a coleta de lixo era muito difícil de "resolver" e sempre fui fascinado por JVMs e tecnologia de middleware. Minha paixão por JVMs começou quando eu fazia parte da equipe JRockit, codificando uma nova maneira de aprender e depurar algoritmos de coleta de lixo (consulte Recursos). Este projeto (que se tornou um recurso experimental do JRockit e se tornou a base para o algoritmo Deterministic Garbage Collection) iniciou minha jornada na tecnologia JVM. Trabalhei na BEA Systems, Intel, Sun e Oracle (como a Oracle adquiriu a BEA Systems, trabalhei brevemente na Oracle). Depois entrei na equipe da Azul Systems para gerenciar a JVM Zing e agora trabalho na Cloudera.
O código otimizado para máquina pode alcançar melhor desempenho (mas às custas da flexibilidade), mas isso não é motivo para considerá-lo para aplicativos corporativos com carregamento dinâmico e funcionalidade que muda rapidamente. Pelas vantagens do Java, a maioria das empresas está mais disposta a sacrificar o desempenho quase perfeito proporcionado pelo código otimizado para máquina.
1. Fácil de codificar e desenvolver funções (o que significa menos tempo para responder ao mercado)
2. Obtenha programadores experientes
3. Use APIs Java e bibliotecas padrão para um desenvolvimento mais rápido
4. Portabilidade – não há necessidade de reescrever aplicativos Java para novas plataformas
Do código Java ao bytecode
Como programador Java, você provavelmente está familiarizado com codificação, compilação e execução de aplicativos Java. Exemplo: vamos supor que você tenha um programa (MyApp.java) e agora queira que ele seja executado. Para executar este programa você precisa primeiro compilá-lo com javac (a linguagem Java estática para compilador de bytecode embutido no JDK). Com base no código Java, javac gera o bytecode executável correspondente e o salva no arquivo de classe com o mesmo nome: MyApp.class. Depois de compilar o código Java em bytecode, você pode iniciar o arquivo de classe executável por meio do comando java (por meio da linha de comando ou script de inicialização, sem usar a opção de inicialização) para executar seu aplicativo. Dessa forma, sua classe é carregada no tempo de execução (ou seja, a execução da máquina virtual Java) e o programa começa a ser executado.
Isso é o que todo aplicativo executa superficialmente, mas agora vamos explorar o que exatamente acontece quando você executa um comando java. O que é uma máquina virtual Java? A maioria dos desenvolvedores interage com a JVM por meio de depuração contínua - também conhecida como seleção e atribuição de opções de inicialização para fazer com que seus programas Java sejam executados mais rapidamente, evitando os infames erros de "falta de memória". Mas você já se perguntou por que precisamos de uma JVM para executar aplicativos Java?
O que é uma máquina virtual Java?
Simplificando, uma JVM é um módulo de software que executa bytecode de aplicativo Java e converte o bytecode em instruções específicas de hardware e sistema operacional. Ao fazer isso, a JVM permite que um programa Java seja executado em um ambiente diferente depois de ter sido escrito pela primeira vez, sem exigir alterações no código original. A portabilidade do Java é a chave para uma linguagem de aplicação empresarial: os desenvolvedores não precisam reescrever o código da aplicação para diferentes plataformas porque a JVM cuida da tradução e da otimização da plataforma.
Uma JVM é basicamente um ambiente de execução virtual que atua como uma máquina de instruções de bytecode e é usado para alocar tarefas de execução e realizar operações de memória interagindo com a camada subjacente.
Uma JVM também cuida do gerenciamento dinâmico de recursos para execução de aplicativos Java. Isso significa que ele domina a alocação e liberação de memória, mantém um modelo de threading consistente em cada plataforma e organiza instruções executáveis onde o aplicativo é executado de maneira adequada à arquitetura da CPU. A JVM libera os desenvolvedores de acompanhar referências a objetos e quanto tempo eles precisam existir no sistema. Da mesma forma, não exige que administremos quando liberar memória - um problema em linguagens não dinâmicas como C.
Você pode pensar na JVM como um sistema operacional projetado especificamente para executar Java; sua função é gerenciar o ambiente de execução de aplicativos Java; Uma JVM é basicamente um ambiente de execução virtual que interage com o ambiente subjacente como uma máquina de instruções de bytecode para alocar tarefas de execução e realizar operações de memória.
Visão geral do componente JVM
Existem muitos artigos escritos sobre componentes internos da JVM e otimização de desempenho. Como base desta série, resumirei e apresentarei uma visão geral dos componentes da JVM. Esta breve visão geral é particularmente útil para desenvolvedores que são novos na JVM e fará com que você queira aprender mais sobre as discussões mais aprofundadas que se seguem.
De uma linguagem para outra - Sobre compiladores Java
Um compilador usa uma linguagem como entrada e então gera outra instrução executável. O compilador Java tem duas tarefas principais:
1. Tornar a linguagem Java mais portável e não precisar mais ser fixada em uma plataforma específica ao escrever pela primeira vez;
2. Certifique-se de que seja produzido código executável válido para uma plataforma específica.
Os compiladores podem ser estáticos ou dinâmicos. Um exemplo de compilação estática é javac. Ele pega o código Java como entrada e o converte em bytecode (uma linguagem executada na máquina virtual Java). O compilador estático interpreta o código de entrada uma vez e gera um formato executável, que será usado quando o programa for executado. Como a entrada é estática, você sempre verá o mesmo resultado. Somente se você modificar o código original e recompilar, você verá uma saída diferente.
Compiladores dinâmicos , como os compiladores Just-In-Time (JIT), convertem uma linguagem para outra dinamicamente, o que significa que fazem isso enquanto o código está sendo executado. O compilador JIT permite coletar ou criar análises de tempo de execução (inserindo contagens de desempenho), usando as decisões do compilador, usando os dados do ambiente disponíveis. Um compilador dinâmico pode implementar melhores sequências de instruções durante o processo de compilação em uma linguagem, substituir uma série de instruções por outras mais eficientes e até mesmo eliminar operações redundantes. Com o tempo, você coletará mais dados de configuração de código e tomará mais e melhores decisões de compilação. Todo o processo é o que normalmente chamamos de otimização e recompilação de código.
A compilação dinâmica oferece a vantagem de se adaptar a mudanças dinâmicas com base no comportamento ou a novas otimizações à medida que o número de carregamentos de aplicativos aumenta. É por isso que os compiladores dinâmicos são perfeitos para operações Java. Vale ressaltar que o compilador dinâmico solicita estruturas de dados externas, recursos de thread, análise e otimização do ciclo de CPU. Quanto mais profunda for a otimização, mais recursos serão necessários. Na maioria dos ambientes, no entanto, a camada superior acrescenta muito pouco ao desempenho - desempenho 5 a 10 vezes mais rápido do que a sua interpretação pura.
Alocação causa coleta de lixo
Alocado em cada thread com base em cada "espaço de endereço de memória alocado para processo Java", ou chamado de heap Java, ou diretamente chamado de heap. No mundo Java, a alocação de thread único é comum em aplicativos clientes. Entretanto, a alocação de thread único não é benéfica em aplicativos corporativos e servidores de carga de trabalho porque não aproveita o paralelismo dos ambientes multinúcleo atuais.
O design de aplicativos paralelos também força a JVM a garantir que vários threads não aloquem o mesmo espaço de endereço ao mesmo tempo. Você pode controlar isso colocando um bloqueio em todo o espaço alocado. Mas essa técnica (geralmente chamada de bloqueio de heap) exige muito desempenho e a retenção ou enfileiramento de threads pode afetar a utilização de recursos e o desempenho de otimização do aplicativo. A vantagem dos sistemas multi-core é que eles criam a necessidade de uma variedade de novos métodos para evitar gargalos de thread único durante a alocação de recursos e serialização.
Uma abordagem comum é dividir o heap em partes, onde cada partição tem um tamanho razoável para a aplicação - obviamente elas precisam ser ajustadas, as taxas de alocação e os tamanhos dos objetos variam significativamente entre as aplicações, e o número de threads para a mesma também é diferente. O Thread Local Allocation Buffer (TLAB), ou às vezes Thread Local Area (TLA), é uma partição especializada na qual os threads podem alocar livremente sem declarar um bloqueio de heap completo. Quando a área está cheia, o heap está cheio, o que significa que não há espaço livre suficiente no heap para colocar objetos e é necessário alocar espaço. Quando o heap estiver cheio, a coleta de lixo começará.
fragmentos
Usar TLABs para capturar exceções fragmenta o heap para reduzir a eficiência da memória. Se um aplicativo não conseguir aumentar ou alocar totalmente um espaço TLAB ao alocar objetos, existe o risco de o espaço ser muito pequeno para gerar novos objetos. Esse espaço livre é considerado “fragmentação”. Se a aplicação mantiver uma referência ao objeto e então alocar o espaço restante, eventualmente o espaço ficará livre por um longo tempo.
A fragmentação ocorre quando os fragmentos são espalhados pelo heap - desperdiçando espaço do heap em pequenas seções de espaço de memória não utilizado. A alocação de espaço TLAB "errado" para seu aplicativo (em relação ao tamanho do objeto, tamanho do objeto misto e taxa de retenção de referência) é a causa do aumento da fragmentação do heap. À medida que o aplicativo é executado, o número de fragmentos aumenta e ocupa espaço no heap. A fragmentação causa degradação do desempenho e o sistema não consegue alocar threads e objetos suficientes para novos aplicativos. O coletor de lixo terá dificuldade em evitar exceções de falta de memória.
Os resíduos TLAB são gerados no trabalho. Uma maneira de evitar a fragmentação total ou temporariamente é otimizar o espaço TLAB em cada operação subjacente. Uma abordagem típica para esta abordagem é que, enquanto a aplicação tiver comportamento de alocação, ela precisará ser reajustada. Isto pode ser conseguido através de algoritmos JVM complexos. Outro método é organizar partições heap para obter uma alocação de memória mais eficiente. Por exemplo, a JVM pode implementar listas livres, que são vinculadas como uma lista de blocos de memória livres de um tamanho específico. Um bloco contíguo de memória livre é conectado a outro bloco contíguo de memória do mesmo tamanho, criando assim um pequeno número de listas vinculadas, cada uma com seus próprios limites. Em alguns casos, as listas livres resultam em melhor alocação de memória. Threads podem alocar objetos em blocos de tamanho semelhante, criando potencialmente menos fragmentação do que se você confiasse apenas em TLABs de tamanho fixo.
Curiosidades sobre GC
Alguns coletores de lixo antigos tinham várias gerações antigas, mas ter mais de duas gerações antigas faria com que a sobrecarga superasse o valor. Outra forma de otimizar as alocações e reduzir a fragmentação é criar o que é chamado de geração jovem, que é um espaço de heap dedicado à alocação de novos objetos. A pilha restante torna-se a chamada geração antiga. A geração antiga é usada para alocar objetos de longa duração. Objetos que se presume existirem por muito tempo incluem objetos que não são coletados como lixo ou objetos grandes. Para entender melhor esse método de alocação, precisamos falar sobre alguns conhecimentos sobre coleta de lixo.
Coleta de lixo e desempenho de aplicativos
A coleta de lixo é o coletor de lixo da JVM para liberar memória heap ocupada que não é referenciada. Quando a coleta de lixo é acionada pela primeira vez, todas as referências de objetos ainda são retidas e o espaço ocupado pelas referências anteriores é liberado ou realocado. Após toda a memória recuperável ter sido coletada, o espaço aguarda para ser capturado e alocado novamente para novos objetos.
O coletor de lixo nunca pode declarar novamente um objeto de referência, pois isso quebraria a especificação padrão da JVM. A exceção a esta regra é uma referência suave ou fraca que pode ser capturada se o coletor de lixo estiver prestes a ficar sem memória. Entretanto, recomendo fortemente que você tente evitar referências fracas, porque a ambigüidade da especificação Java leva a interpretações errôneas e erros de uso. Além do mais, Java foi projetado para gerenciamento dinâmico de memória, porque você não precisa pensar em quando e onde liberar memória.
Um dos desafios do coletor de lixo é alocar memória de uma forma que não afete a execução dos aplicativos. Se você não coletar o lixo tanto quanto possível, seu aplicativo consumirá memória; se você coletar com muita frequência, perderá rendimento e tempo de resposta, o que terá um impacto negativo no aplicativo em execução.
Algoritmo de GC
Existem muitos algoritmos diferentes de coleta de lixo. Vários pontos serão discutidos em profundidade posteriormente nesta série. No nível mais alto, os dois principais métodos de coleta de lixo são a contagem de referência e os coletores de rastreamento.
O coletor de contagem de referências controla quantas referências um objeto aponta. Quando a referência de um objeto atingir 0, a memória será recuperada imediatamente, o que é uma das vantagens desta abordagem. A dificuldade com a abordagem de contagem de referências reside na estrutura circular dos dados e em manter todas as referências atualizadas em tempo real.
O coletor de rastreamento marca objetos que ainda estão sendo referenciados e usa os objetos marcados para seguir e marcar repetidamente todos os objetos referenciados. Quando todos os objetos ainda referenciados forem marcados como "ativos", todo o espaço não marcado será recuperado. Essa abordagem gerencia estruturas de dados em anel, mas em muitos casos o coletor deve esperar até que toda a marcação seja concluída antes de recuperar a memória não referenciada.
Existem várias maneiras de fazer o método acima. Os algoritmos mais famosos são algoritmos de marcação ou cópia, algoritmos paralelos ou concorrentes. Discutirei isso em um artigo posterior.
De modo geral, o significado da coleta de lixo é alocar espaço de endereço para objetos novos e antigos no heap. "Objetos antigos" são objetos que sobreviveram a muitas coletas de lixo. Use a nova geração para alocar objetos novos e a geração antiga para objetos antigos. Isso pode reduzir a fragmentação reciclando rapidamente objetos de vida curta que ocupam memória. Tudo isso reduz a fragmentação entre objetos de longa duração e economiza memória heap contra fragmentação. Um efeito positivo da nova geração é que ela atrasa a coleta mais cara de objetos da geração antiga, e você pode reutilizar o mesmo espaço para objetos efêmeros. (A coleta de espaço antigo custará mais porque objetos de longa duração conterão mais referências e exigirão mais travessias.)
O último algoritmo que vale a pena mencionar é a compactação, que é um método de gerenciamento de fragmentação de memória. A compactação basicamente move objetos juntos para liberar maior espaço de memória contíguo. Se você estiver familiarizado com a fragmentação de disco e com as ferramentas que lidam com ela, descobrirá que a compactação é muito semelhante a ela, exceto que esta é executada na memória heap Java. Discutirei a compactação em detalhes posteriormente nesta série.
Resumo: Revisão e Destaques
A JVM permite portabilidade (programar uma vez, executar em qualquer lugar) e gerenciamento dinâmico de memória, todos os principais recursos da plataforma Java que contribuem para sua popularidade e aumento de produtividade.
No primeiro artigo sobre sistemas de otimização de desempenho JVM, expliquei como um compilador converte bytecode na linguagem de instrução da plataforma alvo e ajuda a otimizar dinamicamente a execução de programas Java. Aplicações diferentes requerem compiladores diferentes.
Também abordei brevemente a alocação de memória e a coleta de lixo e como elas se relacionam com o desempenho de aplicativos Java. Basicamente, quanto mais rápido você preencher o heap e acionar a coleta de lixo com mais frequência, maior será a taxa de utilização do seu aplicativo Java. Um desafio para o coletor de lixo é alocar memória de uma forma que não afete o aplicativo em execução, mas antes que o aplicativo fique sem memória. Em artigos futuros discutiremos a coleta de lixo tradicional e nova e as otimizações de desempenho da JVM com mais detalhes.