JavaScriptの4種類のメモリリークとその対処方法

この記事では、クライアント側JavaScriptの一般的なタイプのメモリリークについて説明します。 また、Chrome開発ツールを使用してそれらを検出する方法も学びます。







Chrome Dev Toolsのタイムライン







翻訳者のメモ:この記事最初の部分は、翻訳者のメモでいっぱいです。 材料の分析中に、いくつかのポイントを個別に明確にする必要があることが明らかになりました。







エントリー



メモリリークは、遅かれ早かれすべての開発者が直面する種類のタスクに属します。 自動メモリ管理を備えた言語では、すべての問題を一度に解決できるわけではありません-メモリリークが発生する状況はまだあります。 リークは、ユーザーインターフェイスの遅延、フリーズ、他のアプリケーションの問題など、さまざまな問題を引き起こします。







メモリリークとは何ですか?



メモリリークとは、アプリケーションで不要になったメモリですが、何らかの理由でオペレーティングシステムまたは使用可能なメモリプールに返されません( 翻訳者のメモ: ヒープ内 )。 プログラミング言語は、メモリリークのリスクを減らすさまざまなアプローチを使用しますが、特定のメモリが必要かどうかというタスクは、 アルゴリズム的に解決できません( 翻訳者のメモ: 停止問題に要約されます )。 つまり、特定のメモリをオペレーティングシステムに返すことができるかどうかを判断できるのは開発者だけです。 プログラミング言語のメモリ管理は、手動と自動に分けられます。 最初のタイプは、メモリと直接対話するのに役立つツールのセットを開発者に提供します。 2番目には、「ガベージコレクター」と呼ばれる特別なプロセスがあり、自動的に呼び出されてメモリを削除します。







翻訳者注:これについては、ウィキペディアで詳しく説明しています: ガベージコレクション手動メモリ管理ガベージコレクション







JavaScriptメモリ管理



JavaScriptは、ガベージコレクターが組み込まれたプログラミング言語です。 コレクターは、アプリケーションに割り当てられたメモリフラグメントのうち、このアプリケーションのさまざまな部分から「到達可能」なものを定期的にチェックします。 つまり、ガベージコレクタは、「どのようなメモリがまだ必要ですか?」という質問を「どのようなメモリにアクセスできますか?」という質問に変換します。 違いは取るに足らないように見えますが、そうではありません:割り当てられたメモリのフラグメントが将来必要になるかどうかは開発者だけが知っていますが、到達不能メモリはアルゴリズムで計算され、OSに返されるようにマークされます。







ガベージコレクタを持たない言語は、さまざまな原則に基づいて機能します。 たとえば、明示的なメモリ管理があります。開発者は、このメモリの一部を削除できることをコンパイラに直接伝えます。 リンクをカウントするアルゴリズムもあります。このアルゴリズムでは、使用回数が各メモリブロックに関連付けられています(リセットされると、ブロックがOSに返されます)。 これらの手法には長所と短所があり、メモリリークを引き起こす可能性があります。







翻訳者注:リンクカウントアルゴリズムは、ガベージコレクターでも使用されます。 さらに、このアルゴリズムの基本的な形式での操作により、未使用のオブジェクトが相互に参照しあい、相互に削除をブロックする循環リンクが発生する可能性があることに注意してください。 詳細については、ウィキペディアをご覧ください







JavaScriptのメモリリーク



ガベージコレクター言語でのメモリリークの主な原因は、 不要なリンクです。 それが何であるかを理解するために、まず、ガベージコレクターがオブジェクトの到達可能性をチェックする方法を正確に見てみましょう。







マーキングアルゴリズム(マークアンドスイープ)



ほとんどのガベージコレクターは、 マークアンドスイープアルゴリズムを使用します。







  1. ガベージコレクターは、「ルートオブジェクト」または「ルート」のリストを作成します。 原則として、それらはコードで宣言されたグローバル変数になります。 JavaScriptでは、典型的なルートはwindow



    オブジェクトです。 window



    はページ全体に存在するため、ガベージコレクターは、このオブジェクトとその子孫が常にプログラムランタイムに存在することを理解します(つまり、ガベージになりません)。







  2. コレクターは、ルートとその子孫を再帰的に走査し、それらをアクティブにします(つまり、ガベージではありません)。 ルートから到達できるものはすべてゴミとは見なされません。







  3. 2番目の手順の後、アクティブとしてマークされていないメモリフラグメントは、ガベージと見なすことができます。 これで、コレクターはこのメモリを解放してOSに返すことができます。


