はじめに
ウェブゲームやウェブアプリのパフォーマンスが一定時間経過すると低下するというメールが届いたとします。コードを調べても特に問題は見つかりません。しかし、Chrome のメモリ パフォーマンス ツールを開くと、次のような結果が表示されます。

同僚の 1 人が、メモリ関連のパフォーマンスの問題があることに気づいて笑う。
メモリグラフビューでは、この鋸歯状のパターンは、重大なパフォーマンスの問題の可能性を示す非常に有用な情報です。メモリ使用量が増加すると、タイムライン キャプチャのグラフ領域も拡大します。グラフが急激に低下した場合は、ガベージ コレクタが実行され、参照されたメモリ オブジェクトがクリーンアップされたことを示します。

このようなグラフでは、ガベージ コレクション イベントが大量に発生していることがわかります。これはウェブアプリのパフォーマンスに悪影響を及ぼす可能性があります。この記事では、メモリ使用量を管理してパフォーマンスへの影響を軽減する方法について説明します。
ガベージ コレクションとパフォーマンスの費用
JavaScript のメモリモデルは、ガベージ コレクタと呼ばれる技術に基づいて構築されています。多くの言語では、システムのメモリヒープからメモリの割り当てと解放をプログラマが直接行います。一方、ガベージ コレクタ システムはプログラマーに代わってこのタスクを管理します。つまり、プログラマーがオブジェクトの参照を解除したときにオブジェクトがメモリから直接解放されるのではなく、GC のヒューリスティクスが解放が有益であると判断した後で解放されます。この意思決定プロセスでは、GC がアクティブ オブジェクトと非アクティブ オブジェクトに対して統計分析を実行する必要があります。この処理には時間がかかります。
ガベージ コレクションは、手動のメモリ管理とは対照的に説明されることが多く、手動のメモリ管理では、プログラマがどのオブジェクトを割り当て解除してメモリ システムに戻すかを指定する必要があります。
GC がメモリを再利用するためのプロセスは無料ではなく、通常は処理を行うために一定の時間を要するため、利用可能なパフォーマンスが低下します。また、実行するタイミングはシステム自体が決定します。このアクションは制御できません。GC パルスはコード実行中にいつでも発生する可能性があり、完了するまでコード実行がブロックされます。このパルスの所要時間は通常不明です。実行には、プログラムが特定の時点でメモリをどのように使用しているかによって、ある程度の時間がかかります。
高パフォーマンス アプリケーションでは、一貫したパフォーマンス境界を使用して、ユーザーにスムーズなエクスペリエンスを提供します。ガベージ コレクタ システムは、ランダムな時間にランダムな時間実行されるため、この目標を短絡する可能性があります。これにより、アプリケーションがパフォーマンス目標を達成するために必要な使用可能な時間が減ります。
メモリの切り替えを減らし、ガベージ コレクションの負荷を軽減する
前述のように、GC パルスは、一連のヒューリスティックによって、パルスが有益なほど十分な数の非アクティブ オブジェクトが存在すると判断された場合に発生します。そのため、ガベージ コレクタがアプリケーションで費やす時間を短縮する鍵は、オブジェクトの過剰な作成と解放をできるだけ排除することです。オブジェクトの作成と解放を頻繁に行うこのプロセスは「メモリ チャーン」と呼ばれます。アプリケーションの存続期間中にメモリ チャーンを減らすと、GC の実行にかかる時間も短縮されます。つまり、作成および破棄されるオブジェクトの数を削除または減らす必要があります。つまり、メモリの割り当てを停止する必要があります。
このプロセスにより、メモリグラフは次のようになります。

の行を以下のように変更します。

