メインコンテンツへ移動

JS メモリリーク調査方法

無料2017-08-06#JS#JS内存泄漏#闭包内存泄漏#JS内存问题#javascript memory leak#JS内存性能

メモリ問題の発見方法、調査方法、解決方法、予防方法

前言

JS のメモリ問題は、シングルページアプリケーション(SPA)でよく発生します。一般的に以下の状況特徴があります:

  • ページライフサイクルが長い(ユーザーが 10 分、30 分、場合によっては 2 時間残存する可能性)

  • 対話機能が多い(ページは表示よりも機能重視)

  • JS 重視(フロントエンドに複雑なデータ状態、ビュー管理がある)

メモリリークは累積的なプロセスであり、ページライフサイクルがやや長い場合にのみ問題となります(いわゆる「リフレッシュすれば復活」)。頻繁な対話はこの累積プロセスを加速させ、表示重視のページではこのような問題が表面化しにくいのです。最後に、JS ロジックが比較的複雑でなければメモリ問題は発生しません(「バグが多いのはコード量が多く、自分自身も把握しきれない」)。単純なフォーム検証提出程度では、メモリに影響を与える機会はほとんどありません。

では、対話機能が多く JS ロジックが複雑である基準は何でしょうか?どの程度までが危険なのでしょうか?

実際には、少しの対話機能(部分的な更新など)がある単純なページでも、少し注意を怠ればメモリの隐患が残ります。それが表面化すればメモリ問題と呼ばれます。

一.ツール環境

ツール:

  • Chrome Task Manager ツール

  • Chrome DevTools Performance パネル

  • Chrome DevTools Memory パネル

環境:

  • 安定性、ネットワークなどの変動要因を排除(フェイクデータを使用)

  • 操作の再現性、「累積」の難易度を下げる(操作手順を簡素化、SMS 認証などの环节は削除を検討)

  • 干渉なし、プラグインの影響を排除(シークレットモードで開く)

つまり(Mac 环境下):

  1. Command + Shift + Nでシークレットモードに入る

  2. Command + Alt + Iで DevTools を開く

  3. URL を入力してページを開く

これで模倣して始められます

二.用語概念

まず基本的なメモリ知識を備え、DevTools が提供する各項記録の意味を理解する必要があります

Mark-and-sweep

JS 関連の GC アルゴリズムは主に参照カウント(IE の BOM、DOM オブジェクト)とマークアンドスイープ(主流の做法)で、それぞれ長所短所があります:

  • 参照カウントは回収がタイムリー(参照数が 0 になると即座に解放)だが、循環参照は永遠に解放できない

  • マークアンドスイープは循環参照の問題がない(アクセス不可なら回収)が、回収がタイムリーでなく Stop-The-World が必要

マークアンドスイープアルゴリズムのステップは以下の通り:

  1. GC は root リストを維持し、root は通常コード内で参照を保持するグローバル変数。JS では、window オブジェクトが root となるグローバル変数の一例。window オブジェクトは常に存在するため、GC はそれとそのすべての子が常に存在(非ゴミ)とみなす

  2. すべての root がチェックされ、アクティブ(非ゴミ)としてマークされ、そのすべての子も再帰的にチェックされる。root からアクセス可能なすべてはゴミとして扱われない

  3. アクティブとしてマークされていないすべてのメモリブロックはゴミとして扱われ、GC はそれらを解放して OS に返すことができる

現代の GC 技術はこのアルゴリズムに様々な改良を加えているが、本質はすべて同じ:アクセス可能なメモリブロックがこうしてマークされ、残りがゴミとなる

Shallow Size & Retained Size

メモリは基本型(数字や文字列など)とオブジェクト(連想配列)からなるグラフと見なすことができる。形象的に言えば、メモリは複数の相互接続された点からなるグラフとして表すことができる。以下の通り:

  3-->5->7
  ^      ^
 /|      |
1 |      6-->8
 \|     /^
  v    /
  2-->4

オブジェクトは 2 つの方法でメモリを占有できる:

  • オブジェクト自身を通じて直接占有

  • 他のオブジェクトへの参照を保持することで間接的に占有し、これらのオブジェクトがガベージコレクター(以下 GC)によって自動的に処理されるのを防ぐ