最新のガベージコレクターはこのアルゴリズムを改善しますが、その本質は同じです。達成可能なメモリをマークし、残りをガベージとして宣言します。 これで、 不要なリンクを定義できます。これらは、ルートから到達可能なリンクですが、絶対に二度と必要とされないメモリの断片を指します。 JavaScriptでは、 不要なリンクはコードで忘れられた古い変数になり、より多くの不要なオブジェクトをメモリに保持します。 ところで、一部の人々は、これらは言語ではなく開発者の間違いだと考えています。







そのため、JavaScriptのメモリリークの原因を特定するには、 不要なリンクにつながる状況を理解する必要があります







翻訳者のメモ:さらに読む前に、 メモリ管理に関するMDNの記事をご覧になることをお勧めします。







JavaScriptで最も一般的な4つのタイプのメモリリーク



翻訳者注:以下の例では、クライアント側のJavaScriptを検討します。 不必要な説明や予約なしに、グローバルwindow



オブジェクトがあることを考慮します。 別の実行環境にはJavaScript window



がない場合がありますが、この記事で説明するリークの原因は関連しています。







1:ランダムグローバル変数



JavaScriptの開発における目標の1つは、Javaに似た言語を作成することでしたが、初心者でも使用できるほど厳密ではありません。 この言語の弱点の1つは、宣言されていない変数の処理です。このような変数にアクセスすると、 グローバルオブジェクトに新しい変数が作成されます。 したがって、コードを検討する場合:







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





実際には、次のことを意味します。







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





関数foo



スコープ内でのみ変数へのリンクをbar



含めたいが、宣言でvar



を指定するのを忘れると、グローバル変数が作成されます。 この場合、単純な文字列によってメモリリークが発生します。 それほど害はありませんが、もちろん、状況はさらに悪化する可能性があります。







ランダムなグローバル変数を作成する別の方法は、 this



を使用するthis



です:







 function foo() { this.variable = "potential accidental global"; } //  foo    , this   //    (window), //  ,   undefined. foo();
      
      





このようなエラーを回避するには、 'use strict'



追加します。 JavaScriptファイルの先頭へ。 これは、ランダムなグローバル変数の発生を防ぐJavaScriptの厳密な解析モードを含むディレクティブです。







グローバル変数に関する注意



ランダムについてではなく、明示的に宣言されたグローバル変数について説明します。 定義上、これらをnull



と同等にするか再割り当てしない限り、ガベージコレクターによって処理されません。 特に、これは一時的なストレージと大きなデータブロックの処理に使用されるグローバル変数に適用されます。 大量の情報を書き込むためにグローバル変数が必要な場合は、データの操作の最後にその値がnull



に設定されるか再定義されることを確認してください。







グローバル変数に関連するメモリ使用量の増加の例は、 キャッシュ -再利用可能なデータを保存するオブジェクトです。 効果的な運用のためには、サイズを制限する必要があります。 キャッシュが制限なしで大きくなると、ガベージコレクターによってキャッシュの内容をクリアできないため、メモリが大量に消費される可能性があります。







2:忘れられたタイマーとコールバック



多くの場合、 setInterval



関数の同様の使用法がありsetInterval









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





この例は、デッドタイマーがどのように有害であるかを示しています(古いノードまたはデータへのアクセス)。 ノードを削除すると、ハンドラー関数内のブロック全体が不要になります。 ただし、 setInterval



アクティブである限り、ハンドラーもアクティブであり、ガベージコレクターによってクリアできません(このため、最初にインターバルを停止する必要があります)。 そのため、その依存関係もメモリから削除できません。 おそらく大量のデータを格納するsomeResource



は、ガベージコレクターによってクリアできないことがsomeResource



ます。







コールバックについて話します。 ほとんどの場合、イベントハンドラーおよびサードパーティライブラリで使用されます。 ライブラリは通常、コールバックを処理する独自のイベントハンドラーおよびその他のサポートツールを作成します。 通常、オブジェクトが到達不能になった後に外部ハンドラーを削除する方法も提供します。







次に、イベントハンドラーの状況を検討します。 ハンドラーは、不要になった場合、または関連付けられたオブジェクトが使用できなくなった場合に削除する必要があります。 これは、一部のブラウザ(Internet Explorer 6)が循環リンクを正しく処理する方法を知らなかったため、過去に重要でした(下記の注を参照)。 最新のブラウザのほとんどは、オブジェクトに到達できなくなるとすぐにイベントハンドラーを削除します。 ただし、オブジェクト自体を削除する前に、イベントハンドラーを明示的に削除するのが、やはり好みのルールです。 例:







 var element = document.getElementById('button'); function onClick(event) { element.innerHtml = 'text'; } element.addEventListener('click', onClick); // - . element.removeEventListener('click', onClick); element.parentNode.removeChild(element); // ,      , //      ,  onClick. //       , //      .
      
      





