Gerenciamento de Memória na JVM com Exemplos Práticos em Java

Componentes da Memória de Execução da JVM

A Java Virtual Machine (JVM) organiza a memória de forma a otimizar a execução de programas Java. Compreender como essa memória é estruturada e gerenciada é fundamental para o desenvolvimento de aplicações eficientes e robustas. As principais áreas de memória de tempo de execução na JVM são a Pilha (Stack), o Heap e o Metaspace (anteriormente conhecido como PermGen).

A Pilha (Stack)

  • Cada thread em uma aplicação Java possui sua própria Pilha de execução, tornando-a uma área de memória privada por thread.
  • Esta área de memória é responsável por armazenar quadros de pilha (Stack Frames). Cada vez que um método é invocado, um novo quadro é criado e empilhado.
  • Um quadro de pilha contém informações como variáveis locais, parâmetros do método, valores de retorno e a pilha de operandos (operand stack) para operações aritméticas ou lógicas.
  • A Pilha opera no princípio LIFO (Last-In, First-Out): o último quadro a entrar é o primeiro a sair quando o método correspondente termina.
  • A alocação de memória na Pilha é extremamente rápida e gerenciada automaticamente pelo sistema; o espaço é liberado assim que um método retorna.
  • É uma área de memória contígua.

O Heap

  • O Heap é a área de memória compartilhada por todas as threads de uma aplicação Java.
  • É aqui que todos os objetos criados pela aplicação (instâncias de classes e arrays) são armazenados.
  • Ao contrário da Pilha, o Heap é uma área de memória não contígua, o que permite maior felxibilidade na alocação, mas pode ser um pouco mais lenta.
  • A gestão do Heap é de responsabilidade do Coletor de Lixo (Garbage Collector) da JVM, que automaticamente libera a memória de objetos que não são mais referenciados.

O Metaspace (Área de Métodos)

  • O Metaspace é a área de memória da JVM onde as definições de classes e seus metadados são armazenados. É uma área de memória compartilhada por todas as threads.
  • Contém informações sobre a estrutura das classes, como nomes de métodos, nomes de campos, código de bytecode, tabelas de símbolos e outros dados relacionados à definição das classes.
  • Variáveis estáticas e constantes de string (String Pool, que pode ter partes no Heap e no Metaspace dependendo da versão e configuração) também residem nesta área ou em uma área relacionada e acessível globalmente.

Alocação de Objetos em Tempo de Execução

Para ilustrar como a JVM gerencia a memória durante a execução do programa, vamos analisar um exemplo prático com classes Java, simulando operações geométricas.

Exemplo de Código: Geometria Simples


class Ponto {
    private double coordenadaX;
    private double coordenadaY;

    // Construtor
    Ponto(double x, double y) {
        this.coordenadaX = x;
        this.coordenadaY = y;
    }

    public double getCoordenadaX() { return coordenadaX; }
    public double getCoordenadaY() { return coordenadaY; }

    public void setCoordenadaX(double val) { this.coordenadaX = val; }
    public void setCoordenadaY(double val) { this.coordenadaY = val; }
}

class Circulo {
    private Ponto centro;
    private double raio;

    // Construtor
    Circulo(Ponto p, double r) {
        this.centro = p;
        this.raio = r;
    }

    public void definirCentro(double x, double y) {
        centro.setCoordenadaX(x);
        centro.setCoordenadaY(y);
    }

    public Ponto getCentro() { return centro; }
    public double getRaio() { return raio; }

    public void setRaio(double r) { this.raio = r; }

    public double calcularArea() { return Math.PI * raio * raio; }

    // Incrementa o raio e retorna a própria instância do Círculo
    Circulo aumentarRaio() {
        this.raio++;
        return this;
    }
}

public class DemonstracaoCirculo {
    public static void main(String args[]) {
        Circulo circulo1 = new Circulo(new Ponto(1.0, 2.0), 2.0);
        System.out.println("Coordenada X do centro de circulo1: " + circulo1.getCentro().getCoordenadaX());
        circulo1.definirCentro(5, 6);
        System.out.println("Área após aumentar o raio duas vezes: " + circulo1.aumentarRaio().aumentarRaio().calcularArea());
    }
}

Aálise da Alocação: Circulo circulo1 = new Circulo(new Ponto(1.0, 2.0), 2.0);