DevTools のヒープメモリスナップショット分析パネルでは、Shallow SizeRetained Size がそれぞれオブジェクトがこれら 2 つの方法で占有するメモリサイズを表す

Shallow Size

オブジェクト自身が占有するメモリのサイズ。通常、配列と文字列のみが顕著な Shallow Size を持つ。ただし、文字列と外部配列のメインストレージは通常 renderer メモリにあり、小さなラッパーオブジェクトのみを JavaScript ヒープ上に置く

renderer メモリはページレンダリングプロセスのメモリ合計:ネイティブメモリ + ページの JS ヒープメモリ + ページが起動したすべての専用ワーカーの JS ヒープメモリ。それでも、小さなオブジェクトでも他のオブジェクトが自動ガベージコレクションプロセスによって処理されるのを防ぐことで間接的に大量のメモリを占有する可能性がある

Retained Size

オブジェクト自身とそれに依存するオブジェクト(GC root からアクセスできなくなったオブジェクト)が削除された後に解放されるメモリサイズ

多くの内部 GC root があり、そのほとんどは注目する必要がない。アプリケーションの観点から見ると、GC root には以下の種類がある:

  • Window グローバルオブジェクト(各 iframe に存在)。ヒープスナップショットには distance フィールドがあり、window からの最短保持パス上のプロパティ参照数を表す。

  • ドキュメント DOM ツリー、document を通じてアクセスできるすべてのネイティブ DOM ノードで構成される。すべてのノードが JS ラッパーを持つわけではないが、ラッパーがあり document がアクティブ状態の場合、ラッパーもアクティブ状態となる

  • 場合により、オブジェクトがデバッガコンテキストと DevTools console によって保持されることがある(例えば、console で評価計算した後)。そのため、ヒープスナップショットをデバッグする際は、console をクリアしブレークポイントを外す必要がある

メモリ図は root から始まり、root はブラウザの window オブジェクトまたは Node.js モジュールの Global オブジェクトで、root オ��ジェクトのガベージコレクション方法は制御できない

  3-->5->7   9-->10
  ^      ^
 /|      |
1 |      6-->8
 \|     /^
  v    /
  2-->4

ここで、1 は root(ルートノード)、7 と 8 は基本値(リーフノード)、9 と 10 は GC される(孤立ノード)、残りはオブジェクト(非ルート非リーフノード)

Object's retaining tree

ヒープは相互接続されたオブジェクトのネットワークである。数学的には、このような構造は「グラフ」またはメモリグラフと呼ばれる。グラフは与えられたラベルで表されるエッジで接続されたノードで構成される:

  • ノード(またはオブジェクト)はコンストラクタ(ノードを構築するために使用される)の名前でラベル付けされる

  • エッジはプロパティ名でラベル付けされる

distance は GC root からの距離を表す。あるタイプのほとんどのオブジェクトの distance が同じで、少数のオブジェクトの距離が著しく大きい場合、注意深く調べる必要がある

Dominator

支配オブジェクトはすべてツリー構造を構成する。各オブジェクトには 1 つの(直接)支配者のみがあるため、オブジェクトの支配者は支配されるオブジェクトへの直接参照を持たない場合があり、支配者ツリーはグラフの生成ツリーではない

オブジェクト参照図において、オブジェクト B へのすべてのパスがオブジェクト A を通る場合、A は B を支配すると見なす。オブジェクト A がオブジェクト B に最も近い支配者である場合、A は B の直接支配者と見なす

下図において:

  1     1 は 2 を支配
  |     2 は 3 4 6 を支配
  v
  2
/   \
v   v
4   3   3 は 5 を支配
|  /|
| / |
|/  |
v   v
6   5   5 は 8 を支配; 6 は 7 を支配
|   |
v   v
7   8

したがって 7 の直接支配者は 6 であり、7 の支配者は 1, 2, 6 である

V8 の JS オブジェクト表現

primitive type

3 つの基本型:

  • 数値

  • 布尔値

  • 文字列

これらは他の値を参照できないため、常にリーフまたはターミナルノードである

