DevGuideTK
← Trilha Backend

Gerenciamento de memória

Pilha, heap e coletor de lixo, e de onde vêm os vazamentos de memória em linguagens gerenciadas.

Onde os dados moram: pilha e heap

Um programa guarda os seus dados em duas regiões de memória com regras diferentes:

Pilha (stack)Heap
O que guardaVariáveis locais e dados de cada chamada de função em andamentoObjetos que vivem além da função que os criou: listas, mapas, objetos, textos longos
Como é liberadaAutomaticamente, quando a função terminaPor alguém: o programador, ou o coletor de lixo
VelocidadeMuito rápidaMais lenta para reservar e liberar
TamanhoPequena, alguns megabytes por threadGrande, limitada pela memória disponível

Quase tudo o que importa num backend mora no heap: os pedidos recebidos, as respostas montadas, os resultados das consultas, os caches. É no heap que a memória acaba.

Quem libera a memória

Em C e C++, o programador reserva e libera a memória à mão. Esquecer de liberar vaza memória; liberar duas vezes ou usar algo já liberado corrompe o programa e abre falhas de segurança.

As cinco linguagens desta trilha usam um coletor de lixo (garbage collector): o runtime descobre sozinho quais objetos não são mais usados e libera a memória deles.

LinguagemComo o coletor funciona
Node (V8)Por geração: objetos novos são coletados com frequência; os que sobrevivem passam para uma área coletada com menos frequência
Python (CPython)Contagem de referências: cada objeto sabe quantas referências tem e é liberado quando chega a zero; um coletor extra trata ciclos
GoConcorrente: marca e libera enquanto o programa roda, com pausas muito curtas; sem gerações
JavaPor geração, com vários coletores para escolher (G1 é o padrão em servidores; ZGC para pausas mínimas)
C# (.NET)Por geração, com modo de estação de trabalho e modo de servidor

A regra do coletor: o que é alcançável fica

O coletor não sabe se um objeto ainda vai ser usado. Ele sabe se o objeto ainda é alcançável: se existe um caminho de referências partindo das variáveis globais, das variáveis locais em uso ou de outras raízes até ele.

raízes (variáveis globais, pilhas das threads)
   │
   ├──▶ cache global ──▶ usuário 1 ──▶ perfil
   │                 ──▶ usuário 2 ──▶ perfil      ← alcançáveis: ficam
   │
   └──▶ pedido atual ──▶ resposta

   objeto de um pedido que já terminou             ← inalcançável: é coletado

Isso explica a forma dos vazamentos em linguagens com coletor: uma referência esquecida num lugar que dura para sempre. O objeto nunca mais será usado, mas um mapa global ainda aponta para ele, e o coletor é obrigado a mantê-lo.

De onde vêm os vazamentos

CausaExemplo
Cache sem limiteUm mapa global que guarda o perfil de cada usuário que já fez um pedido
Coleções globais que só crescemUma lista de pedidos processados guardada "para depuração"
Ouvintes não removidosUm ouvinte de eventos registrado a cada pedido e nunca desregistrado
Closures que capturam demaisUma função guardada que referencia, sem precisar, um objeto grande do contexto em que foi criada
Tarefas que nunca terminamGoroutines ou tarefas esperando um canal ou um evento que nunca chega
Temporizadores esquecidosUm setInterval criado por pedido e nunca cancelado
Valores por threadThreadLocal em Java preso a threads de um pool, que nunca morrem

O mais comum é o primeiro. Medimos com um cache em Node que guardava o perfil de cada usuário novo, num container de 512 MB:

CacheDepois de 400 mil pedidosDepois de 2 milhões de pedidos
Limitado a 10 mil itens, removendo o mais antigo15 MB de heap12 a 18 MB de heap
Sem limite156 MB de heapO processo morreu com JavaScript heap out of memory antes de 800 mil

Com tráfego real, o cache sem limite leva dias para encher, e o serviço reinicia sozinho de tempos em tempos. Sem a medição, a causa parece aleatória.

Quando a memória acaba: duas mortes diferentes

Quem desisteO que aconteceComo aparece
O runtimeO heap chegou ao limite configurado do runtimeUm erro com mensagem: JavaScript heap out of memory, OutOfMemoryError, MemoryError
O sistema operacionalO processo inteiro passou do limite do containerO processo é morto sem mensagem nenhuma; código de saída 137 e o motivo OOMKilled

A segunda é a mais confusa, porque não deixa rastro no log da aplicação. E ela acontece quando o runtime não sabe que existe um limite.

O coletor precisa conhecer o limite

