閉じる

クロージャはなぜメモリリークの原因になりうるのかについてわかりやすく解説

「クロージャがメモリリークの原因になる」と聞くと、少し怖く感じるかもしれません。

しかし、仕組みを丁寧に追いかけていくと、どのようなときに危険になり、どう避ければよいのかがはっきり見えてきます。

本記事では、JavaScriptを例にクロージャとメモリの関係を図解とサンプルコードを交えながら解説し、最後に実務で役立つ防止策まで整理します。

クロージャとは何か

クロージャの基本概念とは

まずは前提となるクロージャの定義から整理します。

クロージャとは、関数が定義されたときのスコープ(環境)を、関数の外に出たあとも保持し続ける仕組みです。

もう少し噛み砕くと、「関数の外側の変数を、あとからも参照できるようにした関数」と考えるとイメージしやすくなります。

典型的な例を見てみましょう。

JavaScript
function createCounter() {
  let count = 0; // 外側の変数

  function increment() {
    count += 1;
    console.log(count);
  }

  return increment; // 内側の関数を返す
}

const counter = createCounter();
counter(); // 1
counter(); // 2

このコードでは、counter は createCounter の内部で定義された increment 関数ですが、外から呼び出してもcount 変数の値が保持されています。

ここで起きているのがクロージャです。

このように、関数が自分の生まれた環境(スコープ)への参照を持ち続けることが、クロージャの本質と言えます。

関数スコープと変数の寿命をおさらい

クロージャとメモリリークの関係を理解するには、「変数がいつまでメモリに残るのか」を押さえる必要があります。

通常、JavaScriptでは次のように変数の寿命が決まります。

  • 関数の中で宣言された変数(let / const / var)は、その関数の実行が終わると不要になる
  • 不要になった変数は、他から参照されていなければガーベジコレクション(GC)によって解放される

単純な関数だけであれば、処理が終わるタイミングでローカル変数は消えるイメージです。

JavaScript
function add(a, b) {
  const result = a + b;
  return result;
}

add(1, 2); // この呼び出しが終わると、a, b, result は不要になる

この例では、add 関数の実行が終わったあとに a, b, result を使う手段はありません

したがって、GCはこれらの変数を含むスコープを安全に解放できます。

なぜクロージャは「外側の変数」を覚えていられるのか

クロージャの場合は事情が異なります。

先ほどの counter の例に戻ると、createCounter 関数の実行はすでに終わっていますが、それでも count 変数は残り続けています。

なぜでしょうか。

理由はシンプルで、返り値として外に出た関数(counter)が、count を含むスコープを参照し続けているからです。

JavaScriptエンジンは、関数が参照している外側スコープをまとめて「環境(環境レコードやクロージャ環境)」としてヒープ上に確保します。

この環境オブジェクトが、クロージャによってキャプチャされた変数の実体です。

この「環境」がいつ解放されるかが、メモリリークと深く関わってきます。

クロージャとメモリの関係を理解する

JavaScriptのガーベジコレクション(GC)の仕組み

JavaScriptのメモリ管理は自動で行われます。

ガーベジコレクション(GC)は「到達不能になったオブジェクト」を検出して解放します。

多くのエンジンでは、マーク・アンド・スイープ(mark-and-sweep)アルゴリズムがベースになっています。

ざっくり言うと次のようなイメージです。

  1. まず「ルート」と呼ばれる起点から探索を始める
    1. 代表的なルート: グローバルオブジェクト(window など)、コールスタック上の変数、実行中のクロージャなど
  2. これらから辿れるオブジェクトに「マーク」を付ける
  3. マークが付いていないオブジェクトは「どこからも辿れない」とみなし、メモリから解放する

ポイントは、「参照があるかどうか」ではなく「ルートから辿れるかどうか」が基準ということです。

参照が残るとメモリが解放されない理由

ここで、メモリリークに直結する重要な点が見えてきます。

