はじめに
以下のエラーに遭遇したことをきっかけに、ヒープについて調べました。
FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory
これは、 Node.js が内部で使っているメモリ領域(ヒープ)が上限に達し、新しいデータを置く場所がなくなったときに発生するエラーです。該当の Node.js プロセスは強制終了されます。
この記事では、以下の内容について扱います( ・ᴗ・ )
- ヒープとスタックの違い
- Node.jsプロセス全体のメモリ構造
- V8が採用している世代別ヒープ構造
スタックとヒープ
Node.js(V8エンジン)がメモリを管理する領域は大きく スタック と ヒープ の2つに分かれています。
スタック
スタックは、ローカル変数や関数呼び出し情報を格納するために使用されるメモリ領域です。サイズが固定されており、LIFO(後入れ先出し)で管理されます。関数が終わると格納していた情報は自動的に取り除かれ、GCは関与しません。
function greet() {
const name = '田中'; // ローカル変数 → スタックに積まれる
const age = 30; // ローカル変数 → スタックに積まれる
return name;
} // 関数終了 → スタックから自動的に取り除かれる
ヒープ
ヒープは、JavaScriptのオブジェクト・配列・クロージャのメモリが割り当てられるメモリ領域です。サイズは実行時に変動し、V8のGCが不要になったものを回収します。
const user = { name: '田中', age: 30 }; // オブジェクト → ヒープに確保される
const nums = [1, 2, 3]; // 配列 → ヒープに確保される
const greet = () => `Hello, ${user.name}`; // クロージャ → ヒープに確保される
2つの違いをまとめると以下の通りです。
| スタック | ヒープ | |
|---|---|---|
| 格納されるもの | ローカル変数や関数呼び出し情報 | オブジェクト・配列・クロージャ |
| サイズ | 固定 | 動的に変動 |
| 解放タイミング | スコープを抜けると自動 | GCが回収するまで残る |
| GCの関与 | なし | あり |
| アクセス速度 | 速い | 遅い |
🥕 補足
Node.jsは内部でGoogleのV8 JavaScriptエンジンを使ってJavaScriptを実行しています。
そのため、Node.jsのメモリ管理はV8のメモリ管理の仕組みに基づいています。
Node.jsプロセス全体のメモリ構造
process.memoryUsage() を使用すると、Node.jsプロセス全体のメモリ使用状況をバイト単位で確認できます。
ここで言う「プロセス全体」とは、実行中のファイルだけでなく、読み込まれているすべてのライブラリやV8エンジン本体も含んだ1つのNode.jsプロセスのことです。
実際に実行すると以下のような出力が得られます(値は実行環境によって異なります)。
const memory = process.memoryUsage();
console.log(memory);
// {
// rss: 44531712,
// heapTotal: 5345280,
// heapUsed: 3783720,
// external: 1305759,
// arrayBuffers: 10511
// }
返り値のフィールドにはそれぞれ異なる領域が対応しており、それらは入れ子の包含関係にあります。以下の図はその構造を示しています。
rss(Resident Set Size)
Node.jsプロセスがRAM(物理メモリ)上に確保している総メモリ量です。具体的には以下の3つで構成されています。
- ヒープ:オブジェクト・文字列・クロージャが格納される領域
- スタック:ローカル変数や関数呼び出し情報が格納される領域
- コードセグメント:実行するJavaScriptコード自体が置かれる領域
heapTotal
V8がヒープとしてあらかじめ確保した領域の合計です。実際に使われている部分(heapUsed)と、まだ空きの部分の両方を含みます。
heapUsed
heapTotalのうち、現在JavaScriptオブジェクトや配列が実際に使っている領域です。
external
JavaScriptオブジェクトと紐づいて、V8ヒープの外側にC++が確保するメモリです。
arrayBuffers
ArrayBuffer は、画像・音声・ファイルの生データなどバイナリデータをバイト列として格納するための低レベルなメモリ領域です。Node.jsの Buffer はこの ArrayBuffer をベースに実装されており、ファイル読み書きやネットワーク通信で広く使われています。SharedArrayBuffer は複数のWorkerスレッド間でメモリを共有できる ArrayBuffer の一種です。
arrayBuffers は、ArrayBuffer・Buffer・SharedArrayBufferに割り当てられたメモリの合計で、external の内訳に含まれます。
V8のヒープ構造
世代別GCは「新しく生成されたオブジェクトのほとんどは、すぐに不要になる」という考え方を前提としています。これを世代別仮説と呼びます。
この仮説に基づき、V8はヒープを 新世代(Young Generation) と 旧世代(Old Generation) の主に3つに分けて管理します。
それぞれの世代を頻度や方法の異なるGCで掃除することで、全体の効率を上げています。
V8のヒープは以下のように構成されています。
New Space(新世代)
新しく生成されたオブジェクトがまず配置される場所です。
内部はさらに Nursery(新生) と Intermediate(中間) の2段階に分かれています。
- サイズは小さく保たれており、GCは頻繁に・高速に行われる
- 1回のGCを生き延びたオブジェクトは Nursery → Intermediate へ移動する
- さらにもう1回GCを生き延びると Old Space へ移動する
Old Space(旧世代)
New SpaceのGCを2回生き延びたオブジェクトが移動してくる領域です。
- 長命なオブジェクト(グローバル変数、長期間参照されるキャッシュなど)が蓄積される
- 旧世代の生存オブジェクトが一定量を超えたとき、Mark-Sweep-CompactによるGCが発動する
- ヒープ全体を対象とするGCのため、処理が重い
Large Object Space
一定サイズを超える大きなオブジェクト(大きな配列など)専用の領域です。
大きなオブジェクトのコピーは非常にコストが高いため、コンパクション(移動)の対象外になります。
まとめ
| 概念 | ポイント |
|---|---|
| ヒープ | オブジェクト・配列などが動的に置かれる領域 |
| スタック | ローカル変数・関数呼び出し情報を格納。関数終了で自動的に取り除かれる |
| New Space | 新生オブジェクトの置き場。小さく・GCが頻繁 |
| Old Space | 長命オブジェクトの置き場。大きく・GCが重い |
| Large Object Space | 大きなオブジェクト専用。コンパクションの対象外 |
参考

