メインコンテンツまでスキップ

メモリ管理

はじめに

JVMのメモリ管理を理解することは、アプリケーションのパフォーマンス最適化とトラブルシューティングに不可欠です。この章では、JVMの各メモリ領域の役割と特徴を解説します。


JVMメモリ構造の全体像

┌─────────────────────────────────────────────────────────────────────┐
│ JVM Process │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ┌────────────────────────────────────────────────────────────────┐ │
│ │ Heap │ │
│ │ ┌─────────────────────────┐ ┌─────────────────────────────┐ │ │
│ │ │ Young Generation │ │ Old Generation │ │ │
│ │ │ ┌─────┐ ┌────┐ ┌────┐ │ │ │ │ │
│ │ │ │Eden │ │ S0 │ │ S1 │ │ │ Long-lived objects │ │ │
│ │ │ └─────┘ └────┘ └────┘ │ │ │ │ │
│ │ └─────────────────────────┘ └─────────────────────────────┘ │ │
│ │ -Xms / -Xmx │ │
│ └────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌────────────────────────────────────────────────────────────────┐ │
│ │ Metaspace │ │
│ │ クラスメタデータ、定数プール、メソッド情報 │ │
│ │ -XX:MaxMetaspaceSize │ │
│ └────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌───────────────────┐ ┌───────────────────┐ ┌─────────────────┐ │
│ │ Thread Stacks │ │ Code Cache │ │ Direct Memory │ │
│ │ -Xss │ │ JIT compiled │ │ NIO buffers │ │
│ └───────────────────┘ └───────────────────┘ └─────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘

ヒープ(Heap)

概要

ヒープは、オブジェクトインスタンスと配列が格納される領域です。GC(ガベージコレクション)の対象となります。

世代別構成

┌─────────────────────────────────────────────────────────────────────┐
│ Heap │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ Young Generation(若い世代) │
│ ┌────────────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ ┌──────────────────────────────┐ │ │
│ │ │ Eden │ 新しいオブジェクトはここに │ │
│ │ │ │ 割り当てられる │ │
│ │ └──────────────────────────────┘ │ │
│ │ │ │
│ │ ┌────────────────┐ ┌────────────────┐ │ │
│ │ │ Survivor 0 │ │ Survivor 1 │ Eden から生き残った │ │
│ │ │ (From) │ │ (To) │ オブジェクトがコピー │ │
│ │ └────────────────┘ └────────────────┘ │ │
│ │ │ │
│ └────────────────────────────────────────────────────────────────┘ │
│ │
│ Old Generation(古い世代) │
│ ┌────────────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ 長期間生存したオブジェクト │ │
│ │ (Survivor を何度か経由したもの) │ │
│ │ │ │
│ └────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘

オブジェクトのライフサイクル

1. new Object()


Eden に割り当て

↓ (Minor GC発生)

┌───┴───┐
│ │
生存 死亡 → 回収


Survivor へコピー
(年齢 +1)

↓ (Minor GC繰り返し)

年齢が閾値に達する
(デフォルト: 15)


Old Generation へ昇格

↓ (Major GC発生)

┌───┴───┐
│ │
生存 死亡 → 回収

ヒープサイズの設定

# 初期ヒープサイズと最大ヒープサイズ
java -Xms512m -Xmx2g -jar app.jar

# 同じ値に設定するのが推奨(動的リサイズのオーバーヘッド回避)
java -Xms2g -Xmx2g -jar app.jar
オプション説明推奨設定
-Xms初期ヒープサイズ-Xmxと同じ値
-Xmx最大ヒープサイズ物理メモリの50-70%
-XmnYoung Generationサイズ通常は自動調整に任せる

世代別サイズ比率

# Young Generation の割合を指定
java -XX:NewRatio=2 -jar app.jar # Old:Young = 2:1

# Survivor の割合を指定
java -XX:SurvivorRatio=8 -jar app.jar # Eden:S0:S1 = 8:1:1

スタック(Stack)

概要

各スレッドには専用のスタックがあり、メソッド呼び出しごとにスタックフレームが積まれます。

┌─────────────────────────────────────────────────────────────────────┐
│ Thread Stack │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ┌────────────────────────────────────────────────────────────────┐ │
│ │ Stack Frame (methodC) │ │
│ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────────────┐ │ │
│ │ │ローカル変数 │ │オペランド │ │フレームデータ │ │ │
│ │ │テーブル │ │スタック │ │(戻りアドレス等) │ │ │
│ │ └──────────────┘ └──────────────┘ └──────────────────────┘ │ │
│ └────────────────────────────────────────────────────────────────┘ │
│ ┌────────────────────────────────────────────────────────────────┐ │
│ │ Stack Frame (methodB) │ │
│ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────────────┐ │ │
│ │ │ローカル変数 │ │オペランド │ │フレームデータ │ │ │
│ │ │テーブル │ │スタック │ │ │ │ │
│ │ └──────────────┘ └──────────────┘ └──────────────────────┘ │ │
│ └────────────────────────────────────────────────────────────────┘ │
│ ┌────────────────────────────────────────────────────────────────┐ │
│ │ Stack Frame (methodA) │ │
│ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────────────┐ │ │
│ │ │ローカル変数 │ │オペランド │ │フレームデータ │ │ │
│ │ │テーブル │ │スタック │ │ │ │ │
│ │ └──────────────┘ └──────────────┘ └──────────────────────┘ │ │
│ └────────────────────────────────────────────────────────────────┘ │
│ │
│ ↓ スタックの成長方向 │
│ │
└─────────────────────────────────────────────────────────────────────┘

スタックのライフサイクル

