DEV Community

Cover image for O ciclo de vida da memória JavaScript
Ivan Trindade
Ivan Trindade

Posted on

O ciclo de vida da memória JavaScript

Independentemente da linguagem que você usa para escrever o código, seu programa precisa alocar e acessar a memória para armazenar variáveis. Em muitas linguagens de alto nível, o gerenciamento de memória é feito para você, o que torna a experiência de condificação mais simples. Mesmo se você estiver usando uma linguagem de alto nível, ainda é uma boa ideia entender o processo de gerencimento de memória e suas possíveis armadilhas.

Então, o que queremos dizer com gerencimento de memória? Linguagens de baixo nível como C, incluem comandos de gerenciamento de memória como malloc() e free() que permitem aos desenvolvedores gerenciar explicitamente a alocação e desalocação de memória. No entanto, esses comandos não existem em JavaScript. Então, como isso acontece?

Quando o código JavaScript é executado, o processo de execução do código é gerenciado pelo ambiente de tempo de execução, que no navegador Google Chrome ou Node.js, é o mecanismo V8 do Chrome.

Como o V8 compila o código JavaScript para código de máquina (uma linguagem de baixo nível que o computador pode interpretar diretamente), ele realiza otimizações no código e contabiliza a alocação de memória. Ele também gerencia separadamente a desalocação de memória, por meio de um processo automatizado conhecido como "coleta de lixo".

Para entender quanta memória seu programa está usando em um determinado momento, você precisa entender como ela está sendo desalocada. Quando uma variável não é mais necessária em seu programa, mas ainda está presente na memória, isso é chamado de "vazamento de memória". Com vazamentos de memória suficientes, seu programa pode, em teoria, exceder a memória disponível e travar.

Em linguagens de baixo nível onde você deve gerenciar sua memória, isso ocorre sempre que você esquece de liberar sua memória. Em JavaScript, o coletor de lixo avalia e remove variáveis desnecessárias da memória para você, e embora isso seja mais seguro do que depender do desenvolvedor para desalocar a memória, ainda existem algumas circunstâncias em Javascript, em que o coletor de lixo pode falhar e podem ocorrer vazamentos de memória.

Ciclos de Vida da Memória

O ciclo de vida de memória tem três etapas, comuns na maioria das linguagens de programação:

Imagem de um gráfico

A primeira e a terceira etapas estão implícitas no JavaScript.

1. Alocação de memória

O JavaScript alocará memória automaticamente quando os valores forem inicialmente declarados. Isso pode acontecer de várias maneiras:

// alocando memória via atribuição regular
const memoryVariable = 'I am assigned';

// alocando memória para um objeto e seus valores contidos
const memoryObject = {
    a: 1,
    b: 2,
    c: 3
}

// alocando memória para funções chamáveis
const today = new Date();

// alocando memória via chamadas de função
function changeMemoryVar(newVar) {
    memoryVariable = newVar;
}
Enter fullscreen mode Exit fullscreen mode

2. Usando Variáveis

Este estágio do ciclo de vida da memória, ocorre quando uma variável que foi previamente alocada na memória, é usada por um programa; por exemplo, seu valor é lido ou reescrito, a variável é passada para uma função, etc.

3. Liberando Memória

Em JavaScript e outras linguagens de alto nível, o processo de remoção de variáveis não utilizadas da memória, é chamado de "coleta de lixo". Um coletor de lixo monitora a alocação de memória e determina se as variáveis ainda são necessárias e, se não, libera esse pedaço de memória. Não existe um algoritmo ou combinação de algoritmos que possa prever com total precisão quais variáveis estão prontas para coleta de lixo e, como tal, a questão de quando liberar memória é considerada "indecidível".

Como não existe um método infalível para determinar quando a memória deve ser liberada, ocasionalmente as variáveis que não são mais necessárias para um programa, não são deletadas pelo algoritmo de coleta de lixo e permanecem na memória (vazamento de memória).


Vejamos como os coletores de lixo avaliam quais objetos podem ser removidos da memória. Existem dois algoritmos primários de coleta de lixo:

Algoritmo de contagem de referência

Este algoritmo é o mais ingênuo dos dois algoritmos. No algoritmo de contagem de referência, um objeto é avaliado como 'lixo' e pronto para coleta se nenhuma outra parte do código fizer referência a ele, implícita ou explicitamente. Alguns navegadores mais antigos, como o Internet Explorer 6 e 7, dependiam dessa abordagem, e alguns coletores de lixo ainda a usam em conjunto com a abordagem de marcação e varredura.

