JavaScript
memoryLeak

JavaScript の仕組み:メモリ管理+ 4つの共通のメモリリーク処理方法

この記事は sessionstack blog に投稿されている、How JavaScript works シリーズの一記事 "How JavaScript works: memory management + how to handle 4 common memory leaks" の和訳です。投稿されたのは Alexander Zlatkov, 原文はこちらです。翻訳については許諾いただいています。

メモリ管理もしくはC言語におけるメモリ解説他、用語なども怪しい箇所は多分にありますので、間違いがありましたら修正のご指摘・編集リクエスト等ください。

日本語の参考 URL

先に日本語の参考URLを記載しておきます。


JavaScript の仕組み:メモリ管理+ 4つの共通のメモリリーク処理方法

数週間前、私たちは JavaScript を深く掘り下げ、実際にどのように動作するかを目的とした連載を開始しました。JavaScript が構成する要素を知り、どう付き合っていくかを知ることで、より良いコードやアプリケーションを書くことができると考えました。

シリーズの最初の記事は、 エンジン、ランタイム、コールスタックの概要を提供することに重点を置いていました。 2番目の記事では、Google の V8 JavaScript エンジンの内部を詳しく調べ、優れた JavaScript コードの書き方に関するヒントを紹介しました。

この3回目の記事では、多くの開発者が普段扱うプログラム言語への習熟度を上げようとして無視しがちであり、クリティカルな問題でもあるメモリ管理について話していきます。また SessionStack がメモリリークを起こさないようにするため、もしくは我々が統合している Web アプリケーションのメモリ消費を増加させないため、JavaScript でメモリリークを処理する方法について、いくつかのヒントを紹介していきます。

概要

C のような言語には低レベルのメモリ管理がプリミティブで備わっています、malloc(), free() のようなものです。これらのプリミティブな関数はメモリ解放を行い OS に対してメモリを割り当てるなど開発者によって明示的に使用されます。

同じように、JavaScript もオブジェクトや文字列が作成された際にメモリ割当を行い、それらがもう使用されないと判断された時に、"自動的に" メモリ開放を行います。これがいわゆるガベージコレクションというプロセスです。これは一見すると "自動的に" リソースの開放を行うように思えてしまい、ある種の混乱を招いてしまう原因なのです。さらにこれによって JavaScript(または他の高水準言語)を扱う開発者が、メモリ管理に関して気をかけることに注力しなくてよいという誤解を生んでしまいます。これが大きな間違いなのです

高水準言語を扱って仕事をする時も、開発者はメモリ管理について(もしくは少なくとも基礎的な部分だけでも)理解をすべきです。自動的なメモリ管理については問題が生じる場合もあるのです(例えば、ガベージコレクションのバグや実装の限界のような)。その議論は開発者が適切にメモリのハンドリングを行うために理解する必要があります(適切な回避策を模索するために最小限のトレードオフやコードの責務とともに)。

メモリのライフサイクル

あなたが使用するどんなプログラム言語であろうとも、メモリのライフサイクルというものはいつも同じです。

以下が各ステップで何が起こっているかの概要です:

  • メモリ割り当て - メモリは OS によって割り当てられ、プログラムで使用することができるようになります。低水準言語(例えば C 言語のような)において、この割当は、開発者が処理すべき時に自らが行う明示的な操作です。しかし、高水準言語において、この割当は開発者であるあなたのために言語が処理を担当します。
  • メモリ使用 - プログラムが実際に前段で割り当てられたメモリを使うタイミングのことです。コード上の変数割り当てが使用される際に、読み出し・書き込みの操作が行われます。
  • メモリ解放 - 不要なメモリが開放され、そのメモリは解放後に再度利用することができる状態です。メモリ割り当ての操作と同じく、この操作は低水準言語では明示的に行うことが可能です。

コールスタックとメモリヒープのコンセプトについての概要は、この投稿で話題にしているので読んでみてください。

メモリとは何なのか

