Uma introdução a JVM
Tags: java, jvm
Published: 2023-05-23 14:05:25

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 **V**irtual **J**ava, 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](https://man.archlinux.org/man/syscalls.2.en), 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**

```java
public class OlaMundo {

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

}
```

**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
}
```

**Kotlin**

```kotlin
fun main() {
    print("Olá, mundo")
}
```

**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
}
```

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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/tk0q8f5wgyj8wena9bwv.png)


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`](https://docs.oracle.com/javase/specs/jvms/se19/html/jvms-2.html#jvms-2.6). 

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.