数値には 2 つのストレージ方法がある:

  • 直接の 31 ビット整数値は SMI(Small Integer)と呼ばれる

  • ヒープオブジェクト、ヒープ数値参照として。ヒープ数値は SMI 形式に適合しない値(例えば double 型)を格納するため、または値にプロパティを設定する必要がある場合などのボックス化が必要な場合に使用される

文字列にも 2 つのストレージ方法がある:

  • VM ヒープ

  • renderer メモリ(外部)、外部ストレージスペースにアクセスするためのラッパーオブジェクトを作成する。例えば、スクリプトソースコードや Web から受信した他のコンテンツは VM ヒープにコピーされるのではなく外部ストレージスペースに配置される

新しい JS オブジェクトのメモリは専用 JS ヒープ(または VM ヒープ)から割り当てられ、これらのオブジェクトは V8 の GC によって管理される。したがって、それらへの強い参照が存在する限り、それらはアクティブであり続ける

Native Object

ネイティブオブジェクトは JS ヒープ外のすべてである。ヒープオブジェクトと比較して、ネイティブオブジェクトのライフサイクル全体は V8 の GC によって管理されず、ラッパーオブジェクトを通じてのみ JS からアクセスできる

Cons String

連結文字列(concatenated string)は格納され連結された文字列のペアで構成され、連結文字列のコンテンツを必要に応じて連結する。例えば、連結文字列のサブストリングを取得する必要がある場合

例えば、ab を連結して文字列 (a, b) を得て、次に d をこの結果と連結すると、別の連結文字列 ((a, b), d) が得られる

Array

配列は数値 key を持つオブジェクトである。V8 VM において広く使用され、大量のデータを格納するために使用され、辞書のキーバリューペアコレクションも配列形式で(格納)使用される

典型的な JS オブジェクトは 2 つの配列タイプに対応し、以下を格納するために使用される:

  • 名前付きプロパティ

  • 数値要素

プロパティ数が非常に少ない場合、JS オブジェクト自体の内部に配置できる

Map

オブジェクトの種類とそのレイアウトを記述するオブジェクト。例えば、map は高速なプロパティアクセスを実現するための暗黙的オブジェクト階層構造を記述するために使用される

Object group

(オブジェクトグループ内の)各ネイティブオブジェクトは相互に参照を保持するオブジェクトで構成される。例えば、DOM サブツリーの各ノードはその親、次の子、次の兄弟へのポインタを持ち、したがって接続グラフを形成する。ネイティブオブジェクトは JS ヒープには表示されないため、そのサイズは 0 である。ラッパーオブジェクトが作成される

各ラッパーオブジェクトは対応するネイティブオブジェクトへの参照を保持し、コマンドを自身にリダイレクトするために使用される。このように、オブジェクトグループはラッパーオブジェクトを保持する。しかし、回収できない循環は形成されない。GC は賢く、谁的ラッパーが参照されなくなったかを検知し、対応するオブジェクトグループを解放する。しかし、ラッパーの解放を忘れると、オブジェクトグループ全体と関連するラッパーを保持することになる

三.ツールの使い方

Task Manager

メモリ使用状況を大まかに確認するために使用

入口は 右上の 3 つの点 -> その他のツール -> タスクマネージャー。次に 右クリックヘッダー -> JS メモリ使用量をチェック。主に 2 つの列に注目:

  • メモリ列はネイティブメモリを表す。DOM ノードはネイティブメモリに格納される。この値が増加している場合、DOM ノードが作成されていることを示す

  • JS メモリ使用量列は JS ヒープを表す。この列には 2 つの値が含まれ、リアルタイム値(括弧内の数値)に注目する必要がある。リアルタイム数値はページ上のアクセス可能なオブジェクトが使用しているメモリ量を表す。この数値が増加している場合、新しいオブジェクトが作成されているか、既存のオブジェクトが成長している

Performance

メモリ変化傾向を観察するために使用

入口は DevTools の Performance パネル。次に Memory をチェック。ページ初回読み込み時のメモリ使用状況を見たい場合は、Command + R でページをリフレッシュすると、読み込みプロセス全体を自動的に記録する。特定の操作前後のメモリ変化を見たい場合は、操作前に「黒い点」ボタンをクリックして記録を開始し、操作完了後に「赤い点」ボタンをクリックして記録を終了する