JavaScript でのメモリとは何であるかの話へと移る前に、一般的にメモリとは何なのか・端的に言うとどのように動いているのかについて話していきましょう。

ハードウェアレベルの話でいけば、コンピュータのメモリは相当数のフリップフロップ回路から成っている。どのフリップフロップも、いくつかのトランジスタを含み、1ビットの記憶領域を持っています。各々のフリップフロップはユニークな識別子でアドレスが指定できます。そのため我々は読み出したり上書きをすることが可能なのです。したがって概念的にはでありますが、コンピュータのメモリというものは読み出したり書き込んだりすることができる唯一な巨大なビット配列であると考えることができます。

ビット計算やメモリが何をしているかすべてを考えるというのは良いアイデアではないですし、人間がやることではありません。我々は数によって表現される、もう少し大きなグループにそれらを分類します。8ビットを1バイトと呼びそのバイトを超えるとワードとなるのです(それらは時に16ビットであったり、32ビットであったりします)。

メモリに保持される多くはこのようなものです:

  1. すべてのプログラムで使用されるすべての変数、そして他のデータ
  2. OS を含む、プログラムのコード

コンパイラと OS は連携してメモリ管理に多くの部分を担当していますが、見えないところでどのように動いているのか見ていくことにしましょう。

コードをコンパイルする際、コンパイラはプリミティブなデータ型を調べ、それらにどのくらいメモリが必要なのか事前に計算します。必要な量がプログラムへ割当てられるわけですが、これがいわゆるスタック領域と呼ばれるものです。割り当てられたこの領域をスタック領域と呼ぶのは、関数が呼ばれた際にこれらのメモリは既存メモリの上部にスタックされるからです。それらが終了させられると後入れ先出し(LIFO)の順で除かれていきます。例えばこんな宣言を考えていきましょう:

int n; // 4 bytes
int x[4]; // array of 4 elements, each 4 bytes
double m; // 8 bytes

コンパイラは直ちにこのコードが必要とするのは 4 + 4 × 4 + 8 = 28 だと分かります。

これらは、整数型・ダブル型の現サイズがどのように動くかというものです。20年前、整数は通常2バイトでダブルは4バイトでした。現時点では、何が基本のデータ型のサイズであるか依存する必要はありません。

コンパイラは、変数を格納するために、スタック上に必要なバイト数を要求するべく OS と対話するコードを挿入します。

上記の例では、コンパイラは各変数の正確なメモリアドレスがわかっています。実際には変数nはいつでも書き換えられ、内部的には“memory address 4127963”のようなものに変換されるのです。

ここで我々はx[4]にアクセスしようとした時にmと結びついたデータにもアクセスしなければならないと気づくでしょう。なぜなら存在しない配列の要素にアクセスしようとしているからです。これは、配列の最後に割り当てられた要素x[3]よりもさらに4バイト多いからです。そしてmのビットを読み出すか(上書きして)終了し、これはほぼ確実に残りのプログラムで望まれない結果が見込めます。

関数が他の関数を呼び出す際、自身が呼びされる際にスタックそれぞれのチャンクを取得します。そこにはすべてのローカル変数が保持されますが、実行した場所を記憶しているプログラムカウンタもあります。関数が終了すると、メモリブロックはまた他の目的に利用可能な状態となるのです。

動的割当

残念ながら、コンパイルする際ひとつの変数にどのくらいのメモリが必要なのか知るということは、それほど簡単なことではありません。下記のようなことをやろうと思った時のことを考えて見てください:

int n = readInput(); // ユーザによって入力値が変わる
...
// "n" 要素を持つ配列を作る

ここでのコンパイル時、ユーザによって決められるnの値は変動するため配列がどのくらいメモリを必要とするのか分かりません。

したがってスタック上の変数に空き領域を割り当てることができません。その代わり、プログラムはランタイム時に適切な領域を OS に要求する必要があるのです。このメモリはヒープ領域から割り当てられます。静的・動的なメモリの割り当ての違いは下記の表にまとめました:

