T-CREATOR

JavaScript メモリリーク診断術:DevTools の Heap スナップショット徹底活用

JavaScript メモリリーク診断術:DevTools の Heap スナップショット徹底活用

JavaScript でアプリケーションを開発していると、時間が経つにつれてブラウザの動作が重くなったり、メモリ使用量が増加し続けたりする現象に遭遇することがあります。これらの症状の多くは メモリリーク が原因です。

メモリリークは、不要になったオブジェクトがガベージコレクションされずにメモリ上に残り続ける現象で、放置すると深刻なパフォーマンス問題を引き起こします。しかし、適切な診断ツールと手法を使えば、効率的にメモリリークを発見し、解決することができるのです。

本記事では、Chrome DevTools の Heap スナップショット 機能を活用した、実践的なメモリリーク診断術を詳しく解説いたします。基礎知識から応用テクニックまで、段階的に学べる構成でお届けいたします。

メモリリークの基礎知識

メモリリークとは何か

メモリリークとは、プログラムが使用したメモリ領域を適切に解放せず、そのメモリが再利用できない状態になる現象のことです。

JavaScript のようなガベージコレクション機能を持つ言語では、通常は不要になったオブジェクトが自動的に削除されますが、オブジェクト間の参照関係が複雑になると、本来不要なオブジェクトがメモリに残り続けてしまいます。

以下の図は、メモリリークが発生する基本的な仕組みを示しています。

mermaidflowchart TB
    app[アプリケーション] -->|オブジェクト生成| mem[メモリ領域]
    mem -->|正常な場合| gc[ガベージコレクション]
    gc -->|解放| free[空きメモリ]

    mem -->|リーク発生| leak[参照が残存]
    leak -->|蓄積| overflow[メモリ不足]

    style leak fill:#ff9999
    style overflow fill:#ff6666

正常な場合は不要なオブジェクトがガベージコレクションによって解放されますが、リークが発生すると参照が残存し、メモリ不足につながります。

JavaScript でメモリリークが発生する仕組み

JavaScript では、以下のような状況でメモリリークが発生しやすくなります。

循環参照パターン

最も典型的なメモリリークの原因は、オブジェクト間の循環参照です。

javascript// 循環参照の例
function createCircularReference() {
  const obj1 = {};
  const obj2 = {};

  // 相互に参照を持つ
  obj1.ref = obj2;
  obj2.ref = obj1;

  // 関数終了後もオブジェクトが解放されない
  return obj1;
}

この例では、obj1obj2 が相互に参照しているため、ガベージコレクターがこれらのオブジェクトを削除できません。

グローバル変数への意図しない代入

予期しないグローバル変数の作成も、メモリリークの原因となります。

javascript// 意図しないグローバル変数の作成
function createAccidentalGlobal() {
  // 'var' や 'let' を使わないとグローバル変数になる
  accidentalGlobal = 'これはグローバル変数になってしまう';

  // this の誤用もグローバル変数を作成する
  this.anotherGlobal = 'これもグローバル変数';
}

イベントリスナーの削除忘れ

イベントリスナーを適切に削除しないと、関連するオブジェクトがメモリに残り続けます。

javascript// イベントリスナーのメモリリーク例
function setupEventListener() {
  const element = document.getElementById('button');
  const data = new Array(1000000).fill('large data');

  // イベントリスナーがdata配列への参照を保持
  element.addEventListener('click', function () {
    console.log(data.length);
  });

  // element削除時にリスナーを削除しないとdataも残る
}

メモリリークが引き起こす問題

メモリリークは段階的に深刻な問題を引き起こします。以下の表に主な影響をまとめました。

段階症状影響度対処の緊急度
1アプリケーションの動作が重くなる軽微
2ブラウザのレスポンス低下中程度
3メモリ使用量の継続的増加深刻
4ブラウザのクラッシュ致命的最高

初期段階の影響

メモリリークの初期段階では、以下のような症状が現れます。

  • アニメーションのフレームレート低下
  • ユーザーインタラクションの遅延
  • CPU 使用率の上昇

進行した場合の深刻な影響

メモリリークが進行すると、以下のような深刻な問題が発生します。

javascript// メモリリークの影響を示すシミュレーション
function simulateMemoryLeak() {
  const leakedObjects = [];

  setInterval(() => {
    // 1秒ごとに大きなオブジェクトを作成し続ける
    const largeObject = {
      data: new Array(100000).fill(Math.random()),
      timestamp: Date.now(),
    };

    // 配列に追加するが削除しない(メモリリーク)
    leakedObjects.push(largeObject);

    console.log(
      `リークしたオブジェクト数: ${leakedObjects.length}`
    );
  }, 1000);
}

このコードを実行すると、時間の経過とともにメモリ使用量が増加し続け、最終的にはブラウザのクラッシュを引き起こします。

DevTools Heap スナップショットの基本

Heap スナップショットとは

Heap スナップショットは、特定の時点でのメモリ使用状況を記録する機能です。JavaScript のヒープ(動的メモリ領域)内のすべてのオブジェクトの詳細情報を含んでいます。

ヒープスナップショットには以下の情報が含まれます。

  • オブジェクトの種類と数量
  • メモリ使用量
  • オブジェクト間の参照関係
  • ガベージコレクションの状態

以下の図は、Heap スナップショットが捉える情報の概要を示しています。

mermaidflowchart LR
    snapshot[Heap スナップショット] --> objects[オブジェクト情報]
    snapshot --> memory[メモリ使用量]
    snapshot --> references[参照関係]

    objects --> dom[DOM 要素]
    objects --> functions[関数]
    objects --> arrays[配列]
    objects --> closures[クロージャー]

    memory --> allocated[割り当て済み]
    memory --> retained[保持サイズ]
    memory --> shallow[シャローサイズ]

    references --> parents[親オブジェクト]
    references --> children[子オブジェクト]
    references --> cycles[循環参照]

Chrome DevTools での基本操作

Chrome DevTools でヒープスナップショットを取得する基本手順を説明します。

DevTools の起動

まず、Chrome DevTools を開きます。

javascript// 対象となるWebページで以下のキーを押下
// Windows/Linux: F12 または Ctrl + Shift + I
// Mac: Cmd + Option + I

Memory タブへのアクセス