Vamos detalhar o que acontece na memória da JVM quando a linha acima é executada, passo a passo:

  1. Declaração da referência circulo1: No Stack da thread principal, uma variável de referência chamada circulo1 é criada. Inicialmente, ela contém um valor nulo ou aponta para uma área indefinida.
  2. Criação do objeto Ponto (new Ponto(1.0, 2.0)):
    • No Heap, a JVM aloca espaço para um novo objeto da classe Ponto. Seus campos coordenadaX e coordenadaY são inicializados com valores padrão (0.0 para double).
    • Um novo Stack Frame é criado e empilhado para a execução do construtor Ponto(double x, double y). As variáveis locais x e y dentro deste Stack Frame recebem os valores 1.0 e 2.0, respectivamente.
    • Dentro do construtor, os valores de x e y são copiados para os campos correspondentes (coordenadaX e coordenadaY) do objeto Ponto no Heap.
    • Após o construtor ser concluído, seu Stack Frame é descartado, liberando as variáveis locais x e y. O objeto Ponto, agora com as coordenadas definidas, permanece no Heap, e sua referência é temporariamente armazenada para ser passada ao construtor de Circulo.
  3. Criação do objeto Circulo (new Circulo(...)):
    • No Heap, um novo objeto da classe Circulo é alocado. Seus campos centro (uma referência para Ponto) e raio (double) são inicializados.
    • Um novo Stack Frame é criado para a chamada do construtor Circulo(Ponto p, double r).
    • A variável local p neste Stack Frame recebe a referência ao objeto Ponto que acabamos de criar, e r recebe o valor 2.0.
    • Dentro do construtor, this.centro = p; faz com que o campo centro do objeto Circulo no Heap aponte para o objeto Ponto no Heap. Em seguida, this.raio = r; atribui 2.0 ao campo raio do objeto Circulo.
    • Ao final do construtor, seu Stack Frame é liberado, descartando as variáveis locais p e r. O objeto Circulo, agora totalmente construído, reside no Heap.
  4. Atribuição final: A referência ao objeto Circulo recém-criado no Heap é finalmente atribuída à variável de referência circulo1 no Stack. Agora circulo1 aponta para o objeto Circulo no Heap.

Análise da Alocação: Chamadas de Método

circulo1.getCentro().getCoordenadaX()

Esta linha demonstra o encadeamento de chamadas de método:

  1. A JVM avalia circulo1.getCentro(). Um Stack Frame para getCentro() é criado. Este método acessa o campo centro do objeto circulo1 no Heap e retorna a referência ao objeto Ponto. Esta referência é um valor de retorno temporário no Stack.
  2. Sobre a referência temporária do Ponto, .getCoordenadaX() é invocado. Um novo Stack Frame para getCoordenadaX() é criado. Este método acessa o campo coordenadaX do objeto Ponto no Heap e retorna seu valor (1.0).
  3. Este valor (1.0) é então utilizado pela chamada System.out.println() para exibição.
  4. Após a conclusão da impressão, os Stack Frames e os valores temporários associados a getCentro() e getCoordenadaX() são liberados da Pilha.

circulo1.definirCentro(5, 6)

Quando este método é invocado:

  1. Um Stack Frame para definirCentro(double x, double y) é criado. As variáveis locais x e y dentro deste Stack Frame recebem os valores 5.0 e 6.0.
  2. Dentro do método, centro.setCoordenadaX(x); e centro.setCoordenadaY(y); são chamados. Cada uma dessas chamadas cria seus próprios Stack Frames temporários. Elas acessam o objeto Ponto (para o qual centro aponta) no Heap e modificam seus campos coordenadaX e coordenadaY para 5.0 e 6.0, respectivamente.
  3. Quando definirCentro() termina, seu Stack Frame é liberado, e as variáveis locais x e y desaparecem. As alterações nos campos do objeto Ponto no Heap são persistentes.

circulo1.aumentarRaio().aumentarRaio().calcularArea()