動的なメモリ割当がどのように機能するかについて完璧に理解するためには、ポインタについて多くの時間を割かなければなりません。ただしこの投稿で取り上げるには逸脱しすぎるため、もし興味が湧いて学習を進めたいならコメントでお知らせください。他の投稿でポインタについて詳細を取り上げます。

JavaScript における割当

では、最初のステップであるメモリ割当が JavaScript ではどう動いているのか見ていくことにします。

JavaScript では、開発者自身は自らメモリ割当を操作する責任を負いません。JavaScript は値を宣言するだけで、言語自身がメモリ割当を行うのです。

var n = 374; // 数値としてメモリを割り当てる
var s = 'sessionstack'; // 文字列としてメモリを割り当てる 
var o = {
  a: 1,
  b: null
}; // 値を含んだオブジェクトとしてメモリを割り当てる
var a = [1, null, 'str'];  // (オブジェクトと似たように)値を含んだ配列としてメモリを割り当てる
function f(a) {
  return a + 3;
} // 関数として割り当てる(コール可能なオブジェクトと同意)
// 関数式はオブジェクトとしても割り当てる
someElement.addEventListener('click', function() {
  someElement.style.backgroundColor = 'blue';
}, false);

いくつかの関数呼び出しの結果、下記のようなオブジェクトの割り当ても行われます:

var d = new Date(); // Dateオブジェクトを割り当てる
var e = document.createElement('div'); // DOM Element を割り当てる

メソッドは新しい値もしくはオブジェクトを割り当てることができます:

var s1 = 'sessionstack';
var s2 = s1.substr(0, 3); // s2 は新しい文字列です
// 文字列はイミュータブルなので、 
// JavaScript はメモリを割り当てないかもしれない。 
// ですが [0,3] という範囲は保持します。
var a1 = ['str1', 'str2'];
var a2 = ['str3', 'str4'];
var a3 = a1.concat(a2); 
// 新しい配列は4つの要素を持つ。
// a1 と a2 の要素を連結したものです。

JavaScript におけるメモリ使用

JavaScript では割り当てられたメモリが使用されるということは、基本的にはそのメモリの中で読み書きすることを意味します。

変数の値、オブジェクトのプロパティの値を読み書きするか、関数に引数を渡すことによって実行されます。

メモリが不要となった時に解放する

このステップでメモリ管理に関する問題のほとんどが出現します。

割り当てられたメモリがいつ不要となるかを理解するのは、ここでは最も難しい内容です。しかし開発者は不要となったメモリがプログラムのどこにあるか適確に特定し開放する必要があるのです。

高水準言語はガベージコレクタと呼ばれるソフトウェア(=機能)を備えています。この機能は、不要となったメモリの一部を見つけて自動的にそのメモリを開放するために、メモリの割当位置と使用を追跡します。

ただし残念なことに、このプロセスは近似なのです。なぜなら、メモリのある一部が必要であるか知るという一般的な問題は決めることが不可能だからです(アルゴリズムで解決できるものではありません)。

ほとんどのガベージコレクタはもはやアクセスされないメモリ(例えば、スコープから外れていったすべての変数など)を集めることによって動いています。しかしながら、集められた空きメモリの塊もまた近似より精度が低いものとなります。なぜなら、ある時点ではメモリ位置にまだスコープ内の変数のポインタがあったとしても、もう二度とアクセスされることはないのです。

ガベージコレクション

"もう必要とされない"メモリであるかどうかを見つけるということが不可能であるため、ガベージコレクションは一般的なこういった問題を解決するための制限を実装しています。このセクションではガベージコレクションの主たるアルゴリズムと制限を理解するために必要な概念を説明していくことにします。

メモリ参照

ガベージコレクションのアルゴリズムにおける主なコンセプトは参照の一部に依存しています。

