メモリー管理

C言語 のような低水準言語には、malloc()free() のような低水準のメモリー管理プリミティブがあります。これに対して JavaScript では、オブジェクトを作成するときにメモリーを自動的に確保し、使用しなくなったらメモリーを解放します(ガベージコレクション)。この自動性が混乱の元になる可能性があります。メモリー管理について心配する必要がないという誤った印象を開発者に与える可能性があります。

メモリーライフサイクル

プログラミング言語に関係なく、メモリーのライフサイクルはほぼいつも同じです。

  1. 必要なメモリーを割り当てる
  2. 割り当てられたメモリーを使用する(読み込む, 書き込む)
  3. 必要なくなったら、割り当てられたメモリーを解放する

2 に関してはすべての言語で明示的に行われます。1 と 3 は低水準の言語では明示的ですが、JavaScript のような高水準言語では、ほとんどの場合暗黙的に行われます。

JavaScript での割り当て

値の初期化

割り当てでプログラマーを悩まさないために、JavaScript では値を宣言したときと同時にメモリーの割り当ても行われます。

js
const n = 123; // 数値を格納するメモリーが割り当てられます
const s = "azerty"; // 文字列を格納するメモリーが割り当てられます

const o = {
  a: 1,
  b: null,
}; // オブジェクトとそれに含まれる値を格納するためのメモリーが割り当てられます

// (オブジェクトの例と同じように)配列とそれに含まれる値を格納するための
// メモリーが割り当てられます
const a = [1, null, "abra"];

function f(a) {
  return a + 2;
} // 関数を格納するメモリーが割り当てられます (関数は呼び出し可能なオブジェクトです)

// 関数式でもメモリーの割り当てが行われます
someElement.addEventListener(
  "click",
  () => {
    someElement.style.backgroundColor = "blue";
  },
  false,
);

関数呼び出しを介して割り当て

一部の関数呼び出しでは、オブジェクトの割り当てが発生します。

js
const d = new Date(); // Date オブジェクトの割り当て

const e = document.createElement("div"); // DOM要素の割り当て

いくつかのメソッドは、新しい値またはオブジェクトを割り当てます:

js
const s = "azerty";
const s2 = s.substr(0, 3); // s2 は新しい文字列
// JavaScript では文字列は変更不可の値なので、
// メモリーの割当を行わないと思うかもしれません。
// しかし実際には [0, 3] の範囲の文字列が割り当てられます。

const a = ["ouais ouais", "nan nan"];
const a2 = ["generation", "nan nan"];
const a3 = a.concat(a2);
// a, a2 の内容を繋ぎ合わせた 4 要素の配列が作成されました

値の使用

値を使用することは、基本的に割り当てられたメモリーに読み書きすることを意味します。これは変数やオブジェクトの値を読み書きすることや引数を関数に渡すことによって行われます。

メモリーが不要になったときの解放

メモリー管理の問題のほとんどは、この段階に来ます。ここで最も難しい作業は、「割り当てられたメモリーが、必要とされなくなるときを見出すすることです。

プログラム内のどこで、そのようなメモリーの断片が不要になって解放する必要があるかを決定するには、開発者による判断が必要なことが多いです。

一部の高水準言語、例えば JavaScript は、ガベージコレクション (GC) として知られる自動メモリー管理の方式を利用しています。ガベージコレクターの目的は、メモリーの割り当てを監視し、割り当てられたメモリーのブロックができなくなったときに判断し、それを回収することです。特定のメモリーがまだ必要かどうかを判断する一般的な問題は決定不能であるため、この自動処理は近似的なものです。

ガベージコレクション

上述の通り、あるメモリーが「必要なくなった」かどうかを自動的に知るという普遍的問題は、決定不能です。そのため、ガベージコレクションのこの普遍的問題に対する解決策には制限があります。この節では、ガベージコレクションの主なアルゴリズムとその限界を理解するために必要な概念を説明します。

参照

ガベージコレクションアルゴリズムが依存している主な概念は、参照 (reference) の概念です。メモリー管理の文脈では、あるオブジェクトが別のオブジェクトに(明示的にであれ、暗黙的にであれ)アクセスできるとき、前者が後者を参照していると言います。例えば、JavaScript オブジェクトは自身の プロトタイプ(暗黙的な参照)とプロパティ値(明示的な参照)への参照を持ちます。

