Como o JavaScript funciona: gerenciamento de memória + como lidar com os 4 vazamentos de memória mais comuns

Parte 3 - Explorando as entranhas do JavaScript: uma série reveladora sobre o motor, tempo de execução e pilha de chamadas por debaixo dos panos.

Como o JavaScript funciona: gerenciamento de memória + como lidar com os 4 vazamentos de memória mais comuns
Photo by Harrison Broadbent / Unsplash

Algumas semanas atrás, começamos uma série com o objetivo de explorar mais profundamente o JavaScript e como ele realmente funciona: pensamos que, ao conhecer os blocos de construção do JavaScript e como eles se combinam, você será capaz de escrever códigos e aplicativos melhores.

O primeiro artigo da série concentrou-se em fornecer uma visão geral do motor, do tempo de execução e da pilha de chamadas. O segundo artigo examinou de perto as partes internas do motor JavaScript V8 do Google e também forneceu algumas dicas sobre como escrever um código JavaScript melhor.

Neste terceiro artigo, discutiremos outro tópico crucial que está sendo cada vez mais negligenciado pelos desenvolvedores, devido à maturidade crescente e à complexidade das linguagens de programação que são usadas diariamente - o gerenciamento de memória. Também forneceremos algumas dicas sobre como lidar com vazamentos de memória em JavaScript.

Visão Geral

Linguagens como C, possuem funções nativas de gerenciamento de memória de baixo nível, como malloc() e free(). Essas funções nativas são usadas pelo desenvolvedor para alocar e liberar explicitamente a memória do sistema operacional.

Ao mesmo tempo, o JavaScript aloca memória quando coisas (objetos, strings, etc.) são criadas e a "libera automaticamente" quando não são mais utilizadas, em um processo chamado garbage collection (coleta de lixo). Essa natureza aparentemente "automática" de liberar recursos é uma fonte de confusão e dá aos desenvolvedores de JavaScript (e de outras linguagens de alto nível) a falsa sensação de que eles podem optar por não se preocupar com o gerenciamento de memória. Isso é um grande erro.

Mesmo trabalhando com linguagens de alto nível, os desenvolvedores devem ter pleno entendimento sobre o gerenciamento de memória (ou pelo menos o básico). Às vezes, ocorrem problemas com o gerenciamento automático de memória (como bugs ou limitações de implementação nos coletores de lixo, etc.) que os desenvolvedores precisam entender para lidar com eles corretamente (ou encontrar uma solução alternativa adequada, com o mínimo de comprometimento e débito técnico).

Ciclo de vida da memória

Não importa qual linguagem de programação você esteja usando, o ciclo de vida da memória é praticamente sempre o mesmo:

Aqui está um resumo do que acontece em cada etapa do ciclo:

  • Alocar memória: a memória é alocada pelo sistema operacional, permitindo que seu programa a utilize. Em linguagens de baixo nível (como C), essa é uma operação explícita que você, como desenvolvedor, deve manipular. No entanto, em linguagens de alto nível, isso é transparente para você.
  • Usar memória: esse é o momento em que seu programa realmente faz uso da memória previamente alocada. Operações de leitura e escrita estão ocorrendo à medida que você usa as variáveis alocadas em seu código.
  • Liberar memória: agora é o momento de liberar toda a memória que você não precisa mais, para que ela possa ser liberada e disponibilizada novamente. Assim como a operação de Alocar memória, essa também é explícita em linguagens de baixo nível.

Para obter uma visão geral dos conceitos de pilha de chamadas e heap de memória, você pode ler nosso primeiro artigo sobre o assunto.

O que é memória?

Antes de abordarmos diretamente a memória em JavaScript, vamos discutir brevemente o que é a memória em geral e como ela funciona.