メモリ管理のコンテキストの中では、ある1つのオブジェクトは別のオブジェクトを参照すると言われます、前者が後者に暗黙的または明示的にアクセスできるなら。例えば、JavaScript のオブジェクトはそれ自身がもつ prototype への参照を持っており(暗黙的な参照)、それ自身がもつプロパティの値への参照も持っている(明示的な参照)ことからも分かります。

この文脈において、"object" という考え方は一般的な JavaScript のオブジェクトよりもより広義のものへと拡張されており、関数のスコープ(もしくはグローバルレキシカルスコープ)ももちろん含んでいます。

レキシカルスコープはネストされた関数内で変数名がどう解決されるを定義するものです:たとえ親関数がリターンされても内部関数は親関数のスコープを含めみます

参照カウントによるガベージコレクション

これは一番シンプルなガベージコレクションのアルゴリズムです。あるオブジェクトへの参照がゼロになったら、このオブジェクトは "ガベージコレクト可能だ" と判断するのです。

以下のコードを見てみましょう:

var o1 = {
  o2: {
    x: 1
  }
};
// 2つのオブジェクトが作成される。
// 'o2' は 'o1' によって自身のプロパティの一つとして参照される。
// ここでガベージコレクトされるものは何もない。

var o3 = o1; // 'o3' 変数は 'o1' によってポインタオブジェクトを参照する2つ目のものである。

o1 = 1;      // ここでは 'o1' のオリジナルであるオブジェクトは
            // 'o3' 変数によって体現され1つの参照を持つことになる。

var o4 = o3.o2; // オブジェクトの 'o2' プロパティへの参照である。
                // このオブジェクトは今2つ参照がある:一つはプロパティとして
                // そしてもう一つは 'o4' 変数としてだ。

o3 = '374'; // ここで 'o1' のオリジナルとなるオブジェクトは参照がゼロになる。
            // ガベージコレクトできうる。
            // だが、'o2' プロパティがまだ 'o4' によって参照されているので開放できない。

o4 = null; // 'o1' のオリジナルとなるオブジェクトの 'o2' プロパティの参照がゼロになった。
           // ここでガベージコレクトできる。

循環参照が問題を生み出す

循環参照となると限界が出てきます。下記の例の中では、2つのオブジェクトが作られて相互がもう一方を参照する形になっています。つまり、循環参照を生み出しているのです。関数が呼ばれた後にスコープから逸れていくとこれらは目的を果たして不要で解放されうるものですが、参照カウントのアルゴリズムはこの2つのオブジェクトは双方ともに少なくとも一度は参照されるものと判断し、どちらもガベージコレクトできないとみなすのです。

function f() {
  var o1 = {};
  var o2 = {};
  o1.p = o2; // o1 は o2 を参照する
  o2.p = o1; // o2 は o1を参照する。これが循環を生んでいる
}

f();

マーク・アンド・スイープ アルゴリズム

このアルゴリズムは、オブジェクトが必要かどうかを決めるため、オブジェクトに到達可能かを特定するアルゴリズムです。

マーク・アンド・スイープ アルゴリズム は以下の3つのステップを実行していきます:

  1. ルート: 一般的に、ルートはコードにおいて参照されるグローバル変数です。JavaScript では、例えば、ルートとして振る舞うグローバル変数は "window" オブジェクトということになります。Node.js においては "global" と呼ばれるオブジェクトです。ガベージコレクタによってすべてのルートから完全な(ノード)リストが組み立てられます。
  2. アルゴリズムはすべてのルートとその子オブジェクトを辿り調査し、アクティブ(つまりガベージではないということ)なものとしてマークしていきます。ルートから到達できないものはすべてガベージとしてマークされるでしょう。
  3. 最後に、ガベージコレクタはアクティブでないと判断しマークしなかったすべてのメモリを開放し、OS にメモリ領域を返します。