スタックはスレッドと共に生成・破棄されます。

┌─────────────────────────────────────────────────────────────────────┐
│ スタックのライフサイクル │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ 1. スレッド作成時 │
│ new Thread() / Executors.newThread() / Virtual Thread │
│ │ │
│ ↓ │
│ ┌─────────────────────────────────┐ │
│ │ スタック領域を確保 │ ← OSからメモリ割り当て │
│ │ (デフォルト: 1MB) │ (-Xss で指定) │
│ └─────────────────────────────────┘ │
│ │
│ 2. メソッド呼び出し時 │
│ method() を呼び出すたび │
│ │ │
│ ↓ │
│ ┌─────────────────────────────────┐ │
│ │ スタックフレームをプッシュ │ ← スタックポインタ移動 │
│ │ (ローカル変数、戻りアドレス等) │ │
│ └─────────────────────────────────┘ │
│ │
│ 3. メソッド終了時(return / 例外) │
│ method() から戻るたび │
│ │ │
│ ↓ │
│ ┌─────────────────────────────────┐ │
│ │ スタックフレームをポップ │ ← スタックポインタ移動 │
│ │ (メモリは即座に再利用可能) │ GC不要! │
│ └─────────────────────────────────┘ │
│ │
│ 4. スレッド終了時 │
│ run() 完了 / interrupt / 例外 │
│ │ │
│ ↓ │
│ ┌─────────────────────────────────┐ │
│ │ スタック領域を解放 │ ← OSにメモリ返却 │
│ │ (スレッド全体のスタック消滅) │ │
│ └─────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘

具体例:メソッド呼び出しとスタックフレーム

public class StackLifecycleExample {

public static void main(String[] args) { // ← Frame 1 作成
System.out.println("main start");
methodA(); // ← Frame 2 作成
System.out.println("main end");
} // ← Frame 1 破棄

static void methodA() { // Frame 2 の中
int x = 10; // x はスタックに格納
methodB(x); // ← Frame 3 作成
} // ← Frame 2 破棄、x は消滅

static void methodB(int value) { // Frame 3 の中
int y = value * 2; // y はスタックに格納
System.out.println(y);
} // ← Frame 3 破棄、y は消滅
}
┌─────────────────────────────────────────────────────────────────────┐
│ スタックの状態変化 │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ main()開始 methodA()呼出 methodB()呼出 methodB()終了 │
│ │
│ ┌──────────┐ │
│ │ methodB │ │
│ ┌──────────┐ │ y = 20 │ ┌──────────┐ │
│ │ methodA │ ├──────────┤ │ methodA │ │
│ ┌──────────┐│ x = 10 │ │ methodA │ │ x = 10 │ ┌────────┐│
│ │ main │├──────────┤ │ x = 10 │ ├──────────┤ │ main ││
│ │ args=[] ││ main │ ├──────────┤ │ main │ │ args=[]││
│ └──────────┘│ args=[] │ │ main │ │ args=[] │ └────────┘│
│ └──────────┘ │ args=[] │ └──────────┘ │
│ └──────────┘ │
│ ↓ ↓ ↓ ↓ ↓ │
│ Frame 1 Frame 1+2 Frame 1+2+3 Frame 1+2 Frame 1 │
│ │
└─────────────────────────────────────────────────────────────────────┘

スレッドのライフサイクルとスタック

// Platform Thread の場合
Thread thread = new Thread(() -> {
// ここでスタックが使用される
processData();
});
thread.start(); // スタック作成(1MB確保)
thread.join(); // スレッド終了 → スタック解放

// Virtual Thread の場合(Java 21+)
Thread vThread = Thread.startVirtualThread(() -> {
// 小さな初期スタック(数KB)、必要に応じて拡張
processData();
});
// Virtual Thread 終了 → スタック解放(ヒープ上のオブジェクトとしてGC対象)
イベントPlatform ThreadVirtual Thread
スレッド作成OSからスタック確保(1MB固定)小さな初期スタック(ヒープ上)
メソッド呼び出しフレームをプッシュ同左
メソッド終了フレームをポップ同左
ブロック操作OSスレッドがブロックスタックをヒープに退避
スレッド終了OSにメモリ返却GCで回収

スタックとヒープの違い

スタックとヒープは異なる目的で使用されるメモリ領域です。

┌─────────────────────────────────────────────────────────────────────┐
│ メモリ割り当ての例 │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ public void example() { │
│ int x = 10; // x → スタック(プリミティブ値) │
│ String s = "hello"; // s → スタック(参照) │
│ // "hello" → ヒープ(文字列プール) │
│ User user = new User(); // user → スタック(参照) │
│ // User インスタンス → ヒープ │
│ int[] arr = new int[5]; // arr → スタック(参照) │
│ // 配列本体 → ヒープ │
│ } │
│ │
└─────────────────────────────────────────────────────────────────────┘
特性スタックヒープ
格納対象プリミティブ値、オブジェクト参照オブジェクトインスタンス、配列
アクセス速度非常に高速比較的低速
メモリ管理自動(メソッド終了時に解放)GCによる管理
サイズ小さい(スレッドごとに1MB程度)大きい(GB単位)
スレッド安全スレッドごとに独立全スレッドで共有
割り当てコスト低い(ポインタ移動のみ)高い(空き領域探索が必要)

プリミティブ型と参照型の格納