Um coletor de lixo decide quando coletar com base em quanto o heap cresceu. Se ele não sabe que o container tem 512 MB, pode deixar o lixo acumular além disso antes de coletar.

Reproduzimos com um programa Go que mantém 300 MB de dados em uso e cria 3 GB de objetos temporários, num container de 512 MB:

ConfiguraçãoResultado
Padrão do Go, sem limite de memóriaMorto pelo sistema operacional (código 137, OOMKilled)
Com GOMEMLIMIT=450MiBTerminou normalmente, com 30 coletas

Os dados em uso cabiam com folga. O problema era o lixo: por padrão, o Go deixa o heap crescer até o dobro dos dados em uso antes de coletar, e o dobro de 300 MB passa de 512. Com o limite informado, o coletor passou a trabalhar mais cedo.

O Java mostra o problema oposto. No mesmo container, um programa que precisava de 250 MB falhou com OutOfMemoryError: por padrão, a JVM usa só 25% da memória do container para o heap, cerca de 123 MB. Com -XX:MaxRAMPercentage=75, o heap passou a 371 MB e o programa funcionou.

Por que isso importa quando a IA escreve o código

Assistentes escrevem caches, registros e coleções sem pensar em quanto eles crescem em produção:

  • Um Map ou dicionário global usado como cache, sem limite de tamanho nem expiração.
  • Um ouvinte de eventos registrado dentro de uma função chamada a cada pedido.
  • Uma lista que acumula resultados "para estatísticas" e nunca é esvaziada.
  • Goroutines disparadas sem forma de terminar quando o pedido é cancelado.
  • O container configurado com um limite de memória e o runtime deixado no padrão.

O próximo nível compara as formas de configurar cada runtime e de evitar e encontrar vazamentos.

Prompts para aprofundar

Ao abrir no ChatGPT ou no Claude, o prompt é enviado ao serviço escolhido pela URL e também fica copiado na sua área de transferência.

  • Memória e coletor de lixo em cinco minutos

    Explique em no máximo cinco minutos de leitura como funciona o gerenciamento de memória em linguagens com coletor de lixo, para alguém que programa mas nunca investigou um problema de memória.
    
    Cubra só o essencial: a diferença entre pilha e heap, o que o coletor de lixo faz, por que ainda existem vazamentos e o que acontece quando um container fica sem memória.
    
    Use uma analogia simples. No final, me faça três perguntas curtas para eu verificar se entendi, e corrija as minhas respostas.
  • Como o coletor de lixo decide o que liberar

    Quero entender como um coletor de lixo decide o que liberar.
    
    Aja como um mentor. Pergunte primeiro em qual linguagem eu trabalho.
    
    Conduza assim, uma etapa por vez:
    1. A diferença entre pilha e heap, com um exemplo de código que coloca dados em cada uma.
    2. O conceito de raízes e de objetos alcançáveis, com um desenho em texto.
    3. Como funciona o coletor da minha linguagem: por geração, contagem de referências ou concorrente.
    4. Por que um objeto que nunca mais será usado pode continuar na memória para sempre.
    5. O que é uma pausa de coleta e quando ela afeta a latência.
    
    Faça uma pergunta de verificação em cada etapa e espere a minha resposta.
  • Encontrar o vazamento escondido

    Quero treinar o olho para reconhecer vazamentos de memória em linguagens com coletor de lixo.
    
    Pergunte em qual linguagem eu trabalho.
    
    Me mostre oito trechos de código, um por vez, e pergunte se cada um vaza memória e por quê. Inclua:
    - um cache global sem limite;
    - um ouvinte de eventos registrado a cada pedido;
    - uma closure que captura um objeto grande sem precisar;
    - uma tarefa ou goroutine que espera algo que nunca chega;
    - um temporizador criado por pedido;
    - uma lista global "para estatísticas";
    - dois trechos corretos.
    
    Corrija cada resposta e mostre a versão sem vazamento.
  • Por que meu container é morto por falta de memória

    Quero entender as duas formas de um serviço morrer por falta de memória e como cada runtime se comporta num container.
    
    Explique:
    1. A diferença entre o runtime lançar um erro de falta de memória e o sistema operacional matar o processo (OOMKilled, código 137).
    2. Como o coletor decide quando coletar, e por que um coletor que não conhece o limite do container pode deixar o lixo passar do limite.
    3. O comportamento padrão de Node, Python, Go, Java e C# num container com limite de memória.
    4. Por que memória alta depois de um pico não é necessariamente vazamento.
    
    Depois me descreva três situações de produção e pergunte qual é a causa provável em cada uma. Corrija as minhas respostas.