DevTools が開いたら、Memory タブをクリックします。Memory タブが表示されていない場合は、タブ右側の「>>」をクリックして選択してください。

スナップショット取得の基本設定

Memory タブでは、3 つの主要な機能が利用できます。

javascript// Memory タブの主要機能
const memoryFeatures = {
  heapSnapshot: {
    name: 'Heap snapshot',
    purpose: '特定時点のメモリ使用状況を記録',
    useCase: 'メモリリークの発見と分析',
  },
  allocationInstrumentation: {
    name: 'Allocation instrumentation on timeline',
    purpose: '時系列でのメモリ割り当てを記録',
    useCase: 'リアルタイムでのメモリ変化監視',
  },
  allocationSampling: {
    name: 'Allocation sampling',
    purpose: 'サンプリングベースでのメモリ割り当て記録',
    useCase: '軽量なメモリ監視',
  },
};

Memory タブの使い方

Memory タブの各セクションの詳細な使い方を解説します。

スナップショット取得ボタン

"Take snapshot" ボタンをクリックすると、現在のヒープ状態のスナップショットが作成されます。

javascript// スナップショット取得のタイミング例
function takeSnapshotExample() {
  // 1. アプリケーション起動直後
  console.log('初期スナップショット取得タイミング');

  // 2. 特定の操作後
  document
    .getElementById('heavy-operation')
    .addEventListener('click', () => {
      console.log(
        '重い処理後のスナップショット取得タイミング'
      );
    });

  // 3. 定期的な監視
  setInterval(() => {
    console.log('定期監視のスナップショット取得タイミング');
  }, 60000); // 1分ごと
}

スナップショット一覧の管理

取得したスナップショットは左側のパネルに一覧表示されます。各スナップショットには以下の情報が表示されます。

項目内容重要度
Snapshot 番号取得順序
サイズ総メモリ使用量
オブジェクト数ヒープ内のオブジェクト総数
取得時刻スナップショット作成時刻

ビューモードの切り替え

スナップショットの表示方法は、以下の 4 つのビューモードから選択できます。

javascript// ビューモードの特徴
const viewModes = {
  summary: {
    name: 'Summary',
    description: 'コンストラクター別のオブジェクト集計',
    bestFor: '全体的なメモリ使用状況の把握',
  },
  comparison: {
    name: 'Comparison',
    description: '2つのスナップショット間の差分表示',
    bestFor: 'メモリリークの検出',
  },
  containment: {
    name: 'Containment',
    description: 'オブジェクトの参照関係をツリー表示',
    bestFor: '参照チェーンの追跡',
  },
  statistics: {
    name: 'Statistics',
    description: 'メモリ使用量の統計情報',
    bestFor: 'メモリ使用パターンの分析',
  },
};

Heap スナップショット診断の実践手順

スナップショットの取得タイミング

効果的なメモリリーク診断を行うには、適切なタイミングでスナップショットを取得することが重要です。

基本的な取得パターン

メモリリーク診断では、以下の 3 つのタイミングでスナップショットを取得するのが基本です。

javascript// 基本的なスナップショット取得パターン
class MemoryDiagnostics {
  constructor() {
    this.snapshots = [];
  }

  // 1. ベースライン取得(アプリケーション起動直後)
  takeBaselineSnapshot() {
    console.log('ベースラインスナップショット取得');
    // この時点でのメモリ使用量を基準とする
  }

  // 2. 操作後スナップショット取得
  takeAfterOperationSnapshot(operationName) {
    console.log(
      `${operationName} 後のスナップショット取得`
    );
    // 特定の操作後のメモリ変化を記録
  }

  // 3. ガベージコレクション後スナップショット取得
  takeAfterGCSnapshot() {
    // 手動でガベージコレクションを実行
    if (window.gc) {
      window.gc();
    }
    console.log('GC後のスナップショット取得');
  }
}

タイミング最適化のための準備

スナップショット取得前には、以下の準備を行うことで、より正確な診断が可能になります。

javascript// スナップショット取得前の準備
function prepareForSnapshot() {
  // 1. 不要なタイマーやイベントリスナーをクリア
  clearAllTimers();

  // 2. 非同期処理の完了を待つ
  return Promise.all(pendingPromises).then(() => {
    // 3. 手動ガベージコレクション実行(開発時のみ)
    if (
      window.gc &&
      process.env.NODE_ENV === 'development'
    ) {
      window.gc();
    }
  });
}

function clearAllTimers() {
  // 設定されているタイマーを全てクリア
  const maxTimerId = setTimeout(() => {}, 0);
  for (let i = 0; i < maxTimerId; i++) {
    clearTimeout(i);
    clearInterval(i);
  }
}

スナップショットの比較方法

複数のスナップショットを比較することで、メモリリークの発生箇所を特定できます。

Comparison ビューの使用方法

以下の手順で、スナップショット間の差分を確認します。

javascript// スナップショット比較の手順
class SnapshotComparison {
  constructor() {
    this.snapshots = new Map();
  }

  // スナップショットを名前付きで保存
  saveSnapshot(name, snapshot) {
    this.snapshots.set(name, {
      snapshot: snapshot,
      timestamp: Date.now(),
      memoryUsage: performance.memory
        ? {
            used: performance.memory.usedJSHeapSize,
            total: performance.memory.totalJSHeapSize,
            limit: performance.memory.jsHeapSizeLimit,
          }
        : null,
    });
  }

  // 2つのスナップショットを比較
  compareSnapshots(baseline, current) {
    const baselineData = this.snapshots.get(baseline);
    const currentData = this.snapshots.get(current);

    if (!baselineData || !currentData) {
      throw new Error(
        '比較対象のスナップショットが見つかりません'
      );
    }

    return {
      timeDifference:
        currentData.timestamp - baselineData.timestamp,
      memoryIncrease:
        currentData.memoryUsage.used -
        baselineData.memoryUsage.used,
      percentageIncrease:
        ((currentData.memoryUsage.used -
          baselineData.memoryUsage.used) /
          baselineData.memoryUsage.used) *
        100,
    };
  }
}

比較結果の解釈方法

比較結果では、以下の指標に注目します。

指標意味危険度判定基準
# New新しく作成されたオブジェクト数100 個以上で注意
# Deleted削除されたオブジェクト数新規作成数と大幅に差がある場合は危険
# Deltaオブジェクト数の変化継続的な増加は危険
Alloc. Size新規割り当てサイズ1MB 以上で注意
Freed Size解放されたサイズ割り当てサイズと大幅に差がある場合は危険
Size Deltaメモリサイズの変化継続的な増加は危険