public void memoryAllocation() {
// プリミティブ型:値がスタックに直接格納
int number = 42; // 4バイト
long bigNumber = 100L; // 8バイト
double decimal = 3.14; // 8バイト
boolean flag = true; // 1バイト(実装依存)

// 参照型:参照がスタックに、オブジェクトがヒープに格納
String text = "Hello"; // 参照(8バイト) + ヒープ上のString
Integer boxed = 42; // 参照(8バイト) + ヒープ上のInteger

// 配列:参照がスタックに、配列本体がヒープに格納
int[] primitiveArray = {1, 2, 3}; // 参照 + ヒープ上の配列
}
┌─────────────────────────────────────────────────────────────────────┐
│ メモリ配置図 │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ スタック ヒープ │
│ ┌─────────────────┐ ┌───────────────────────┐ │
│ │ number: 42 │ │ │ │
│ ├─────────────────┤ │ String "Hello" │ │
│ │ bigNumber: 100 │ │ ┌─────────────────┐ │ │
│ ├─────────────────┤ ┌───────→│ │ value: [H,e,l..] │ │ │
│ │ decimal: 3.14 │ │ │ └─────────────────┘ │ │
│ ├─────────────────┤ │ │ │ │
│ │ flag: true │ │ │ Integer │ │
│ ├─────────────────┤ │ │ ┌─────────────────┐ │ │
│ │ text: 0x1234 ──┼──────────┘ ┌───→│ │ value: 42 │ │ │
│ ├─────────────────┤ │ │ └─────────────────┘ │ │
│ │ boxed: 0x5678 ──┼──────────────┘ │ │ │
│ ├─────────────────┤ │ int[] array │ │
│ │ primitiveArray: ┼──────────────────→│ ┌─────────────────┐ │ │
│ │ 0x9ABC │ │ │ [1, 2, 3] │ │ │
│ └─────────────────┘ │ └─────────────────┘ │ │
│ └───────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘

スタックフレームの構成

要素内容
ローカル変数テーブルメソッドの引数とローカル変数
オペランドスタック演算の中間結果
フレームデータ戻りアドレス、例外ハンドラ情報

オペランドスタックの動作

JVMはスタックベースの仮想マシンです。演算はオペランドスタックを使って行われます。

// Javaコード
public int add(int a, int b) {
return a + b;
}
// バイトコード
0: iload_1 // ローカル変数1(a)をオペランドスタックにプッシュ
1: iload_2 // ローカル変数2(b)をオペランドスタックにプッシュ
2: iadd // スタックから2つポップして加算、結果をプッシュ
3: ireturn // スタックトップの値を返す
┌─────────────────────────────────────────────────────────────────────┐
│ オペランドスタックの動作 │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ add(3, 5) を実行する場合: │
│ │
│ ローカル変数テーブル │
│ ┌─────┬─────┬─────┐ │
│ │ 0 │ 1 │ 2 │ 0: this, 1: a=3, 2: b=5 │
│ │this │ 3 │ 5 │ │
│ └─────┴─────┴─────┘ │
│ │
│ 命令実行とオペランドスタックの変化: │
│ │
│ iload_1: iload_2: iadd: ireturn: │
│ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ │
│ │ │ │ 5 │ │ 8 │ │ │ → 8を返す │
│ ├─────┤ ├─────┤ └─────┘ └─────┘ │
│ │ 3 │ │ 3 │ │
│ └─────┘ └─────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘

より複雑な例:

// Javaコード
int result = (a + b) * c;
// バイトコード
0: iload_1 // a をプッシュ
1: iload_2 // b をプッシュ
2: iadd // a + b
3: iload_3 // c をプッシュ
4: imul // (a + b) * c
5: istore 4 // 結果を result に格納
┌─────────────────────────────────────────────────────────────────────┐
│ (2 + 3) * 4 の計算過程 │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ iload_1 → iload_2 → iadd → iload_3 → imul │
│ ┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌────┐ │
│ │ │ │ 3 │ │ │ │ 4 │ │ │ │
│ ├───┤ ├───┤ ├───┤ ├───┤ ├────┤ │
│ │ 2 │ │ 2 │ │ 5 │ │ 5 │ │ 20 │ → result │
│ └───┘ └───┘ └───┘ └───┘ └────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘

スタックサイズの設定

# スレッドスタックサイズ(デフォルト: 1MB on Linux 64-bit)
java -Xss512k -jar app.jar
設定影響
小さい(256k-512k)多くのスレッドを作成可能、深い再帰で StackOverflowError
大きい(2MB以上)深い再帰に対応、スレッド数が制限される

StackOverflowError

スタックが溢れると StackOverflowError が発生します。

// 無限再帰
public void infiniteRecursion() {
infiniteRecursion(); // StackOverflowError
}

// 深い再帰
public long factorial(int n) {
if (n <= 1) return 1;
return n * factorial(n - 1); // 大きな n で StackOverflowError
}

スタックトレースの読み方

スタックトレースは問題の原因特定に不可欠です。

Exception in thread "main" java.lang.NullPointerException: Cannot invoke method on null
at com.example.service.UserService.validateUser(UserService.java:45)
at com.example.service.UserService.createUser(UserService.java:28)
at com.example.controller.UserController.register(UserController.java:52)
at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
at org.springframework.web.servlet.FrameworkServlet.service(FrameworkServlet.java:897)
at javax.servlet.http.HttpServlet.service(HttpServlet.java:750)
... 20 more
┌─────────────────────────────────────────────────────────────────────┐
│ スタックトレースの構造 │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ① 例外の種類とメッセージ │
│ java.lang.NullPointerException: Cannot invoke method on null │
│ │
│ ② 呼び出し履歴(上が最新、下が古い) │
│ at クラス名.メソッド名(ファイル名:行番号) │
│ │
│ ③ 読み方: │
│ 1. 最初の "at" 行 = 例外発生箇所(最重要) │
│ 2. 上から順に読む = 呼び出しの流れを追跡 │
│ 3. 自分のコード(com.example.)に注目 │
│ 4. "... N more" = 省略された共通部分 │
│ │
└─────────────────────────────────────────────────────────────────────┘

