Como funciona o gerenciamento de memória no Java

10 minuto(s) de leitura

Esse artigo tem a finalidade de fazer uma breve introdução de como a JVM faz o gerenciamento de memória das aplicações. O artigo apesar de ficar longo, não chega nem perto de ser exaustivo. A verdade é que cada parte desse artigo preencheria o capítulo de um livro, devido a todos os detalhes que eu vou deixar de explicar aqui em prol de uma visão geral.

Uma das grandes vantagens de linguagens modernas em relação às linguagens de mais baixo nível como C e C++ é que você não precisa se preocupar em alocar e liberar memória para seus objetos e variáveis. Tudo é responsabilidade do Garbage Collector, o grande protagonista das linguagens de alto nível.

No Java o gerenciamento de memória é feito em dois espaços distintos, a Stack e o Heap. Vou explicar o espaço da Stack primeiro.

Stack

Para inicializar uma aplicação Java, sempre é necessário criar um método void e estático chamado main:

public class HelloWorld {
    public static void main(String[] args) {
        int x = 2;
        int y = 3;

        System.out.println("Resultado: " + multiplicar(x, y));
    }
    
    public static int multiplicar(int x, int y) {
        return x * y;
    }
}

Eu escrevi uma classe simples que pega dois valores, multiplica e printa a resposta. Não criei nenhum objeto aqui, apenas utilizei variáveis primitivas. E como fiz isso, esse exemplo não irá utilizar a memória Heap, que é utilizada apenas para armazenar objetos, e não referências e valores primitivos.

A Stack tem esse nome justamente porque ela se comporta como uma pilha onde a cada instrução do programa os valores das variáveis e chamadas a métodos são empilhados na ordem de execução. Assim que um método termina de ser executado, todas as variáveis que existam apenas no escopo daquele método deixam de existir imediatamente.

Vejo o segundo exemplo na imagem:

image-center

Cada instrução que altere o valor de alguma variável e todas as chamadas de métodos são empilhados na Stack. Ela serve tanto para armazenar valores temporários quanto para definir o fluxo de execução do programa. No momento que o método calculate é executado, o valor dele é retornado para a variável value e todas as variáveis do escopo daquele método são limpos da Stack:

image-center

No entanto a Stack não possui espaço ilimitado. O tamanho default pode variar entre as versões de JVMs e sistema operacional, mas costuma ser de 1024kb para sistemas 64 bits.

O valor da Stack pode ser alterado através do argumento -Xss2M. Onde o 2 é para 2 mb de espaço.

Cada thread executando no seu código possui uma Stack separada, de forma que a memória não é compartilhada entre elas. Se na sua aplicação estão rodando 20 threads e cada Stack possui 2 mb de espaço, a JVM vai alocar no mínimo 40 megas apenas para guardar todo o espaço necessário pra cada Stack.

Um jeito de verificar a limitação da memória Stack é criar um programa que executa um método recursivo que nunca termina:

public class Recursivo {
    public static void main(String[] args) {
        int resultado = somaInfinita(1);
        System.out.println(resultado);
    }

    private static int somaInfinita(int x) {
        return x + somaInfinita(x);
    }
}

Como chamadas a métodos também envolvem empilhar um dado na Stack, eventualmente ela vai estourar e o clássico erro StackOverflowError vai aparecer.

image-center

Memória Heap

A memória Heap é onde são guardados todos os objetos do seu código. Diferente da Stack, ela é um grande espaço compartilhado entre todas as threads da aplicação e por conta disso, a JVM não consegue ter certeza de quais objetos estão sendo usados ou não a todo momento. Por isso existe o Garbage Collector. Ele é encarregado de tempos em tempos fazer uma varredura nesse espaço e ir limpando os objetos que não estão sendo utilizados em mais nenhum lugar.

O tamanho do Heap deve ser definido pelo programador antes de iniciar a aplicação de forma que a JVM faça bom uso da memória disponível no servidor. Os argumentos utilizados para isso são o -Xms256M e -Xmx512M, sendo os valores 256 e 512 os respectivos valores em MB. O Xms define qual o tamanho mínimo que o Heap deve ter, podendo chegar até o valor máximo definido pelo Xmx.

Veja o exemplo abaixo de como a Stack e Heap trabalham juntas:

public class StackHeap {
    public static void main(String[] args) {
        int valor = 10;

        List<String> lista = new ArrayList<>();
        lista.add("Um");
        lista.add("Dois");
        lista.add("Três");

        System.out.println(lista);
    }
}

image-center

Na Stack ficam todas as variáveis locais utilizadas no método sendo executado no momento. Veja que mesmo a lista está na Stack. Mas nela ficam apenas as referências aos objetos e não eles em si. O objeto lista com seus valores mesmo estão salvos no Heap.