メモリ使用量の読み方

スナップショットに表示される各種メモリ情報の読み方を詳しく解説します。

Shallow Size と Retained Size

メモリサイズには 2 つの重要な概念があります。

javascript// Shallow Size と Retained Size の概念
class MemorySizeExplanation {
  constructor() {
    // Shallow Size: オブジェクト自体のサイズ
    this.shallowSize =
      'オブジェクト自体が占有するメモリサイズ';

    // Retained Size: そのオブジェクトが削除された場合に解放されるメモリサイズ
    this.retainedSize =
      'そのオブジェクトと、そのオブジェクトからのみ参照されるオブジェクトの合計サイズ';
  }

  // 例:親オブジェクトと子オブジェクトの関係
  demonstrateRetainedSize() {
    const parentObject = {
      name: 'parent', // Shallow Size: この文字列のサイズ
      children: [], // Shallow Size: 配列オブジェクト自体のサイズ
    };

    // 子オブジェクトを追加
    for (let i = 0; i < 1000; i++) {
      parentObject.children.push({
        id: i,
        data: new Array(100).fill(i),
      });
    }

    // parentObject の Retained Size は:
    // - parentObject の Shallow Size
    // - children 配列の Shallow Size
    // - 全ての子オブジェクトの Shallow Size の合計
    return parentObject;
  }
}

オブジェクトカウントの意味

スナップショットに表示されるオブジェクト数の意味を理解することも重要です。

javascript// オブジェクトカウントの詳細
function analyzeObjectCounts() {
  return {
    // Constructor 列: オブジェクトのコンストラクター名
    constructorTypes: {
      Array: '配列オブジェクト',
      Object: '一般的なオブジェクト',
      HTMLDivElement: 'div要素のDOMオブジェクト',
      Closure: 'クロージャー',
      String: '文字列オブジェクト',
    },

    // # Objects 列: 該当タイプのオブジェクト数
    objectCount:
      '同じコンストラクターで作成されたオブジェクトの総数',

    // Shallow Size 列: 全オブジェクトのShallow Sizeの合計
    totalShallowSize:
      '該当タイプの全オブジェクトのShallow Sizeの合計',

    // Retained Size 列: 全オブジェクトのRetained Sizeの合計
    totalRetainedSize:
      '該当タイプの全オブジェクトのRetained Sizeの合計',
  };
}

以下の図は、メモリサイズの概念を視覚的に表現しています。

mermaidflowchart TD
    parent[親オブジェクト<br/>Shallow: 100B] --> child1[子オブジェクト1<br/>Shallow: 50B]
    parent --> child2[子オブジェクト2<br/>Shallow: 50B]
    child1 --> grandchild[孫オブジェクト<br/>Shallow: 30B]

    external[外部参照] --> child2

    parent2[親オブジェクト<br/>Retained Size<br/>= 100 + 50 + 30 = 180B]
    parent3[親オブジェクト<br/>Retained Size<br/>= 100 + 50 = 150B<br/>(子オブジェクト2は外部参照があるため含まない)]

    style parent fill:#e1f5fe
    style child2 fill:#fff3e0
    style external fill:#f3e5f5

親オブジェクトの Retained Size は、外部参照の有無によって変わることがわかります。

具体的な診断テクニック

DOM リーク検出方法

DOM 要素のメモリリークは、Web アプリケーションで最も頻繁に発生する問題の一つです。適切な検出手法を身につけることで、効率的に問題を解決できます。

DOM リークの典型的なパターン

DOM 要素がメモリリークを起こす主な原因は以下の通りです。

javascript// DOM リークの典型的な例
class DOMLeakExamples {
  constructor() {
    this.leakedElements = [];
  }

  // パターン1: DOM要素への直接参照保持
  createDirectReference() {
    const element = document.createElement('div');
    element.innerHTML = '<span>大量のデータ</span>'.repeat(
      1000
    );
    document.body.appendChild(element);

    // DOM要素への参照を保持
    this.leakedElements.push(element);

    // DOM から削除しても、参照が残っているためメモリリーク
    document.body.removeChild(element);
  }

  // パターン2: イベントリスナーの削除忘れ
  createEventListenerLeak() {
    const button = document.createElement('button');
    const largeData = new Array(100000).fill('data');

    // クロージャーが大きなデータへの参照を保持
    const clickHandler = () => {
      console.log(`データサイズ: ${largeData.length}`);
    };

    button.addEventListener('click', clickHandler);
    document.body.appendChild(button);

    // ボタン削除時にリスナーを削除しない
    setTimeout(() => {
      document.body.removeChild(button);
      // button.removeEventListener('click', clickHandler); // これを忘れる
    }, 1000);
  }

  // パターン3: DOM要素間の循環参照
  createCircularReference() {
    const parent = document.createElement('div');
    const child = document.createElement('span');

    // DOM構造での親子関係
    parent.appendChild(child);

    // JavaScript オブジェクトでの循環参照
    parent.customChild = child;
    child.customParent = parent;

    document.body.appendChild(parent);

    // DOM から削除しても循環参照により解放されない
    document.body.removeChild(parent);
  }
}

Heap スナップショットでの DOM リーク検出

DOM リークをヒープスナップショットで検出する具体的な手順を説明します。

javascript// DOM リーク検出の手順
class DOMLeakDetection {
  constructor() {
    this.detectionSteps = [];
  }

  // ステップ1: DOM要素フィルタリング
  filterDOMElements() {
    // Summary ビューで以下のConstructorを検索
    const domConstructors = [
      'HTMLDivElement',
      'HTMLSpanElement',
      'HTMLButtonElement',
      'HTMLInputElement',
      'Text', // テキストノード
      'DocumentFragment',
    ];

    return domConstructors;
  }

  // ステップ2: 予期しない DOM要素数の確認
  checkUnexpectedDOMCounts() {
    // 比較ビューで以下を確認
    return {
      beforeOperation: '操作前のDOM要素数',
      afterOperation: '操作後のDOM要素数',
      afterCleanup: 'クリーンアップ後のDOM要素数',
      shouldEqual:
        'beforeOperation === afterCleanup であるべき',
    };
  }

