Articles

Tutorial JVM – Java Virtual Machine Architecture Explained for Beginners

Posted on

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:

  1. Class Loader
  2. Memória de execução/Área de dados
  3. 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.langjava.netjava.utiljava.io, e assim por diante. Estes pacotes estão presentes dentro do rt.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:

  1. Gerador de Código Intermédio – gera código intermédio
  2. Code Optimizer – optimiza o código intermédio para melhor desempenho
  3. Target Code Generator – converte código intermédio para código de máquina nativo
  4. 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:

  1. Marca – nesta etapa, o GC identifica os objectos não utilizados na memória
  2. 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:

  1. 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
  2. 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.
  3. 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() ou ClassLoader.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. 🙂

Deixe uma resposta

O seu endereço de email não será publicado. Campos obrigatórios marcados com *