Vazamento de memória em JavaScript

Vazamento de memória em JavaScript

Acredito que a maioria das pessoas não saiba que é possível termos vazamento de memória em JavaScript. Afinal, nós não alocamos memória explicitamente como em linguagens de baixo nível como C, C++ ou Objective-C. Então, se nós não somos responsáveis por essa alocação de memória, se houver um leak a culpa será da linguagem, e não do programador.

Certo?

Errado.

É importante sabermos como a liberação automática de memória funciona para evitarmos alguns casos específicos onde a memória pode nunca ser liberada. Mas antes de falarmos sobre vazamento de memória vamos utilizar a técnica Jack Estripador e dividir o problema em partes. Primeiro vamos entender um pouco do ciclo de vida da memória, depois como funciona a liberação automática de memória no JavaScript para, só então, sairmos da teoria e vermos um experimento.

Ciclo de vida da memória

O ciclo de vida da memória é praticamente o mesmo, não importando a linguagem. Ele se dá em 3 passos:

  • Alocação
  • Uso
  • Liberação

Nós alocamos memória quando declaramos uma variável, declaramos uma função, aumentamos o tamanho de nosso array, etc. Nós usamos aquele trecho de memória quando acessamos ou escrevemos algo nele. E nós devemos liberar memória para dar espaço para futuras alocações pois, caso contrário, iríamos consumir rapidamente toda a memória do computador.

Esses dois primeiros passos acontecem de forma explícita em todas as linguagens, ou seja, o programador que define quando (não como) a memória será alocada e usada. Já o terceiro passo é explícito apenas em linguagens de baixo nível, onde o programador tem que fazer todo o trabalho de sempre liberar memória que não está mais sendo usada. E implícito em linguagens de alto nível como JavaScript, Ruby e Python, onde a própria linguagem cuidará disso.

Garbage Collection

Como foi dito, a liberação de memória no JavaScript é feita de forma implícita (automática, transparente). E esta mágica é chamada de garbage collection, ou coleta de lixo. Existem algumas maneiras diferentes de se implementar um coletor de lixo, mas hoje todos os grandes navegadores utilizam um algoritmo (heurística) chamado de Mark and Sweep para marcar trechos de memória que não estão mais sendo utilizados. Em outras palavras, ele que diz o que é e o que não é lixo para que o coletor recolha.

Mark and Sweep

Descendo um pouco mais na toca do coelho, vamos entender como funciona o Mark and Sweep. Na primeira etapa, ele sai varrendo todos os objetos alocados em memória. A varredura é iniciada pelos objetos raízes, que no caso do JavaScript no browser é o objeto window. A partir dele, ele sai visitando todos os objetos a que window faz referência. E depois todos os objetos que os objetos referenciados por window fazem referência, e assim por diante. No final desta varredura, todos os objetos que não foram visitados são marcados como lixo e o no próximo evento de garbage collection essa memória será liberada.

Na animação abaixo temos um exemplo do algoritmo Mark and Sweep fazendo a varredura, marcando um objeto como lixo (o círculo laranja) e esse mesmo objeto sendo recolhido pelo Garbage Collector. Para que a animação inicie, deixe seu mouse em cima da imagem.

Mark and Sweep

Exemplo de vazamento

Acredito que a melhor forma de aprender é sempre pondo em prática o que se aprende. Pra isso fiz um experimento pra que fique mais fácil de mostrar um memory leak e como detectá-lo. Ele está no Github e eu aconselho que vocês tentem repetir os passos que vamos fazer para ver na real como detectar um vazamento de memória.

O experimento

O experimento é uma aplicação onde temos várias fotos em miniatura e quando clicamos em uma das miniaturas, podemos vê-la em um tamanho maior. O GIF tosco abaixo vai ajudar a explicar o experimento a quem ainda não clicou no link do Github. Da mesma forma que na animação anterior, deixe seu mouse sobre a imagem para que a animação inicie.

Experimento

Vamos focar apenas nesta parte do código, pois é aqui que a mágica acontece.