A nível de hardware, a memória do computador consiste em um grande número de flip-flops. Cada flip-flop contém alguns transistores e é capaz de armazenar um bit. Flip-flops individuais são endereçáveis por um identificador único, para que possamos lê-los e sobrescrevê-los. Assim, conceitualmente, podemos pensar em toda a memória do computador como um único array gigante de bits que podemos ler e escrever.

Como humanos, não somos tão bons em realizar todo o nosso pensamento e aritmética em bits, então os organizamos em grupos maiores, que juntos podem ser usados para representar números. 8 bits são chamados de 1 byte. Além de bytes, existem palavras (que às vezes têm 16, às vezes 32 bits).

Muitas coisas são armazenadas nessa memória:

  • Todas as variáveis e outros dados usados por todos os programas.
  • O código dos programas, incluindo o sistema operacional.
  • O compilador e o sistema operacional trabalham juntos para cuidar da maioria do gerenciamento de memória para você, mas recomendamos que você dê uma olhada no que está acontecendo nos bastidores.

Quando você compila seu código, o compilador pode examinar os tipos de dados primitivos e calcular antecipadamente quanto espaço de memória eles precisarão. A quantidade necessária é então alocada para o programa no espaço da pilha de chamadas. O espaço no qual essas variáveis são alocadas é chamado de espaço da pilha, porque à medida que as funções são chamadas, sua memória é adicionada em cima da memória existente. À medida que elas são encerradas, são removidas em uma ordem LIFO (último a entrar, primeiro a sair). Por exemplo, considere as seguintes declarações:

int n; // 4 bytes
int x[4]; // array de 4 elementos, onde cada elemento tem 4 bytes
double m; // 8 bytes

O compilador pode ver imediatamente que o código requer 4 + 4 × 4 + 8 = 28 bytes.

É assim que funciona com os tamanhos atuais para inteiros e números de ponto flutuante. Há cerca de 20 anos, os inteiros geralmente tinham 2 bytes e os números de ponto flutuante 4 bytes. Seu código nunca deve depender do tamanho atual dos tipos de dados básicos.

O compilador irá inserir código que irá interagir com o sistema operacional para solicitar o número necessário de bytes na pilha para armazenar suas variáveis.

No exemplo acima, o compilador conhece o endereço de memória exato de cada variável. Na verdade, sempre que escrevemos na variável n , isso é traduzido internamente para algo como "endereço de memória 4127963".

Observe que se tentássemos acessar x[4] aqui, estaríamos acessando os dados associados a m. Isso ocorre porque estamos acessando um elemento no array que não existe - ele está 4 bytes além do último elemento alocado real no array, que é  x[3], e podemos acabar lendo (ou sobrescrevendo) alguns dos bits de m . Isso certamente teria consequências indesejadas para o restante do programa.

Quando as funções chamam outras funções, cada uma recebe sua própria parte da pilha quando é chamada. Ela mantém todas as suas variáveis locais lá, mas também um contador de programa que lembra em que parte de sua execução estava. Quando a função termina, seu bloco de memória fica disponível novamente para outros fins.

Alocação Dinâmica

Infelizmente, as coisas não são tão simples quando não sabemos, em tempo de compilação, quanto de memória uma variável precisará. Suponha que queiramos fazer algo como o seguinte:

int n = readInput(); // lê a entrada do usuário.
...
// cria um array com "n" elementos

Nesse caso, durante a compilação, o compilador não sabe quanto de memória o array irá precisar, pois isso é determinado pelo valor fornecido pelo usuário.

Portanto, ele não pode alocar espaço para uma variável na pilha. Em vez disso, nosso programa precisa solicitar explicitamente ao sistema operacional a quantidade correta de espaço em tempo de execução. Essa memória é atribuída a partir do espaço de heap. As diferenças entre alocação de memória estática e dinâmica estão resumidas a seguir:

Alocação Estática

  • O tamanho deve ser conhecido em tempo de compilação;
  • Realizado em tempo de compilação;
  • Atribuído à pilha;
  • FILO (first-in, last-out).