イベントハンドラーと循環参照に関する注意



JavaScriptの開発者は、イベントハンドラーと循環参照を長い間問題と見なしてきました。 これは、Internet Explorerのガベージコレクターのバグ(または設計上の決定)によるものです。 Internet Explorerの古いバージョンでは、DOM要素とJavaScriptコード間の循環リンクを検出できませんでした。 これに加えて、通常、イベントハンドラにはイベントオブジェクトへの参照が含まれます(上記の例のように)。 これは、Internet ExplorerのリスナーがDOMノードに追加されるたびに、メモリリークが発生したことを意味します。 そのため、Web開発者は、DOMノードを削除する前にイベントハンドラーを明示的に削除するか、ハンドラー内のリンクを無効にし始めました。 最新のブラウザ(Internet ExplorerおよびMicrosoft Edgeを含む)は、循環リンクを見つけて正しく処理するアルゴリズムを使用します。 これで、ノードを削除する前にremoveEventListener



を呼び出す必要がremoveEventListener









jQueryなどのフレームワークとライブラリは、ライブラリAPIを使用して作成された場合、ノード自体を削除する前にハンドラーを削除します。 これはライブラリ自体によって行われ、古いInternet Explorerのような問題のあるブラウザで作業している場合でも、リークがないことを保証します。







3:DOMから削除された要素へのリンク



データ構造内にDOMノードを保存すると便利な場合があります。 テーブル内の複数の行の内容をポイントごとに更新するとします。 辞書または配列に各DOMシリーズへのリンクを保存することは理にかなっています。 この場合、2つのリンクは同じDOM要素を指します。1つはDOMツリーにあり、もう1つは辞書にあります。 将来これらの行を削除することにした場合は、両方のリンクを到達不能にする必要があります。







 var elements = { button: document.getElementById('button'), image: document.getElementById('image'), text: document.getElementById('text') }; function doStuff() { elements.image.src = 'http://some.url/image'; elements.button.click(); console.log(elements.text.innerHTML); //  . } function removeButton() { //     body. document.body.removeChild(document.getElementById('button')); //         #button //    elements. // ..        //       . }
      
      





これに加えて、DOMツリーの内部ノードへのリンクで何かする必要があります。 ( <td>



)テーブル内の特定のセルへのリンクをコードに保存するとします。 将来、DOMからテーブルを削除することにしましたが、このセルへのリンクは保持します。 直感的に、ガベージコレクターはこのセル以外のすべてをクリアすることを期待しています。 ただし、実際には異なります。セルはテーブルの子孫ノードであるため、親へのリンクが格納されます。 テーブルセルへのリンクは、テーブル全体をメモリに保存することを強制します。 DOM要素へのリンクを保存するときは、このことに留意してください。







4:短絡



JavaScriptはクロージャーの基本です。親スコープから変数を取得する関数です。 Meteor開発は、JavaScriptランタイムの実装の特性により、同様のトリッキーな方法でメモリリークを作成できる状況発見しました







 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);
      
      





この例では、 theThing



replaceThing



たびに、 theThing



は大きな文字列と新しいクロージャー( someMethod



)を含む新しいオブジェクトを取得します。 この場合、 unused



変数には、 originalThing



(以前のreplaceThing



呼び出しのreplaceThing



)を参照するクロージャーが含まれています。 既に何か恥ずかしいですよね?







スコープはまだ同じ親スコープにあるクロージャーに対して作成されるため、このスコープは共有されることに注意することが重要です。 この場合、 someMethod



のクローズのために作成されたスコープは、 someMethod



と共有されます。 unused



originalThing



へのリンクを保存します。 unused



および未使用ですが、 someMethod



someMethod



で使用できます。 someMethod



はスコープをunused



と共有するため、 unused



使用されない場合でもoriginalThing



を呼び出すと、常にアクティブのままになります(つまり、ガベージコレクターから保護されます)。







このコードを使用すると、使用メモリが常に増加するのを観察できます。 メモリの量は、ガベージコレクタが入っても減少しません。 実際、関連するクロージャーのリスト( theThing



変数の形式のルートを持つ)を作成します。これらのクロージャーの各スコープには、大きなメモリリークを表す大きな文字列への直接リンクが含まれています。 これは実装成果物です。 クロージャーの異なる実装を使用すると、この状況を潜在的に処理できます。これについては、Meteorブログで説明されています。