記録完了後、中部の JS Heap をチェック。青い折れ線がメモリ変���傾向を表す。全体的な傾向が継続的に上昇し、大幅に回落しない場合、手動 GC で確認する:もう一度操作記録を行い、操作終了前または途中で数回手動 GC(「黒いゴミ箱」ボタンをクリック)を行う。GC の時点で折れ線が大幅に回落せず、全体的な傾向が継続的に上昇する場合、メモリリークが存在する可能性がある

またはより強引な確認方法:記録開始 -> 操作を 50 回繰り返す -> 自動 GC による大幅な下降があるか確認。使用メモリサイズが閾値に達すると自動 GC が発生する。リークがある場合、操作 n 回で必ず閾値に達する。メモリリーク問題が修正されたか確認するためにも使用できる

P.S. document 数(iframe 向け)、ノード数、イベントリスナー数、GPU メモリ占有の変化傾向も確認できる。その中でノード数とイベントリスナー数の変化も指導的意義がある

Memory

このパネルには 3 つのツールがある:ヒープスナップショット、メモリ割り当て状況、メモリ割り当てタイムライン:

  • ヒープスナップショット(Take Heap Snapshot)、各タイプオブジェクトの生存状況を具体的に分析するために使用。インスタンス数、参照パスなどを含む

  • メモリ割り当て状況(Record Allocation Profile)、各関数に割り当てられたメモリサイズを確認するために使用

  • メモリ割り当てタイムライン(Record Allocation Timeline)、メモリの割り当てと回収のリアルタイム状況を確認するために使用

その中でメモリ割り当てタイムラインとヒープスナップショットが有用。タイムラインはメモリリーク操作の特定に使用し、スナップショットは具体的な問題分析に使用する

具体的な使い方についての詳細は メモリ問題の解決 を参照

Record Allocation Timeline

タイムラインを開き、ページに対して様々な対話操作を行う。青い柱は新しいメモリ割り当てを表し、灰色は解放回収を表す。タイムライン上に規則的な青い柱が存在する場合、メモリリークが存在する可能性が非常に高い

その後繰り返し操作して観察し、どの操作が青い柱の残留を引き起こしているかを特定する

Take Heap Snapshot

ヒープスナップショットはさらに詳細な分析に使用し、リークしている具体的なオブジェクトタイプを特定する

ここまでに疑わしい操作を特定できているはず。スナップショットの各項の数値変化を観察することでリークオブジェクトタイプを特定する

ヒープスナップショットには 4 つの表示モードがある:

  • Summary:要約ビュー。サブアイテムを展開して Object's retaining tree(参照パス)を確認

  • Comparison:比較ビュー。他のスナップショットと比較し、増・減・Delta 数値とメモリサイズを確認

  • Containment:俯瞰ビュー。ヒープを上から下に見る。ルートノードには window オブジェクト、GC root、ネイティブオブジェクトなどを含む

  • Dominators:支配ツリービュー。新版 Chrome では削除されたよう。前述の用語概念部分で言及した支配ツリーを表示

最もよく使用されるのは比較ビューと要約ビュー。比較ビューは 2 回の操作と 1 回の操作のスナップショットを diff し、Delta 増分を確認し、どのタイプのオブジェクトが常に増加しているかを特定する。要約ビューはこの疑わしいオブジェクトタイプを分析し、Distance を確認し、奇妙な長経路上のどのリングが切断されなかったかを特定する

要約ビューを見る際の小さな常識は、新規追加は黄地に黒字、削除は赤地に黒字、元々存在したものは白地に黒字である。これが非常に重要

スナップショットの使い方についての詳細な図解は ヒープスナップショットの記録方法 を参照

四.調査ステップ

1.問題確認、疑わしい操作を特定

メモリリークが存在するかまず確認:

  1. Performance パネルに切り替え、記録を開始(最初から記録する必要がある場合)

  2. 記録開始 -> 操作 -> 記録停止 -> 分析 -> 繰り返し確認

  3. メモリリークが存在することを確認したら、範囲を絞り込み、どの対話操作が引き起こしているかを特定

