JavaScript - スタックとヒープの仕組み入門
はじめに
今回はメモリにおけるスタック領域とヒープ領域について整理します。
プログラムにおいて、関数が呼び出されたり、オブジェクトや配列を作ったりする時に、メモリ上で何が起こっているのでしょうか。
JavaScriptをはじめ、多くのプログラミング言語のランタイムやOSはメモリを効率よく管理するためにコールスタックとメモリヒープという2つの異なる領域を使い分けています。
この記事では、この2つのメモリ領域の役割、視覚的な挙動、なぜLIFOが必要なのか、そしてコードの書き方でメモリの挙動がどう変わるのかを整理します。
サンプルコードはJavaScriptに統一します。
スタック・ヒープとは?
コンピュータのメモリ(RAM)という限られたリソースを安全に、高速に使い回すため、データの性質に合わせて保管場所を明確に分けています。
- スタック(コールスタック: CallStack): サイズが小さく、いつ始まりいつ終わるかが予測できるデータを格納する。高速に処理できる。
- ヒープ(メモリヒープ: Memory Heap): サイズが大きかったり、いつまで使われ続けるのか予測できないようなデータを格納する。スタックより遅い。
それぞれ具体的な仕組みを見ていきます。
コールスタック(Call Stack)
コールスタックは、実行中の処理(関数)を順番に積み上げるメモリ領域です。
- 仕組み : 最初に入れたデータを最後に取り出す (LIFO: Last In, First Out) という、本を縦に積み上げるようなルールで動きます。
- 動き :
- 関数が呼ばれると、スタックの一番上に追加(Push)される
- その関数が終わる(returnする)と、一番上から取り除かれる(Pop)
- 全ての処理が終わると、スタックは空になる
具体例
以下のように、関数が別の関数を呼び出すネスト処理を考えてみます。
function third() {
console.log('3. third()の処理');
}
function second() {
console.log('2. second()の開始');
third();
console.log('4. second()の終了');
}
function first() {
console.log('1. first()の開始');
second();
console.log('5. first()の終了');
}
first();まず、first()を呼び出しているので出力は以下のようになりますね。
// 出力結果
1. first()の開始
2. second()の開始
3. third()の処理
4. second()の終了
5. first()の終了この挙動はまさにLIFOであるスタックの挙動を示しています。
STEP 1. first() が呼ばれて積まれる(Push)
[ ]
[ first() ] <-- push
-----------------
スタック:[first]STEP 2. second() が呼ばれて積まれる(Push)
[ second() ] <-- push
[ first() ] <-- 未完了
-----------------
スタック:[first, second]STEP 3. third() が呼ばれて積まれる(Push)
[ third() ] <-- push
[ second() ] <-- 未完了
[ first() ] <-- 未完了
-----------------
スタック:[first, second, third]STEP 4. third() が終わって消える(Pop)
[ ] <-- third()が取り除かれる(pop)
[ second() ] <-- 処理がここに戻る
[ first() ] <-- 未完了
-----------------
スタック:[first, second]STEP 5. second() が終わって消える(Pop)
[ ] <-- second()が取り除かれる(pop)
[ first() ] <-- 処理がここに戻る
-----------------
スタック:[first]STEP 6. first() が終わって消える(Pop)
最後に、firstに処理が戻り、完了するとpopされスタックが空になります。
[ ] <-- first()が取り除かれる(pop)
-----------------
スタック:[]なぜLIFO(後入れ先出し)なのか
もし仮に、これが先入れ先出し(FIFO)だとどうなるでしょうか?
関数がネストになっている場合、「最後に呼び出した関数を一番最初に処理完了し、元の呼び出し元へ戻る」という手順を踏まないと、処理の順番が破綻してしまいます。
また、自分自身を呼び出す再帰関数が動くのも、このLIFO構造のためです。
一番奥まで進んだ処理の結果から順に、綺麗に巻き戻して外側へ持ち帰ることができるため、複雑なネストや再帰的なプログラムが成立します。
⚠️スタックオーバーフロー
再帰関数などで終了条件を忘れて無限ループになると、コールスタックが限界まで積み上がって溢れます。
それがスタックオーバーフロー(RangeError: Maximum call stack size exceeded)です。
メモリヒープ (Memory Heap)
コールスタックがLIFOのような構造をもち「今実行中の関数」を高速に処理する場所であるのに対し、メモリヒープは乱雑に空いている場所にデータを置いておける場所です。
- 仕組み: オブジェクトや配列、関数定義などの「サイズが大きく、いつまで生き残るか予測できないデータ」を置いておくための非構造化された巨大なメモリ倉庫です。 スタックが「整理棚」のように綺麗に順番を守るのに対し、ヒープは「広い部屋にダンボール箱をバラバラに置くような、無秩序で自由度が高い空間」になっています。
- 主な特徴:
- 動的なサイズ拡張: データが増えたり減ったりしても、空いているスペースに柔軟に配置できる。
- アドレスによる管理: データの実体はヒープのあちこちに置かれるため、スタック上の変数にはその参照(ポインタ)だけが保持される。
- ガベージコレクション(GC): どこからも参照されなくなった不要なデータは、ランタイムのGCによって自動的に消去される(メモリ解放)。
具体例
次のようなオブジェクトを生成して返す関数を考えてみます。
function createUser() {
const user = { name: "Tom", age: 30 };
return user;
}
const myUser = createUser();createUser()を呼び出し、myUserに結果を代入する際の挙動を整理します。
STEP 1: createUser() が呼び出され、スタックにフレームが積まれる
createUser()を呼ぶとスタックに積まれ、その関数の処理が始まります。
スタックの状態: [ createUser() ]
STEP 2: オブジェクトが作られヒープに「実体」が置かれる(アロケーション)
コード内の{ name: "Tom", age: 30 };が実行される際、ヒープ上の空いているスペースに置かれます。
ヒープの状態: メモリのどこかに{ name: "Tom", age: 30 };が置かれる
STEP 3: スタックの変数にヒープの参照が書き込まれる
スタック側にある変数userに、step2で置かれたオブジェクトの参照が書き込まれる
STEP 4: 呼び出し元へ参照が返され、createUser() が消える(Pop)
return user;によりmyUserにオブジェクトの参照が渡されます。
createUser()はreturnしてるので処理完了により、スタックから消えます(Pop)。
STEP 5: ガベージコレクションによるデータの掃除
もしこの後、myUser = null;のように書き換えると、どこからもヒープ上のオブジェクト({name: 'Tom'...})は参照されなくなります。
そうなると、OSやランタイム(JavaScriptの場合、V8エンジン)が自動でヒープから消去します(メモリ解放)。
⚠️補足: グローバル変数や関数定義はどこに置かれる?
上記の例ではヒープの動きをシンプルに説明しているためグローバル変数myUserやcreateUser()の定義のアロケーションは省いています。
実際には、
- 関数定義: JavaScriptでは関数定義もオブジェクトの1種です。そのため、コードが読まれると関数の実態もヒープに置かれます
- グローバル領域と変数: コールスタックの一番下には、プログラム全体を動かすための「グローバル領域(グローバルフレーム)」が土台として常に存在しています。グローバル変数や、関数内の変数(ヒープの参照を持つ変数)は、グローバル領域やスタック領域で管理されます。
おわりに
今回はコールスタックとメモリヒープの基本的な仕組みと、LIFOである理由について見てきました。
次回の記事では、実装によるメモリ挙動の違い(値コピーと参照コピー、クロージャのメモリ挙動、関数のstatic呼び出しとnewの違い)といった内容を整理しようと思います。