読み方のポイント:

// この例外の場合
at com.example.service.UserService.validateUser(UserService.java:45) // ← ここで発生
at com.example.service.UserService.createUser(UserService.java:28) // ← ここから呼ばれた
at com.example.controller.UserController.register(UserController.java:52)

// UserService.java の45行目を確認
// → null チェックが必要な箇所を特定

Caused by の読み方:

Exception in thread "main" org.springframework.beans.BeanCreationException: ...
at org.springframework.beans.factory.support.AbstractBeanFactory.getBean(...)
... 15 more
Caused by: java.io.FileNotFoundException: config.properties (No such file) // ← 根本原因
at java.io.FileInputStream.open0(Native Method)
at com.example.config.ConfigLoader.load(ConfigLoader.java:23) // ← 発生箇所
... 20 more

再帰のスタック消費と対策

スタック消費の問題

再帰呼び出しは各呼び出しでスタックフレームを消費します。

// 再帰版フィボナッチ(スタック消費大)
public long fibonacci(int n) {
if (n <= 1) return n;
return fibonacci(n - 1) + fibonacci(n - 2);
}
// fibonacci(40) → 約330万回の再帰呼び出し
┌─────────────────────────────────────────────────────────────────────┐
│ 再帰呼び出しのスタック消費 │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ factorial(5) のスタック状態: │
│ │
│ ┌──────────────────────┐ │
│ │ factorial(1) → 1 │ ← 5つ目のフレーム │
│ ├──────────────────────┤ │
│ │ factorial(2) 待機中 │ ← 4つ目のフレーム │
│ ├──────────────────────┤ │
│ │ factorial(3) 待機中 │ ← 3つ目のフレーム │
│ ├──────────────────────┤ │
│ │ factorial(4) 待機中 │ ← 2つ目のフレーム │
│ ├──────────────────────┤ │
│ │ factorial(5) 待機中 │ ← 1つ目のフレーム │
│ └──────────────────────┘ │
│ │
│ n が大きいと StackOverflowError │
│ │
└─────────────────────────────────────────────────────────────────────┘

対策1: 反復(ループ)に変換

// 反復版(スタック消費: 1フレームのみ)
public long factorialIterative(int n) {
long result = 1;
for (int i = 2; i <= n; i++) {
result *= i;
}
return result;
}

対策2: 末尾再帰の形式に変換

// 末尾再帰版(JVMでは最適化されないが、読みやすい)
public long factorialTailRecursive(int n) {
return factorialHelper(n, 1);
}

private long factorialHelper(int n, long accumulator) {
if (n <= 1) return accumulator;
return factorialHelper(n - 1, n * accumulator); // 末尾位置での再帰
}
備考

注意: JVMは末尾再帰最適化(TCO)をサポートしていません。Scala等の言語では@tailrecアノテーションでコンパイル時に最適化されます。

対策3: 明示的なスタックを使用

// 明示的なスタックを使用(深い再帰をシミュレート)
public long factorialWithStack(int n) {
Deque<Integer> stack = new ArrayDeque<>();

// 再帰の代わりにスタックに積む
for (int i = n; i >= 1; i--) {
stack.push(i);
}

// スタックから取り出して計算
long result = 1;
while (!stack.isEmpty()) {
result *= stack.pop();
}
return result;
}

対策4: メモ化(動的計画法)

// メモ化版フィボナッチ
public long fibonacciMemoized(int n) {
long[] memo = new long[n + 1];
return fibHelper(n, memo);
}

private long fibHelper(int n, long[] memo) {
if (n <= 1) return n;
if (memo[n] != 0) return memo[n];
memo[n] = fibHelper(n - 1, memo) + fibHelper(n - 2, memo);
return memo[n];
}

// または反復版(推奨)
public long fibonacciIterative(int n) {
if (n <= 1) return n;
long prev = 0, curr = 1;
for (int i = 2; i <= n; i++) {
long next = prev + curr;
prev = curr;
curr = next;
}
return curr;
}

Virtual Threads とスタック

Java 21で導入されたVirtual Threadsは、スタック管理に革新をもたらしました。

┌─────────────────────────────────────────────────────────────────────┐
│ Platform Threads vs Virtual Threads │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ Platform Threads(従来) │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ Thread-1 Thread-2 Thread-3 Thread-4 │ │
│ │ ┌───────┐ ┌───────┐ ┌───────┐ ┌───────┐ │ │
│ │ │Stack │ │Stack │ │Stack │ │Stack │ │ │
│ │ │ 1MB │ │ 1MB │ │ 1MB │ │ 1MB │ │ │
│ │ └───────┘ └───────┘ └───────┘ └───────┘ │ │
│ │ │ │
│ │ → 1000スレッド = 1GB のスタックメモリが必要 │ │
│ │ → スレッド数に実質的な上限あり │ │
│ └───────────────────────────────────────────────────────────────┘ │
│ │
│ Virtual Threads(Java 21+) │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ Carrier Thread(数個) │ │
│ │ ┌─────────────────────────────────────────────────────────┐ │ │
│ │ │ Stack (1MB) │ │ │
│ │ └─────────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ Virtual Threads(100万個も可能) │ │
│ │ ┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐ ... │ │
│ │ │ V1 │ │ V2 │ │ V3 │ │ V4 │ │ V5 │ │ V6 │ │ V7 │ │ │
│ │ └────┘ └────┘ └────┘ └────┘ └────┘ └────┘ └────┘ │ │
│ │ ↑ │ │
│ │ 小さな初期スタック、必要に応じて動的に拡張 │ │
│ │ ブロック時にスタックをヒープに退避(継続) │ │
│ └───────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘
特性Platform ThreadVirtual Thread
スタックサイズ固定(デフォルト1MB)動的(数KB〜必要なだけ)
作成可能数数千程度数百万可能
ブロック時OSスレッドがブロックスタックを退避して他の処理を実行
メモリ効率低い非常に高い
用途CPU集約型タスクI/O集約型タスク
// Virtual Threads の使用例
// 100万個のVirtual Threadsを作成してもメモリ問題なし
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
IntStream.range(0, 1_000_000).forEach(i -> {
executor.submit(() -> {
// I/O操作(ブロック時にスタックが自動退避)
Thread.sleep(Duration.ofSeconds(1));
return i;
});
});
}
ヒント