Este é um exemplo clássico de encadeamento de métodos e ilustra o uso da palavra-chave this para retornar a instância atual do objeto:

  1. circulo1.aumentarRaio() é chamado. Um Stack Frame é criado. O método acessa o campo raio do objeto circulo1 no Heap, incrementando-o (para 3.0). A instrução return this; faz com que uma referência à própria instância do Circulo (ou seja, ao objeto circulo1) seja retornada e temporariamente colocada no Stack.
  2. Sobre essa referência temporária, .aumentarRaio() é chamado novamente. Outro Stack Frame é criado. O raio do mesmo objeto circulo1 no Heap é incrementado mais uma vez (para 4.0). Novamente, return this; faz com que a referência à mesma instância seja retornada e empilhada.
  3. Sobre a segunda referência temporária, .calcularArea() é chamado. Um Stack Frame é criado. Este método calcula a área usando o raio atual (4.0) do objeto circulo1 no Heap e retorna o resultado.
  4. O resultado é impresso. Após a conclusão de todas as chamadas e a impressão, todos os Stack Frames temporários e referências temporárias são descartados. O objeto circulo1 no Heap permanece com seu raio final de 4.0.

Variáveis Estáticas e Alocação

Variáveis estáticas (marcadas com static) pertencem à classe em si, e não a instâncias individuais da classe. Sua alocação e ciclo de vida diferem das variáveis de instância.

Exemplo de Código: Contador de Instâncias


class Gato {
    public static int contadorDeGatos = 0; // Variável estática
    String nome;

    Gato(String nome) {
        this.nome = nome;
        contadorDeGatos++; // Incrementa a variável estática para cada nova instância
    }

    public String getNome() { return nome; }
}

public class ExemploStatic {
    public static void main(String[] args) {
        Gato gato1 = new Gato("Mimi");
        Gato gato2 = new Gato("Mia");
        System.out.println("Número total de gatos criados: " + Gato.contadorDeGatos); // Acessa a variável estática
    }
}

Quando o programa acima é executado:

  • A variável estática contadorDeGatos, juntamente com todos os metadados da classe Gato, é carregada no Metaspace da JVM no momento em que a classe Gato é carregada. Seu valor inicial é 0.
  • Quando new Gato("Mimi") é executado, um objeto Gato é criado no Heap. Seus campos de instância (apenas nome neste caso) são inicializados. O construtor é chamado, e contadorDeGatos (que reside no Metaspace) é incrementado para 1.
  • Quando new Gato("Mia") é executado, outro objeto Gato é criado no Heap. O construtor é chamado, e o mesmo contadorDeGatos no Metaspace é incrementado novamente, passando para 2.
  • A linha System.out.println(Gato.contadorDeGatos) acessa diretamente o valor da variável estática no Metaspace, imprimindo 2. As instâncias gato1 e gato2 no Heap não contêm uma cópia de contadorDeGatos; elas apenas "compartilham" o acesso a ela através da definição da classe no Metaspace.

Alocação de Memória com Polimorfismo (Ligação Dinâmica)

O polimorfismo é um conceito chave em Java que afeta como os objetos são referenciados e como os métodos são resolvidos em tempo de execução, permitindo flexibilidade e extensibilidade no código.

Exemplo de Código: Animais e seus Proprietários


class AnimalGenerico {
    public String nome;

    AnimalGenerico(String nome) { this.nome = nome; }

    void fazerSom() { System.out.println("Fazendo algum som..."); }
}

class Cao extends AnimalGenerico {
    public String corPelo;

    Cao(String nome, String corPelo) {
        super(nome); // Chama o construtor da superclasse
        this.corPelo = corPelo;
    }

    @Override
    void fazerSom() { System.out.println("Au Au!"); }
}

class GatoGenerico extends AnimalGenerico {
    GatoGenerico(String nome) { super(nome); }

    @Override
    void fazerSom() { System.System.out.println("Miau!"); }
}

class Pessoa {
    public String nome;
    public AnimalGenerico animalEstimacao; // Referência polimórfica

    Pessoa(String nome, AnimalGenerico pet) {
        this.nome = nome;
        this.animalEstimacao = pet;
    }

    void interagirComPet() {
        this.animalEstimacao.fazerSom(); // Chamada polimórfica
    }

    void trocarPet(AnimalGenerico novoPet) {
        this.animalEstimacao = novoPet;
    }
}