このアルゴリズムは前述したアルゴリズムより - "ある1つのオブジェクトへの参照カウンタがゼロになった" ということはこのオブジェクトには到達できないという意味なので - 優れています。循環参照で見たように参照し合うがアクティブではないメモリを開放しないといこともありません(The opposite is not true as we have seen with cycles. 意訳)。

現時点で、すべてのモダンブラウザはこのマーク・アンド・スイープ アルゴリズムを採用しています。JavaScript のガベージコレクション(世代別/インクリメンタル/同時/並行 様々なガベージコレクション)の分野における過去何年もの間の多くの改善は、ガベージコレクションのアルゴリズムそれ自身の改善ではありません。またオブジェクトに到達できるかを決定するための基準を変えるということでもありません。このアルゴリズム(マーク・アンド・スイープ)の実装改善そのものにほかならないのです。

この記事では、自身の最適化に加えてマーク・アンド・スイープを網羅したトレーシング GC についてのより詳細な内容を読むことができます。

循環参照はもはや問題にならない

上記で見た最初の例において、関数呼び出しが返った後に、2つのオブジェクトはグローバルオブジェクトから到達可能なものによって参照されることはありません。結果として、ガベージコレクタが到達不可能だと判断するのです。

上記の図のように、たとえオブジェクト間の参照があったとして、ルートから到達することはないのです。

ガベージコレクタの直感的な操作

ガベージコレクタは便利であるものの、自身が持ち合わせている特性により使用にはトレードオフがあることも覚えておかなくてはなりません。その中の1つが非決定性です。言い換えれば、GC は予測不可能であり、いつガベージコレクトされたのか知る由がないのです。これは、あるケースでは実際に必要とされるよりも多くのメモリをプログラムが使用してしまうということを意味しています。ただ、非決定性はいつガベージコレクトされているのかが特定できないことを意味するものの、ほとんどの GC の実装では割当の間にガベージコレクトを実行させるというパターンが共通化されています。もし割り当てが実行されなかった場合は、ほとんどの GC がアイドリング状態となっています。以下のシナリオを考えてみましょう:

  1. 相当数のメモリ割り当てが実行される
  2. そのほとんどの構成要素(もしくは全て)が到達不可能としてマークされる。(不要となったキャッシュを参照するポインタを null にすると仮定します)
  3. それ以上の割当は行われない

このシナリオでは、ほとんどの GC がそれ以上のガベージコレクトを実行しようとはしません。言い換えれば、たとえコレクト可能で到達不可能な参照があるにもかかわらず、ガベージコレクタによって回収されることはないのです。厳密に言えば、これはメモリリークではないのですが、結果的に通常よりもメモリ使用量が高くなります。

メモリリークとはなんだろう

メモリが示唆するように、メモリリークはアプリケーションが過去に使用しもはや必要とされていない空き領域のメモリプールや、OS に返されていないメモリの断片のことです。

メモリ管理の方法はプログラミング言語によって様々な方法があります。しかし、あるメモリの断片が使用されているかどうかは実際には決定不可能な問題でもあります。つまり開発者だけがメモリの断片が OS に戻されるべきかどうかをはっきりさせることが出来るのです。

特定のプログラミング言語は開発者がメモリを解放するべきかを判断するための機能を提供しています。他の人はメモリの断片がいつ使用されなくなるかが完全に明示されるかを開発者に期待するのです。Wikipedia には手動自動のメモリ管理について良記事があります、ぜひ読んでみてください。

4種類の一般的な JavaScript 共通のメモリリーク

1:グローバル変数

JavaScript は宣言せれていない変数を大変興味深い方法で処理します:定義されていない変数が参照されると、新しい変数がグローバルオブジェクトとして作成されるのです。ブラウザの中におけるグローバルオブジェクトはwindowであり、つまりそれが意味するのは

function foo(arg) {
    bar = "some text";
}

という関数定義は下記と等価であることを意味します:

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