Memory パネルのメモリ割り当てタイムラインを通じてさらに確認も可能。Performance パネルの利点は DOM ノード数とイベントリスナーの変化傾向を確認できること。パフォーマンスを低下させているのがメモリ問題か特定できていない場合、Performance パネルを通じてネットワーク応答速度、CPU 使用率などの要因も確認可能

2.ヒープスナップショット分析、疑わしいオブジェクトを特定

疑わしい対話操作を特定した後、メモリスナップショットを通じてさらに深く分析:

  1. Memory パネルに切り替え、スナップショット 1 を取得

  2. 疑わしい対話操作を 1 回行い、スナップショット 2 を取得

  3. スナップショット 2 と 1 を比較し、数値 Delta が正常か確認

  4. 疑わしい対話操作をもう 1 回行い、スナップショット 3 を取得

  5. 3 と 2 を比較し、数値 Delta が正常か確認し、Delta 異常のオブジェクト数値変化傾向を推測

  6. 疑わしい対話操作を 10 回行い、スナップショット 4 を取得

  7. 4 と 3 を比較し、推測を検証し、何が予想通りに回収されていないかを特定

3.問題特定、原因を特定

疑わしいオブジェクトを特定した後、さらに問題を特定:

  1. 該タイプオブジェクトの Distance は正常か。ほとんどのインスタンスが 3 級 4 級で、個別に 10 級以上は異常

  2. 経路深度 10 級以上(または明らかに他の同タイプインスタンスより深い)のインスタンスを確認し、何がそれを参照しているか

4.参照解放、修正検証

ここまでに基本的に問題の源を特定できている。次に問題を解決:

  1. この参照を切断する方法を考える

  2. ロジックフローを整理し、他の場所で不会再使用の参照が存在するか確認し、すべて解放

  3. 修正検証。解決していない場合、再特定

もちろん、ロジックフローの整理 は最初から行うことも可能。ツール分析を使用しながら、ロジックフローの脆弱性を確認し、両方同時に進め、最後に Performance パネルの傾向折れ線または Memory パネルのタイムラインで検証可能

五.一般的なケース

これらのシナリオではメモリリークの隐患が存在する可能性がある。もちろん、仕上げ作業を適切に行えば解決可能

1.暗黙的全局変数

function foo(arg) {
    bar = "this is a hidden global variable";
}

barwindow にフックされ、bar が巨大なオブジェクトまたは DOM ノードを指す場合、メモリの隐患となる

もう一つのあまり明白でない方法は、コンストラクタが直接呼び出される場合(new で呼び出さない):

function foo() {
    this.variable = "potential accidental global";
}

// Foo called on its own, this points to the global object (window)
// rather than being undefined.
foo();

または匿名関数内の this も、非厳密モードでは global を指す。lint チェックまたは厳密モードを有効にすることで、これらの明白な問題を回避可能

2.忘れられた timer または callback

var someResource = getData();
setInterval(function() {
    var node = document.getElementById('Node');
    if(node) {
        // Do stuff with node and someResource.
        node.innerHTML = JSON.stringify(someResource));
    }
}, 1000);

後続で idNode のノードが削除された場合、タイマー内の node 変数は依然としてその参照を保持し、遊離 DOM サブツリーが解放されない

コールバック関数のシナリオは timer と類似:

var element = document.getElementById('button');

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

element.addEventListener('click', onClick);
// Do stuff
element.removeEventListener('click', onClick);
element.parentNode.removeChild(element);
// Now when element goes out of scope,
// both element and onClick will be collected even in old browsers that don't
// handle cycles well.

ノードを削除する前にノード上のイベントリスナーを先に削除する必要がある。IE6 は DOM ノードと JS 間の循環参照を処理しない(BOM と DOM オブジェクトの GC 戦略がともに参照カウントのため)ため、メモリリークが発生する可能性がある。現代のブラウザではこの必要はない。ノードがアクセスできなくなった場合、リスナーは回収される

3.遊離 DOM の参照

var elements = {
    button: document.getElementById('button'),
    image: document.getElementById('image'),
    text: document.getElementById('text')
};