Alocação Dinâmica

  • O tamanho pode ser desconhecido em tempo de compilação;
  • Realizado em tempo de execução;
  • Atribuído ao heap;
  • Sem ordem específica de atribuição.

Para entender completamente como a alocação dinâmica de memória funciona, precisamos dedicar mais tempo aos ponteiros, o que pode ser um desvio um pouco grande do tópico deste artigo. Se você estiver interessado em aprender mais, deixe-me saber nos comentários e podemos explorar mais detalhes sobre ponteiros em um futuro artigo.

Alocação de Memória em JavaScript

Agora explicaremos como funciona o primeiro passo (alocar memória) em JavaScript.

O JavaScript alivia os desenvolvedores da responsabilidade de lidar com alocações de memória - o próprio JavaScript faz isso, juntamente com a declaração de valores.

var n = 374; // aloca memória para um número.
var s = 'sessionstack'; // aloca memória para uma string.
var o = {
  a: 1,
  b: null
}; // aloca memória para um objeto e seus valores contidos
var a = [1, null, 'str'];  // (como um objeto) aloca memória para um
                           // array e seus valores contidos
function f(a) {
  return a + 3;
} // aloca memória para uma função (que é um objeto invocável)
// expressões de função também alocam um objeto.
someElement.addEventListener('click', function() {
  someElement.style.backgroundColor = 'blue';
}, false);

Algumas chamadas de função também resultam na alocação de um objeto:

var d = new Date(); // aloca memória para um objeto Date
var e = document.createElement('div'); // aloca memória para um elemento DOM

Métodos podem alocar novos valores ou objetos.

var s1 = 'sessionstack';
var s2 = s1.substr(0, 3); // s2 é um novo string
// Como as strings são imutáveis, o JavaScript pode decidir não alocar memória, mas apenas armazenar o intervalo [0, 3].
var a1 = ['str1', 'str2'];
var a2 = ['str3', 'str4'];
var a3 = a1.concat(a2); 
// Novo array com 4 elementos sendo a concatenação dos elementos de a1 e a2.

Usando memória em JavaScript

Usar a memória alocada em JavaScript basicamente significa ler e escrever nela.

Isso pode ser feito lendo ou escrevendo o valor de uma variável ou propriedade de um objeto, ou até mesmo passando um argumento para uma função.

Liberar quando a memória não é mais necessária

A maioria dos problemas de gerenciamento de memória ocorre nessa etapa.

A tarefa mais difícil aqui é determinar quando a memória alocada não é mais necessária. Muitas vezes, isso requer que o desenvolvedor identifique onde no programa esse pedaço de memória não é mais necessário e o libere.

As linguagens de alto nível incorporam um software chamado coletor de lixo, cuja função é rastrear a alocação e uso de memória para determinar quando um pedaço de memória alocada não é mais necessário, liberando-o automaticamente.

Infelizmente, esse processo é uma aproximação, pois o problema geral de saber se um determinado pedaço de memória é necessário é indecidível (não pode ser resolvido por um algoritmo).

A maioria dos coletores de lixo trabalha coletando memória que não pode mais ser acessada, ou seja, todas as variáveis que apontam para ela saíram de escopo. No entanto, isso é uma subaproximação do conjunto de espaços de memória que podem ser coletados, porque em qualquer momento uma localização de memória ainda pode ter uma variável apontando para ela em escopo, mas nunca será acessada novamente.

Garbage collection

Devido ao fato de que determinar se uma determinada memória "não é mais necessária" é um problema indecidível, as coletas de lixo implementam uma restrição para a solução do problema geral. Esta seção explicará as noções necessárias para entender os principais algoritmos de coleta de lixo e suas limitações.

Referências de memória

O principal conceito em que os algoritmos de coleta de lixo se baseiam é o conceito de referência.

Dentro do contexto do gerenciamento de memória, um objeto é considerado fazer referência a outro objeto se o primeiro tiver acesso ao último (pode ser implícito ou explícito). Por exemplo, um objeto JavaScript faz referência ao seu protótipo (referência implícita) e aos valores de suas propriedades (referência explícita).