barの目的は foo 関数内の変数を参照することであるとしましょう。しかし、もしvarを使わずに宣言したとしたら、冗長にグローバル変数が作成されることになります。上記のケースではこれはあまり問題にはなりません。あなたはもっと弊害のあるシナリオを想像するかもしれません。

例えば次のように誤ってグローバルオブジェクトを作成することも出来るのです:

 function foo() {
 this.var1 = "potential accidental global"; 
 } 
 // Foo が自身をコールすると、`this`は
 // undefined というよりもむしろグローバルオブジェクト(window)を参照するのです
 foo(); 

'use strict'; を使用することでこれらを避けることができます。JavaScript ファイルの冒頭に宣言することで、予期せぬグローバル変数を作成すること抑止する、JavaScript パースのより厳格なモードへとスイッチすることが出来るのです。

予期しないグローバル変数は確かに問題ではあるのですが、ガベージコレクタに回収されないような定義で、コード上に明示的なグローバル変数が含まれることがよくあります。大量な情報を一時的に格納したり処理するには、特別な注意を払う必要があるのです。グローバル変数を使用しデータを格納する必要がある場合、データを保存する際に必ずそのデータを null として割り当てるか、再度割り当ててください

2: 放置されるタイマーもしくはコールバック

JavaScript でよく使用される例としてsetIntervalを取り上げてみましょう。

オブザーバや、コールバックを受け取るため他の手段を提供するライブラリ関数は、通常インスタンスへ到達できなかった際コールバックへの参照を到達不可能にします。次のようなコードはレアなケースでなく、よく見るケースです:

var serverData = loadData();
setInterval(function() {
    var renderer = document.getElementById('renderer');
    if(renderer) {
        renderer.innerHTML = JSON.stringify(serverData);
    }
}, 5000); // これは5秒毎に実行されるでしょう

このスニペットでは、もう必要とされないノードやデータを参照するようなタイマーが使われているということがわかります。

このrendererオブジェクトはsetIntervalのハンドラによってカプセル化されたブロックを冗長化してしまうという点で、置き換えもしくは削除できるものです。このような場合、まずsetIntervalを止める必要があるため、ハンドラと依存関係はガベージコレクトされません(滞留し続けるのだと覚えておいてください)。詰まるところ、データを読み込み格納したserverDataはガベージコレクトされることがないのです。

オブザーバを使う際は、オブザーバが完了したら明示的に呼び出して削除する必要があります(オブザーバはもう必要ないか、オブジェクトは到達不可能になります)。

幸運にもほとんどのモダンブラウザではこれらを自ら実行できます:リスナーを削除するのを忘れていたとしても、オブジェクトが到達不可能となったらオブザーバのハンドラを自動的にガベージコレクトします。過去にはいくつかのブラウザがこのケースを処理できないことはありました(例えば、古き良き IE6 のような)。

それでもまだ、オブジェクトが一旦使われなくなったらオブザーバを削除する、というのはベストプラクティスの1つと言えます。次の例を見てみましょう。

var element = document.getElementById('launch-button');
var counter = 0;

function onClick(event) {
   counter++;
   element.innerHtml = 'text ' + counter;
}

element.addEventListener('click', onClick);

// 何やかんやあって

element.removeEventListener('click', onClick);
element.parentNode.removeChild(element);

// ここで`element`はスコープ外となったので
//`element`、`onClick`のいずれも、
// サイクルをうまく処理できない古いブラウザでさえガベージコレクトします。

最近のブラウザでは、これらのサイクルを検出して適切に処理できるガベージコレクタがサポートされているため、ノードを到達不能にする前にremoveEventListenerを呼び出す必要はありません。

jQueryの API を利用する場合(他のライブラリとフレームワークでもこれをサポートしています)、ノードが廃止される前にリスナーを削除することもできます。 ライブラリは、アプリケーションが古いバージョンのブラウザで実行されている場合でもメモリリークがないことを確認します。

3. クロージャ