  // ステップ3: DOM要素の詳細調査
  investigateDOMDetails() {
    // Containment ビューでDOM要素を展開し、以下を確認
    return {
      parentReferences:
        '予期しない親オブジェクトからの参照',
      eventListeners: '削除されていないイベントリスナー',
      customProperties: 'カスタムプロパティでの循環参照',
      retainerPath: 'DOM要素を保持している参照チェーン',
    };
  }
}

DOM リーク修正のベストプラクティス

検出した DOM リークを修正する際の実践的な手法を紹介します。

javascript// DOM リーク修正の実装例
class DOMLeakFixes {
  constructor() {
    this.managedElements = new WeakMap();
    this.eventListeners = new Map();
  }

  // 修正案1: WeakMap を使用した要素管理
  createElement(tag, options = {}) {
    const element = document.createElement(tag);

    // WeakMapで要素を管理(要素が削除されると自動的にエントリも削除)
    this.managedElements.set(element, {
      created: Date.now(),
      options: options,
    });

    return element;
  }

  // 修正案2: イベントリスナーの適切な管理
  addEventListenerSafely(element, event, handler, options) {
    // リスナーを記録
    const listenerKey = `${element.constructor.name}_${event}`;
    if (!this.eventListeners.has(listenerKey)) {
      this.eventListeners.set(listenerKey, new Set());
    }
    this.eventListeners.get(listenerKey).add(handler);

    // イベントリスナーを追加
    element.addEventListener(event, handler, options);

    // クリーンアップ関数を返す
    return () => {
      element.removeEventListener(event, handler, options);
      this.eventListeners.get(listenerKey).delete(handler);
    };
  }

  // 修正案3: 完全なクリーンアップ
  cleanupElement(element) {
    // 1. 子要素も含めて全てのイベントリスナーを削除
    this.removeAllEventListeners(element);

    // 2. カスタムプロパティをクリア
    this.clearCustomProperties(element);

    // 3. DOM から削除
    if (element.parentNode) {
      element.parentNode.removeChild(element);
    }
  }

  removeAllEventListeners(element) {
    // 要素とその子要素のイベントリスナーを削除
    const allElements = [
      element,
      ...element.querySelectorAll('*'),
    ];

    allElements.forEach((el) => {
      // cloneNode(true) でイベントリスナーを削除
      const cleanElement = el.cloneNode(true);
      if (el.parentNode) {
        el.parentNode.replaceChild(cleanElement, el);
      }
    });
  }

  clearCustomProperties(element) {
    // カスタムプロパティをクリア(循環参照を防ぐ)
    Object.getOwnPropertyNames(element).forEach((prop) => {
      if (
        !prop.startsWith('__') &&
        typeof element[prop] === 'object'
      ) {
        element[prop] = null;
      }
    });
  }
}

イベントリスナーリーク発見術

イベントリスナーによるメモリリークは、見つけにくく、重大な影響を及ぼすことが多い問題です。

イベントリスナーリークの検出方法

以下の手順でイベントリスナーリークを効率的に発見できます。

javascript// イベントリスナーリーク検出ツール
class EventListenerLeakDetector {
  constructor() {
    this.originalAddEventListener =
      EventTarget.prototype.addEventListener;
    this.originalRemoveEventListener =
      EventTarget.prototype.removeEventListener;
    this.listenerRegistry = new Map();
    this.setupMonitoring();
  }

  // イベントリスナーの追加/削除を監視
  setupMonitoring() {
    const self = this;

    // addEventListener をオーバーライド
    EventTarget.prototype.addEventListener = function (
      type,
      listener,
      options
    ) {
      const elementId = self.getElementId(this);
      const listenerId = self.generateListenerId(
        elementId,
        type,
        listener
      );

      // リスナーを登録
      self.registerListener(listenerId, {
        element: this,
        type: type,
        listener: listener,
        options: options,
        stack: new Error().stack,
      });

      // 元の処理を実行
      return self.originalAddEventListener.call(
        this,
        type,
        listener,
        options
      );
    };

    // removeEventListener をオーバーライド
    EventTarget.prototype.removeEventListener = function (
      type,
      listener,
      options
    ) {
      const elementId = self.getElementId(this);
      const listenerId = self.generateListenerId(
        elementId,
        type,
        listener
      );

      // リスナーの登録を削除
      self.unregisterListener(listenerId);

      // 元の処理を実行
      return self.originalRemoveEventListener.call(
        this,
        type,
        listener,
        options
      );
    };
  }

  // 要素の一意ID生成
  getElementId(element) {
    if (!element._leakDetectorId) {
      element._leakDetectorId = `element_${Date.now()}_${Math.random()}`;
    }
    return element._leakDetectorId;
  }

  // リスナーID生成
  generateListenerId(elementId, type, listener) {
    return `${elementId}_${type}_${listener
      .toString()
      .substring(0, 50)}`;
  }

  // リスナー登録
  registerListener(id, details) {
    this.listenerRegistry.set(id, details);
  }

  // リスナー登録削除
  unregisterListener(id) {
    this.listenerRegistry.delete(id);
  }

  // リークの可能性があるリスナーを検出
  detectPotentialLeaks() {
    const potentialLeaks = [];

    this.listenerRegistry.forEach((details, id) => {
      // DOM に存在しない要素のリスナーを検出
      if (!document.contains(details.element)) {
        potentialLeaks.push({
          id: id,
          element: details.element.constructor.name,
          eventType: details.type,
          registrationStack: details.stack,
        });
      }
    });

    return potentialLeaks;
  }

  // 現在のリスナー統計を取得
  getListenerStatistics() {
    const stats = new Map();

    this.listenerRegistry.forEach((details) => {
      const key = `${details.element.constructor.name}_${details.type}`;
      stats.set(key, (stats.get(key) || 0) + 1);
    });

    return Array.from(stats.entries()).map(
      ([type, count]) => ({
        type: type,
        count: count,
      })
    );
  }
}

ヒープスナップショットでのリスナー分析

イベントリスナーリークをヒープスナップショットで分析する手法を説明します。

javascript// ヒープスナップショットでのリスナー分析
class HeapSnapshotListenerAnalysis {
  constructor() {
    this.analysisSteps = [];
  }