ここでは、「オブジェクト」の概念は通常の JavaScript オブジェクトよりも広い概念として用いられており、また、関数のスコープ(もしくは、グローバルレキシカルスコープ)を含みます。

参照カウントのガベージコレクション

メモ: 現代のブラウザーで、ガベージコレクションに参照カウントを使用しているものはもうありません。

これは、最も素朴なガベージコレクションアルゴリズムです。このアルゴリズムは、「あるオブジェクトが必要なくなった」ことを、「あるオブジェクトがその他のオブジェクトから参照されていない」ことと定義します。あるオブジェクトは、それに対する参照がゼロの時にガベージコレクション可能であると見なされます。

js
let x = {
  a: {
    b: 2,
  },
};
// 2 個のオブジェクトが作成されました。一方はもう一方のプロパティとして参照されています。
// もう一方は変数 'x' に代入されているため、こちらも同じく参照されています。
// 明らかに、どちらのオブジェクトもガベージコレクションの対象になりません。

let y = x;
// 変数 'y' は、このオブジェクトを参照する 2 つ目のものです。

x = 1;
// これで、元々 'x' にあったオブジェクトは、変数 'y' によって固有の参照が
// 具現化されたことになります。

let z = y.a;
// オブジェクトのプロパティ 'a' への参照です。
// これで、このオブジェクトは2つの参照先を持つことになりました。
// 1 つはプロパティとして、もう 1 つは変数 'z' としてです。

y = "mozilla";
// もともと 'x' にあったオブジェクトは、これで参照するオブジェクトが
// ゼロになりました。ガベージコレクションすることができます。
// しかし、そのプロパティ 'a' はまだ変数 'z' によって参照されているため、
// 解放することはできません。

z = null;
// もともと x にあったオブジェクトのプロパティ 'a' は、それへの参照が
// ゼロです。ガベージコレクションすることができます。

循環参照があると、制限があります。以下の例では、互いに参照するプロパティを持つ 2 つのオブジェクトが作成され、循環を作り出しています。これらのオブジェクトは、関数の呼び出しが完全に終了すると、スコープ外に出ます。この点で、オブジェクトは不要となり、割り当てられたメモリーを回収する必要があります。しかし、参照カウントアルゴリズムは、2 つのオブジェクトがそれぞれ少なくとも 1 つの参照点を持っているため、それらを再生可能とは見なさず、結果的にどちらもガベージコレクションにマークされないことになります。参照するオブジェクトは、メモリーリークの一般的な発生させる原因です。

js
function f() {
  const x = {};
  const y = {};
  x.a = y; // x references y
  y.a = x; // y references x

  return "azerty";
}

f();

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

このアルゴリズムは、「あるオブジェクトが必要なくなった」ことを、「あるオブジェクトが到達不能である」ことと定義します。

このアルゴリズムは、root と呼ばれるオブジェクトの集合についての知識を前提としています(JavaScript では、root はグローバルオブジェクトです)。定期的に、ガベージコレクターは、これらの root から開始し、これらの root から参照されるすべてのオブジェクト、それから、これらの中から参照されるすべてのオブジェクトなどを見つけます。root から開始すると、ガベージコレクターは、すべての到達可能オブジェクトを見つけ、すべての到達不能なオブジェクトをガベージコレクトします。

「あるオブジェクトが参照を持たない」ということは、そのオブジェクトは到達不能であるということなので、このアルゴリズムは前述のものよりも優れています。循環で見たように、逆は正しくありません。

現在、すべての現代的なブラウザーでは、マークアンドスイープ式のガベージコレクターを持っています。過去数年間で JavaScript のガベージコレクション(世代別/インクリメンタル/並行/並列ガベージコレクション)の分野で行われたすべての改善は、このアルゴリズムの実装の改善であって、ガベージコレクションアルゴリズム自体に対する改善でも、「オブジェクトが必要とされなくなった」と扱う基準を変えるものでもありません。

この手法の直接的な好ましいことは、循環が問題にならなくなることです。上の最初の例では、関数呼び出しを返した後、2つのオブジェクトは、グローバルオブジェクトから到達可能などのリソースからも参照されなくなりました。その結果、これらはガベージコレクターによって到達できないことが分かり、割り当てられたメモリーが再利用されることになります。