JavaScript 開発における重要な面としてクロージャが挙げられます:外側のスコープの関数内にある変数へアクセスできる内部関数のことです。JavaScript ランタイムの実装により、以下の方法ではメモリリークの起きる可能性があります:

var theThing = null;

var replaceThing = function () {

  var originalThing = theThing;
  var unused = function () {
    if (originalThing) // 'originalThing' への参照
      console.log("hi");
  };

  theThing = {
    longStr: new Array(1000000).join('*'),
    someMethod: function () {
      console.log("message");
    }
  };
};

setInterval(replaceThing, 1000);

replaceThingが一度呼ばれると、theThingには巨大な配列と新しいクロージャ(someMethod)で構成される新しいオブジェクトが代入されます。originalThingunused変数(replaceThing呼び出し前のtheThing変数)によって保持されるクロージャから参照されています。覚えていおくべきことは、同じ親スコープ内のクロージャに対してクロージャのスコープが作成されると、そのスコープは共有されるということです。

この場合には、クロージャであるsomeMethodのために作られたスコープはunusedと共有され、unusedoriginalThingへの参照を持っています。unusedは決して使用されませんが、replaceThingの外側のスコープで(例えばグローバルなどこかで)theThingを通してsomeMethodは使用可能になります。それと同時にsomeMethodunusedとクロージャスコープを共有するので、unusedにおけるoriginalThingの参照はアクティブな状態を維持し続けなければなりません(2つのクロージャで共有されるスコープ全体)。これによって、ここにおけるガベージコレクトは阻害されるのです。

上記の例では、クロージャであるsomeMethodのために作られたスコープがunusedと共有されると同時にunusedの参照もまたoriginalThingと共有されています。unusedは決して使用されないという事実があるにもかかわらず、someMethodreplaceThingのスコープの外側でtheThingを通して使用されるのです。someMethodunusedとクロージャスコープを共有しているため、使用されない参照であるoriginalThingは自身がアクティブであり続けようとするのです。

ここで言及したようなものはすべて、メモリリークを起こしかねません。上記のスニペットを何度も繰り返し実行するとメモリ使用量が跳ね上がるでしょう。そのサイズはガベージコレクタが実行されても小さくなりません。クロージャの linked-list が作成され(そのルートはこの例だとtheThing変数です)、クロージャスコープは巨大な配列への間接的な参照を繰り返し続けます。

この問題は Meteor チームにより発見され、詳細を説明した素晴らしい記事を書いています

4. DOM 参照

開発者がデータ構造の中に DOM ノードを格納するようなケースがあります。あるテーブル内のいくつかの行の中身を素早く更新したいとします。ディクショナリか配列に各 DOM 行への参照を保持しているとしたら、同じ DOM 要素への参照を2つ持つことになります:1つは DOM ツリー上にあるもの、もう1つはディクショナリにあるものです。これらの行を除去するためには、両方の参照を到達不可能にする必要があります。

var elements = {
    button: document.getElementById('button'),
    image: document.getElementById('image')
};
function doStuff() {
    elements.image.src = 'http://example.com/image_name.png';
}
function removeImage() {
    // 画像は body 要素の直接の子要素です
    document.body.removeChild(document.getElementById('image'));

    // ここ時点ではグローバルな要素オブジェクトである #button への参照はまだ残っています
    // 言い換えればボタン要素もまだGCによって回収されずメモリに存在することになります
}

DOM ツリー内部ノードもしくはリーフノードへの参照については、考慮すべき追加事項もあります。コード上でテーブルセル(<td>タグ)への参照を保持しており、その特定のセルへの参照を保持したまま DOM からテーブルを削除しようとしているとします。その場合、大きなメモリリークが発生することが予想されます。ガベージコレクタがセル以外のすべてを開放するのではないかと感じるでしょう、しかし、このケースでは当てはまらないのです。セルがテーブルの子ノードであり子はその親を参照し続けるため、このテーブルセルへの単一の参照はメモリ上でテーブル全体を保持し続けることになります。