public class ExemploPolimorfismo {
    public static void main(String[] args) {
        Cao meuCao = new Cao("Buddy", "Dourado");
        Pessoa p1 = new Pessoa("Alice", meuCao);
        p1.interagirComPet(); // Irá imprimir "Au Au!"

        GatoGenerico meuGato = new GatoGenerico("Whiskers");
        Pessoa p2 = new Pessoa("Bob", meuGato);
        p2.interagirComPet(); // Irá imprimir "Miau!"
    }
}

Na execução do código acima:

  • Quando Cao meuCao = new Cao("Buddy", "Dourado"); é executado, um objeto Cao completo (que inclui os campos herdados de AnimalGenerico e os específicos de Cao) é criado no Heap. A referência meuCao no Stack aponta para este objeto.
  • Quando Pessoa p1 = new Pessoa("Alice", meuCao); é executado, um objeto Pessoa é criado no Heap. Seu campo animalEstimacao, que é declarado como tipo AnimalGenerico, recebe a referência ao objeto Cao que já está no Heap.
  • Na chamada p1.interagirComPet();, o método fazerSom() é invocado através da referência animalEstimacao. Embora o tipo da referência seja AnimalGenerico, o objeto *real* para o qual animalEstimacao aponta em tempo de execução é um Cao. A JVM utiliza o mecanismo de ligação dinâmica (dynamic dispatch) para resolver qual implementação de fazerSom() deve ser chamada. Consequentemente, a versão de Cao.fazerSom() é executada, resultando na saída "Au Au!".
  • É importante notar que, embora animalEstimacao aponte para um objeto Cao no Heap, o compilador só permite acessar membros que estão definidos na classe AnimalGenerico (ou em suas superclasses) através dessa referência. Por exemplo, tentar acessar p1.animalEstimacao.corPelo geraria um erro de compilação, pois o tipo da referência animalEstimacao é AnimalGenerico, que não possui o campo corPelo. Apenas após um cast explícito para Cao seria possível acessar corPelo.
  • O mesmo princípio se aplica quando p2 é criado com um GatoGenerico. A chamada p2.interagirComPet() invoca GatoGenerico.fazerSom() devido à ligação dinâmica.

Considerações sobre a Coleta de Lixo

É crucial entender que, embora variáveis primitivas e referências no Stack sejam rapidamente liberadas após o término de um método, os objetos no Heap têm um ciclo de vida diferente, gerenciado pelo Coletor de Lixo.

Um objeto no Heap permanece lá enquanto houver pelo menos uma referência "ativa" apontando para ele. Quando não há mais nenhuma referência ativa apontando para um objeto, ele se torna "elegível para coleta de lixo" (garbage collection eligible). No entanto, isso não significa que a memória será liberada instantaneamente.

O Coletor de Lixo da JVM é um processo em segundo plano que é executado periodicamente para identificar e remover objetos não referenciados do Heap. O tempo e a frequência de sua execução dependem de vários fatores, como a quantidade de memória disponível, o tipo de GC configurado e a carga da aplicação. Portanto, um objeto pode estar inativo por um tempo considerável antes que sua memória seja realmente recuperada.

Exemplo de Objeto Órfão


class Item {
    int id;

    Item(int valorId) {
        id = valorId;
    }
    // Suponha outros métodos e campos
}

public class DescarteDeObjetos {
    public static void main(String[] args) {
        Item itemA = new Item(101); // Cria objeto Item(101) no Heap
        Item itemB = new Item(102); // Cria objeto Item(102) no Heap

        System.out.println("Item A ID: " + itemA.id); // Saída: 101
        System.out.println("Item B ID: " + itemB.id); // Saída: 102

        itemA = itemB; // Agora itemA aponta para o mesmo objeto que itemB
                       // O objeto Item(101) original não tem mais nenhuma referência ativa.
                       // Ele se torna um "objeto órfão" e elegível para GC.

        System.out.println("Item A ID (após reatribuição): " + itemA.id); // Saída: 102
        System.out.println("Item B ID: " + itemB.id); // Saída: 102
    }
}

Neste exemplo, após a linha itemA = itemB;, o objeto Item que originalmente tinha id=101 não é mais acessível através de nenhuma referência. Ele se torna um candidato para ser coletado pelo Coletor de Lixo, mas a limpeza da memória não é imediata. Ambas as referências itemA e itemB agora apontam para o mesmo objeto Item com id=102.

Tags: java JVM GerenciamentoDeMemoria Stack heap

Publicado em 6-5 20:22 por Thomas