idp-serverでの活用: 大量の同時リクエスト処理にVirtual Threadsを活用することで、スレッドプールのサイズを気にせず、シンプルなthread-per-requestモデルを採用できます。


スレッドセーフとメモリ

スレッドセーフとは

複数のスレッドから同時にアクセスされても、正しく動作することを「スレッドセーフ」と呼びます。

┌─────────────────────────────────────────────────────────────────────┐
│ スレッドセーフの問題 │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ スタック(スレッドセーフ) ヒープ(要注意) │
│ ┌─────────────────────┐ ┌─────────────────────┐ │
│ │ Thread-1 Stack │ │ 共有オブジェクト │ │
│ │ ┌─────────────────┐ │ │ ┌─────────────┐ │ │
│ │ │ localVar = 10 │ │ 参照 ─────→│ │ count = ? │ │ │
│ │ └─────────────────┘ │ │ └─────────────┘ │ │
│ └─────────────────────┘ │ ↑ │ │
│ │ │ │ │
│ ┌─────────────────────┐ │ 参照 ─┘ │ │
│ │ Thread-2 Stack │ │ │ │
│ │ ┌─────────────────┐ │ └─────────────────────┘ │
│ │ │ localVar = 20 │ │ │
│ │ └─────────────────┘ │ ローカル変数は他スレッドから見えない │
│ └─────────────────────┘ ヒープのオブジェクトは共有される │
│ │
└─────────────────────────────────────────────────────────────────────┘

なぜスタックはスレッドセーフか

各スレッドは独自のスタックを持ち、他のスレッドからアクセスできません。

public void processRequest() {
// ローカル変数はスタックに格納 → スレッドセーフ
int requestId = generateId();
String userId = getCurrentUser();

// これらの変数は他のスレッドから見えない
// 同時に100リクエストが来ても、各スレッドが独自の変数を持つ
}

なぜヒープは危険か

ヒープ上のオブジェクトは全スレッドで共有されるため、競合状態(Race Condition)が発生します。

// 危険な例:共有されるカウンター
public class UnsafeCounter {
private int count = 0; // ヒープ上のフィールド

public void increment() {
count++; // スレッドセーフではない!
}
}
┌─────────────────────────────────────────────────────────────────────┐
│ 競合状態(Race Condition) │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ count++ は実際には3つの操作: │
│ 1. count の値を読む │
│ 2. 1 を加算する │
│ 3. 結果を count に書き戻す │
│ │
│ Thread-1 Thread-2 count の値 │
│ ───────────────────────────────────────────────── │
│ 読む (count=0) 0 │
│ 読む (count=0) 0 │
│ 加算 (0+1=1) 0 │
│ 加算 (0+1=1) 0 │
│ 書く (count=1) 1 │
│ 書く (count=1) 1 ← 期待値は2なのに1! │
│ │
└─────────────────────────────────────────────────────────────────────┘

共有オブジェクトになるケース

どのような場合にオブジェクトが複数スレッドから共有されるかを理解することが重要です。

┌─────────────────────────────────────────────────────────────────────┐
│ 共有 vs 非共有 の判断 │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ 【共有される】 【共有されない】 │
│ ・インスタンスフィールド ・ローカル変数 │
│ ・staticフィールド ・メソッド引数(プリミティブ) │
│ ・Singleton ・メソッド内で new したオブジェクト│
│ ・Spring Bean(デフォルト) (参照を外部に渡さない場合) │
│ ・キャッシュ │
│ ・コレクションの要素 │
│ │
└─────────────────────────────────────────────────────────────────────┘

ケース1: インスタンスフィールド

// 危険:インスタンスが共有されるとフィールドも共有される
public class UserService {
private int requestCount = 0; // 共有される!

public void handleRequest() {
requestCount++; // 複数スレッドから同時アクセス → 競合
}
}

// 使用例(この UserService インスタンスが共有される)
UserService service = new UserService(); // 1つのインスタンス

// Thread-1
executor.submit(() -> service.handleRequest());

// Thread-2
executor.submit(() -> service.handleRequest());
// → 同じ service インスタンスの requestCount に同時アクセス

ケース2: static フィールド

// 危険:static フィールドは常に共有される
public class GlobalCounter {
private static int count = 0; // 全スレッドで共有!

public static void increment() {
count++; // スレッドセーフではない
}
}

// どこから呼んでも同じ count にアクセス
// Thread-1
GlobalCounter.increment();

// Thread-2
GlobalCounter.increment();

ケース3: Spring Bean(Singleton スコープ)