  // ステップ1: 関数オブジェクトの調査
  analyzeFunctionObjects() {
    return {
      searchTargets: [
        '(anonymous)', // 無名関数
        'bound ', // bind された関数
        'Function', // 一般的な関数
      ],
      analysisMethod: `
                1. Summary ビューで "Function" を検索
                2. オブジェクト数の異常な増加を確認
                3. 個別の関数をクリックして詳細を表示
                4. Retainers で関数を保持しているオブジェクトを確認
            `,
    };
  }

  // ステップ2: クロージャーの分析
  analyzeClosures() {
    return {
      detectionMethod: `
                1. "(closure)" で検索
                2. 予期しないクロージャーの残存を確認
                3. クロージャーが参照している変数を調査
                4. 大きなデータを参照しているクロージャーを特定
            `,
      commonPatterns: [
        'イベントハンドラー内での大きなデータ参照',
        'setTimeout/setInterval のコールバック',
        'Promise の then/catch ハンドラー',
      ],
    };
  }

  // ステップ3: DOM イベントターゲットの分析
  analyzeDOMEventTargets() {
    return {
      process: `
                1. Containment ビューを使用
                2. Window → Document → HTML → ... の順で展開
                3. DOM要素を選択し、プロパティを確認
                4. "__eventListeners" プロパティの存在確認
            `,
      warningSign:
        '削除されたはずの要素に __eventListeners が残っている',
    };
  }
}

クロージャーによるメモリリーク特定

クロージャーは JavaScript の強力な機能ですが、不適切に使用するとメモリリークの原因となります。

クロージャーリークの典型例

以下の例は、クロージャーによるメモリリークのパターンを示しています。

javascript// クロージャーによるメモリリークの例
class ClosureLeakExamples {
  // パターン1: 大きなデータを参照するクロージャー
  createDataReferenceLeak() {
    const hugeData = new Array(1000000).fill(
      '大きなデータ'
    );

    // 小さな関数だが、hugeData 全体への参照を保持
    const smallFunction = () => {
      return hugeData.length; // hugeData 全体が解放されない
    };

    // hugeData の一部だけ必要な場合の修正例
    const fixedFunction = () => {
      const dataLength = hugeData.length; // 必要な値だけ取得
      return () => dataLength; // 元のデータへの参照はここで切れる
    };

    return smallFunction;
  }

  // パターン2: 循環参照を作るクロージャー
  createCircularReferenceLeak() {
    const obj = {
      data: new Array(100000).fill('data'),
    };

    // オブジェクトが自分自身を参照する関数を持つ
    obj.getSize = () => {
      return obj.data.length; // obj への参照で循環参照
    };

    // 修正例: WeakRef を使用
    const weakRef = new WeakRef(obj);
    obj.getSizeSafely = () => {
      const target = weakRef.deref();
      return target ? target.data.length : 0;
    };

    return obj;
  }

  // パターン3: タイマー関数でのクロージャーリーク
  createTimerClosureLeak() {
    const componentData = {
      users: new Array(10000).fill({
        name: 'user',
        data: 'large data',
      }),
      settings: { theme: 'dark' },
    };

    // 全体のデータを参照するタイマー
    const intervalId = setInterval(() => {
      console.log(componentData.settings.theme); // settings だけ必要
      // しかし componentData 全体への参照が保持される
    }, 1000);

    // 修正例: 必要なデータだけ抽出
    const theme = componentData.settings.theme;
    const fixedIntervalId = setInterval(() => {
      console.log(theme); // componentData への参照はない
    }, 1000);

    return { intervalId, fixedIntervalId };
  }
}

ヒープスナップショットでのクロージャー分析

クロージャーリークをヒープスナップショットで特定する詳細な手順を説明します。

javascript// クロージャー分析の詳細手順
class ClosureAnalysisDetailed {
  constructor() {
    this.analysisWorkflow = [];
  }

  // フェーズ1: クロージャーの識別
  identifyClosures() {
    return {
      step1: {
        action: "Summary ビューで '(closure)' を検索",
        observation:
          'クロージャーの総数と総メモリ使用量を確認',
      },
      step2: {
        action: 'サイズの大きなクロージャーを選択',
        observation:
          '個別のクロージャーの Retained Size を確認',
      },
      step3: {
        action: 'クロージャーをダブルクリック',
        observation: 'クロージャーの詳細情報を表示',
      },
    };
  }

  // フェーズ2: クロージャーの参照分析
  analyzeClosureReferences() {
    return {
      contextAnalysis: {
        description:
          'クロージャーのコンテキスト(スコープ)を分析',
        method: 'Containment ビューでクロージャーを展開',
        lookFor: [
          'context: クロージャーが作成されたスコープ',
          'function: クロージャー関数本体',
          'shared: 共有されている変数',
        ],
      },
      retainerAnalysis: {
        description:
          'クロージャーを保持しているオブジェクトを分析',
        method:
          'クロージャーを選択後、下部の Retainers タブを確認',
        interpretation:
          'どのオブジェクトがクロージャーを参照しているかを把握',
      },
    };
  }

  // フェーズ3: メモリリーク判定
  determineMemoryLeak() {
    return {
      leakIndicators: [
        {
          indicator: '大きなコンテキストサイズ',
          description:
            'クロージャーが不要に大きなスコープを保持',
          threshold: '100KB以上のコンテキスト',
        },
        {
          indicator: '予期しない保持者',
          description:
            '削除されたはずのオブジェクトがクロージャーを保持',
          example: 'DOM要素、コンポーネントインスタンス',
        },
        {
          indicator: '循環参照パターン',
          description: 'クロージャーとその保持者が相互参照',
          detection: 'Retainer チェーンで循環を発見',
        },
      ],
      analysisCode: `
                // クロージャーリーク判定の実装例
                function analyzeClosureLeak(closureSnapshot) {
                    const analysis = {
                        contextSize: closureSnapshot.context.retainedSize,
                        retainerCount: closureSnapshot.retainers.length,
                        hasCircularRef: checkCircularReference(closureSnapshot),
                        hasUnexpectedRetainer: checkUnexpectedRetainers(closureSnapshot)
                    };
                    
                    return {
                        isLeak: analysis.contextSize > 100000 || 
                               analysis.hasCircularRef || 
                               analysis.hasUnexpectedRetainer,
                        severity: calculateSeverity(analysis),
                        recommendations: generateRecommendations(analysis)
                    };
                }
            `,
    };
  }
}