Nesse contexto, a ideia de um "objeto" é estendida para algo mais amplo do que os objetos regulares do JavaScript e também inclui escopos de funções (ou o escopo léxico global).

O escopo léxico define como os nomes das variáveis são resolvidos em funções aninhadas: as funções internas contêm o escopo das funções pai, mesmo que a função pai já tenha retornado.

Coleta de lixo por contagem de referências

Este é o algoritmo de coleta de lixo mais simples. Um objeto é considerado "coletável pelo lixo" se não houver referências apontando para ele.

Dê uma olhada no seguinte código:

var o1 = {
  o2: {
    x: 1
  }
};
// 2 objetos são criados. "o2" é referenciado pelo objeto "o1" como uma de suas propriedades. Nenhum pode ser coletado pelo coletor de lixo.

var o3 = o1; // a variável 'o3' é a segunda que faz referência ao objeto apontado por 'o1'.
                                                       
o1 = 1; // agora, o objeto que estava originalmente em 'o1' tem uma única referência, representada pela variável 'o3'.

var o4 = o3.o2; // referência à propriedade 'o2' do objeto. Este objeto agora possui 2 referências: uma como propriedade e outra como variável 'o4'.

o3 = '374'; // o objeto que estava originalmente em 'o1' agora não tem mais referências a ele. Ele pode ser coletado pelo coletor de lixo. No entanto, o que era sua propriedade 'o2' ainda é referenciado pela variável 'o4', portanto, não pode ser liberado.

o4 = null; // o que era a propriedade 'o2' do objeto originalmente em 'o1' não possui mais referências a ele. Ele pode ser coletado pelo coletor de lixo.

Ciclos estão causando problemas

Existe uma limitação quando se trata de ciclos. No exemplo a seguir, dois objetos são criados e fazem referência um ao outro, criando assim um ciclo. Eles sairão de escopo após a chamada da função, portanto, são efetivamente inúteis e poderiam ser liberados. No entanto, o algoritmo de contagem de referências considera que, uma vez que cada um dos dois objetos é referenciado pelo menos uma vez, nenhum deles pode ser coletado pelo coletor de lixo.

function f() {
  var o1 = {};
  var o2 = {};
  o1.p = o2; // o1 referencia o2
  o2.p = o1; // o2 referencia o1. Isso cria um ciclo.
}

f();

Algoritmo de Marcar e Limpar

Para decidir se um objeto é necessário, este algoritmo determina se o objeto é alcançável.

O algoritmo de Marcar e Limpar passa por estas 3 etapas:

  • Raízes: Em geral, as raízes são variáveis globais que são referenciadas no código. No JavaScript, por exemplo, uma variável global que pode atuar como raiz é o objeto "window". No Node.js, o objeto equivalente é chamado de "global". Uma lista completa de todas as raízes é construída pelo coletor de lixo.
  • O algoritmo então inspeciona todas as raízes e seus filhos, marcando-os como ativos (ou seja, não são lixo). Tudo o que uma raiz não puder alcançar será marcado como lixo.
  • Finalmente, o coletor de lixo libera todas as peças de memória que não estão marcadas como ativas e devolve essa memória ao sistema operacional.

Este algoritmo é melhor do que o anterior, uma vez que "um objeto ter zero referências" leva a esse objeto se tornar inacessível. O oposto não é verdadeiro, como vimos com os ciclos.

A partir de 2012, todos os navegadores modernos incluem um coletor de lixo do tipo marcar e limpar. Todas as melhorias feitas no campo da coleta de lixo em JavaScript (coleta de lixo geracional/incremental/concorrente/paralela) nos últimos anos são melhorias na implementação desse algoritmo (marcar e limpar), mas não melhorias no algoritmo de coleta de lixo em si, nem no objetivo de decidir se um objeto é alcançável ou não.