Este algoritmo tem uma grande desvantagem: as referências circulares não são captadas por este método. Se duas variáveis fazem referência uma á outra, mas não são necessárias em nenhuma outra parte do código, esse algoritmo não as selecionaria, pois são referenciadas e, pelos padrões desse algoritmo, são "necessárias".

/* mesmo que foo e bar não existam fora desta função, o algoritmo de contagem de referência não os contará como prontos para coleta de lixo */

function circularFunc() {
    const foo = {};
    const bar = {};
    foo.a = bar;
    bar.a = foo;
}

circularFunc();
Enter fullscreen mode Exit fullscreen mode

Algoritmo de marcação e varredura

Esse algoritmo mais amplamente usado, conta uma variável como pronta param coleta se não estiver conectada ao objeto global. Na parte de "marcação" deste algoritmo, o coletor de lixo visita todos os elementos conectados á raiz conhecida de um programa (por exemplo, o DOM ou objeto global) e marca esses elementos como "acessíveis" ou "ativos". Em seguida, visita recursivamente e "marca" todos os elementos conectados a esses elementos ativos. Essa abordagem elimina o problema de refências circulares; se um elemento não estiver conectado ao objeto global, ele não será marcado como ativo, independentemente de ser referenciado por outros elementos não ativos.

Na fase de 'varredura', o coletor de lixo limpa a memória heap de todos os objetos não marcados. Essa memória recém-liberada é adicionada a uma lista de memória disponível e será realocada à medida que novas variáveis ​​forem criadas.

A abordagem de marcação e varredura

Historicamente, enquanto a coleta de lixo estava ocorrendo, todos os outros processos eram pausados ​​(conhecido como uma abordagem de 'parar o mundo'), o que significa que a coleta de lixo lenta pode ter um impacto muito real no desempenho. Por exemplo, em navegadores mais antigos, você pode ver claramente travamentos de vídeo (conhecidos como 'jank'), onde a coleta de lixo atrasa o carregamento de imagens de vídeo.

Os coletores de lixo mais modernos tentam minimizar os atrasos de renderização e a latência de várias maneiras. Para usar o coletor de lixo Orinoco do V8 como exemplo, existem três maneiras principais pelas quais o programa funciona para fazer isso.

Coleção Paralela

Esta ainda é uma abordagem de parar o mundo, no entanto, em vez de apenas o thread JavaScript principal realizar o processo de coleta de lixo, um número adicional de threads auxiliares realiza uma quantidade semelhante de trabalho ao mesmo tempo, portanto, o tempo de pausa é dividido por o número de threads em jogo (mais algum tempo de sobrecarga para sincronizá-los). Essa é a abordagem mais fácil das três, pois o encadeamento principal do JavaScript está em pausa e nenhuma variável nova está sendo gravada na memória enquanto a coleta de lixo é executada.

Coleta incremental

Nessa abordagem, toda a coleta de lixo é realizada pela thread principal, mas em estágios intermitentes. Dessa forma, a latência é reduzida, mesmo que o tempo de processamento geral não seja menor do que com um método tradicional de parar o mundo. Isso é mais complicado do que uma coleta paralela, pois entre cada processo incremental a memória pode mudar, invalidando o trabalho anterior realizado.

Coleta Simultânea

Aqui, o thread principal opera o código JavaScript ininterruptamente e toda a coleta de lixo é realizada por threads auxiliares em segundo plano. Isso deixa a thread principal completamente livre para executar o JavaScript, mas é a abordagem mais desafiadora: a qualquer momento, novas variáveis ​​podem ser criadas, o que invalida o trabalho feito até agora, e pode haver corridas entre as threads principal e auxiliar para acessar os mesmos objetos.


Apesar dessas melhorias contínuas nos programas de coleta de lixo, ainda podem ocorrer vazamentos de memória em JavaScript. Existem quatro maneiras principais pelas quais isso pode ocorrer:

Variáveis ​​globais acidentais

Se você atribuir um valor a uma variável que não foi declarada, o JavaScript irá designá-la automaticamente como uma variável global e anexá-la ao objeto global. As variáveis ​​globais, por definição, não estão disponíveis para coleta de lixo devido a esse anexo ao elemento raiz.

// Como baz é declarado aqui, ele possui apenas escopo local
function myFunc() {
  const baz = 'locally scoped variable'
}

/* Assumindo que baz não foi declarado anteriormente na função, aqui ele se anexa ao
objeto global e acidentalmente se torna uma variável global */
function myFunc() {
  baz = 'variável global não declarada'
}
Enter fullscreen mode Exit fullscreen mode