以下の図は、クロージャーによるメモリリークの構造を示しています。

mermaidflowchart TD
    outer[外部スコープ<br/>大きなデータ配列] --> closure[クロージャー関数]
    closure --> handler[イベントハンドラー]
    handler --> dom[DOM要素]
    dom --> detached[分離されたDOM<br/>(削除済み)]

    outer -.->|不要な参照| unused[使用されないデータ]

    style detached fill:#ff9999
    style unused fill:#ffcccc
    style outer fill:#ffffcc

    subgraph "メモリリーク領域"
        detached
        unused
        outer
    end

この図では、DOM 要素が削除されてもクロージャーが外部スコープの大きなデータを保持し続け、メモリリークが発生している状況を表しています。

実践的なメモリリーク対策

メモリリーク修正のベストプラクティス

効果的なメモリリーク対策を実装するには、予防的なアプローチと修正技術の両方が重要です。

WeakMap と WeakSet の活用

弱い参照を使用することで、循環参照によるメモリリークを防ぐことができます。

javascript// WeakMap を使用したメモリリーク対策
class WeakReferencePatterns {
  constructor() {
    // WeakMap は key が削除されると自動的にエントリも削除される
    this.elementData = new WeakMap();
    this.componentCache = new WeakMap();
  }

  // DOM 要素とデータの関連付け
  attachDataToElement(element, data) {
    // 従来の方法(メモリリークリスク)
    // element.customData = data; // 循環参照の可能性

    // WeakMap を使用した安全な方法
    this.elementData.set(element, data);

    // element が削除されると、自動的に WeakMap のエントリも削除される
  }

  // コンポーネントのキャッシュ管理
  cacheComponent(instance, cacheData) {
    this.componentCache.set(instance, {
      lastUpdated: Date.now(),
      computedValues: cacheData,
      // 他の一時的なデータ
    });
  }

  // WeakSet を使用したオブジェクト追跡
  trackProcessedObjects() {
    const processedObjects = new WeakSet();

    return function processObject(obj) {
      // 既に処理済みかチェック
      if (processedObjects.has(obj)) {
        return; // 重複処理を避ける
      }

      // オブジェクトを処理済みとしてマーク
      processedObjects.add(obj);

      // 実際の処理
      console.log('オブジェクトを処理中...');
    };
  }
}

リソース管理の自動化

リソースの適切な管理を自動化することで、手動でのクリーンアップ忘れを防げます。

javascript// 自動リソース管理システム
class AutoResourceManager {
  constructor() {
    this.resources = new Set();
    this.cleanupCallbacks = new Map();
    this.setupAutoCleanup();
  }

  // リソースの登録
  registerResource(resource, cleanupCallback) {
    this.resources.add(resource);
    this.cleanupCallbacks.set(resource, cleanupCallback);

    // リソースが削除された時の自動クリーンアップ
    if (resource instanceof EventTarget) {
      this.setupElementCleanup(resource);
    }

    return resource;
  }

  // DOM 要素の自動クリーンアップ設定
  setupElementCleanup(element) {
    // MutationObserver で DOM からの削除を監視
    const observer = new MutationObserver((mutations) => {
      mutations.forEach((mutation) => {
        mutation.removedNodes.forEach((node) => {
          if (node === element || node.contains(element)) {
            this.cleanupResource(element);
            observer.disconnect();
          }
        });
      });
    });

    observer.observe(document.body, {
      childList: true,
      subtree: true,
    });
  }

  // リソースのクリーンアップ実行
  cleanupResource(resource) {
    if (this.resources.has(resource)) {
      const cleanupCallback =
        this.cleanupCallbacks.get(resource);
      if (cleanupCallback) {
        try {
          cleanupCallback();
        } catch (error) {
          console.error('クリーンアップエラー:', error);
        }
      }

      this.resources.delete(resource);
      this.cleanupCallbacks.delete(resource);
    }
  }

  // ページアンロード時の全リソースクリーンアップ
  setupAutoCleanup() {
    window.addEventListener('beforeunload', () => {
      this.cleanupAllResources();
    });

    // SPA での画面遷移時のクリーンアップ
    window.addEventListener('popstate', () => {
      this.cleanupAllResources();
    });
  }

  cleanupAllResources() {
    this.resources.forEach((resource) => {
      this.cleanupResource(resource);
    });
  }
}

// 使用例
const resourceManager = new AutoResourceManager();

function createComponent() {
  const element = document.createElement('div');
  const data = new Array(100000).fill('data');

  // リソース登録とクリーンアップ関数の定義
  resourceManager.registerResource(element, () => {
    // カスタムクリーンアップ処理
    data.length = 0; // 配列をクリア
    element.removeEventListener('click', handleClick);
    console.log('コンポーネントをクリーンアップしました');
  });

  const handleClick = () => {
    console.log(`データサイズ: ${data.length}`);
  };

  element.addEventListener('click', handleClick);
  return element;
}

メモリプールパターンの実装

頻繁に作成・削除されるオブジェクトに対しては、メモリプールパターンを使用してガベージコレクションの負荷を軽減できます。

javascript// メモリプールパターンの実装
class ObjectPool {
  constructor(createFn, resetFn, maxSize = 100) {
    this.createFunction = createFn;
    this.resetFunction = resetFn;
    this.maxSize = maxSize;
    this.pool = [];
    this.activeObjects = new Set();
  }

  // オブジェクトの取得
  acquire() {
    let obj;

    if (this.pool.length > 0) {
      // プールから再利用
      obj = this.pool.pop();
    } else {
      // 新規作成
      obj = this.createFunction();
    }

    this.activeObjects.add(obj);
    return obj;
  }

  // オブジェクトの返却
  release(obj) {
    if (!this.activeObjects.has(obj)) {
      return; // 管理外のオブジェクト
    }

    this.activeObjects.delete(obj);

    // リセット処理
    if (this.resetFunction) {
      this.resetFunction(obj);
    }

    // プールサイズ制限内であれば再利用のためプールに追加
    if (this.pool.length < this.maxSize) {
      this.pool.push(obj);
    }
    // サイズ制限を超える場合は破棄(ガベージコレクション対象になる)
  }