このモデルでは、グラフに鋸歯状のパターンはなく、最初は急激に増加し、その後は時間の経過とともにゆっくりと増加していることがわかります。メモリ チャーンが原因でパフォーマンスの問題が発生している場合は、このタイプのグラフを作成することをおすすめします。
静的メモリ JavaScript への移行
静的メモリ JavaScript は、アプリの開始時に、アプリの存続期間に必要なすべてのメモリを事前に割り当てし、オブジェクトが不要になったときに実行中にそのメモリを管理する手法です。この目標を達成するには、次の簡単な手順を行います。
- アプリケーションを計測して、さまざまなユースケースで必要なライブメモリ オブジェクトの最大数(タイプごと)を特定します。
- コードを再実装して、その最大量を事前に割り当て、メインメモリにアクセスせずに手動でフェッチ/解放します。
実際には、#1 を達成するには #2 の一部を行う必要があるため、まずは #2 から始めましょう。
オブジェクト プール
簡単に言うと、オブジェクト プーリングとは、型を共有する未使用のオブジェクトのセットを保持するプロセスです。コードに新しいオブジェクトが必要な場合は、システムのメモリヒープから新しいオブジェクトを割り振るのではなく、プール内の未使用のオブジェクトをリサイクルします。外部コードがオブジェクトの処理を完了すると、メインメモリに解放されるのではなく、プールに返されます。オブジェクトはコードから参照解除(削除)されることがないため、ガベージ コレクションは行われません。オブジェクト プールを使用すると、メモリの制御をプログラマが行えるため、ガベージ コレクタがパフォーマンスに及ぼす影響を軽減できます。
アプリケーションが保持するオブジェクトタイプは異種であるため、オブジェクト プールを適切に使用するには、アプリケーションの実行中に頻繁に変更されるタイプごとに 1 つのプールが必要です。
var newEntity = gEntityObjectPool.allocate();
newEntity.pos = {x: 215, y: 88};
//..... do some stuff with the object that we need to do
gEntityObjectPool.free(newEntity); //free the object when we're done
newEntity = null; //free this object reference
ほとんどのアプリケーションでは、新しいオブジェクトを割り振る必要性に関して、最終的にはある程度のレベルに達します。アプリを複数回実行することで、この上限を把握し、アプリの開始時にその数のオブジェクトを事前に割り振ることができます。
オブジェクトの事前割り当て
プロジェクトにオブジェクト プールを実装すると、アプリケーションの実行中に必要なオブジェクト数の理論上の最大値が決まります。さまざまなテストシナリオでサイトを実行すると、必要なメモリ要件の種類を把握できます。そのデータをどこかにカタログ化して分析し、アプリケーションのメモリ要件の上限を把握できます。
その後、アプリの製品版では、すべてのオブジェクトプールを指定した量にプリフィルするように初期化フェーズを設定できます。これにより、すべてのオブジェクトの初期化がアプリの先頭にプッシュされ、実行中に動的に発生する割り当ての量が減ります。
function init() {
//preallocate all our pools.
//Note that we keep each pool homogeneous wrt object types
gEntityObjectPool.preAllocate(256);
gDomObjectPool.preAllocate(888);
}
選択する量は、アプリケーションの動作に大きく影響します。理論上の最大値が最適な選択肢ではない場合があります。たとえば、平均最大値を選択すると、パワーユーザー以外のメモリ フットプリントを小さくできます。
万能薬ではない
静的メモリ増加パターンが有利なアプリには、さまざまな種類があります。ただし、Chrome DevRel の同僚である Renato Mangini が指摘しているように、いくつかの欠点があります。
まとめ
JavaScript がウェブに最適な理由の一つは、JavaScript が高速で、楽しく、簡単に始められる言語であるということです。これは主に、構文の制限が緩く、メモリの問題を自動的に処理するためです。コードを記述して、面倒な作業を任せることができます。ただし、HTML5 ゲームなどの高性能ウェブ アプリケーションでは、GC によって重要なフレームレートが低下し、エンドユーザー エクスペリエンスが低下することがあります。慎重な計測とオブジェクト プールの導入により、フレームレートに対するこの負荷を軽減し、その時間をより優れたものに活用できます。
ソースコード
オブジェクト プールの実装はウェブ上にたくさんあるので、ここでは別の実装を説明しません。代わりに、これらの方法を紹介します。それぞれに実装の違いがあります。各アプリケーションの使用方法に固有の実装ニーズがある可能性があるため、これは重要です。
- Gamecore.js のオブジェクトプール
- Beej のオブジェクト プール
- Emehrkay の超シンプルなオブジェクト プール
- Steven Lambert のゲームに特化したオブジェクトプール
- RenderEngine の objectPool の設定