Neste artigo, você pode ler em maior detalhe sobre a coleta de lixo por rastreamento, que também abrange o marcar e limpar juntamente com suas otimizações.

Ciclos não são mais um problema

No primeiro exemplo acima, após o retorno da chamada da função, os dois objetos não são mais referenciados por algo alcançável a partir do objeto global. Consequentemente, eles serão considerados inacessíveis pelo coletor de lixo.

Mesmo que existam referências entre os objetos, eles não são alcançáveis a partir da raiz.

Comportamento contraintuitivo dos coletores de lixo

Embora os coletores de lixo sejam convenientes, eles vêm com suas próprias compensações. Um deles é a não determinismo. Em outras palavras, os coletores de lixo são imprevisíveis. Não é possível prever quando uma coleta será realizada. Isso significa que, em alguns casos, os programas usam mais memória do que o necessário. Em outros casos, pausas curtas podem ser perceptíveis em aplicativos particularmente sensíveis. Embora a não determinismo signifique que não se pode ter certeza de quando uma coleta será realizada, a maioria das implementações de GC segue o padrão comum de fazer passagens de coleta durante a alocação. Se nenhuma alocação for realizada, a maioria dos GC's ficam ociosos. Considere o seguinte cenário:

  • Um conjunto considerável de alocações é realizado.
  • A maioria desses elementos (ou todos eles) é marcada como inacessível (suponha que anulamos uma referência que aponta para um cache que não precisamos mais).
  • Nenhuma outra alocação é feita.

Nesse cenário, a maioria dos GC's não executará mais passagens de coleta. Em outras palavras, mesmo que haja referências inacessíveis disponíveis para coleta, elas não serão coletadas pelo coletor. Isso não é estritamente um vazamento de memória, mas ainda resulta em uso de memória mais alto que o usual.

O que são vazamentos de memória?

Assim como o termo sugere, vazamentos de memória são pedaços de memória que a aplicação utilizou no passado, mas que não são mais necessários e ainda não foram devolvidos para o sistema operacional ou para o pool de memória livre.

Linguagens de programação adotam diferentes abordagens para gerenciar a memória. No entanto, determinar se uma determinada parte da memória está sendo usada ou não é na verdade um problema indecidível. Em outras palavras, somente os desenvolvedores podem deixar claro se um pedaço de memória pode ser devolvido ao sistema operacional ou não.

Algumas linguagens de programação fornecem recursos que ajudam os desenvolvedores nesse aspecto. Outras esperam que os desenvolvedores sejam completamente explícitos sobre quando um pedaço de memória está sem uso. A Wikipedia possui bons artigos sobre gerenciamento de memória manual e automático.

Os quatro tipos de vazamentos comuns de JavaScript

1. Variáveis globais

O JavaScript lida com variáveis não declaradas de uma maneira interessante: quando uma variável não declarada é referenciada, uma nova variável é criada no objeto global. Em um navegador, o objeto global seria o window, o que significa que...

function foo(arg) {
    bar = "texto";
}

é o equivalente a:

function foo(arg) {
    window.bar = "texto";
}

Digamos que o objetivo de bar seja apenas fazer referência a uma variável na função "foo". Uma variável global redundante será criada se você não usar var para declará-la. No caso acima, isso não causará muitos danos. No entanto, é possível imaginar um cenário mais prejudicial.

Você também pode criar acidentalmente uma variável global usandothis:

function foo() {
    this.var1 = "potential accidental global";
}
// Quando "foo" é chamado por si só, "this" aponta para o objeto global (window), em vez de ser undefined..
foo();
Você pode evitar tudo isso adicionando "use strict"; no início do seu arquivo JavaScript, o que ativará um modo de análise muito mais restrito do JavaScript e impedirá a criação inesperada de variáveis globais.