// Spring Bean はデフォルトで Singleton → 共有される
@Service
public class OrderService {
// 危険:状態を持つフィールド
private Order currentOrder; // 共有される!

public void processOrder(Order order) {
this.currentOrder = order; // 他のリクエストに上書きされる可能性
validate();
save();
}
}

// 安全:状態を持たない(ステートレス)設計
@Service
public class OrderService {
private final OrderRepository repository; // 依存性は OK(それ自体がスレッドセーフなら)

public void processOrder(Order order) {
// ローカル変数で処理 → スレッドセーフ
ValidationResult result = validate(order);
if (result.isValid()) {
repository.save(order);
}
}
}

ケース4: キャッシュ

// 危険:通常の HashMap をキャッシュに使用
@Component
public class TokenCache {
private Map<String, Token> cache = new HashMap<>(); // スレッドセーフではない!

public Token get(String key) {
return cache.get(key); // 同時アクセスで ConcurrentModificationException
}

public void put(String key, Token token) {
cache.put(key, token); // 同時書き込みでデータ破損
}
}

// 安全:ConcurrentHashMap を使用
@Component
public class TokenCache {
private final Map<String, Token> cache = new ConcurrentHashMap<>();

public Token get(String key) {
return cache.get(key);
}

public void put(String key, Token token) {
cache.put(key, token);
}
}

ケース5: コレクションに格納されたオブジェクト

// コレクションの要素も共有される
public class SessionManager {
private final List<Session> sessions = new CopyOnWriteArrayList<>();

public void addSession(Session session) {
sessions.add(session);
}

public void updateSession(String sessionId, Consumer<Session> updater) {
sessions.stream()
.filter(s -> s.getId().equals(sessionId))
.findFirst()
.ifPresent(session -> {
// session オブジェクト自体が共有されている
// updater の中で session を変更すると競合の可能性
updater.accept(session);
});
}
}

非共有の例(スレッドセーフ)

public class SafeProcessor {

public Result process(Request request) {
// ローカル変数 → スレッドごとに独立
int count = 0;
List<String> items = new ArrayList<>(); // このメソッド内でのみ使用

for (String item : request.getItems()) {
items.add(transform(item));
count++;
}

// メソッド内で new → 他スレッドからアクセス不可
Result result = new Result(items, count);
return result; // 返却後も呼び出し元スレッドのみがアクセス
}
}

判断フローチャート

┌─────────────────────────────────────────────────────────────────────┐
│ オブジェクトは共有されるか? │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ オブジェクトの参照はどこにある? │
│ │ │
│ ├─→ ローカル変数のみ ─────→ 共有されない ✓ │
│ │ │
│ ├─→ メソッド引数(参照型)─→ 呼び出し元次第 │
│ │ │ │
│ │ └─→ 呼び出し元で共有されている? │
│ │ │ │
│ │ Yes ──┴── No │
│ │ ↓ ↓ │
│ │ 共有される 共有されない │
│ │ │
│ ├─→ インスタンスフィールド ─→ インスタンスが共有なら共有 │
│ │ │
│ ├─→ static フィールド ────→ 常に共有される ⚠️ │
│ │ │
│ └─→ コレクションの要素 ───→ コレクションが共有なら共有 │
│ │
└─────────────────────────────────────────────────────────────────────┘

Java Memory Model(JMM)

JMMは、マルチスレッド環境でのメモリの可視性と順序を定義します。

┌─────────────────────────────────────────────────────────────────────┐
│ Java Memory Model │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ Thread-1 Thread-2 │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ CPU Cache │ │ CPU Cache │ │
│ │ ┌───────────┐ │ │ ┌───────────┐ │ │
│ │ │ flag=false│ │ │ │ flag=false│ │ │
│ │ └───────────┘ │ │ └───────────┘ │ │
│ └────────┬────────┘ └────────┬────────┘ │
│ │ │ │
│ └──────────────┬──────────────────────┘ │
│ ↓ │
│ ┌───────────────────────┐ │
│ │ Main Memory │ │
│ │ ┌───────────┐ │ │
│ │ │ flag=true │ │ ← 書き込みがいつ反映される? │
│ │ └───────────┘ │ │
│ └───────────────────────┘ │
│ │
│ 問題:Thread-1がflag=trueを書いても、 │
│ Thread-2のキャッシュには反映されない可能性がある │
│ │
└─────────────────────────────────────────────────────────────────────┘

スレッドセーフにする方法

方法1: synchronized(排他制御)

public class SynchronizedCounter {
private int count = 0;

// メソッド全体を同期
public synchronized void increment() {
count++;
}

// ブロック単位で同期(より細かい制御)
public void incrementWithBlock() {
synchronized (this) {
count++;
}
}
}
┌─────────────────────────────────────────────────────────────────────┐
│ synchronized の動作 │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ Thread-1 Thread-2 │
│ ───────── ───────── │
│ ロック取得 ────────┐ │
│ count を読む │ ロック待ち... │
│ 加算 │ │ │
│ count に書く │ │ │
│ ロック解放 ────────┘ │ │
│ ロック取得 ────────┐ │
│ count を読む │ │
│ 加算 │ │
│ count に書く │ │
│ ロック解放 ────────┘ │
│ │
│ → 一度に1スレッドのみがクリティカルセクションを実行 │
│ │
└─────────────────────────────────────────────────────────────────────┘

方法2: volatile(可視性の保証)

public class VolatileExample {
// volatile: 全スレッドに即座に変更が見える
private volatile boolean running = true;

public void stop() {
running = false; // 他のスレッドから即座に見える
}

public void run() {
while (running) { // 最新の値を常に読む
// 処理
}
}
}
警告

