DEV Community

Lucas Henrique Alves Rosa
Lucas Henrique Alves Rosa

Posted on

Garbage Collector: quem limpa a memória do JavaScript?

Se JavaScript possui Garbage Collector, por que ainda existe memory leak?

A dúvida faz sentido. Se o ambiente gerencia a memória automaticamente, parece razoável esperar que ele também descarte tudo aquilo de que nossa aplicação não precisa mais.

Mas existe uma diferença entre um objeto que não pode mais ser alcançado e um objeto que deixou de ser útil. O Garbage Collector trabalha com a primeira ideia. A segunda depende das decisões da aplicação.

Este é mais um artigo da série “Coisas que usamos todos os dias, mas raramente paramos para entender.” No texto sobre stack, heap e memória, explorei como os dados são armazenados e compartilhados. Agora, vamos aprofundar o que acontece quando esses dados poderiam deixar de existir, mas alguma referência continua mantendo-os acessíveis.

1. A memória não desaparece porque sua administração é automática

Quando criamos objetos, arrays e funções, o motor precisa de memória para representá-los. O heap é uma região administrada pelo motor para armazenar objetos e outras estruturas.

Não precisamos chamar malloc e free, como fazemos em determinadas situações em C. O ambiente acompanha as alocações e executa a coleta de lixo.

Considere:

function criarResumo() {
  const report = {
    title: "Relatório de vendas",
    items: [100, 200, 300]
  };

  return report.title;
}

const title = criarResumo();
Enter fullscreen mode Exit fullscreen mode

A função retorna uma string, sem expor o objeto report. Se nenhuma outra referência mantiver aquele objeto alcançável, sua memória poderá ser recuperada.

Poderá é importante: não controlamos o instante exato da coleta. Também não há garantia de devolução imediata dessa memória ao sistema operacional.

2. Referências: os caminhos até os objetos

Uma atribuição de objeto não cria automaticamente uma cópia independente:

let report = {
  title: "Relatório mensal"
};

const history = [report];

report = null;

console.log(history[0].title);
// "Relatório mensal"
Enter fullscreen mode Exit fullscreen mode

A variável report deixou de permitir acesso ao objeto. O array history, porém, continua permitindo esse acesso.

Atribuir null removeu um caminho, mas não todos.

Esse exemplo ajuda a entender por que espalhar atribuições de null pelo código não resolve automaticamente problemas de memória. Precisamos identificar quem ainda mantém a referência.

3. Reachability: o que ainda pode ser alcançado?

Reachability, ou alcançabilidade, descreve a possibilidade de chegar a um objeto seguindo referências a partir de um conjunto de raízes considerado pelo motor.

Essas raízes incluem referências associadas à execução ativa e ao ambiente. A partir delas, o coletor percorre os objetos acessíveis e suas relações.

No exemplo anterior, enquanto history permanecer alcançável, seu conteúdo também permanece.

Por outro lado, referências circulares não impedem necessariamente a coleta:

function criarCiclo() {
  const first = {};
  const second = {};

  first.next = second;
  second.previous = first;
}

criarCiclo();
Enter fullscreen mode Exit fullscreen mode

Os objetos referenciam um ao outro, mas nenhum é retornado ou guardado fora da função. Se o conjunto se tornar inalcançável, um coletor baseado em rastreamento consegue identificá-lo como coletável.

Ter uma referência recebida de outro objeto não é suficiente para continuar vivo: importa de onde parte o caminho até ele.

4. Mark-and-sweep: um modelo para entender a coleta

O algoritmo mark-and-sweep ajuda a visualizar esse processo em duas etapas:

  1. Marcação: partir das raízes e marcar os objetos alcançáveis, percorrendo suas referências.
  2. Varredura: identificar o armazenamento dos objetos não marcados e torná-lo disponível para reutilização.

Esse modelo explica por que um ciclo isolado pode ser coletado: nenhum caminho a partir das raízes chega aos objetos do ciclo.

Implementações reais vão além desse desenho. O V8, por exemplo, utiliza técnicas geracionais, marcação e compactação, além de trabalho incremental, paralelo e concorrente. Essas técnicas distribuem trabalho e reduzem determinados impactos, mas não eliminam todo o custo nem todas as pausas.

Não precisamos dominar cada detalhe do coletor para investigar um vazamento. O primeiro passo costuma ser localizar o caminho que mantém os dados vivos.

5. Um exemplo: fechamos a tela, mas guardamos os dados

Imagine uma aplicação que mantém o estado de telas abertas:

const screenStates = new Map();

function openScreen(id) {
  const state = {
    rows: Array.from({ length: 10_000 }, (_, index) => ({
      id: index,
      description: `Registro ${index}`
    }))
  };

  screenStates.set(id, state);

  return state.rows.length;
}

function closeScreen(id) {
  console.log(`Tela ${id} fechada`);
}
Enter fullscreen mode Exit fullscreen mode

Ao abrir uma tela, criamos seus dados e os guardamos no Map. Ao fechar, apenas registramos uma mensagem.

for (let i = 0; i < 10; i++) {
  openScreen(`screen-${i}`);
  closeScreen(`screen-${i}`);
}

console.log(screenStates.size); // 10
Enter fullscreen mode Exit fullscreen mode

As telas foram fechadas para a aplicação, mas os dez estados continuam armazenados. Enquanto o Map permanecer acessível, existe um caminho até todos esses registros.

O GC não tem como concluir que a mensagem “tela fechada” significa que os dados podem ser descartados.

Se o estado não precisa ser preservado, a função de encerramento deve remover a entrada:

function closeScreen(id) {
  screenStates.delete(id);
}
Enter fullscreen mode Exit fullscreen mode

Isso remove a referência mantida pelo Map. Se outro lugar ainda guardar o mesmo estado, ele continuará alcançável por esse outro caminho.

6. Closures: a função terminou, mas o acesso continua

Closures permitem que uma função mantenha acesso ao ambiente léxico em que foi criada, mesmo depois de a chamada externa terminar.

const exporters = [];

function registerExporter(report) {
  exporters.push(() => JSON.stringify(report));
}
Enter fullscreen mode Exit fullscreen mode

Cada função adicionada a exporters permite acessar um relatório:

registerExporter({
  id: "report-1",
  rows: [{ total: 100 }, { total: 200 }]
});
Enter fullscreen mode Exit fullscreen mode

A chamada terminou, mas o array mantém a função, e a função mantém acesso ao relatório.

Esse comportamento pode ser exatamente o que queremos: permitir exportar o relatório depois. O problema aparece quando a opção de exportação deixa de existir, mas o callback continua registrado.

Uma solução é definir explicitamente o encerramento desse registro:

const exporters = new Map();

function registerExporter(id, report) {
  exporters.set(id, () => JSON.stringify(report));
}

function unregisterExporter(id) {
  exporters.delete(id);
}
Enter fullscreen mode Exit fullscreen mode

Closures não são vazamentos por natureza. Precisamos alinhar a duração dos callbacks à duração da funcionalidade que eles atendem.

7. Event listeners: remover o elemento pode não bastar

Considere uma tela que registra um listener no window:

function mountPanel() {
  const panel = document.createElement("section");
  panel.textContent = "Painel de resultados";
  document.body.append(panel);

  function handleResize() {
    panel.dataset.viewportWidth = String(window.innerWidth);
  }

  window.addEventListener("resize", handleResize);

  return function unmount() {
    panel.remove();
  };
}
Enter fullscreen mode Exit fullscreen mode

Ao desmontar, removemos o painel do documento, mas mantemos o listener. Durante a vida daquela página, o window continua acessível e mantém o callback, que acessa panel.

O elemento saiu da árvore visual, mas ainda pode ser alcançado.

A limpeza deve acompanhar os recursos registrados:

return function unmount() {
  window.removeEventListener("resize", handleResize);
  panel.remove();
};
Enter fullscreen mode Exit fullscreen mode

Para remover o listener, precisamos usar a mesma função e uma configuração compatível de capture. Criar outra função com o mesmo corpo não remove a anterior.

Também não devemos concluir que todo elemento removido com listener causa vazamento. Se o elemento, o listener e os objetos relacionados se tornarem inalcançáveis, o conjunto pode ser coletado. Neste exemplo, o caminho persistente passa pelo window.

8. Timers também mantêm trabalho registrado

Um intervalo pode continuar usando dados de uma tela que já foi encerrada:

function startMonitoring(report) {
  const intervalId = setInterval(() => {
    console.log("Quantidade de linhas:", report.rows.length);
  }, 5000);

  return function stopMonitoring() {
    clearInterval(intervalId);
  };
}
Enter fullscreen mode Exit fullscreen mode

Enquanto o intervalo estiver ativo, o ambiente mantém o callback registrado. Como ele acessa report, esse registro pode prolongar a vida do relatório.

O código que inicia o monitoramento também precisa encerrar esse trabalho:

const stopMonitoring = startMonitoring({
  rows: [{ id: 1 }, { id: 2 }]
});

// Quando o monitoramento deixar de ser necessário:
stopMonitoring();
Enter fullscreen mode Exit fullscreen mode

Um setTimeout distante também pode reter dados enquanto aguarda execução. Isso pode ser legítimo; a questão é cancelar o agendamento quando sua finalidade deixar de existir.

9. Caches: guardar mais dados também exige uma política

Um cache simples pode crescer indefinidamente:

const reportsCache = new Map();

function cacheReport(id, report) {
  reportsCache.set(id, report);
}
Enter fullscreen mode Exit fullscreen mode

Se chegam novos IDs continuamente e nenhuma entrada é removida, o consumo pode continuar aumentando.

Para a aplicação, talvez relatórios antigos já não tenham utilidade. Para o GC, eles continuam acessíveis pelo Map.

Uma política de cache pode considerar:

  • Limite de entradas ou de memória.
  • Expiração com remoção efetiva.
  • Descarte dos itens menos utilizados.
  • Invalidação quando os dados mudam.

Registrar uma data de expiração sem remover as entradas não elimina as referências. Mesmo uma expiração verificada apenas na leitura pode deixar entradas nunca mais consultadas ocupando espaço.

O comportamento de descarte precisa fazer parte do desenho do cache.

10. Memória crescendo sempre significa vazamento?

Não. A aplicação pode estar carregando mais dados, preenchendo um cache legítimo ou criando objetos temporários que ainda serão coletados.

É comum observar crescimento seguido de quedas. Também existe diferença entre memória do heap JavaScript e consumo total do processo, que inclui outras estruturas e recursos.

Uma suspeita mais forte aparece quando repetimos uma operação que deveria retornar a um estado equivalente, mas cada repetição mantém mais objetos desnecessários.

Por exemplo: abrir e fechar o mesmo painel várias vezes deveria permitir descartar os estados anteriores, caso o produto não precise preservá-los.

11. Como investigar no DevTools?

Uma abordagem prática é:

  1. Abrir a aplicação e concluir seu carregamento inicial.
  2. Capturar um heap snapshot.
  3. Repetir algumas vezes o fluxo suspeito.
  4. Voltar ao mesmo estado funcional.
  5. Capturar outro snapshot e comparar os objetos retidos.

O painel Retainers ajuda a localizar o caminho que mantém um objeto acessível. Já o tamanho retido ajuda a perceber que um objeto pequeno pode sustentar uma estrutura muito maior.

Um callback, por exemplo, pode ocupar pouco espaço e ainda manter um relatório inteiro.

A coleta forçada disponível nas ferramentas pode auxiliar o diagnóstico, mas não deve fazer parte da lógica da aplicação. Também é importante evitar guardar objetos no console durante a investigação, porque as próprias ferramentas podem mantê-los acessíveis.

Depois de encontrar o caminho de retenção, conseguimos avaliar se ele é necessário ou se faltou uma etapa de limpeza.

12. O que continua sendo responsabilidade da aplicação?

Não precisamos sair atribuindo null a todas as variáveis locais. Precisamos definir o tempo de vida das estruturas e dos registros que mantemos.

Ação Encerramento a considerar
Adicionar estado a uma coleção persistente Remover quando não precisar preservá-lo
Registrar um listener Desregistrar quando sua finalidade terminar
Iniciar um intervalo Cancelar ao encerrar o monitoramento
Guardar um callback Remover quando a operação deixar de existir
Armazenar dados em cache Aplicar uma política de descarte

Essas ações não liberam necessariamente a memória naquele instante. Elas removem caminhos de acesso que poderiam impedir a coleta.

Conclusão

O gerenciamento automático de memória retira uma responsabilidade importante das nossas mãos, mas a aplicação continua definindo o que permanece acessível.

Um Map pode guardar estados de telas fechadas. Um listener pode manter um elemento fora do documento. Um timer pode preservar um relatório. Uma closure pode prolongar o acesso a dados depois que a função externa terminou.

Em todos esses casos, o coletor pode estar funcionando corretamente. O problema é que o programa continua mantendo referências para informações de que já não precisa.

O GC sabe o que não pode mais ser alcançado; ele não sabe o que deixou de ser útil para sua aplicação.

Entender essa diferença muda a investigação: em vez de perguntar apenas “por que o JavaScript não limpou?”, passamos a procurar “quem ainda está mantendo isso vivo?”.

Referências

Top comments (0)