ガベージコレクターの明らかな動作



ガベージコレクターは便利ですが、欠点があり、その1つは非決定性です。 これは、ガベージコレクタが予測不能であることを意味します。通常、ガベージコレクションがいつ行われるかを判断することはできません。 その結果、プログラムが必要以上のメモリを消費する場合があります。 短い一時停止も観察できます。これは、アクションにすばやく応答するプログラムで特に顕著になります。







非決定論とは、ガベージコレクションがいつ行われるかを正確に言えないことを意味しますが、ガベージコレクタのほとんどの実装は同様の動作をします。 メモリが割り当てられていない場合、ガベージコレクタは現れません。 次のシナリオを検討してください。







  1. かなりの数のメモリ割り当てが発生しました。
  2. ほとんどの要素(おそらくすべて)は到達不能としてマークされました(たとえば、不要なキャッシュへのリンクをnull



    設定しnull



    )。
  3. 後続のメモリ割り当ては行われません。


この場合、ほとんどのガベージコレクターはそれ以上のアクションを実行しません。 つまり、処理可能な到達不能リンクがありますが、ガベージコレクターはそれらに影響しません。 このような小さなリークにより、アプリケーションは必要以上のメモリを消費します。 Googleはこの動作の優れた例を提供しました-JavaScriptメモリプロファイリングドキュメント、例#2







Chromeのプロファイリングツールの概要



Chromeは、JavaScriptでメモリ消費をプロファイリングするためのツールセットを提供します。 メモリを操作するための2つの重要なツールは、タイムラインタブとプロファイルタブです。







タイムラインタブ



タイムラインタブ







タイムラインタブは、異常なメモリ動作を検出するのに非常に役立ちます。 大きなリークを検索する場合は、ガベージコレクション後にわずかに減少する定期的なジャンプに注意してください。 スクリーンショットは、メモリリークオブジェクトの継続的な増加を示しています。 最後に大きなスイープを行った後でも、占有メモリの総量は最初よりも大きくなります。 DOMノードの数も増加しています。 すべては、DOMノードに関連付けられたコードにリークがあることを示しています。







[プロファイル]タブ



[プロフアイル]タブ







ほとんどの時間をこのタブで作業します。 プロファイルを使用すると、メモリのスナップショットを作成し、それらを相互に比較できます。 メモリ配布プロセスを記録することもできます。 どのモードでも、さまざまなタイプの出力を使用できますが、最も重要なのは一般リストと比較リストです。







一般的なリストは、さまざまなタイプの関連オブジェクトの概要とそれらのサイズの組み合わせを提供します: 浅いサイズ (表面サイズ、特定のタイプのすべてのオブジェクトの合計)および保持サイズ (保持サイズ、表面サイズ、およびこれに関連する他のオブジェクトのサイズ)。 また、オブジェクトがそのルート( 距離フィールド)からどれだけ離れているかを知ることもできます。







比較リストは同じ情報を提供し、異なるスナップショットを比較することを可能にします。 これは、メモリリークを見つけるために特に重要です。







例:Chromeを使用してエラーを検索する



メモリリークには主に2つのタイプがあります。メモリ使用量の定期的な増加を引き起こすリークと、それ以上メモリを増加させない単一のリークです。 明らかに、定期的なリークを追跡する最も簡単な方法。 さらに、それらは最も危険です:消費されるメモリが絶えず増加している場合、最終的にそのようなリークはブラウザを遅くするか、スクリプトの実行を停止します。 非周期的な漏れは、他の人の間で認識されるのに十分な大きさであれば簡単に見つけることができます。 通常、深刻な問題を引き起こすことはないため、多くの場合検出されません。 一度だけ発生するリークは、最適化の問題としてのみ考慮することができます。 ただし、定期的なリークは修正が必要な完全なバグです。







Chromeドキュメントの例のコードを検討してください。







 var x = []; function createSomeNodes() { var div, i = 100, frag = document.createDocumentFragment(); for (;i > 0; i--) { div = document.createElement("div"); div.appendChild(document.createTextNode(i + " - "+ new Date().toTimeString())); frag.appendChild(div); } document.getElementById("nodes").appendChild(frag); } function grow() { x.push(new Array(1000000).join('x')); createSomeNodes(); setTimeout(grow,1000); }
      
      