function doStuff() {
    image.src = 'http://some.url/image';
    button.click();
    console.log(text.innerHTML);
    // Much more logic
}

function removeButton() {
    // The button is a direct child of body.
    document.body.removeChild(document.getElementById('button'));

    // At this point, we still have a reference to #button in the global
    // elements dictionary. In other words, the button element is still in
    // memory and cannot be collected by the GC.
}

DOM ノード参照をキャッシュすることが多い(パフォーマンス考慮またはコード簡潔性考慮)が、ノードを削除する際にキャッシュされた参照を同期して解放する必要がある。否则遊離サブツリーが解放されない

もう一つのより隠れたシナリオ:

var select = document.querySelector;
var treeRef = select("#tree");
var leafRef = select("#leaf");
var body = select("body");

body.removeChild(treeRef);

//#tree can't be GC yet due to treeRef
treeRef = null;

//#tree can't be GC yet due to indirect
//reference from leafRef

leafRef = null;
//#NOW can be #tree GC

下図の通り:

[caption id="attachment_1464" align="alignnone" width="368"]treegc treegc[/caption]

遊離サブツリーの任意の 1 つのノード参照が解放されていない場合、サブツリー全体が解放できない。1 つのノードを通じて他のすべてのノードを見つける(アクセスする)ことができるため、すべてアクティブとしてマークされ、清除されない

4.クロージャ

var theThing = null;
var replaceThing = function () {
  var originalThing = theThing;
  var unused = function () {
    if (originalThing)
      console.log("hi");
  };
  theThing = {
    longStr: new Array(1000000).join('*'),
    someMethod: function () {
      console.log(someMessage);
    }
  };
};
setInterval(replaceThing, 1000);

console に貼り付けて実行し、Performance パネルの傾向折れ線または Memory パネルのタイムラインでメモリ変化を確認すると、非常に規則的なメモリリーク(折れ線が着実に上昇、毎秒 1 本の青い柱が真っ直ぐ)が発見できる

クロージャの典型的な実装方法は、各関数オブジェクトが辞書オブジェクトへのポインタを持ち、この辞書オブジェクトがその詞法的作用域を表す。replaceThing で定義された関数がすべて実際に originalThing を使用する場合、それらがすべて同じオブジェクトを取得する必要があるため、originalThing が何度も再割り当てされても、これらの(replaceThing で定義された)関数は同じ詞法環境を共有する

しかし V8 はすでに、どのクロージャによっても使用されない変数を詞法環境から削除するほど賢いため、unused を削除する(または unused 内の originalThing アクセスを削除する)ことで、メモリリークを解決できる

変数が任意のクロージャによって使用されると、詞法環境に追加され、該作用域下のすべてのクロージャによって共有される。これがクロージャがメモリリークを引き起こす鍵である

P.S. この興味深いメモリリーク問題の詳細情報は、An interesting kind of JavaScript memory leak を参照

六.その他のメモリ問題

メモリリーク以外にも、2 つの一般的なメモリ問題がある:

  • メモリ膨張

  • 頻繁な GC

メモリ膨張とは占有メモリが多すぎることを指すが、明確な限界はなく、デバイスごとにパフォーマンスが異なるため、ユーザー中心である必要がある。どのデバイスがユーザー層で人気があるかを理解し、これらのデバイスでページをテストする。体験が非常に悪い場合、ページにメモリ膨張の問題が存在する可能性がある

頻繁な GC は体験に非常に影響する(ページが停止する感じ、Stop-The-World のため)。Task Manager のメモリサイズ数値または Performance 傾向折れ線で確認可能:

  • Task Manager でメモリまたは JS メモリ使用量数値が頻繁に上昇下降する場合、頻繁な GC を表す

  • 傾向折れ線で、JS ヒープサイズまたはノード数が頻繁に上昇下降する場合、頻繁な GC が存在することを表す

ストレージ構造の最適化(細かい粒度の小さなオブジェクトの作成を避ける)、キャッシュ再利用(ファクトリーパターンを使用して再利用を実現)などの方法で頻繁な GC 問題を解決可能

参考資料

コメント

コメントはまだありません

コメントを書く