DEV Community

Cover image for Uma introdução a JVM
Yuri Matheus
Yuri Matheus

Posted on

Uma introdução a JVM

Uma das coisas que fez o Java ganhar popularidade foi o uso de uma máquina virtual para executar seu código. Todo código Java, antes de rodar de fato, é compilado para uma linguagem chamada de Bytecode que é interpretada na Máquina Virtual Java, ou JVM para os íntimos.

Dessa forma, conseguimos escrever um único código e pedir para ele ser executado em qualquer sistema que tenha a JVM instalada. Foi nesse contexto que surge a expressão Write once, run everywhere, ou Escreva uma única vez e rode em qualquer lugar.

A JVM vai cuidar de abstrair o sistema operacional (SO) das pessoas que escrevem um código que rode nela. Dessa forma, system calls, gerenciamento de memória, threads e processos, entre diversas outras coisas são abstraídas para nós que escrevemos o código.

Por não interpretar código Java, mas sim um bytecode gerado no processo de compilação, a JVM permite com que nós utilizemos a linguagem de programação que achemos melhor. Clojure, Kotlin, Scala e Groovy são exemplos de linguagem que roda na JVM.

Quando compiladas, essas linguagens vão para uma estrutura de bytecode que consegue ser interpretada na JVM. Vamos pensar em um simples Olá, mundo! escrito em Kotlin e em Java.

Java

public class OlaMundo {

   public static void main(String[] args) {
       System.out.println("Olá, mundo!");
   }

}
Enter fullscreen mode Exit fullscreen mode

Bytecode do código Java gerado:

Compiled from "OlaMundo.java"
public class OlaMundo {
  public OlaMundo();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return

  public static void main(java.lang.String[]);
    Code:
       0: getstatic     #7                  // Field java/lang/System.out:Ljava/io/PrintStream;
       3: ldc           #13                 // String Olá, mundo!
       5: invokevirtual #15                 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
       8: return
}
Enter fullscreen mode Exit fullscreen mode

Kotlin

fun main() {
    print("Olá, mundo")
}
Enter fullscreen mode Exit fullscreen mode

Bytecode do código Kotlin gerado:

Compiled from "olamundo.kt"
public final class OlamundoKt {
  public static final void main();
    Code:
       0: ldc           #11                 // String Olá, mundo
       2: astore_0
       3: iconst_0
       4: istore_1
       5: getstatic     #17                 // Field java/lang/System.out:Ljava/io/PrintStream;
       8: aload_0
       9: invokevirtual #23                 // Method java/io/PrintStream.print:(Ljava/lang/Object;)V
      12: return

  public static void main(java.lang.String[]);
    Code:
       0: invokestatic  #9                  // Method main:()V
       3: return
}
Enter fullscreen mode Exit fullscreen mode

Note que o bytecode do código Kotlin é bem similar ao do código Java. Mas, como esses bytecodes são carregados na JVM?

O processo de interpretação da JVM

Todo arquivo compilado para bytecode Java ganha um .class ao final de seu nome. Esse sufixo é uma forma de informar a JVM sobre que aquela é uma classe Java pronta para ser carregada. Esse processo é chamado Loading.

Esse Loading é efetuado por um Class Loader que, basicamente, vai gerar um objeto em memória do tipo Class (instância de java.lang.Class) no qual carregará as metainformações daquela classe, como nome, referências das superclasses, nomes de métodos, etc.

A JVM tem diversos Class Loader e segue uma ordem específica para a fim de promover uma certa segurança no código. Por exemplo, se criarmos uma classe chamada String, como garantir que outras partes do código estão usando de fato a classe java.lang.String e não a classe que a gente criou?

Para saber mais sobre os class loader, vale uma olhada na especificação da JVM https://docs.oracle.com/javase/specs/jvms/se19/html/jvms-5.html#jvms-5.3.1

Após a etapa de Loading, começa a etapa de Linking. Nesta fase, é verificado se o bytecode está bem formatado (etapa de verificação), alocação de espaço para as variáveis estáticas (etapa de preparação) e, caso necessário, uma resolução das referências da classe e do objeto em questão (etapa de resolução).

Essa etapa de resolução pode acontecer antes ou depois da próxima etapa que é a etapa de inicialização. Essa última etapa é a que de fato vai inicializar as variáveis do objeto e deixá-lo pronto para uso.

No diagrama abaixo, tem uma simples ilustração dessas fases:

Etapas para carregar um objeto na JVM

Agora sabemos o que acontece cada vez que um objeto é criado na JVM, mas o que acontece quando não precisamos mais desse objeto?

Um pouco sobre o modelo de memória

Em linguagens como C, precisamos ativamente alocar e desalocar espaços de memória quando necessário. Se nesse processo de alocação, a aplicação pedir um espaço de memória para o sistema operacional e não conseguir obter o espaço necessário a aplicação apresentará um erro.