  // プール統計の取得
  getStatistics() {
    return {
      poolSize: this.pool.length,
      activeCount: this.activeObjects.size,
      totalManaged:
        this.pool.length + this.activeObjects.size,
    };
  }
}

// 使用例:DOM要素プール
const elementPool = new ObjectPool(
  // 作成関数
  () => document.createElement('div'),

  // リセット関数
  (element) => {
    element.innerHTML = '';
    element.className = '';
    element.style.cssText = '';
    // 全てのイベントリスナーを削除
    const newElement = element.cloneNode(false);
    if (element.parentNode) {
      element.parentNode.replaceChild(newElement, element);
    }
    return newElement;
  },

  50 // 最大プールサイズ
);

// 使用例
function createTemporaryElement() {
  const element = elementPool.acquire();
  element.textContent = 'テンポラリ要素';

  // 使用後に返却
  setTimeout(() => {
    elementPool.release(element);
  }, 5000);

  return element;
}

予防的なコーディング手法

メモリリークを事前に防ぐためのコーディング手法を実践することが重要です。

ライフサイクル管理の実装

コンポーネントのライフサイクルを明確に管理することで、適切なタイミングでのクリーンアップが可能になります。

javascript// ライフサイクル管理の実装
class LifecycleManager {
  constructor() {
    this.components = new Map();
    this.globalCleanupTasks = new Set();
  }

  // コンポーネントの登録
  registerComponent(id, component) {
    if (this.components.has(id)) {
      // 既存コンポーネントがある場合は先にクリーンアップ
      this.destroyComponent(id);
    }

    // ライフサイクルフックを追加
    const managedComponent =
      this.wrapWithLifecycle(component);
    this.components.set(id, managedComponent);

    // 初期化実行
    if (managedComponent.onMount) {
      managedComponent.onMount();
    }

    return managedComponent;
  }

  // ライフサイクルフックの追加
  wrapWithLifecycle(component) {
    const cleanupTasks = [];

    return {
      ...component,

      // クリーンアップタスクの追加
      addCleanupTask(task) {
        cleanupTasks.push(task);
      },

      // 自動クリーンアップの実行
      destroy() {
        // 登録されたクリーンアップタスクを実行
        cleanupTasks.forEach((task) => {
          try {
            task();
          } catch (error) {
            console.error(
              'クリーンアップタスクエラー:',
              error
            );
          }
        });

        // オリジナルの destroy メソッドを実行
        if (component.destroy) {
          component.destroy();
        }

        // ライフサイクルフック実行
        if (component.onDestroy) {
          component.onDestroy();
        }
      },
    };
  }

  // コンポーネントの破棄
  destroyComponent(id) {
    const component = this.components.get(id);
    if (component) {
      component.destroy();
      this.components.delete(id);
    }
  }

  // 全コンポーネントの破棄
  destroyAll() {
    this.components.forEach((component, id) => {
      this.destroyComponent(id);
    });

    // グローバルクリーンアップタスクの実行
    this.globalCleanupTasks.forEach((task) => {
      try {
        task();
      } catch (error) {
        console.error(
          'グローバルクリーンアップエラー:',
          error
        );
      }
    });
  }
}

// 使用例
const lifecycleManager = new LifecycleManager();

// コンポーネントの定義
class DataVisualization {
  constructor(containerId, data) {
    this.container = document.getElementById(containerId);
    this.data = data;
    this.eventListeners = [];
    this.timers = [];
  }

  onMount() {
    this.render();
    this.setupEventListeners();

    // クリーンアップタスクの登録
    this.addCleanupTask(() => {
      this.eventListeners.forEach(
        ({ element, type, listener }) => {
          element.removeEventListener(type, listener);
        }
      );
    });

    this.addCleanupTask(() => {
      this.timers.forEach((timerId) =>
        clearInterval(timerId)
      );
    });
  }

  setupEventListeners() {
    const resizeHandler = () => this.handleResize();
    window.addEventListener('resize', resizeHandler);
    this.eventListeners.push({
      element: window,
      type: 'resize',
      listener: resizeHandler,
    });
  }

  render() {
    // データの可視化処理
    this.container.innerHTML =
      '<div>データビジュアライゼーション</div>';

    // 定期更新タイマー
    const updateTimer = setInterval(() => {
      this.updateVisualization();
    }, 1000);
    this.timers.push(updateTimer);
  }

  updateVisualization() {
    // 可視化の更新処理
  }

  handleResize() {
    // リサイズハンドリング
  }

  onDestroy() {
    // 最終的なクリーンアップ
    if (this.container) {
      this.container.innerHTML = '';
    }
  }
}

// コンポーネントの使用
const visualization = lifecycleManager.registerComponent(
  'data-viz-1',
  new DataVisualization('chart-container', largeDataSet)
);

パフォーマンステストの自動化

継続的なメモリ監視とテストの自動化により、メモリリークの早期発見が可能になります。

自動メモリ監視システム

javascript// 自動メモリ監視システム
class MemoryMonitor {
  constructor(options = {}) {
    this.config = {
      interval: options.interval || 5000, // 5秒間隔
      thresholds: {
        warningIncrease: options.warningIncrease || 10, // 10%増加で警告
        criticalIncrease: options.criticalIncrease || 25, // 25%増加で危険
        maxMemoryMB: options.maxMemoryMB || 100, // 100MB上限
      },
      ...options,
    };

    this.baseline = null;
    this.measurements = [];
    this.alerts = [];
    this.isMonitoring = false;
  }

  // 監視開始
  startMonitoring() {
    if (this.isMonitoring) return;

    this.isMonitoring = true;
    this.baseline = this.getCurrentMemoryUsage();

    this.monitoringInterval = setInterval(() => {
      this.takeMeasurement();
    }, this.config.interval);

    console.log('メモリ監視を開始しました', this.baseline);
  }

  // 監視停止
  stopMonitoring() {
    if (!this.isMonitoring) return;

    this.isMonitoring = false;
    clearInterval(this.monitoringInterval);

    console.log('メモリ監視を停止しました');
    return this.generateReport();
  }

  // 現在のメモリ使用量取得
  getCurrentMemoryUsage() {
    if (performance.memory) {
      return {
        used: performance.memory.usedJSHeapSize,
        total: performance.memory.totalJSHeapSize,
        limit: performance.memory.jsHeapSizeLimit,
        timestamp: Date.now(),
      };
    }

    // fallback: 概算値
    return {
      used: 0,
      total: 0,
      limit: 0,
      timestamp: Date.now(),
      note: 'performance.memory not available',
    };
  }