注意: volatile は可視性のみを保証し、原子性は保証しません。count++ のような複合操作には使えません。

方法3: Atomic クラス(CAS操作)

import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;

public class AtomicCounter {
// ロックフリーで原子的な操作を提供
private final AtomicInteger count = new AtomicInteger(0);

public void increment() {
count.incrementAndGet(); // 原子的にインクリメント
}

public int get() {
return count.get();
}
}

// CAS(Compare-And-Swap)の仕組み
public void casExample() {
AtomicInteger value = new AtomicInteger(0);

// 期待値が一致した場合のみ更新
boolean success = value.compareAndSet(0, 1); // 0なら1に更新
// success = true, value = 1

success = value.compareAndSet(0, 2); // 0なら2に更新(現在は1)
// success = false, value = 1(更新されない)
}
┌─────────────────────────────────────────────────────────────────────┐
│ CAS(Compare-And-Swap) │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ compareAndSet(expected, newValue): │
│ │
│ 1. 現在の値を読む │
│ 2. 現在の値 == expected か確認 │
│ 3. 一致すれば newValue に更新(これら全てがCPU命令で原子的) │
│ 4. 不一致なら何もしない │
│ │
│ 利点:ロックを使わないため、高スループット │
│ 欠点:競合が多いと再試行が増える │
│ │
└─────────────────────────────────────────────────────────────────────┘

方法4: 不変オブジェクト(Immutable)

// 不変オブジェクトは本質的にスレッドセーフ
public final class ImmutableUser {
private final String name;
private final int age;

public ImmutableUser(String name, int age) {
this.name = name;
this.age = age;
}

public String getName() { return name; }
public int getAge() { return age; }

// 変更が必要な場合は新しいインスタンスを作成
public ImmutableUser withAge(int newAge) {
return new ImmutableUser(this.name, newAge);
}
}

// Java 16+ の Record は自動的に不変
public record User(String name, int age) {
// フィールドは自動的に final
// getter は自動生成
}

方法5: スレッドローカル(ThreadLocal)

public class ThreadLocalExample {
// 各スレッドが独自のコピーを持つ
private static final ThreadLocal<SimpleDateFormat> dateFormat =
ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));

public String formatDate(Date date) {
// 各スレッドが独自のSimpleDateFormatを使用
return dateFormat.get().format(date);
}
}
┌─────────────────────────────────────────────────────────────────────┐
│ ThreadLocal の動作 │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ Thread-1 Thread-2 │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ ThreadLocal │ │ ThreadLocal │ │
│ │ ┌───────────┐ │ │ ┌───────────┐ │ │
│ │ │DateFormat │ │ │ │DateFormat │ │ │
│ │ │ instance1 │ │ │ │ instance2 │ │ │
│ │ └───────────┘ │ │ └───────────┘ │ │
│ └─────────────────┘ └─────────────────┘ │
│ │
│ 各スレッドが独自のインスタンスを持つ → 競合なし │
│ │
└─────────────────────────────────────────────────────────────────────┘
警告

Virtual Threads との注意点: ThreadLocal は Virtual Threads と組み合わせると、大量のインスタンスが生成される可能性があります。Virtual Threads では ScopedValue(Java 21 Preview)の使用を検討してください。

スレッドセーフなコレクション

import java.util.concurrent.*;

public class ThreadSafeCollections {

// ConcurrentHashMap: 高性能な並行Map
private final ConcurrentHashMap<String, User> users = new ConcurrentHashMap<>();

// CopyOnWriteArrayList: 読み取りが多い場合に最適
private final CopyOnWriteArrayList<String> logs = new CopyOnWriteArrayList<>();

// BlockingQueue: プロデューサー・コンシューマーパターン
private final BlockingQueue<Task> taskQueue = new LinkedBlockingQueue<>();

public void example() {
// ConcurrentHashMap の原子的操作
users.computeIfAbsent("user1", key -> new User(key));
users.compute("user1", (key, existing) -> existing.incrementLoginCount());

// BlockingQueue でスレッド間通信
taskQueue.put(new Task()); // キューが満杯なら待機
Task task = taskQueue.take(); // キューが空なら待機
}
}
コレクション特徴用途
ConcurrentHashMapセグメント単位ロック、高スループットキャッシュ、共有Map
CopyOnWriteArrayList書き込み時にコピー、読み取りロックフリーイベントリスナー一覧
BlockingQueueブロッキング操作をサポートタスクキュー
ConcurrentLinkedQueueロックフリー、無制限高スループットキュー

Metaspace

概要

Java 8以降、クラスメタデータはヒープ外の Metaspace に格納されます(Java 7 以前は PermGen)。

┌─────────────────────────────────────────────────────────────────────┐
│ Metaspace │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ┌────────────────────────────────────────────────────────────────┐ │
│ │ Class Metadata │ │
│ │ ・クラス構造(フィールド、メソッド情報) │ │
│ │ ・バイトコード │ │
│ │ ・定数プール │ │
│ │ ・アノテーション │ │
│ └────────────────────────────────────────────────────────────────┘ │
│ │
│ 特徴: │
│ ・ネイティブメモリ(ヒープ外)に配置 │
│ ・デフォルトでは無制限(OSのメモリまで使用可能) │
│ ・クラスローダーがGCされるとアンロード │
│ │
└─────────────────────────────────────────────────────────────────────┘

PermGen との違い

項目PermGen (Java 7以前)Metaspace (Java 8以降)
配置場所ヒープ内ネイティブメモリ
デフォルトサイズ固定(64MB等)無制限
拡張固定サイズ動的に拡張
GCFull GC時ClassLoaderがGC時

サイズ設定