No caso de um código que roda na JVM, todo esse processo de alocação de memória é transparente. A JVM cria espaços virtuais de memória no qual a aplicação, isto é, o código Java, Kotlin, etc, interage com essa memória virtual, ao invés da memória real do computador.

Existem, basicamente, três espaços de memória na JVM: stack, heap e a method area.

A stack é um espaço único por thread. Nele são registrado variáveis de tipos primitivos, resultados parciais de execuções, retornos de métodos, entre outros tipos de dados e metadados. Segundo a especificação da JVM, esses tipos de dados são armazenados em frames.

Na heap são armazenados os objetos de fato. Cada vez que damos um new Obj();, este novo objeto é armazenado na heap e uma referência a ele fica em algum frame da stack. Por conta dessa natureza, a heap é um espaço de memória bem maior que a stack.

Quando esses objetos que estão na heap perdem a sua referência, a JVM automagicamente consegue identificar esse objeto como lixo e coletá-lo da memória. Para isso ela utiliza de algoritmos de Garbage Collection, ou GC, para os íntimos.

Cada implementação da JVM pode ter um, ou mais, algoritmos de GC, com otimizações diferentes. A especificação apenas define que objetos na heap devem ser recolhidos por esse sistema.

Existem diversos algoritmos de GC e diversas formas de otimizá-los. Cada algoritmo conta com bônus e ônus e pode se aplicar a aplicações e casos de uso distintos. Como é um assunto complexo e extenso, foge um pouco do escopo deste post. Contudo, o Yuri do futuro deve escrever alguns posts sobre o assunto.

Para agora, basta com que saibamos que esses algoritmos existem e, por causa deles, não precisamos nos preocupar tanto em gerenciar a memória das nossas aplicações que rodam sob a JVM

Conhecendo o JIT

O código interpretado pela JVM é, em geral, mais lento que um código de máquina, isto é, um código que roda diretamente no computador, sem um intermediário (como é o caso da máquina virtual).
Pensando em resolver esse problema, a especificação da JVM apresentou o conceito de JIT, ou just-in-time compiler.

O JIT nada mais é do que outro compilador que é executado pela JVM e transforma o bytecode Java em código nativo. Além de compilar para código nativo, a JVM também pode fazer outras melhorias de performance nos código, a fim de ter uma execução mais rápida.

Cada implementação de JVM tem seu próprio algoritmo e formas de compilar e otimizar esse código, mas basicamente ela vai se dar através de trechos de código que são mais executados em determinado período de tempo.

Uma JVM para cada uso

A JVM é um grande trunfo para quem programa com Java ou outras linguagens suportadas por ela. Ela tem uma boa especificação e diversas implementações para casos de uso e necessidades distintas.

Entender mais dessa ferramenta nos ajuda no processo de resolução de erros (troubleshooting), a melhorar a performance e arquitetar aplicações mais robustas e resilientes.

Top comments (3)

Collapse
 
brunonovais profile image
Bruno Rezende Novais

Artigo muito bacana e uma boa forma de introduzir a JVM.

Uma coisa que acho legal complementar é que a JVM não possui só um fabricante como muitos pensam. Temos a Azul Systems, OpenJDK e Oracle JVM, GraalVM, entre outros.

Cada uma segue uma especificação que podem ou não ser diferentes e com suas vantagens e desvantagens, isso torna o mundo JVM-based bem mais legal.

Collapse
 
wldomiciano profile image
Wellington Domiciano

Ficou muito bom. Obrigado por compartilhar.

Uma coisa importante é que quando vc diz "Pensando em resolver esse problema, a especificação da JVM apresentou o conceito de JIT, ou just-in-time compiler." dá a entender que compiladores JIT são parte da especificação e isso não é verdade.

Compilação JIT é um detalhe de implementação que não é abordado pela especificação e é deixado para os implementadores decidirem se vão usar ou não este tipo de estratégia.

Um lugar onde a especificação deixa isto claro é na seção 2.13 onde JIT é apresentado como uma das opções oferecidas pela flexibilidade do design da JVM:

The implementor can use this flexibility to tailor Java Virtual Machine implementations for high performance, low memory use, or portability. What makes sense in a given implementation depends on the goals of that implementation. The range of mplementation options includes the following:

  • Translating Java Virtual Machine code at load-time or during execution into the instruction set of another virtual machine.
  • Translating Java Virtual Machine code at load-time or during execution into the native instruction set of the host CPU (sometimes referred to as just-in-time, or JIT, code generation).

FONTE: docs.oracle.com/javase/specs/jvms/...

Collapse
 
jordihofc profile image
Jordi Henrique Silva

Excelente Artigo, Yuri!
Você desmistificou a JVM!