しかし、ガベージコレクションを手動で制御することができないのは変わりません。いつ、どんなメモリーを解放するかを手動で決めることができれば便利な時があります。オブジェクトのメモリーを解放するためには、明示的に到達できないようにする必要があります。また、JavaScript ではプログラムによってガベージコレクションを発生させることはできませんし、エンジンがオプトインフラグで API を公開することはあっても、コア言語の中で発生することはないでしょう。

エンジンのメモリーモデルを構成する

JavaScript エンジンは通常、メモリーモデルを公開するフラグを提供します。例えば、Node.js は、メモリーの問題を構成し、デバッグするために、基盤となるV8のメカニズムを公開する追加オプションやツールを提供しています。この設定は、ブラウザーでは利用できないかもしれませんし、ウェブページでは(HTTP ヘッダーなどを通じて)さらに利用できないかもしれません。

利用できるヒープメモリーの最大量は、フラグで増やすことができます。

bash
node --max-old-space-size=6000 index.js

また、フラグや Chrome デバッガーを用いて、メモリーの問題をデバッグするためにガベージコレクターを公開することができます。

bash
node --expose-gc --inspect index.js

メモリー管理を支援するデータ構造

JavaScript はガベージコレクター API を直接公開していませんが、この言語はガベージコレクションを間接的に監視するデータ構造をいくつか提供しており、メモリー使用量を管理するために使用することができます。

WeakMap と WeakSet

WeakMapWeakSet には、データ構造がよく似た弱くない方の API、MapSet があります。WeakMap はキーと値のペアの集合を、WeakSet は固有の値の集合を保持することができ、どちらも追加、削除、問い合わせの実行が可能である。

WeakMapWeakSet は、weakly held 値の概念から取った名前です。xy によって弱く保持されている場合、x の値には y を介してアクセスできますが、マークアンドスイープアルゴリズムでは、何か強く保持するものがなければ x に到達できるとは考えないということを意味しています。ここで議論するものを除くほとんどのデータ構造は、合格したオブジェクトをいつでも取り出せるように強く保持します。WeakMapWeakSet のキーは、プログラム内でそのキーを参照しているものがない限り、ガベージコレクションすることができます(WeakMap オブジェクトの場合、値もガベージコレクションの対象となります)。これは、2 つの特徴によって確実に保持されます。

  • WeakMapWeakSet はオブジェクトのみを格納することができます。これは、オブジェクトだけがガベージコレクションされるからです。プリミティブ値は常に偽造することができるので(つまり、1 === 1 でも {} !== {})、永遠に集合に留まることになります。
  • WeakMapWeakSet は反復可能なオブジェクトではありません。このため、Array.from(map.keys()).lengthを使用してオブジェクトの生存率を監視したり、ガベージコレクションの対象となるような任意のキーを取得することができません。(ガベージコレクションは使用可能な限り不可視であるべきです。)

WeakMapWeakSet の典型的な説明(上記のようなもの)では、キーが最初にガベージコレクションされ、値も同様にガベージコレクションのために無料になることが暗示されています。しかし、値をキーに参照する場合を考えてみましょう。

js
const wm = new WeakMap();
const key = {};
wm.set(key, { key });
// これで `key` はガベージコレクションできなくなりました。
// 値はキーへの参照を保持し、値はマップに強く保持されて
// いるからです。

もし key が実際の参照として格納されると、循環参照を作成し、他に key を参照するものがない場合でも、キーと値の両方をガベージコレクションの対象外にしてしまいます。もし key がガベージコレクションされると、具体的なある瞬間に value.key が存在しないアドレスを指すことになり、これは不正な状態であるため。これを修正するために、WeakMapWeakSet の項目は実際の参照ではなく、マークアンドスイープ機構を強化したエフェメロンです。Barros らは、このアルゴリズムの良い概要を提供しています(4 ページ目)。一段落を引用します。

エフェメロンは弱いペアを改良したもので、鍵も値も弱いとも強いとも分類できません。鍵の接続性は値の接続性を決定しますが、値の接続性は鍵の接続性には影響しません。 […] ガベージコレクションがエフェメロンに対応する場合、2 つのフェーズ(マークとスイープ)ではなく、3 つのフェーズで発生します。)

大まかなメンタルモデルとして、WeakMap は以下のような実装だと考えてください。

警告: これはポリフィルではなく、エンジンで実装されている方法(ガベージコレクション機構にフックしている)にも近いものです。