あるオブジェクトが、ルートから辿れるどこかに参照を持たれている限り、GCはそれを解放できません

これを裏返すと、「本当はもう使わないのに、どこかから参照してしまっているオブジェクト」はメモリに居座り続ける、ということになります。

クロージャが問題になるのは、この「どこかからの参照」を意図せず生み出してしまいやすいからです。

具体的には次のような構図になります。

  • クロージャが外側スコープの環境を参照する
  • そのクロージャ(関数オブジェクト)を、グローバルや長寿命のオブジェクト(イベントリスナー、タイマーなど)が保持する
  • 結果として外側スコープの環境が GC の対象にならない

クロージャがキャプチャする変数とヒープメモリ

JavaScriptエンジン実装の詳細は異なりますが、概念的には次のように理解しておくとよいです。

  • 関数のローカル変数は、通常は「スタックフレーム」の中で管理される
  • しかしクロージャがその変数を参照するとき、変数はヒープ上の「環境オブジェクト」に移される(または最初からそこに置かれる)
  • この環境オブジェクトが、クロージャとともに長く生きるメモリ領域になる

このヒープ上の環境オブジェクトに、意図せず巨大なデータや本来不要になったオブジェクトをぶら下げてしまうと、メモリリークの温床になります。

クロージャがメモリリークの原因になりうる具体例

ここからは、現場で遭遇しやすいパターンを具体的に見ていきます。

いずれも共通するのは「長生きする何かに、クロージャを登録し、そのクロージャが不要なデータを掴んでいる」という構図です。

不要になったイベントリスナーとクロージャ

イベントリスナーは、DOM要素が存在する限り呼び出される可能性があります。

このとき、イベントハンドラとして登録した関数がクロージャになっていると、意図せずメモリリークの原因になることがあります。

JavaScript
function registerHandler() {
  const bigData = new Array(1000000).fill("x"); // 大きな配列

  document.getElementById("btn").addEventListener("click", () => {
    console.log(bigData[0]); // bigData をキャプチャするクロージャ
  });
}

registerHandler();

ここで問題になるのは、次の組み合わせです。

  • イベントリスナーは DOM 要素(btn)に紐づいて長生きする
  • イベントハンドラのクロージャは、その外側スコープのbigData をキャプチャしている
  • 結果として、ボタンが存在する限り、巨大な bigData 配列は解放されない

さらに悪いケースとして、SPAなどでボタン要素自体を削除しても、ライブラリや実装のミスでイベントリスナーが解除されていないと、DOMは解放されず、そこから辿れるクロージャと bigData もメモリに残り続けてしまいます。

タイマー(setTimeout・setInterval)とクロージャの注意点

setTimeout や setInterval に渡した関数も、内部的には「スケジューラ」に保持されるため、同じ構図の問題が起こり得ます。

JavaScript
function startTimer() {
  const cache = createHugeCache(); // 仮にかなり大きいオブジェクト

  const id = setInterval(() => {
    console.log("tick", cache.status);
  }, 1000);

  // どこかで clearInterval(id) し忘れると…
}

このコードでは、

  • setInterval のコールバックが cache をキャプチャしている
  • clearInterval されない限り、タイマーは永遠に生き続ける
  • したがって、cache も永遠に GC 対象にならない

という状態になります。

特に SPA やフロントエンド長時間稼働の環境では、解除し忘れたタイマー + クロージャによるメモリリークが蓄積し、パフォーマンス低下やクラッシュの原因になります。

大きなオブジェクトをキャプチャするクロージャの落とし穴

クロージャ自体は便利ですが、必要以上に大きなオブジェクトをキャプチャすると、解放タイミングを誤ったときのダメージが大きくなります

JavaScript
function createHandler(hugeList) {
  return function handleClick() {
    // hugeList の一部しか使っていないように見える
    console.log(hugeList[0]);
  };
}

const hugeList = new Array(1000000).fill("data");
const handler = createHandler(hugeList);