grow



関数が呼び出されると、 <div>



ノードの作成とDOMへの追加が開始されます。 また、大きな文字列を関連付けて、グローバルスコープで作成された配列にアタッチします。 これにより、メモリが着実に増加し、調査したツールを使用して検出できます。







ガベージコレクターを持つ言語は、メモリスケジュールの変動によって特徴付けられます。 これは、通常のように、メモリの伝播が周期的である場合に予想されます。 ガベージコレクターを実行した後、元の状態に戻らないメモリの定期的な増加を検討します。







定期的なメモリ増加を検出する方法



これを行うには、 タイムラインタブが必要です。 Chromeで開き、開発ツールを開き、 タイムラインを選択し、 メモリを選択して、エントリをクリックします。 次に、ページに移動して[ The Button



]をクリックします。 メモリリークが始まります。 しばらくして、記録を停止し、結果を確認します。







テーブルの例







この例では、メモリリークが毎秒作成され続けます。 記録を停止した後、スクリプトが停止してChromeがページを閉じないように、 grow



関数にブレークポイントを設定します。 このスクリーンショットには、メモリリークの2つの大きな兆候があります。ノードのグラフ(ノード、緑色の線)とJavaScriptコードのグラフ(青色の線)です。 DOMノードは常に成長し、縮小することはありません。 これは考える理由です。







JavaScriptコードグラフは、メモリ消費量の着実な増加も示しています。 ガベージコレクターのため、認識が困難です。 最初にメモリがどのように増加し、次に減少し、再び増加してジャンプし、続いてメモリがさらに減少するかなどを確認できます。 この状況で重要なのは、メモリをクリーニングするたびに、その合計サイズが以前のものよりも大きくなることです。 つまり、ガベージコレクターはかなりの量のメモリを解放しますが、それでも一部は定期的にリークします。







これで、リークがあることが明らかになりました。 彼女を見つけましょう。







2つのスナップショットを撮る



リークを見つけるには、 プロファイルセクションに移動します 。 メモリの量を制御するには、ページをリロードします。 ヒープスナップショットの取得機能が必要になります。







ページを再読み込みし、読み込みが完了したらすぐに写真を撮ります。 この写真を基礎として撮影します。 その後、 The Button



もう一度押して、数秒待ってから2番目のショットを撮ります。 写真を撮った後は、スクリプトにブレークポイントを追加して、メモリの消費を停止することをお勧めします。







スナップショットの例1







2つのスナップショット間のメモリの広がりを追跡するには、2つの方法があります。 [ サマリー ]を選択してから、スナップショット1とスナップショット2の間に割り当てられオブジェクトを右クリックするか、[ サマリー ]ではなく[ 比較]を選択できます。 どちらの場合も、2つのショットの間に生じたオブジェクトのリストが表示されます。







この例では、リークを検出するのは非常に簡単です。リークは大きいです。 Size Deltaコンストラクター(string)



注意してください。 8 MBおよび58の新しいプロパティ。 : , , 8 MB.







(string)



, . . - , - retainers .







スナップショット2の例







, . , x



. , — , . . 素晴らしい。 — . DOM-, . , . Chrome , — Record Heap Allocations .







Record Heap Allocations



, , . . Record Allocation Timeline . , . . , ( , Chrome ).







レコードヒープ割り当ての確認







, : , . . : ( (string)



), — DOM-, — Text



( DOM-).







HTMLDivElement



Allocation stack .







ヒープ割り当ての記録-選択したアイテム







! , ( grow



-> createSomeNodes



). , , HTMLDivElement



. , , , . , , . . , , ( createSomeNodes



). .









: , Dev Tools -> Settings "record heap allocation stack traces" .







Allocation Summary :







メモリ割り当てリスト







. grow



createSomeNodes



. grow



, . (string)



, HTMLDivElement



Text



, , , .







. それらを使用します。 ( ). , , , , ( ).







追加資料



Memory Management — Mozilla Developer Network

JScript Memory Leaks — Douglas Crockford (old, in relation to Internet Explorer 6 leaks)

JavaScript Memory Profiling — Chrome Developer Docs

Memory Diagnosis — Google Developers

An Interesting Kind of JavaScript Memory Leak — Meteor blog

Grokking V8 closures







おわりに



, , JavaScript, . , . , . . , . , .










: 4 Types of Memory Leaks in JavaScript and How to Get Rid Of Them , : Sebastián Peyrott .







: aalexeev , : iamo0 , jabher , spearance , zeckson , , .








All Articles