Variáveis globais inesperadas certamente são um problema, no entanto, na maioria das vezes, seu código estará infestado de variáveis globais explícitas, que, por definição, não podem ser coletadas pelo coletor de lixo. É necessário dar atenção especial às variáveis globais usadas para armazenar temporariamente e processar grandes quantidades de informações. Use variáveis globais para armazenar dados se necessário, mas quando o fizer, certifique-se de atribuir null a elas ou reatribuí-las quando não forem mais necessárias.

2. Timers ou callbacks que são esquecidos

Vamos pegar o setInterval como exemplo, já que ele é frequentemente usado em JavaScript.

Bibliotecas que fornecem observadores e outros instrumentos que aceitam callbacks geralmente garantem que todas as referências aos callbacks se tornem inacessíveis uma vez que suas instâncias também se tornam inacessíveis. Ainda assim, o código abaixo não é incomum de se encontrar:

var serverData = loadData();
setInterval(function() {
    var renderer = document.getElementById('renderer');
    if(renderer) {
        renderer.innerHTML = JSON.stringify(serverData);
    }
}, 5000); // Será executado a cada 5 segundos.

O trecho acima mostra as consequências de usar timers que fazem referência a nós ou dados que não são mais necessários.

O objeto renderer pode ser substituído ou removido em algum momento, o que tornaria o bloco encapsulado pelo manipulador de intervalo redundante. Se isso acontecer, nem o manipulador nem suas dependências serão coletados, pois o intervalo precisa ser interrompido primeiro (lembre-se, ele ainda está ativo). Tudo se resume ao fato de que serverData, que certamente armazena e processa uma grande quantidade de dados, também não será coletado.

Ao usar observadores, você precisa garantir que faça uma chamada explícita para removê-los quando não precisar mais deles (seja porque o observador não é mais necessário ou porque o objeto se tornará inacessível).

Felizmente, a maioria dos navegadores modernos faz o trabalho para você: eles automaticamente coletam os manipuladores dos observadores quando o objeto observado se torna inacessível, mesmo se você esqueceu de remover o ouvinte. No passado, alguns navegadores não conseguiam lidar com esses casos (bom e velho IE6).

Ainda assim, está em linha com as melhores práticas remover os observadores assim que o objeto se tornar obsoleto. Veja o exemplo a seguir:

var element = document.getElementById('launch-button');
var counter = 0;
function onClick(event) {
   counter++;
   element.innerHtml = 'text ' + counter;
}
element.addEventListener('click', onClick);
// Realiza Tarefas
element.removeEventListener('click', onClick);
element.parentNode.removeChild(element);
// Agora, quando o elemento sai de escopo, tanto o elemento quanto o onClick serão coletados, mesmo em navegadores antigos que não lidam bem com ciclos.

Você não precisa mais chamar o removeEventListener antes de tornar um nó inacessível, pois os navegadores modernos suportam coletores de lixo que podem detectar esses ciclos e lidar com eles adequadamente.

Se você utilizar as API's do jQuery (outras bibliotecas e frameworks também oferecem suporte a isso), também é possível remover os ouvintes antes que um nó se torne obsoleto. A biblioteca também garantirá que não haja vazamentos de memória, mesmo quando a aplicação estiver sendo executada em versões mais antigas do navegador.

3. Closures (Encerramentos)

Um aspecto fundamental do desenvolvimento em JavaScript são os encerramentos (closures): uma função interna que tem acesso às variáveis da função externa (envolvente). Devido aos detalhes de implementação do tempo de execução do JavaScript, é possível vazar memória da seguinte maneira:

var theThing = null;
var replaceThing = function () {
  var originalThing = theThing;
  var unused = function () {
    if (originalThing) // uma referência para 'originalThing'
      console.log("hi");
  };
  theThing = {
    longStr: new Array(1000000).join('*'),
    someMethod: function () {
      console.log("mensagem");
    }
  };
};
setInterval(replaceThing, 1000);

