信頼度ランク
| S | 公式ソース確認済み |
| A | 成功実績多数・失敗例少数 |
| B | 賛否両論 |
| C | 動作未確認・セキュリティリスク高 |
| Z | 個人所感 |
プログラマーが知るべき「メモリ」の正体 ── RAM・CPU・OS・プロセスの関係を0から
RAM とは何か、CPU はどうメモリを使うか、OS はどう管理するか、プロセスのメモリマップとは何か。スタックとヒープの「物理的な正体」から、Java/JVM の理解に繋がる根本的なコンピュータメモリ入門。
一言結論
メモリは「番地付きの棚」。CPU は命令を読み、OS は仮想的な空間を与え、プロセスはその中でスタックとヒープを使う。この構造を理解すると、Java・C・Rust のメモリモデルがすべて同じ土台の上にあることがわかる。
「スタックとヒープの違いは?」と聞かれて答えられる人は多い。でも「なぜそもそもスタックとヒープという分け方をするのか」まで説明できる人は急に減る。
その答えは Java の仕様書にはない。コンピュータのハードウェアと OS の設計にある。
この記事では、「メモリとは物理的に何なのか」から出発して、CPU・OS・プロセス・そして Java/JVM へと、1 段ずつ積み上げて繋げる。プログラミング言語を問わず使える根本的な知識だ。
1. メモリ(RAM)とは何か
コンピュータには様々な「記憶」がある。SSD・HDD はデータを永続的に保存する。でもプログラムが「今動いている最中に使う作業場所」として使うのが RAM(Random Access Memory) だ。
RAM を一言で言うと、「番地付きの棚」 だ。
【RAM のイメージ(実際は何十億もあるが、簡略化)】
アドレス 内容(1バイト = 8ビット)
─────────────────────────────────────
0x0000 01001010
0x0001 11000011
0x0002 00000000
0x0003 10110101
0x0004 01111110
...
0xFFFF 00100001
- アドレス: 棚の番号。0 番から始まり、メモリサイズ分の番号が振られている
- 1 バイト: 各棚に入る量。8 ビット(0 か 1 が 8 個)
- 8GB の RAM: 約 85 億個の棚がある
RAM の「Random Access」とは「どの番地でも同じ速さでアクセスできる」という意味だ。先頭から順番に読まないといけない磁気テープと対照的な名前として生まれた言葉。
RAM は「揮発性」
RAM の重要な性質として、電源を切ると中身が消える(揮発性)。プログラムを実行するとき、SSD の実行ファイルが RAM に読み込まれるのはこのためだ。SSD から直接実行するには遅すぎる。CPU が高速に読み書きできる RAM にコピーしてから動かす。
電源オン時の流れ:
SSD/HDD RAM CPU
┌──────────────┐ ┌──────────────┐ ┌──────┐
│ プログラム │──────►│ プログラムの │──────►│ 実行 │
│ (永続保存) │ 読込 │ コピー │ 使用 │ │
└──────────────┘ └──────────────┘ └──────┘
遅い(μs単位) 速い(ns単位) 超速(ps単位)
2. CPU とメモリの関係 ── メモリ階層という概念
CPU はメモリからデータを読み、計算し、メモリに書き戻す。しかし CPU の処理速度と RAM の速度には大きな差がある。
CPU の処理速度: 数十億命令 / 秒(GHz 単位)
RAM のアクセス: 数十〜数百 ns(CPU からすると数十〜数百クロック待ち)
CPU が命令を処理するたびに RAM を待っていたら、CPU の性能のほとんどが無駄になる。そこで登場するのがメモリ階層だ。
【メモリ階層(速い順)】
┌─────────────┐
│ レジスタ │ 容量: 数十バイト
│ (CPU内) │ 速度: 1クロック
└──────┬──────┘
│
┌──────┴──────┐
│ L1 キャッシュ│ 容量: 32〜64KB
│ (CPU内) │ 速度: 4クロック
└──────┬──────┘
│
┌──────┴──────┐
│ L2 キャッシュ│ 容量: 256KB〜1MB
│ (CPU内/近く)│ 速度: 12クロック
└──────┬──────┘
│
┌──────┴──────┐
│ L3 キャッシュ│ 容量: 8〜64MB
│ (CPU近く) │ 速度: 40クロック
└──────┬──────┘
│
┌──────┴──────┐
│ RAM │ 容量: 数GB〜数十GB
│ (マザーボード)│ 速度: 200クロック
└──────┬──────┘
│
┌──────┴──────┐
│ SSD/HDD │ 容量: 数TB
│ │ 速度: 数万〜数億クロック
└─────────────┘
上に行くほど速く、容量が小さく、高価
レジスタは CPU 内部にある超高速の記憶場所だ。int i = 5; のような計算は実際にはレジスタ上で行われる。i という変数は「特定のレジスタ(または RAM 上のアドレス)に名前をつけたもの」と言える。
キャッシュは「よく使うデータを CPU の近くに置いておく」仕組みだ。プログラムには局所性(直前に使ったデータや命令の近くを再び使う傾向)があるため、キャッシュは非常に効果的に機能する。Java の配列が ArrayList より速い場面があるのも、配列はメモリ上で連続しているためキャッシュに乗りやすいからだ。
3. OS によるメモリ管理 ── 「仮想メモリ」という抽象化
複数のプログラムを同時に動かすとき(ブラウザ・エディタ・Slack が同時に動くように)、各プログラムが好き勝手にメモリのアドレスを使ったら衝突する。これを解決するのが OS の**仮想メモリ(Virtual Memory)**だ。
プロセスごとに「独立した仮想的なアドレス空間」を与える
OS は各プログラム(プロセス)に対して、「0 から始まる自分だけのアドレス空間がある」という幻想を与える。各プロセスは自分のアドレス空間しか見えない。
【物理メモリ(RAM 実体)】 【各プロセスから見える仮想アドレス空間】
実アドレス プロセス A の仮想空間
┌──────────┐ ┌──────────┐ 0x0000
│ OS カーネル│ │ 空き │
├──────────┤ ├──────────┤
│ プロセスA │◄─ マッピング ── │ コード │
│ のデータ │ ├──────────┤
├──────────┤ │ ヒープ │ ↓ 伸びる
│ プロセスB │ ├──────────┤
│ のデータ │ │ │
├──────────┤ ├──────────┤
│ プロセスC │ │ スタック │ ↑ 伸びる
│ のデータ │ └──────────┘ 0xFFFF...
└──────────┘
プロセス B の仮想空間
┌──────────┐ 0x0000
│ 同じ構造 │
│ でも別物 │
└──────────┘ 0xFFFF...
プロセス A が 0x3000 番地に書いても、プロセス B の 0x3000 番地には影響しない。OS が仮想アドレスを物理アドレスに変換(マッピング)しており、両者は実際には別々の物理メモリを指しているからだ。
この仕組みをページングと呼ぶ。メモリを「ページ」(通常 4KB)単位に分け、仮想ページと物理ページの対応表(ページテーブル)を OS が管理する。
なぜこれが重要か
- 隔離(Isolation): あるプロセスのバグが他プロセスのメモリを壊せない。Javaプログラムがクラッシュしても他のプロセスへの影響がない
- セキュリティ: プロセスが他プロセスのメモリを勝手に読めない
- 「メモリ不足」を遅らせる(スワッピング): 物理 RAM が足りなくなったとき、OS は使われていないページを SSD/HDD に退避(スワップ)できる。アプリから見るとメモリがあるように見えるが、スワップが頻発すると極端に遅くなる
4. プロセスのメモリマップ ── 「スタックとヒープ」の物理的な正体
OS からメモリ空間を与えられたプロセスは、その中を役割ごとに区画分けする。これを**メモリマップ(メモリレイアウト)**と呼ぶ。
【64bit プロセスの仮想アドレス空間(概念図)】
高アドレス ──────────────────────────────
┌────────────────────────────┐
│ カーネル空間 │ OS が使う(ユーザーは触れない)
├────────────────────────────┤
│ スタック(Stack) │ ↑ 上から下へ伸びる
│ ローカル変数 │
│ 関数呼び出し記録 │
├─────────────┬──────────────┤
│ │ │
│ (空き) │ │
│ ↓ │
├────────────────────────────┤
│ ヒープ(Heap) │ ↑ 下から上へ伸びる
│ malloc / new したもの │
├────────────────────────────┤
│ BSS 領域 │ 未初期化のグローバル変数
├────────────────────────────┤
│ データ領域 │ 初期化済みのグローバル・static 変数
├────────────────────────────┤
│ テキスト領域(コード) │ 実行する機械語命令
低アドレス ──────────────────────────────
各領域の役割:
| 領域 | 何が入るか | 特徴 |
|---|---|---|
| テキスト | 実行命令(機械語) | 読み取り専用。書き換えるとセグフォ |
| データ | 初期化済み static/グローバル変数 | プログラム起動時に確定 |
| BSS | 未初期化 static/グローバル変数 | OS が 0 埋めして渡す |
| ヒープ | malloc / new したオブジェクト | 低→高アドレス方向に伸びる |
| スタック | ローカル変数・関数呼び出し情報 | 高→低アドレス方向に伸びる |
スタックはなぜ「逆方向」に伸びるのか
スタックが高アドレスから低アドレス方向に伸びる(つまり積むほどアドレスが小さくなる)のは、歴史的な経緯とハードウェアの都合による慣習だ。重要なのは「スタックとヒープが両端から中央に向けて伸びる」設計になっていること。
低アドレス ← ヒープ成長方向 スタック成長方向 → 高アドレス
[コード][データ][ヒープ ─────────────────────── スタック]
↑ ↑
どんどん伸びる どんどん伸びる
(new するたびに) (関数を呼ぶたびに)
ヒープとスタックが衝突するほどメモリを使うと**OOM(Out of Memory)**になる。
5. スタックの実体 ── 関数呼び出しの記録係
スタックに積まれる「フレーム」の中身を、もう少し掘り下げてみる。
// C 言語の例(Java ではなく、より低レベルで確認)
int add(int a, int b) {
int result = a + b;
return result;
}
int main() {
int x = 3;
int y = add(x, 7);
return 0;
}
main が add を呼んだとき、スタックでは何が起きているか:
【スタック(高アドレス → 低アドレス方向に積まれる)】
高アドレス
┌───────────────────────────────────┐
│ main フレーム │
│ x = 3 │
│ y = (未確定) │
│ 戻りアドレス: OS │ ← main 終了後に戻る場所
├───────────────────────────────────┤ ← スタックポインタ(add 呼び出し前)
│ add フレーム │
│ a = 3 (引数) │
│ b = 7 (引数) │
│ result = 10 (ローカル変数) │
│ 戻りアドレス: main の y = の行 │ ← add 終了後に戻る場所
├───────────────────────────────────┤ ← 現在のスタックポインタ
│ (空き) │
低アドレス
**スタックポインタ(SP)**という CPU レジスタが「スタックの現在位置」を常に指している。関数を呼ぶとき SP を下げる(=フレームを積む)、関数が終わると SP を上げる(=フレームを捨てる)。
これが「スタックのローカル変数は関数終了と同時に消える」の物理的な正体だ。メモリの内容が書き換えられるのではなく、スタックポインタが戻るだけ。実際のデータはしばらく残っているが、次のフレームが同じ場所を上書きする。
スタックは速い理由
スタックへのアクセスは「スタックポインタから数バイトずれたアドレス」を読むだけだ。計算は単純で、しかも最近使ったデータはCPUのキャッシュに乗っている可能性が高い。これがスタックが「速い」理由だ。
6. ヒープの実体 ── 自由だが管理コストがかかる
ヒープはスタックと違い、「どのサイズでも・どのタイミングでも・好きな場所に」確保できる。その代わり、使い終わったら明示的に(CやC++では手動で、Javaでは GC が自動で)解放しないといけない。
// C 言語の例
int *p = malloc(100 * sizeof(int)); // ヒープに 400 バイト確保
// ... 使う ...
free(p); // 解放(Java の GC に相当する作業を自分でやる)
ヒープの確保・解放を繰り返すと、断片化(フラグメンテーション)が起きる:
【断片化のイメージ】
確保直後:
[A: 100B][B: 200B][C: 50B][空き: 650B]
B を解放:
[A: 100B][空き:200B][C: 50B][空き: 650B]
300B の確保を試みる:
→ 200B と 650B の空きがあるが、連続していないため確保できない場合がある
[A: 100B][空き:200B][C: 50B][D: 300B][空き: 350B]
↑連続した空きがなく、単純には入らない
Java の GC がオブジェクトを移動(コンパクション)するのは、この断片化を解消するためでもある。
7. C・Java・Rust ── 同じ土台の上にある
ここまでの話は C 言語を例にしてきたが、Java も同じ土台の上で動いている。
物理マシン(CPU + RAM)
│
▼
OS(Linux / macOS / Windows)
│ プロセスを起動、仮想メモリを与える
▼
JVM プロセス(java コマンド)
│ OS から大きな仮想メモリブロックをもらう
│ その中を「Java のヒープ」「Metaspace」等に自分で分割して管理
▼
Java プログラム
JVM は OS から見ると「ただの 1 プロセス」だ。OS からメモリを受け取り、その中を Java 流に再配布している。
| 管理層 | スタック | ヒープ |
|---|---|---|
| C 言語 | OS が管理(呼び出し規約) | malloc / free をプログラマが管理 |
| Java | JVM が管理(スタックフレーム) | JVM の GC が自動管理 |
| Rust | OS が管理(Cと同じ) | 所有権システムがコンパイル時に管理(GC なし) |
言語が変わってもスタックとヒープの物理的な意味は変わらない。変わるのは「ヒープの解放を誰がどう行うか」だけだ。
8. 全体像を繋げる
ここまでの内容を一枚の図に収める:
【コンピュータのメモリ全体像】
CPU
┌────────────────────────────┐
│ レジスタ(変数の実体) │
│ L1/L2/L3 キャッシュ │
│ ← メモリ階層の頂点 │
└──────────────┬─────────────┘
│ アドレスバスで接続
▼
RAM(物理メモリ)
┌────────────────────────────┐
│ 番地付きの棚。電源切で消える│
│ OS が全体を管理 │
└──────────────┬─────────────┘
│ OS が仮想メモリで抽象化
▼
プロセスの仮想アドレス空間
┌────────────────────────────┐
│ テキスト … 実行命令 │
│ データ … static 変数 │
│ ヒープ … new したもの │ ← Java: GC が管理
│ ↕ │
│ スタック … ローカル変数 │ ← Java: JVM が管理
└────────────────────────────┘
│ JVM はこの仮想空間の中で
▼ さらに独自の管理をする
JVM のメモリ管理(前の記事の話)
┌────────────────────────────┐
│ ヒープ: Young + Old │
│ Metaspace: クラス情報 │
│ スタック: フレーム単位 │
└────────────────────────────┘
まとめ
| 概念 | 正体 |
|---|---|
| RAM | 番地付きの棚。電源オフで消える揮発性メモリ |
| レジスタ | CPU 内の超高速記憶。変数の演算は実際ここで行われる |
| キャッシュ | CPU とRAMの速度差を埋める中間バッファ |
| 仮想メモリ | OS がプロセスごとに与える「独立したアドレス空間の幻想」 |
| スタック | 関数呼び出しのたびに積まれ、終わると消えるフレームの山 |
| ヒープ | new/malloc で動的に確保する自由な領域。解放が必要 |
| GC | Java における「ヒープの free」を自動化した仕組み |
「スタックとヒープの違いは?」という質問の本当の答えは、CPU・OS・プロセスという 3 層の構造に由来している。Java の GC も Spring の DI も、この構造の上で動いている。
プログラミング言語は違っても、コンピュータは同じメモリで動いている。ここを理解すると、新しい言語を学ぶたびに「このメモリ管理はどの層が担当しているのか」という視点が自然に身につく。
次に深掘りするなら
- 『コンピュータの構成と設計』(パターソン&ヘネシー): CPU・メモリ・命令セットの教科書。重いが圧倒的
/proc/<pid>/maps(Linux): 実際に動いているプロセスのメモリマップをコマンドで確認できる- valgrind / AddressSanitizer: C/C++ でメモリの確保・解放のバグを検出するツール。「メモリリーク」の実体を体感できる
- Java の前の記事: JVM が OS から受け取ったメモリをどう使うか(Young/Old/Metaspace の話)