# 最大Metaspaceサイズを制限
java -XX:MaxMetaspaceSize=256m -jar app.jar

# 初期サイズを設定
java -XX:MetaspaceSize=128m -jar app.jar

Metaspace 監視

# jstat でMetaspaceを監視
jstat -gcmetacapacity <pid> 1000

# jcmd で詳細確認
jcmd <pid> VM.metaspace

Code Cache

概要

JITコンパイラが生成したネイティブコードが格納される領域です。

┌─────────────────────────────────────────────────────────────────────┐
│ Code Cache │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ┌───────────────────┐ ┌───────────────────┐ ┌──────────────────┐ │
│ │ Non-method │ │ Profiled Code │ │ Non-profiled │ │
│ │ Code │ │ (C1 compiled) │ │ Code │ │
│ │ │ │ │ │ (C2 compiled) │ │
│ │ JVM内部コード │ │ 軽度最適化 │ │ 高度最適化 │ │
│ └───────────────────┘ └───────────────────┘ └──────────────────┘ │
│ │
│ デフォルト: 240MB (64-bit JVM) │
│ │
└─────────────────────────────────────────────────────────────────────┘

サイズ設定

# Code Cacheサイズ設定
java -XX:ReservedCodeCacheSize=256m -jar app.jar

# 初期サイズ
java -XX:InitialCodeCacheSize=64m -jar app.jar

Code Cache が満杯になると

Code Cacheが満杯になると、JITコンパイルが停止し、パフォーマンスが低下します。

Java HotSpot(TM) 64-Bit Server VM warning: CodeCache is full.
Compiler has been disabled.

Direct Memory

概要

NIO(New I/O)で使用されるオフヒープメモリです。ByteBuffer.allocateDirect() で確保されます。

// Direct Bufferの確保
ByteBuffer buffer = ByteBuffer.allocateDirect(1024 * 1024); // 1MB

特徴

項目Heap BufferDirect Buffer
配置場所ヒープ内ネイティブメモリ
GC対象はい間接的(参照がなくなった時)
I/O効率コピーが必要ゼロコピー可能
確保コスト低い高い

サイズ設定

# Direct Memoryの最大サイズ(デフォルト: -Xmx と同じ)
java -XX:MaxDirectMemorySize=512m -jar app.jar

メモリ領域のサマリー

┌─────────────────────────────────────────────────────────────────────┐
│ JVMメモリ領域一覧 │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ 領域 設定オプション デフォルト │
│ ───────────────────────────────────────────────────────────────── │
│ Heap -Xms, -Xmx 物理メモリの1/4 │
│ Metaspace -XX:MaxMetaspaceSize 無制限 │
│ Thread Stack -Xss 1MB │
│ Code Cache -XX:ReservedCodeCacheSize 240MB │
│ Direct Memory -XX:MaxDirectMemorySize -Xmxと同じ │
│ │
└─────────────────────────────────────────────────────────────────────┘

メモリ使用量の確認

jcmd

# ネイティブメモリトラッキング有効化
java -XX:NativeMemoryTracking=summary -jar app.jar

# メモリサマリー表示
jcmd <pid> VM.native_memory summary

# 詳細表示
jcmd <pid> VM.native_memory detail

出力例:

Native Memory Tracking:

Total: reserved=5GB, committed=1GB

- Java Heap (reserved=4GB, committed=512MB)
(mmap: reserved=4GB, committed=512MB)

- Class (reserved=1GB, committed=50MB)
(classes #10000)
(malloc=5MB #50000)
(mmap: reserved=1GB, committed=45MB)

- Thread (reserved=100MB, committed=100MB)
(thread #100)
(stack: reserved=100MB, committed=100MB)

jstat

# ヒープ使用状況を1秒間隔で表示
jstat -gc <pid> 1000

# 出力列の意味
# S0C S0U S1C S1U EC EU OC OU MC MU
# 容量 使用 容量 使用 Eden Eden Old Old Meta Meta
# Survivor 容量 使用 容量 使用 容量 使用

VisualVM / JConsole

GUIツールでリアルタイムにメモリ使用量を可視化できます。

# JConsole起動
jconsole

# VisualVM起動(別途インストール)
visualvm

OutOfMemoryError の種類

エラーメッセージ原因対処
Java heap spaceヒープ不足-Xmx増加、メモリリーク調査
Metaspaceクラスメタデータ領域不足-XX:MaxMetaspaceSize増加
GC overhead limit exceededGCに時間がかかりすぎヒープ増加、アプリ改善
unable to create new native threadスレッド作成不可-Xss減少、スレッド数削減
Direct buffer memoryDirect Memory不足-XX:MaxDirectMemorySize増加

本番環境の推奨設定

java \
# ヒープ(同一サイズで固定)
-Xms4g -Xmx4g \

# Metaspace(上限設定)
-XX:MaxMetaspaceSize=256m \

# スタック(スレッド多い場合は小さく)
-Xss512k \

# Direct Memory
-XX:MaxDirectMemorySize=512m \

# OOM時にヒープダンプ
-XX:+HeapDumpOnOutOfMemoryError \
-XX:HeapDumpPath=/var/log/app/ \

# Native Memory Tracking(デバッグ用)
-XX:NativeMemoryTracking=summary \

-jar app.jar

まとめ

領域格納内容GC対象
Heapオブジェクトインスタンス、配列はい
Metaspaceクラスメタデータ、定数プールClassLoader経由
Stackローカル変数、メソッド呼び出し情報いいえ(自動解放)
Code CacheJITコンパイル済みコード部分的
Direct MemoryNIO バッファ間接的

次のステップ