js
class MyWeakMap {
  #marker = Symbol("MyWeakMapData");
  get(key) {
    return key[this.#marker];
  }
  set(key, value) {
    key[this.#marker] = value;
  }
  has(key) {
    return this.#marker in key;
  }
  delete(key) {
    delete key[this.#marker];
  }
}

ご覧のように、MyWeakMapは実際にはキーの集合を保持することはありません。単に、合格した各オブジェクトにメタデータを追加するだけです。そして、そのオブジェクトはマークアンドスイープによってガベージコレクションされます。したがって、WeakMap内のキーを反復処理したり、WeakMapをクリアしたりすることはできない(これもキーコレクション全体の知識に頼っているからである)。

これらの API の詳細については、キー付きコレクション のガイドを参照してください。

WeakRefs と FinalizationRegistry

メモ: WeakRefFinalizationRegistry は、ガベージコレクション機構に直接触れる機会を提供します。実行時の意味づけが完全に保証されていないため、可能な限り使用しないでください

オブジェクトを値とする変数はすべて、そのオブジェクトを参照しています。しかし、このような参照は 強い ものであり、その存在によってガベージコレクタがそのオブジェクトを収集対象としてマークすることができなくなります。WeakRef は、オブジェクトへの弱い参照で、オブジェクトをガベージコレクションすることができ、かつオブジェクトが生きている間にそのコンテンツを読むことができるようにします。

WeakRef の用途として、文字列の URL を大きなオブジェクトに割り当てるキャッシュシステムがあります。そのために WeakMap を使用することはできません。なぜなら、WeakMap オブジェクトは キー を弱く保持しますが、 は弱く保持しないからです。キーにアクセスすれば、常に決定論的に値が取得されます(キーにアクセスしていることは、それがまだ生きていることを意味するからです)。ここで、キーに対して undefined を取得しても(対応する値が使えなくなった場合)、再計算すればよいので問題ありませんが、到達できないオブジェクトがキャッシュに残ってしまうのは困ります。この場合、通常の Map を使用しますが、各値は実際のオブジェクトの値ではなく、オブジェクトの WeakRef となります。

js
function cached(getter) {
  // 文字列の URL から結果の WeakRef へ割り当てられたマップ
  const cache = new Map();
  return async (key) => {
    if (cache.has(key)) {
      return cache.get(key).deref();
    }
    const value = await getter(key);
    cache.set(key, new WeakRef(value));
    return value;
  };
}

const getImage = cached((url) => fetch(url).then((res) => res.blob()));

FinalizationRegistry は、ガベージコレクションを監視するさらに強力なメカニズムを提供します。これは、オブジェクトを登録し、それらがガベージコレクションされたときに通知されるようにするものです。例えば、上記のキャッシュシステムでは、Blob 自体が集合に無料であっても、それを保持する WeakRef オブジェクトはそうではなく、時間とともに Map に多くの無駄な項目が蓄積される可能性があります。FinalizationRegistryを使用することで、このような用途のクリーンアップを行うことができます。

js
function cached(getter) {
  // 文字列の URL から結果の WeakRef へ割り当てられたマップ
  const cache = new Map();
  // 値がガベージコレクションされた後、毎回、キャッシュ内の
  // キーを引数にコールバックが呼び出され、キャッシュの項目を
  // 削除することができる
  const registry = new FinalizationRegistry((key) => {
    // 注: WeakRef が本当に空であることをテストすることが重要です。
    // さもなければ、このキーで新しいオブジェクトが追加され、その生きている新しい
    // オブジェクトが削除された後にコールバックが呼び出される可能性があります。
    if (!cache.get(key)?.deref()) {
      cache.delete(key);
    }
  });
  return async (key) => {
    if (cache.has(key)) {
      return cache.get(key).deref();
    }
    const value = await getter(key);
    cache.set(key, new WeakRef(value));
    registry.register(value, key);
    return value;
  };
}

const getImage = cached((url) => fetch(url).then((res) => res.blob()));

パフォーマンスとセキュリティの関係で、コールバックがいつ呼び出されるか、あるいはすべて呼び出されるかどうかは保証されていません。コールバックはクリーンアップにのみ使用すべきであり、しかも重要でないクリーンアップにのみ使用すべきです。他にも、try...finally で常に実行される finally ブロックで実行するなど、より決定的にリソースを管理する方法があります。WeakRefFinalizationRegistry は、長時間実行するプログラムのメモリー使用量を最適化するためだけに存在します。

WeakRef および FinalizationRegistry の API について詳しくは、それぞれのリファレンスページを参照してください。