document.addEventListener("click", handler);
// hugeList = null; // こうしても leak の可能性は残る

ここで hugeList = null と代入しても、handler が hugeList を含むスコープを参照しているため、実際には hugeList は GC されません。

このパターンを避けるには、キャプチャするスコープを小さく保つ、あるいは必要な情報だけをコピーして持つといった工夫が必要です。

防止策は後半で整理します。

グローバル変数的に使われるクロージャとメモリリーク

もう1つ見落としがちなのが、「グローバルのどこかにクロージャを保存しておく」というパターンです。

たとえば次のようなコードです。

JavaScript
const handlers = [];

function registerUserHandler(user) {
  handlers.push(function () {
    console.log("User:", user.name);
  });
}

// 多数のユーザーに対してハンドラを登録
users.forEach(user => registerUserHandler(user));

この例では、handlers 配列がグローバルに存在している限り、配列の中に格納されたクロージャも残り続けます。

そしてそのクロージャが参照するuser オブジェクトもGCされません

とくに、「一度登録すると二度と使わない・削除しない」グローバルなリストにクロージャを溜め込んでいく設計は、長期運用のアプリケーションで深刻なメモリリークを引き起こす可能性があります。

クロージャによるメモリリークを防ぐベストプラクティス

ここまで見てきたように、クロージャ自体は悪者ではありません。

「どこにクロージャを保持するか」と「何をキャプチャするか」を意識すれば、メモリリークのリスクを大幅に下げることができます。

使い終わったクロージャの参照を明示的に切る

最も基本的な対策は、「もう使わないクロージャへの参照は必ず手動で切る」ことです。

これは配列・オブジェクト・クラスプロパティなどあらゆる場所に当てはまります。

JavaScript
let handler = createHandler();

// どこかで handler を使い終わったあと
handler = null; // 参照を切ることで、GC の対象にできる

特に、長寿命のオブジェクト(シングルトン、グローバルストアなど)のプロパティとしてクロージャを格納している場合は要注意です。

使い終わったタイミングでプロパティを null にする / delete するようにしましょう。

イベントリスナーやタイマーを適切に解除する

イベントリスナーとタイマーは、クロージャと組み合わさったときにメモリリークを引き起こしやすい代表格です。

登録したら必ず解除するという癖をつけましょう。

イベントリスナーの解除

JavaScript
function attach() {
  const bigData = createBigData();

  function onClick() {
    console.log(bigData.summary);
  }

  const btn = document.getElementById("btn");
  btn.addEventListener("click", onClick);

  return () => {
    // クリーンアップ関数
    btn.removeEventListener("click", onClick);
  };
}

const cleanup = attach();
// 後で必要なくなったとき
cleanup();

イベント登録と解除をセットで設計し、「クリーンアップ関数」を返すパターンは、React などのフレームワークでも一般的です。

このように構造化しておくと、解除し忘れを防ぎやすくなります。

タイマーの解除

JavaScript
function startPolling() {
  const cache = createHugeCache();

  const id = setInterval(() => {
    console.log("polling", cache.status);
  }, 1000);

  return () => {
    clearInterval(id); // タイマーを止める
  };
}

const stop = startPolling();
// もう不要になったら
stop();

コンポーネントのアンマウントや画面遷移のタイミングで、必ず clearInterval / clearTimeout を呼ぶようにしましょう。

必要以上のデータをクロージャでキャプチャしない書き方

そもそもクロージャに渡す情報量を減らすことで、リーク時の被害を小さくし、リーク発生確率自体も下げられます

具体的なテクニックをいくつか紹介します。

1. 大きなオブジェクトをまるごと持たない

JavaScript
// 悪い例: hugeObject をまるごとキャプチャしている
function createHandler(hugeObject) {
  return () => {
    console.log(hugeObject.user.name);
  };
}