Esse tipo de vazamento de memória pode ser evitado estando atento ao declarar variáveis ​​globais ou certificando-se de definir todas as variáveis ​​com escopo local. O uso do modo estrito também garantirá que quaisquer variáveis ​​não declaradas sejam selecionadas em seu código.

Temporizadores

Vejamos os métodos setInterval() e setTimeout(). Enquanto estiverem ativos (que no caso do setInterval() pode ser até que o programa tenha conclúido a execução), os callbacks dentro deles e as próprias funções, não podem ser marcadas como prontas para coleta de lixo, independentemente se as variáveis referenciadas dentro de seu callback, as funções são removidas do escopo.

setInterval(() => {
  let node = document.getElementById('myNode');
  if (node) {
    node.innerText = someExternalResource; 
  }
}, 2000)

/* mesmo que o nó seja removido, se o intervalo estiver ativo, este não pode ser removido,
e nem someExternalResource */
Enter fullscreen mode Exit fullscreen mode

No exemplo acima, se removermos node e someExternalReference do escopo, eles ainda apontarão um para o outro e, portanto, não serão captados pelo algoritmo de contagem de referência. Isso costumava ser uma fonte comum de vazamentos de memória quando esse algoritmo ainda era muito usado para liberar memória.

Embora a introdução da abordagem de marcação e varredura para coleta de lixo cuide do problema de referência circular, continua sendo uma boa prática cancelar quaisquer ouvintes de eventos anexados antes de remover um elemento de seu programa.

Fora das referências DOM

Referências fora do DOM referem-se a nós que foram removidos do DOM, mas ainda são referenciados no JavaScript. Por exemplo, se eu armazenar uma referência a um determinado elemento em meu código, mas remover o elemento do DOM, pois ainda há uma referência a ele no código JavaScript, isso não pode ser removido da memória.

const myNode = document.getElementById('myNodeId');

function removeNode() {
  document.body.removeChild(document.getElementById('myNodeId'));
}

removeNode();

/* myNode agora está separado do DOM, mas ainda retido na memória,
conforme é capturado na variável myNode */
Enter fullscreen mode Exit fullscreen mode

Lembre-se de que vazamentos de memória desse tipo também afetam os elementos pais. Por exemplo, se você tiver uma referência a um determinado elemento <li> salvo em seu código e tentar remover a lista inteira, não apenas o elemento <li> referenciado será mantido na memória, mas também a lista inteira. Isso acontece porque o DOM está duplamente vinculado; todos os pais contêm referências a seus filhos e vice-versa. Portanto, se algum elemento for anexado ao objeto global, toda a árvore de nós será impedida de ser desalocada na memória.

Fecho

Existe um cenário específico em que fechamentos podem resultar em vazamentos de memória, o que pode ser um pouco confuso, mas vale a pena estar ciente.

Considere este exemplo:

let myVar = null;

function replaceMyVar() {
  let previousVar = myVar;

  const uselessFunc = function() {
    if (previousVar) {
      console.log('hello!');
    }
  };
  myVar = {
    largeString: new Array(10000).join(),
    uselessMethod() {
      console.log('hello again!'),
    }
  };
};

setInterval(replaceMyVar, 1000);
Enter fullscreen mode Exit fullscreen mode

Como um lembrete rápido, um encerramento é a combinação de uma função com referências ao seu ambiente lexical. Toda vez que replaceMyVar é executado, um novo fechamento de método inútil é criado. Isso compartilha um ambiente léxico com uselessFunc, que faz referência a previousVar. Uma vez que uma variável é usada por um encerramento, ela é mantida no ambiente léxico de todos os encerramentos que compartilham o mesmo escopo. Neste exemplo, como uselessFunc faz referência a previousVar, essa variável também é vinculada ao fechamento uselessFunc, pois eles compartilham o mesmo escopo.

Apesar de uselessFunc nunca ser chamado (a pista está no nome aqui!), esta referência apontando para a previousVar liga-a a uselessMethods. Então, na verdade, acabamos com uma cadeia de uselessMethods referenciando o myVar anterior (que continha um uselessMethods, que referenciava o myVar anterior… e assim por diante) de cada vez que replaceMyVar é chamado. Isso mantém os objetos previousVar 'ativos' na memória e, portanto, os impede de serem elegíveis para coleta de lixo.


Espero que essa breve visão geral tenha sido útil para entender os fundamentos do gerenciamento de memória em JavaScript! Se você quiser saber mais, os artigos abaixo foram úteis para mim na construção de minha compreensão do assunto:

Top comments (0)