Uma questão chave no desenvolvimento em JavaScript são os encerramentos (closures): uma função interna que tem acesso às variáveis da função externa (envolvente). Devido aos detalhes de implementação do tempo de execução do JavaScript, é possível ocorrer vazamento de memória da seguinte maneira:

Uma vez que replaceThing é chamado, theThing recebe um novo objeto que consiste em uma grande matriz e um novo encerramento (someMethod). No entanto, originalThing é referenciado por um encerramento que é mantido pela variável não utilizada (que é a variável theThing da chamada anterior a replaceThing). O ponto importante a lembrar é que, uma vez que um escopo para encerramentos é criado para encerramentos no mesmo escopo pai, o escopo é compartilhado.

Nesse caso, o escopo criado para o encerramento someMethod é compartilhado com unused. unused faz referência a originalThing. Mesmo que unused nunca seja usado, someMethod pode ser usado através de theThing fora do escopo de replaceThing (por exemplo, em algum lugar globalmente). E como someMethod compartilha o escopo de encerramento com unused, a referência de unused a originalThing obriga-a a permanecer ativa (todo o escopo compartilhado entre os dois encerramentos). Isso impede a sua coleta.

No exemplo acima, o escopo criado para o encerramento someMethod é compartilhado com unused, enquanto unused faz referência a originalThing. someMethod pode ser usado através de theThing fora do escopo de replaceThing, apesar do fato de que unused nunca é utilizado. O fato de unused fazer referência a originalThing requer que ela permaneça ativa, já que someMethod compartilha o escopo de encerramento com unused.

Tudo isso pode resultar em um vazamento considerável de memória. Você pode esperar ver um aumento no uso de memória quando o trecho de código acima é executado repetidamente. O tamanho não diminuirá quando o coletor de lixo for executado. Uma lista encadeada de encerramentos é criada (sendo theThing a raiz neste caso) e cada escopo de encerramento carrega uma referência indireta para a grande matriz.

Essa questão foi encontrada pela equipe do Meteor e eles têm um ótimo artigo que descreve o problema em grande detalhe.

4. Fora das referências DOM

Existem casos em que os desenvolvedores armazenam nós DOM dentro de estruturas de dados. Suponha que você queira atualizar rapidamente o conteúdo de várias linhas em uma tabela. Se você armazenar uma referência a cada linha DOM em um dicionário ou em um array, haverá duas referências para o mesmo elemento DOM: uma na árvore DOM e outra no dicionário. Se você decidir se livrar dessas linhas, precisará se lembrar de tornar ambas as referências inacessíveis.

var elements = {
    button: document.getElementById('button'),
    image: document.getElementById('image')
};
function doStuff() {
    elements.image.src = 'http://example.com/image_name.png';
}
function removeImage() {
    // A imagem é um filho direto do elemento body.
    document.body.removeChild(document.getElementById('image'));
    // Neste ponto, ainda temos uma referência ao #button no objeto global elements. Em outras palavras, o elemento de botão ainda está na memória e não pode ser coletado pelo coletor de lixo (GC).
}

Existe uma consideração adicional que deve ser levada em conta quando se trata de referências a nós internos ou folhas dentro de uma árvore DOM. Se você mantiver uma referência a uma célula de tabela (uma tag ) no seu código e decidir remover a tabela do DOM, mas manter a referência a essa célula específica, é de se esperar que ocorra um vazamento de memória significativo. Você pode pensar que o coletor de lixo liberaria tudo, exceto essa célula. No entanto, esse não será o caso. Como a célula é um nó filho da tabela e os filhos mantêm referências aos seus pais, essa única referência à célula da tabela manteria toda a tabela na memória.

Referências

Este é um artigo traduzido. O artigo original pode ser lido no link abaixo.

How JavaScript works: memory management + how to handle 4 common memory leaks
A few weeks ago we started a series aimed at digging deeper into JavaScript and how it actually works: we thought that by knowing the…

Autor do post original — Alexander Zlatkov— Co-founder & CEO @SessionStack