$('.miniatura').on('click', function(event) {
  var img = $('<img />')
    .addClass('lightbox')
    .attr('src', $(this).attr('src'))
    .on('click', function(event) {
      $(this).remove()
    })
    .appendTo('body')

  $(window).on('resize', function() {
    resizeImg(img)
  })
})

function resizeImg(img) {
  console.log(img)
}

Sem pânico! Vamos entender o código passo-a-passo. Estou definindo que ao clicar em uma miniatura, é criada um novo elemento img com o mesmo src da imagem clicada. E nessa imagem que acabamos de criar, adicionamos a classe lightbox para deixá-la maior e centralizada por CSS. E ao final disso tudo, inserimos ela no DOM com o método appendTo('body'). E, no click desta imagem recém-criada, ela é removida do DOM.

Logo depois setamos um evento resize à window que dispara a função resizeImg passando a imagem criada há pouco como parâmetro. Imaginem que gostariamos de mudar as dimensões da imagem de acordo com o tamanho do viewport. Essa funcionalidade não foi implementada para deixar o experimento simples e, também, porque não iria fazer nenhuma diferença para demonstrar o leak.

Até aqui tudo bem. Quando clicamos em uma imagem de miniatura ela cria uma imagem de forma dinâmica. E quando clicamos nela novamente, ela é removida. Nenhum leak aparente. Estamos alocando, usando e liberando a memória, pois estamos removendo a imagem do DOM, permintindo assim que ela seja coletada pelo coletor de lixo.

Certo?

Errado.

Detectando

Vamos analisar o comportamento do experimento com a ajuda do Chrome Dev Tools. Abrindo a aba Timeline, iremos gravar uma sessão de uso com o seguinte cenário: Vamos clicar uma vez em uma miniatura para abrir uma imagem maior, logo depois na imagem maior para que ele seja removida do DOM, e repetir este processo 5 vezes.

Chrome Dev Tools Timeline

Na imagem acima, o gráfico verde corresponde ao número de nós na árvore DOM na nossa página. É nele que temos que ficar de olho. Cada degrau no gráfico verde corresponde a um clique em uma miniatura. A cada clique são criados dois nós do DOM. Um dos nós é a imagem, e o outro nó é a imagem adicionada à árvore com o método appendTo('body').

A página é iniciada com 39DOM nodes. No pico do gráfico temos 49 nós, pois demos 5 cliques e a cada clique eram criados 2 nós adicionais. Depois temos uma queda no número de nós. É quando o garbage collector é chamado. Mas ele deveria remover todos os nós que acabamos de criar. Porém só remove 5 dos 10 que acabamos de criar. Por que?

O que houve

O erro acontece quando setamos o evento resize da window. Quando removemos a imagem do DOM, o evento continua a ter uma referência à imagem, evitando que o mark and sweep a marque como lixo para que seja recolhida pelo coletor de lixo. Deixe seu mouse sobre a imagem abaixo para ver uma animação do que aconteceu.

Vazamento passo-a-passo

Na animação acima podemos ver com mais clareza porque as imagens criadas não são marcadas como lixo. O objeto window possui uma referência para document, document possui uma referência para body, e body possui uma referência para img, pois acabamos de adicioná-la a ele.

Quando adicionamos o evento resize do window, passamos a imagem como referência. Então window tem uma referência para o evento resize e este evento possui uma referência para a imagem.

Quando removemos a imagem do DOM, body não aponta mais para a imagem, porém o evento resize do window continua. O que torna a imagem inelegível para ser marcada como lixo. E por isso ela nunca será liberada da memória.

Pra quê isso tudo?

Em sites convencioanais, um refresh faz com que toda a memória alocada para aquela página seja liberada. O que não acontece em web apps, como Gmail. É verdade que alguns frameworks como Ember e AngularJS tentam resolver esses casos especiais de retenção de memória, mas nem sempre seu uso é justificado e, principalmente, é importante que o artesão domine suas ferramentas e seja mestre no que faz.

#40