  // 測定実行
  takeMeasurement() {
    const current = this.getCurrentMemoryUsage();
    this.measurements.push(current);

    // 異常検知
    this.detectAnomalies(current);

    // 古い測定データのクリーンアップ(最新100件のみ保持)
    if (this.measurements.length > 100) {
      this.measurements = this.measurements.slice(-100);
    }
  }

  // 異常検知
  detectAnomalies(current) {
    if (!this.baseline || current.used === 0) return;

    const increasePercent =
      ((current.used - this.baseline.used) /
        this.baseline.used) *
      100;
    const currentMB = current.used / (1024 * 1024);

    let alertLevel = null;
    let message = '';

    // 増加率による判定
    if (
      increasePercent >
      this.config.thresholds.criticalIncrease
    ) {
      alertLevel = 'critical';
      message = `メモリ使用量が${increasePercent.toFixed(
        1
      )}%増加しました(危険レベル)`;
    } else if (
      increasePercent >
      this.config.thresholds.warningIncrease
    ) {
      alertLevel = 'warning';
      message = `メモリ使用量が${increasePercent.toFixed(
        1
      )}%増加しました(警告レベル)`;
    }

    // 絶対値による判定
    if (currentMB > this.config.thresholds.maxMemoryMB) {
      alertLevel = 'critical';
      message = `メモリ使用量が${currentMB.toFixed(
        1
      )}MBに到達しました(上限超過)`;
    }

    if (alertLevel) {
      const alert = {
        level: alertLevel,
        message: message,
        timestamp: current.timestamp,
        memoryUsage: current,
        increasePercent: increasePercent,
      };

      this.alerts.push(alert);
      console.warn(`[メモリ監視] ${message}`, alert);

      // カスタムアラートハンドラーの実行
      if (this.config.onAlert) {
        this.config.onAlert(alert);
      }
    }
  }

  // 監視レポート生成
  generateReport() {
    if (this.measurements.length === 0) {
      return { error: '測定データがありません' };
    }

    const latest =
      this.measurements[this.measurements.length - 1];
    const peak = this.measurements.reduce((max, current) =>
      current.used > max.used ? current : max
    );

    return {
      duration: latest.timestamp - this.baseline.timestamp,
      baseline: this.baseline,
      latest: latest,
      peak: peak,
      totalAlerts: this.alerts.length,
      criticalAlerts: this.alerts.filter(
        (a) => a.level === 'critical'
      ).length,
      warningAlerts: this.alerts.filter(
        (a) => a.level === 'warning'
      ).length,
      memoryIncrease: latest.used - this.baseline.used,
      memoryIncreasePercent:
        ((latest.used - this.baseline.used) /
          this.baseline.used) *
        100,
      alerts: this.alerts,
      recommendations: this.generateRecommendations(),
    };
  }

  // 推奨事項の生成
  generateRecommendations() {
    const recommendations = [];

    if (this.alerts.length > 0) {
      recommendations.push(
        'メモリリークの可能性があります。ヒープスナップショットを取得して詳細を調査してください。'
      );
    }

    if (this.alerts.some((a) => a.level === 'critical')) {
      recommendations.push(
        '緊急対応が必要です。アプリケーションの再起動を検討してください。'
      );
    }

    const latestIncrease =
      this.measurements.length > 1
        ? ((this.measurements[this.measurements.length - 1]
            .used -
            this.baseline.used) /
            this.baseline.used) *
          100
        : 0;

    if (latestIncrease > 50) {
      recommendations.push(
        '大幅なメモリ増加が検出されました。最近の操作を確認し、原因を特定してください。'
      );
    }

    return recommendations;
  }
}

// 使用例とテスト統合
const monitor = new MemoryMonitor({
  interval: 2000,
  thresholds: {
    warningIncrease: 15,
    criticalIncrease: 30,
    maxMemoryMB: 50,
  },
  onAlert: (alert) => {
    // カスタムアラート処理
    if (alert.level === 'critical') {
      // 開発環境では自動でヒープスナップショット取得
      if (process.env.NODE_ENV === 'development') {
        console.log(
          '自動ヒープスナップショット取得を実行...'
        );
      }
    }
  },
});

// テストケースでの使用例
async function runMemoryTest() {
  monitor.startMonitoring();

  // テスト対象の処理を実行
  await performTestOperations();

  // 結果の取得
  const report = monitor.stopMonitoring();

  // アサーション
  if (report.memoryIncreasePercent > 20) {
    throw new Error(
      `メモリ増加率が閾値を超えました: ${report.memoryIncreasePercent}%`
    );
  }

  return report;
}

まとめ

JavaScript メモリリーク診断術として、Chrome DevTools の Heap スナップショット機能を中心とした包括的な診断手法をご紹介いたしました。

メモリリークは現代の Web アプリケーション開発において避けては通れない重要な課題です。しかし、適切な知識と手法を身につけることで、効率的に問題を発見し、解決することができます。

本記事で解説した内容を段階的に実践することで、メモリリークに悩まされることなく、高品質な Web アプリケーションを開発していただけるでしょう。特に以下の点を重視して日々の開発に取り組んでください。

基礎知識の重要性: メモリリークの仕組みを理解することで、問題の本質を見抜く力が身につきます。DOM リーク、イベントリスナーリーク、クロージャーリークという 3 つの主要パターンを覚えておくことで、迅速な原因特定が可能になります。

Heap スナップショットの活用: DevTools の Memory タブは非常に強力なツールです。Summary、Comparison、Containment の各ビューを使い分けることで、様々な角度からメモリ使用状況を分析できます。

予防的アプローチの実践: WeakMap や WeakSet の活用、適切なライフサイクル管理、リソースの自動化など、メモリリークを未然に防ぐコーディング手法を習慣化することが最も効果的です。

継続的な監視: 自動メモリ監視システムの導入により、メモリリークの早期発見と対応が可能になります。開発段階から本番環境まで、一貫した監視体制を構築することが重要です。

メモリ最適化は一度やれば終わりではなく、継続的な取り組みが必要な分野です。本記事の内容を参考に、チーム全体でメモリリーク対策に取り組んでいただければと思います。

関連リンク