Se já usou Java para desenvolver programas ou não, poderá ter ouvido falar da Máquina Virtual Java (JVM) em algum momento ou outro.
JVM é o núcleo do ecossistema Java, e torna possível que programas de software baseados em Java sigam a abordagem “escrever uma vez, correr em qualquer lugar”. Pode-se escrever código Java numa máquina, e executá-lo em qualquer outra máquina utilizando o JVM.
JVM foi inicialmente concebido para suportar apenas Java. No entanto, ao longo do tempo, muitas outras linguagens como Scala, Kotlin e Groovy foram adoptadas na plataforma Java. Todas estas linguagens são colectivamente conhecidas como linguagens JVM.
Neste artigo, vamos aprender mais sobre a JVM, como funciona, e os vários componentes de que é feita.
Antes de saltarmos para a JVM, vamos revisitar o conceito de uma Máquina Virtual (VM).
Uma máquina virtual é uma representação virtual de um computador físico. Podemos chamar à máquina virtual a máquina hóspede, e o computador físico em que ela funciona é a máquina anfitriã.
Uma única máquina física pode executar várias máquinas virtuais, cada uma com o seu próprio sistema operativo e aplicações. Estas máquinas virtuais estão isoladas umas das outras.
O que é a máquina virtual Java?
Em linguagens de programação como C e C++, o código é primeiro compilado em código de máquina específico da plataforma. Estas linguagens são chamadas linguagens compiladas.
Por outro lado, em linguagens como JavaScript e Python, o computador executa as instruções directamente sem ter de as compilar. Estas linguagens são chamadas linguagens interpretadas.
Java utiliza uma combinação de ambas as técnicas. O código Java é primeiro compilado em código byte para gerar um ficheiro de classe. Este ficheiro de classe é então interpretado pela Máquina Virtual Java para a plataforma subjacente. O mesmo ficheiro de classe pode ser executado em qualquer versão de JVM em execução em qualquer plataforma e sistema operativo.
Similiar às máquinas virtuais, o JVM cria um espaço isolado numa máquina anfitriã. Este espaço pode ser utilizado para executar programas Java independentemente da plataforma ou do sistema operativo da máquina.
Java Virtual Machine Architecture
O JVM é composto por três componentes distintos:
- Class Loader
- Memória de execução/Área de dados
- Máquina de execução
Vejamos cada uma delas com mais detalhe.
Class Loader
Quando se compila um ficheiro .java
source file, este é convertido em código byte como um ficheiro .class
. Quando se tenta utilizar esta classe no seu programa, o carregador de classes carrega-a na memória principal.
A primeira classe a ser carregada na memória é normalmente a classe que contém o main()
método.
Há três fases no processo de carregamento da classe: carregamento, ligação, e inicialização.
Carregamento
Carregamento envolve tomar a representação binária (bytecode) de uma classe ou interface com um nome particular, e gerar a partir daí a classe ou interface original.
Existem três carregadores de classe incorporados disponíveis em Java:
- Bootstrap Class Loader – Este é o carregador de classe raiz. É a superclasse do Carregador de Classe de Extensão e carrega os pacotes padrão Java como
java.lang
java.net
java.util
java.io
, e assim por diante. Estes pacotes estão presentes dentro dort.jar
ficheiro e outras bibliotecas centrais presentes no$JAVA_HOME/jre/lib
directório. - Carregador de Classe de Extensão – Esta é a subclasse do Carregador de Classe de Bootstrap e a superclasse do Carregador de Classe de Aplicação. Isto carrega as extensões das bibliotecas Java padrão que estão presentes no directório
$JAVA_HOME/jre/lib/ext
. - Carregador de Classe de Aplicação – Este é o carregador de classe final e a subclasse do Carregador de Classe de Extensão. Carrega os ficheiros presentes no classpath. Por defeito, o classpath é definido para o directório actual da aplicação. O classpath também pode ser modificado adicionando o
-classpath
ou-cp
opção de linha de comando.
O JVM usa o método ClassLoader.loadClass()
para carregar a classe na memória. Ele tenta carregar a classe com base num nome totalmente qualificado.
Se um carregador de classe pai é incapaz de encontrar uma classe, ele delega o trabalho a um carregador de classe criança. Se o último carregador de classe criança também não for capaz de carregar a classe, lança NoClassDefFoundError
ou ClassNotFoundException
.
Linking
Depois de uma classe ser carregada na memória, ela passa pelo processo de ligação. A ligação de uma classe ou interface envolve a combinação dos diferentes elementos e dependências do programa.
Link inclui os seguintes passos:
Verificação: Esta fase verifica a correcção estrutural do ficheiro .class
, comparando-o com um conjunto de restrições ou regras. Se a verificação falhar por alguma razão, obtemos um VerifyException
.
Por exemplo, se o código tiver sido construído utilizando Java 11, mas estiver a ser executado num sistema que tenha Java 8 instalado, a fase de verificação falhará.
Preparação: Nesta fase, a JVM atribui memória para os campos estáticos de uma classe ou interface, e inicializa-os com valores por defeito.
Por exemplo, suponha que declarou a seguinte variável na sua classe:
private static final boolean enabled = true;
Durante a fase de preparação, a JVM atribui memória para a variável enabled
e define o seu valor para o valor por defeito de um booleano, que é false
.
Resolução: Nesta fase, as referências simbólicas são substituídas por referências directas presentes no pool da constante de tempo de execução.
Por exemplo, se tiver referências a outras classes ou variáveis constantes presentes noutras classes, elas são resolvidas nesta fase e substituídas pelas suas referências reais.
Inialização
Inialização envolve a execução do método de inicialização da classe ou interface (conhecido como <clinit>
). Isto pode incluir chamar o construtor da classe, executar o bloco estático, e atribuir valores a todas as variáveis estáticas. Esta é a fase final do carregamento da classe.
Por exemplo, quando declarámos o seguinte código anteriormente:
private static final boolean enabled = true;
A variável enabled
foi definida para o seu valor por defeito de false
durante a fase de preparação. Na fase de inicialização, a esta variável é atribuído o seu valor real de true
.
Nota: a JVM é multi-threaded. Pode acontecer que vários fios estejam a tentar inicializar a mesma classe ao mesmo tempo. Isto pode levar a problemas de simultaneidade. É necessário lidar com a segurança dos fios para garantir que o programa funciona correctamente num ambiente multi-tarefa.
Área de Dados de Tempo de Execução
Existem cinco componentes dentro da área de dados de tempo de execução:
p>Vejamos cada um individualmente.
Área do Método
Todos os dados de nível de classe, tais como o conjunto de dados de constante de tempo de execução, de campo e de método, e o código para métodos e construtores, são armazenados aqui.
Se a memória disponível na área do método não for suficiente para o arranque do programa, a JVM lança um OutOfMemoryError
.
Por exemplo, suponha que tem a seguinte definição de classe:
public class Employee { private String name; private int age; public Employee(String name, int age) { this.name = name; this.age = age; }}
Neste exemplo de código, os dados ao nível do campo como name
e age
e os detalhes do construtor são carregados na área do método.
A área de método é criada no arranque da máquina virtual, e existe apenas uma área de método por JVM.
Heap Area
Todos os objectos e as suas variáveis de instância correspondentes são armazenados aqui. Esta é a área de dados de tempo de execução a partir da qual é atribuída memória para todas as instâncias e arrays de classe.
Por exemplo, suponha que está a declarar a seguinte instância:
Employee employee = new Employee();
Neste exemplo de código, uma instância de Employee
é criada e carregada na área da pilha.
A pilha é criada no arranque da máquina virtual, e existe apenas uma área de pilha por JVM.
Nota: Uma vez que o Método e as áreas de Heap partilham a mesma memória para múltiplos threads, os dados aqui armazenados não são seguros para threads.
Stack Area
Quando um novo thread é criado na JVM, uma pilha de tempo de execução separada é também criada ao mesmo tempo. Todas as variáveis locais, chamadas de métodos e resultados parciais são armazenados na área da pilha.
Se o processamento a ser feito numa thread requer um tamanho de pilha maior do que o disponível, a JVM lança um StackOverflowError
.
Para cada chamada de método, é feita uma entrada na memória da pilha que se chama Estrutura da Pilha. Quando a chamada de método está completa, a Estrutura de Pilha é destruída.
A Estrutura de Pilha é dividida em três sub-partes:
- Variáveis Locais – Cada estrutura contém um conjunto de variáveis conhecidas como as suas variáveis locais. Todas as variáveis locais e os seus valores são armazenados aqui. O comprimento deste conjunto é determinado em tempo de compilação.
- Pilha de Operandos – Cada frame contém uma pilha de último a entrar-primeiro a sair (LIFO) conhecida como a sua pilha de operandos. Esta actua como um espaço de trabalho em tempo de execução para realizar quaisquer operações intermédias. A profundidade máxima desta pilha é determinada em tempo de compilação.
- Dados da Moldura – Todos os símbolos correspondentes ao método são armazenados aqui. Isto também armazena a informação do bloco de captura em caso de excepções.
Por exemplo, suponha que tem o seguinte código:
double calculateNormalisedScore(List<Answer> answers) { double score = getScore(answers); return normalizeScore(score);}double normalizeScore(double score) { return (score – minScore) / (maxScore – minScore);}
Neste exemplo de código, variáveis como answers
e score
são colocadas na matriz de Variáveis Locais. A Pilha Operand contém as variáveis e operadores necessários para efectuar os cálculos matemáticos de subtracção e divisão.
Nota: Uma vez que a Área da Pilha não é partilhada, é intrinsecamente segura.
Registos do Contador de Programas (PC)
A JVM suporta vários fios ao mesmo tempo. Cada thread tem o seu próprio registo no PC para guardar o endereço da instrução JVM actualmente em execução. Uma vez executada a instrução, o registo do PC é actualizado com a instrução seguinte.
Pilha de Métodos Nativos
O JVM contém pilhas que suportam métodos nativos. Estes métodos são escritos numa linguagem diferente do Java, tal como C e C++. Para cada novo tópico, uma pilha de métodos nativos separada é também atribuída.
Motor de Execução
Após o bytecode ter sido carregado na memória principal, e os detalhes estarem disponíveis na área de dados de tempo de execução, o próximo passo é executar o programa. O Motor de Execução trata disto executando o código presente em cada classe.
No entanto, antes de executar o programa, o bytecode precisa de ser convertido em instruções em linguagem de máquina. O JVM pode utilizar um intérprete ou um compilador JIT para o motor de execução.
Interprete
O intérprete lê e executa as instruções do bytecode linha a linha. Devido à execução linha a linha, o intérprete é comparativamente mais lento.
Outra desvantagem do intérprete é que quando um método é chamado várias vezes, sempre que é necessária uma nova interpretação.
Compilador JIT
O Compilador JIT supera a desvantagem do intérprete. O Motor de Execução utiliza primeiro o intérprete para executar o código de bytes, mas quando encontra algum código repetido, utiliza o Compilador JIT.
O compilador JIT compila então o bytecode inteiro e altera-o para código de máquina nativo. Este código de máquina nativo é utilizado directamente para chamadas repetidas de métodos, o que melhora o desempenho do sistema.
O Compilador JIT tem os seguintes componentes:
- Gerador de Código Intermédio – gera código intermédio
- Code Optimizer – optimiza o código intermédio para melhor desempenho
- Target Code Generator – converte código intermédio para código de máquina nativo
- Profiler – encontra os hotspots (código que é executado repetidamente)
Para compreender melhor a diferença entre intérprete e compilador JIT, assuma que tem o seguinte código:
int sum = 10;for(int i = 0 ; i <= 10; i++) { sum += i;}System.out.println(sum);
Um intérprete irá buscar o valor de sum
à memória para cada iteração no laço, adicionar o valor de i
a ele, e escrevê-lo de volta à memória. Esta é uma operação dispendiosa porque está a aceder à memória cada vez que entra no laço.
No entanto, o compilador JIT reconhecerá que este código tem um HotSpot, e efectuará optimizações no mesmo. Guardará uma cópia local de sum
no registo do PC para o fio e continuará a adicionar-lhe o valor de i
no laço. Uma vez completado o laço, escreverá o valor de sum
de volta à memória.
Nota: um compilador JIT leva mais tempo a compilar o código do que para o intérprete interpretar o código linha a linha. Se vai executar um programa apenas uma vez, utilizar o intérprete é melhor.
Garbage Collector
O Garbage Collector (GC) recolhe e remove objectos não referenciados da área da pilha de lixo. É o processo de recuperação automática da memória não utilizada, destruindo-os.
A recolha do lixo torna a memória Java eficiente porque remove os objectos não referenciados da memória da pilha de lixo e cria espaço livre para novos objectos. Envolve duas fases:
- Marca – nesta etapa, o GC identifica os objectos não utilizados na memória
- Sweep – nesta etapa, o GC remove os objectos identificados durante a fase anterior
A recolha de lixo é feita automaticamente pela JVM a intervalos regulares e não precisa de ser tratada separadamente. Também pode ser accionada chamando System.gc()
, mas a execução não é garantida.
A JVM contém 3 tipos diferentes de colectores de lixo:
- Serial GC – Esta é a implementação mais simples de GC, e foi concebida para pequenas aplicações que funcionam em ambientes com uma única rosca. Utiliza uma única rosca para a recolha de lixo. Quando funciona, conduz a um evento “pare o mundo” onde toda a aplicação é pausada. O argumento da JVM para usar o Serial Garbage Collector é
-XX:+UseSerialGC
- Parallel GC – Esta é a implementação padrão de GC na JVM, e é também conhecida como Throughput Collector. Utiliza múltiplos fios para a recolha de lixo, mas ainda pausa a aplicação quando em funcionamento. O argumento JVM para usar o Colector Paralelo de Lixo é
-XX:+UseParallelGC
. - Garbage First (G1) GC – G1GC foi concebido para aplicações multi-tarefas que têm um grande tamanho de pilha disponível (mais de 4GB). Divide a pilha num conjunto de regiões de igual tamanho, e utiliza múltiplos fios para as digitalizar. G1GC identifica as regiões com mais lixo e realiza primeiro a recolha do lixo nessa região. O argumento da JVM para usar G1 Garbage Collector é
-XX:+UseG1GC
Nota: Existe outro tipo de colector de lixo chamado Concurrent Mark Sweep (CMS) GC. No entanto, foi depreciado desde Java 9 e completamente removido em Java 14 a favor de G1GC.
Interface Nativa de Java (JNI)
Por vezes, é necessário utilizar código nativo (não-Java) (por exemplo, C/C++). Isto pode ser em casos em que precisamos de interagir com hardware, ou para superar as limitações de gestão de memória e desempenho em Java. Java suporta a execução de código nativo através da Interface Nativa Java (JNI).
JNI actua como ponte para permitir os pacotes de suporte para outras linguagens de programação como C, C++, e assim por diante. Isto é especialmente útil nos casos em que é necessário escrever código que não é inteiramente suportado por Java, como algumas características específicas da plataforma que só podem ser escritas em C.
Pode usar a palavra-chave native
para indicar que a implementação do método será fornecida por uma biblioteca nativa. Também terá de invocar System.loadLibrary()
para carregar a biblioteca nativa partilhada em memória, e disponibilizar as suas funções para Java.
Native Method Libraries
Native Method Libraries são bibliotecas escritas noutras linguagens de programação, tais como C, C++, e assembly. Estas bibliotecas estão normalmente presentes na forma de ficheiros .dll
ou .so
. Estas bibliotecas nativas podem ser carregadas através da JNI.
Erros comuns da JVM
- ClassNotFoundExcecption – Isto ocorre quando o Class Loader está a tentar carregar classes usando
Class.forName()
ClassLoader.loadClass()
ouClassLoader.findSystemClass()
mas não é encontrada nenhuma definição para a classe com o nome especificado. - NoClassDefFoundError – Isto ocorre quando um compilador compilou com sucesso a classe, mas o Class Loader não é capaz de localizar o ficheiro da classe no momento da execução.
- OutOfMemoryError – Isto ocorre quando a JVM não pode atribuir um objecto porque está fora de memória, e não pode ser disponibilizada mais memória pelo colector do lixo.
- StackOverflowError – Isto ocorre quando a JVM fica sem espaço enquanto cria novas estruturas de pilha enquanto processa um fio.
Conclusion
Neste artigo, discutimos a arquitectura da Máquina Virtual Java e os seus vários componentes. Muitas vezes não nos aprofundamos na mecânica interna da JVM nem nos preocupamos com o seu funcionamento enquanto o nosso código está a funcionar.
É apenas quando algo corre mal, e precisamos de ajustar a JVM ou reparar uma fuga de memória, que tentamos compreender a sua mecânica interna.
Esta é também uma questão de entrevista muito popular, tanto a nível júnior como sénior para funções back end. Uma compreensão profunda da JVM ajuda-o a escrever melhor o código e evitar armadilhas relacionadas com erros de pilha e memória.
Obrigada por ter ficado comigo até agora. Espero que tenha gostado do artigo. Pode ligar-se comigo no LinkedIn onde discuto regularmente tecnologia e vida. Veja também alguns dos meus outros artigos e o meu canal no YouTube. Boa leitura. 🙂