← 記事一覧

テクノロジー

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の違い)といった内容を整理しようと思います。