Mesmo as Strings dentro da lista estão armazenadas em lugares diferentes também. A lista armazena apenas o endereço de memória dos objetos salvos no Heap, como se fossem ponteiros. A ideia por trás de toda essa estrutura é esconder o uso de ponteiros que está sendo feito por baixo dos panos e abstrair toda a lógica do gerenciamento de memória.

Garbage Collector

Como a memória Heap é única e compartilhada entre threads, é necessário existir um mecanismo que consiga buscar todos os objetos que não são mais necessários e removê-los.

Qualquer objeto que não pode ser acessado através de uma variável local na Stack, pode ser deletado pelo Garbage Collector. Se você remove um elemento da lista no exemplo acima, o heap ficaria assim:

image-center

Enquanto o Garbage Collector não for executado, a String ficará alocada na Heap sem que ninguém mais consiga acessá-la. Ela, no entanto, ficará elegível para ser removida.

Como a memória Heap é um espaço grande e existem muitos objetos a serem removidos a todo momento, seria muito custoso executar o GC em toda memória a todo momento. Por esse motivo, existem estratégias de divisão da Heap que tentam diminuir o espaço de busca por objetos que possam ser removidos.

Dentre vários algoritmos existentes, o normal é dividir a Heap em três grandes blocos, são os chamados Garbage Collectors geracionais. Veja o exemplo abaixo:

image-center

Essa é uma imagem tirada do VisualVM, um programa que serve para acompanharmos o Garbage Collection em tempo real enquanto sua aplicação está rodando. Nela vemos os espaços de memória principais que são o Metaspace, Young Generation (Compostas por Eden, S0 e S1) e Old Generation. Abaixo vou explicar cada um deles.

Young Generation

Esses três espaços de memória são os menores do Heap. São destinados a objetos que são criados e destruidos rapidamente. A maioria dos objetos no Java perdem a utilidade logo que são criados, por isso eles podem ser removidos pelo Garbage Collector na primeira execução. Assim que um objeto é criado, ele vai pra memória Eden, onde o Garbage Collector é bem rapido e por isso é executado muito mais vezes.

O S0 e S1 servem para armazenar todos os objetos que não foram removidos na execução do último garbage collector. A cada execução, o Eden é zerado e envia todos os objetos remanescentes para o S0. Na próxima execução do GC, o Eden e S0 são limpos e objetos que sobrevivem vão para o S1. NA próxima execução será a vez de rodar o garbage no Eden e S1, e os objetos que restarem voltam para o S0. Os objetos vão sendo movidos entre S0 e S1 até serem promovidos ao espaço Old Generation.

Old Generation

Se um objeto sobrevive aos espaços Eden e Survival, ele será promovido para a Old Generation. Esse é o maior espaço de memória do Heap. Aqui ficam objetos que praticamente nunca morrem e estão sempre sendo referenciados em alguma variável local de alguma thread. O objetivo desse espaço é que o Garbage Collector tenha que ser executado o menor número de vezes aqui.

Quando o GC precisa rodar na Old Generation, acontece um evento chamado Stop The World. Como esse espaço é muito grande e o Garbage Collector pode demorar pra finalizar, toda a sua aplicação precisa ser pausada até que ele termine de limpar os objetos daqui, o que pode deteriorar bastante a performance.

Quando o Java precisa rodar um programa que sempre fica no limite da memória máxima definida na inicialização, o Garbage Collector pode acabar dominando todo o uso de CPU que deveria ser utilizado pela aplicação. Por isso é bom sempre deixar uma folga na memória. Quanto mais memória a JVM possui, menor a frequência de execução do Garbage Collector.

Metaspace

Esse é um espaço fora do Heap que serve basicamente para armazenar as classes utilizadas pela aplicação. É um espaço que por default não possui limite, e pode ir crescendo à medida que for necessário. O Garbage Collector só irá rodar aqui caso a flag -XX:MaxMetaspaceSize=N seja passada no início, sendo N o tamanho definido.

Conclusão

Esse é o funcionamento geral dos Garbage Collectors geracionais, que são o padrão hoje. O que eu expliquei aqui se aplica à maioria dos GCs disponíveis pela JVM. No entanto existem vários algoritmos que podem ser escolhidos no início da aplicação como SerialGC, ParallelGC, ConcMarkSweepGC ou G1GC que possuem detalhes e especificidades diferentes.

Está fora de escopo aqui explicar como fazer tunning e como escolher o tipo de GC pra cada aplicação, pois o artigo ficaria muito mais longo.

Se você quer saber mais, sugiro a excelente documentação da Oracle sobre Tunning de Garbage Collectors.

Se tiver alguma dúvida ou sugestão, não deixe de escrever nos comentários.

Até mais!

Deixe um comentário