// 改善例: 必要な情報だけコピーしておく
function createHandlerBetter(hugeObject) {
  const userName = hugeObject.user.name;
  return () => {
    console.log(userName);
  };
}

必要なプロパティだけを取り出してスカラー値として保持することで、巨大なオブジェクト全体を環境にぶら下げないようにできます。

2. ループ内でのクロージャに注意する

ループで大量にクロージャを生成する場合、外側スコープに重いデータを置かないことも重要です。

JavaScript
// 悪い例
function setupHandlers(items) {
  const bigConfig = createBigConfig();

  items.forEach(item => {
    item.onClick = () => {
      console.log(item.id, bigConfig.option);
    };
  });
}

// 改善例: bigConfig を切り離す
function setupHandlersBetter(items, option) {
  items.forEach(item => {
    item.onClick = () => {
      console.log(item.id, option);
    };
  });
}

クロージャが参照する外側スコープを、できるだけ軽量に設計することを意識してください。

開発ツールでメモリリークを検出・確認する方法

理屈を理解していても、実際のアプリケーションでは思わぬところでリークが発生することがあります。

そこで重要になるのがブラウザ開発ツールを使った検証です。

ここでは Chrome DevTools を例に流れを説明します。

1. Heap snapshot でオブジェクトの残り具合を確認

  1. Chrome DevTools を開く(F12 など)
  2. “Memory” タブを開く
  3. “Heap snapshot” を選択
  4. 「Take snapshot」をクリックして、現時点のメモリ状況を保存
  5. 画面操作(ページ遷移やコンポーネントアンマウントなど)を行ったあと、再度 snapshot を取得
  6. 2つの snapshot を比較し、本来解放されるべきオブジェクトが残っていないかを確認

クロージャが関係している場合、「Closures」や「(closure)」と表示されるエントリから、どの変数がどこから参照され続けているかを追うことができます。

2. Allocation instrumentation on timeline で増え続けるオブジェクトを確認

同じく Memory タブの “Allocation instrumentation on timeline” を使うと、時間経過とともにどのようなオブジェクトが増えているかを観察できます。

  • オプションを選択して「Start」をクリック
  • 問題が起こりそうな操作を繰り返す
  • 「Stop」をクリックし、グラフを確認
  • メモリ使用量が右肩上がりになっていないか、どの種類のオブジェクトが溜まっているかを調べる

クロージャが原因の場合、同じ種類の関数オブジェクトや環境が増え続けているといった兆候が見られることがあります。

まとめ

クロージャは、状態を持った関数を簡潔に書ける強力な機能ですが、その裏側では「外側スコープをヒープ上に残し続ける」という挙動があります。

この性質と、長寿命のオブジェクト(イベントリスナー、タイマー、グローバル配列など)への参照が組み合わさることで、メモリリークが発生しやすくなります。

本記事で押さえたポイントを整理すると、次のようになります。

  • クロージャは、定義時のスコープ(環境)を、関数が外に出たあとも保持する
  • JavaScript の GC は「ルートから辿れないオブジェクト」を解放するため、どこかから参照されている限りメモリは解放されない
  • イベントリスナーやタイマー、グローバル配列などがクロージャを保持し、そのクロージャが大きなオブジェクトをキャプチャしていると、メモリリークになりうる
  • 防止策として、
    • 使い終わったクロージャへの参照を明示的に切る
    • イベントリスナー・タイマーを適切なタイミングで解除する
    • 必要以上に大きなオブジェクトをクロージャでキャプチャしない
    • 開発ツール(Heap snapshot など)で定期的にメモリ状況を確認する : ことが有効

「クロージャ = 危険」ではなく、「クロージャ + 長寿命の参照 + 大きなデータ = 危険」という構図を理解しておけば、安心してクロージャを活用できます。

コードを書くときには、そのクロージャはどこに保存され、どのくらいの期間生きるのかを意識しながら、設計と実装を進めていくことが大切です。

クラウドSSLサイトシールは安心の証です。

URLをコピーしました!