O gerenciamento de memória em JavaScript é realizado de forma automática e invisível para nós. Criamos primitivas, objetos, funções… Tudo isso requer memória.
O que acontece quando algo não é mais necessário? Como o mecanismo JavaScript o descobre e o limpa?
O principal conceito de gerenciamento de memória em JavaScript é acessibilidade .
Simplificando, valores “alcançáveis” são aqueles que são acessíveis ou utilizáveis de alguma forma. Eles têm garantia de serem armazenados na memória.
Existe um conjunto básico de valores inerentemente acessíveis, que não podem ser excluídos por razões óbvias.
Por exemplo:
Esses valores são chamados de raízes .
A função atualmente em execução, suas variáveis locais e parâmetros.
Outras funções na cadeia atual de chamadas aninhadas, suas variáveis e parâmetros locais.
Variáveis globais.
(há alguns outros internos também)
Qualquer outro valor é considerado acessível se for acessível a partir de uma raiz por uma referência ou por uma cadeia de referências.
Por exemplo, se houver um objeto em uma variável global e esse objeto tiver uma propriedade que faça referência a outro objeto, esse objeto será considerado alcançável. E aqueles aos quais ele faz referência também são acessíveis. Exemplos detalhados a seguir.
Há um processo em segundo plano no mecanismo JavaScript chamado coletor de lixo. Ele monitora todos os objetos e remove aqueles que se tornaram inacessíveis.
Aqui está o exemplo mais simples:
//o usuário tem uma referência ao objeto deixe usuário = { nome: "João" };
Aqui a seta representa uma referência de objeto. A variável global "user"
faz referência ao objeto {name: "John"}
(vamos chamá-lo de John por questões de brevidade). A propriedade "name"
de John armazena uma primitiva, portanto ela é pintada dentro do objeto.
Se o valor do user
for sobrescrito, a referência será perdida:
usuário = nulo;
Agora John se torna inacessível. Não há como acessá-lo, nem referências a ele. O coletor de lixo descartará os dados e liberará memória.
Agora vamos imaginar que copiamos a referência de user
para admin
:
//o usuário tem uma referência ao objeto deixe usuário = { nome: "João" }; deixe admin = usuário;
Agora, se fizermos o mesmo:
usuário = nulo;
…Então o objeto ainda pode ser acessado por meio da variável global admin
, portanto ele deve permanecer na memória. Se substituirmos admin
também, ele poderá ser removido.
Agora um exemplo mais complexo. A família:
função casar(homem, mulher) { mulher.marido = homem; homem.esposa = mulher; retornar { pai: homem, mãe: mulher } } deixe família = casar({ nome: "João" }, { nome: "Ann" });
A função marry
“casa” dois objetos, dando-lhes referências um ao outro e retorna um novo objeto que contém ambos.
A estrutura de memória resultante:
A partir de agora, todos os objetos estão acessíveis.
Agora vamos remover duas referências:
excluir família.pai; exclua família.mãe.marido;
Não basta excluir apenas uma dessas duas referências, pois todos os objetos ainda estariam acessíveis.
Mas se excluirmos ambos, poderemos ver que John não tem mais nenhuma referência de entrada:
As referências de saída não importam. Somente os que chegam podem tornar um objeto acessível. Assim, John agora está inacessível e será removido da memória com todos os seus dados que também ficaram inacessíveis.
Após a coleta de lixo:
É possível que toda a ilha de objetos interligados fique inacessível e seja removida da memória.
O objeto de origem é o mesmo acima. Então:
família = nulo;
A imagem na memória se torna:
Este exemplo demonstra a importância do conceito de acessibilidade.
É óbvio que John e Ann ainda estão ligados, ambos têm referências recebidas. Mas isso não é suficiente.
O antigo objeto "family"
foi desvinculado da raiz, não há mais referência a ele, então toda a ilha se torna inacessível e será removida.
O algoritmo básico de coleta de lixo é chamado de “marcar e varrer”.
As seguintes etapas de “coleta de lixo” são realizadas regularmente:
O coletor de lixo cria raízes e as “marca” (lembra).
Depois ele visita e “marca” todas as referências deles.
Depois visita os objetos marcados e marca suas referências. Todos os objetos visitados são lembrados, para não visitar o mesmo objeto duas vezes no futuro.
…E assim por diante até que todas as referências acessíveis (desde as raízes) sejam visitadas.
Todos os objetos, exceto os marcados, são removidos.
Por exemplo, deixe nossa estrutura de objeto ficar assim:
Podemos ver claramente uma “ilha inacessível” do lado direito. Agora vamos ver como o coletor de lixo “marcar e varrer” lida com isso.
O primeiro passo marca as raízes:
Depois seguimos suas referências e marcamos os objetos referenciados:
…E continue a seguir outras referências, sempre que possível:
Agora os objetos que não puderam ser visitados no processo são considerados inacessíveis e serão removidos:
Também podemos imaginar o processo como o derramamento de um enorme balde de tinta desde a raiz, que flui por todas as referências e marca todos os objetos acessíveis. Os não marcados são então removidos.
Esse é o conceito de como funciona a coleta de lixo. Os mecanismos JavaScript aplicam muitas otimizações para torná-lo executado mais rápido e não introduzir atrasos na execução do código.
Algumas das otimizações:
Coleção geracional – os objetos são divididos em dois conjuntos: “novos” e “antigos”. No código típico, muitos objetos têm uma vida útil curta: eles aparecem, fazem seu trabalho e morrem rapidamente, por isso faz sentido rastrear novos objetos e limpar a memória deles, se for o caso. Aqueles que sobrevivem por tempo suficiente ficam “velhos” e são examinados com menos frequência.
Coleta incremental – se houver muitos objetos, e tentarmos percorrer e marcar todo o conjunto de objetos de uma só vez, pode demorar algum tempo e introduzir atrasos visíveis na execução. Assim, o mecanismo divide todo o conjunto de objetos existentes em múltiplas partes. E então limpe essas partes uma após a outra. Existem muitas pequenas coletas de lixo em vez de uma total. Isso requer alguma contabilidade extra entre eles para rastrear as alterações, mas temos muitos pequenos atrasos em vez de grandes.
Coleta em tempo ocioso – o coletor de lixo tenta rodar apenas enquanto a CPU está ociosa, para reduzir o possível efeito na execução.
Existem outras otimizações e variações de algoritmos de coleta de lixo. Por mais que eu queira descrevê-los aqui, tenho que esperar, porque diferentes mecanismos implementam diferentes ajustes e técnicas. E, o que é ainda mais importante, as coisas mudam conforme os motores se desenvolvem, então estudar mais a fundo “com antecedência”, sem uma necessidade real provavelmente não vale a pena. A menos, é claro, que seja uma questão de puro interesse, haverá alguns links para você abaixo.
As principais coisas a saber:
A coleta de lixo é realizada automaticamente. Não podemos forçá-lo ou impedi-lo.
Os objetos são retidos na memória enquanto estão acessíveis.
Ser referenciado não é o mesmo que ser acessível (a partir de uma raiz): um pacote de objetos interligados pode tornar-se inacessível como um todo, como vimos no exemplo acima.
Os motores modernos implementam algoritmos avançados de coleta de lixo.
Um livro geral “The Garbage Collection Handbook: The Art of Automatic Memory Management” (R. Jones et al) cobre alguns deles.
Se você estiver familiarizado com programação de baixo nível, informações mais detalhadas sobre o coletor de lixo do V8 estão no artigo Um tour pelo V8: Coleta de lixo.
O blog V8 também publica artigos sobre mudanças no gerenciamento de memória de tempos em tempos. Naturalmente, para aprender mais sobre coleta de lixo, é melhor você se preparar aprendendo sobre os componentes internos do V8 em geral e ler o blog de Vyacheslav Egorov, que trabalhou como um dos engenheiros do V8. Estou dizendo: “V8”, porque é melhor abordado em artigos na internet. Para outros mecanismos, muitas abordagens são semelhantes, mas a coleta de lixo difere em muitos aspectos.
O conhecimento profundo dos motores é bom quando você precisa de otimizações de baixo nível. Seria sensato planejar isso como o próximo passo depois de estar familiarizado com o idioma.