C
Java/JVM/Lesson 06

JVM — アーキテクチャ・GC・String Pool

60分·theory

JVM — アーキテクチャ・GC・String Pool

🎯 このlessonを読み終えたら

このlessonを読み終えると、以下の3つを自信を持って説明できるようになります。

  • ✅ JVMメモリ構造 (Heap / Stack / Metaspace) + GCアルゴリズム
  • ✅ 運用JVMオプション (-Xms/-Xmx/-XX:+UseG1GC) 5種の推奨設定
  • ✅ OOM発生時のHeapDump分析ワークフローの説明

学習目標をチェックリストとして手元に置き、すべてに答えられるようになったらlessonを閉じてください。

JVMとは何か — *一度書けばどこでも動く*

一言でまとめると

JVM (Java Virtual Machine) = Javaコードを実行する仮想マシン"Write Once, Run Anywhere" — 一度書いたJavaコードがWindows・macOS・Linuxどこでもまったく同じように動きます。

なぜ可能なのか

Javaコードは直接実行されません。2つのステップを経ます:

1. コンパイル: .javaファイル → .classファイル (バイトコード)。OSに依存しない中間言語
2. 実行: JVMが.classファイルを読み込み、各OSのネイティブ命令に変換して実行。

このバイトコード + JVMの組み合わせがJavaの核心です。書く時点でWindowsやMacを意識する必要はなく、実行時点でそのOS用JVMがすべて処理します。

JVMメモリ — 何がどこに格納されるか

JVMはメモリを複数の領域に分けて管理します。重要な4つ:

  • ヒープすべてのオブジェクトと配列。最大の領域。GCの舞台。
  • スタックメソッド呼び出しフレームローカル変数。スレッドごとに独立。
  • Method Areaクラスメタデータ (フィールド・メソッド情報)。全スレッド共有。
  • PCレジスタ現在実行中の命令位置。スレッドごと。
java
public void run() {
    int x = 5;                    // x → Stack
    User u = new User("ホン");      // User オブジェクト → Heap, u (参照) → Stack
}

xのようなプリミティブ値はスタックへ。newで生成したオブジェクトはヒープへ。そのオブジェクトを指す参照だけがスタックに置かれます。この区別がJavaメモリの基本の図です。

JITコンパイラ — よく使うコードをさらに速く

最初、JVMはバイトコードをインタープリットします — 一行ずつ読んで実行。遅いです。

しかし、どのメソッドが頻繁に呼ばれるかを追跡し、閾値を超えるとそのメソッドをネイティブコードにコンパイルします。これをJIT (Just-In-Time)と呼びます。

JITがプロファイリングに基づいて最適化するため、長く動いたJVMほど速くなります (ウォームアップ)。Javaサーバーが最初は遅くて徐々に速くなる理由です。

C++のように事前コンパイルされたコードも速いですが、JITは実際の実行パターンを見て最適化するため、場合によってはC++より速くなることもあります。

まとめ

JVMは単なる翻訳機ではありません。メモリ管理・最適化・GCまですべて自動で処理します。Java開発者がメモリの確保・解放を手動で行わなくてよい理由です。

Garbage Collection — *自動メモリクリーンアップ*

GCが行うこと

C・C++ではfree()手動でメモリを解放する必要があります。忘れるとメモリリーク、2回解放するとプログラムクラッシュ

JavaのGarbage Collectorが自動で処理します。もう参照されていないオブジェクトを見つけてメモリを回収します。開発者は生成するだけで、あとは忘れてかまいません。

世代仮説 — ほとんどのオブジェクトはすぐに死ぬ

JVMのGCが速い理由は世代仮説です。

> ほとんどのオブジェクトは短命で死ぬ。メソッド内で作られたnewString.split()の結果など — メソッドが終われば使われなくなります。

この仮説を利用してヒープを2つの領域に分けます:

  • Young Generation — 新しく生成されたオブジェクト。ほとんどはここで死ぬ
  • Old Generation — Youngから生き残ったオブジェクト。長生きする可能性が高い。
code
Young Generation              Old Generation
┌────┬──────┬─────┐          ┌─────────────┐
│Eden│  S0  │ S1  │          │  Tenured    │
└────┴──────┴─────┘          └─────────────┘

新しいオブジェクトはEdenに入ります。Minor GCを生き残るとSurvivor (S0)へ、さらに生き残ればS1へ — 複数回生き残るとOld Generationへ昇格します。

Minor GC vs Full GC

  • Minor GC: Young領域のみ清掃。頻繁・高速 (数十ミリ秒)。
  • Full GC: Oldまで清掃。まれだが低速 (数百ミリ秒〜数秒)。全スレッド停止 (Stop-The-World)。

本番環境でFull GCが頻繁に発生すると大きな問題です。レスポンスタイムが突然跳ね上がり、ユーザーは遅くなったと感じます。Old Genがよく埋まったり、メモリリークがあるサインかもしれません。

GCアルゴリズム — 選択可能

JVMは複数のGCアルゴリズムを提供します。どれを使うかはチューニングで決めます。

  • Serial GC — シングルスレッド。小規模アプリ・組み込み。
  • Parallel GC — マルチスレッド。スループット優先 (バッチ処理)。
  • G1 GC — Java 9+のデフォルト。予測可能な一時停止。ほとんどの場合に適切。
  • ZGC — Java 11+。一時停止1ミリ秒未満。大規模・低レイテンシ。
  • Shenandoah — ZGCと類似。RedHat主導。

-XX:+UseG1GCのようなJVMオプションで選択します。基本ガイドライン:小規模アプリはG1、大規模・低レイテンシはZGC

まとめ

GCは自動メモリ管理という大きな利便性をもたらします。しかしタダではありません — Full GCが頻発するとレスポンス遅延が発生します。メモリ使用パターンを適切に設計し、JVMオプションをチューニングすることが実務の核心スキルです。

String — *不変オブジェクトの代表例*

Stringは変わらない

JavaのString不変 (Immutable)オブジェクトです。一度作られると、その内容を変えることができません

java
String s = "hello";
s.concat(" world");      // 新しいオブジェクトを返す。s はそのまま
System.out.println(s);   // "hello"

concatreplacetoUpperCaseなどすべてのメソッドは新しいStringを生成して返します。元のオブジェクトには触れません。

なぜ不変なのか

不変であることには大きなメリットがあります:

  • スレッドセーフ: 複数のスレッドが同時アクセスしても変更されることがない
  • HashMapキーとして安全: キーとして使用可能 (ハッシュ値が変わらない)
  • セキュリティ: ファイルパス・URLを勝手に変更できない
  • String Pool最適化: 同じ値は共有可能

String Pool — 同じ文字列は共有

JVMはStringのための特別な空間 — String Pool — を持ちます。同じ内容のStringは1つだけ保存して共有します。

java
String a = "hello";          // プールに保存
String b = "hello";          // プールから再利用 — a と同じオブジェクト!
String c = new String("hello");  // new はプールを迂回 — 新しいオブジェクト

a == b   // true  (参照が同じ)
a == c   // false (参照が異なる)
a.equals(c)  // true  (値は同じ)

ここでJavaで最も混乱しやすいことが登場します。

== vs equals():

  • ==参照比較 (同じオブジェクトか?)
  • equals()値比較 (同じ内容か?)

Stringの比較は必ずequals()を使ってください。==で比較すると偶然合ったり外れたりします。

StringBuilder — 文字列連結の落とし穴

java
// ❌ ループ内で + を使用 — 非常に遅い
String result = "";
for (int i = 0; i < 1000; i++) {
    result += i;   // 毎回新しいStringオブジェクトを生成。O(n²)
}

result += i新しいStringを生成してresultに再代入します。1000回繰り返すと1000個の一時オブジェクトが生成されます。GCがフル稼働します。

StringBuilderを使うと内部バッファに積み重ねて、最後に一度だけStringに変換します。

java
// ✅ StringBuilder
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 1000; i++) {
    sb.append(i);
}
String result = sb.toString();    // O(n)

> 💡 単純なa + b + cはコンパイラが自動的にStringBuilderに変換します。ループ内でのみ明示的に使用してください。

Text Block (Java 15+) — 複数行文字列

java
String html = """
    <html>
        <body>
            <p>こんにちは</p>
        </body>
    </html>
    """;

インデントや改行をそのまま維持しながらきれいに書けます。JSON・SQL・HTMLの記述に劇的に便利です。

まとめ

Stringは不変です。比較はequals()、繰り返し連結はStringBuilder、複数行はText Block。この3つを知るだけで日常コードの90%は安全です。

Java 17〜21 最新機能 — *コードが短くなる*

record (Java 14・16正式) — 不変データクラス

データだけを保持するシンプルなクラスを毎回コンストラクタ・getter・equals・hashCode・toStringすべて書くと50行になります。recordなら1行で済みます。

java
public record User(Long id, String name, String email) { }

この1行が以下を自動生成します:

  • コンストラクタ new User(1L, "ホン", "[email protected]")
  • getter u.id()u.name() (getId()ではない — 少し異なる)
  • equals()hashCode()toString()

DTO・イベント・VOのようなシンプルなデータオブジェクトに最適です。Lombokの@Valueと同じ役割を言語レベルでサポートするものです。

sealed class (Java 17) — 許可されたサブクラスのみ

java
public sealed interface Shape permits Circle, Square, Triangle { }
final class Circle implements Shape { }

Shapeを実装できるクラスをCircle・Square・Triangle制限します。他の場所でShapeを実装しようとするとコンパイルエラー。

なぜ便利なのか? switchパターンマッチングと組み合わせるとすべてのケースをコンパイラが検証してくれます。新しい図形が追加された場合、すべてのswitchを更新しないとコンパイルできない — 見落としミスがなくなります。

switch式 (Java 14) — 値を返す

旧switchはだったため値を返せませんでした。モダンなswitchはなので可能です:

java
String type = switch (status) {
    case PENDING -> "待機";
    case PAID, REFUNDED -> "処理完了";
    default -> "不明";
};

->アロー構文 + break不要 + 値を返す + 複数caseのまとめ。旧switchのフォールスルーの罠もなくなりました。

パターンマッチング (Java 21) — if-instanceofにさようなら

java
// 昔
if (obj instanceof String) {
    String s = (String) obj;
    if (s.length() > 0) ...
}

// モダン
if (obj instanceof String s && s.length() > 0) ...

instanceof変数宣言を1行に。switchと組み合わせるとさらに強力です:

java
String describe(Object o) {
    return switch (o) {
        case Integer i -> "int " + i;
        case String s when s.length() > 0 -> "str " + s;
        case null -> "null";
        default -> "other";
    };
}

sealed classと組み合わせるとすべてのケースの強制も加わります。

Optional (Java 8) — nullの明示的な表現

User getUser()を呼んでnullの可能性があることを誰が知るでしょうか?誰も知りません。そしてNullPointerExceptionが爆発します。

java
Optional<User> getUser(Long id) { ... }

戻り値の型にOptionalがあれば必ず空の可能性があることを認識させます。

java
Optional<User> u = userRepo.findById(id);
u.map(User::getEmail)
 .filter(e -> e.endsWith("@company.com"))
 .ifPresent(this::sendEmail);

String email = u.map(User::getEmail).orElse("unknown");

> 💡 Optionalは戻り値型にのみ使ってください。フィールドやパラメータに使うのはアンチパターンです。

Virtual Thread (Java 21) — 並行性の革命

Collections + Functionalのlessonで扱ったその機能です。1万以上の並行タスクをOSスレッドの負担なく処理します。I/O待機中に自動でyield。JavaがLGo・Kotlinレベルの並行性を手に入れた出来事です。

まとめ

Java 17〜21の新機能はコードを短くし、コンパイラがより多くを検証できるようにします。record・sealed・switchパターン・Optional・Virtual Thread — 新規プロジェクトなら積極的に使用を推奨。既存プロジェクトは段階的に導入してください。

🎮 JVMメモリ・GC可視化

クラスロード → オブジェクト生成 → GCの流れをステップごとに確認しましょう。
📝 Hello.java — 개발자가 작성
public class Hello {
    public static void main(String[] args) {
        System.out.println("안녕, Java!");
    }
}
💡 .java 파일 — 사람이 읽을 수 있는 소스코드
⚙️ javac Hello.java → Hello.class (바이트코드)
Hello.java
📄
사람이 읽는 코드

javac
Hello.class
💾
JVM이 읽는 바이트코드
💡 바이트코드는 어떤 OS에서도 실행 가능 — "Write Once, Run Anywhere"
📦 ClassLoader — .class 파일을 메모리에 적재
Bootstrap ClassLoader → java.lang.* (JDK 기본 클래스)
Extension ClassLoader → javax.*, ext 라이브러리
Application ClassLoader → Hello.class ← 우리 코드!
💡 JVM 메모리: Method Area(클래스 정보) → Heap(객체) → Stack(메서드 호출)
🚀 JIT 컴파일러 — 바이트코드 → 네이티브 코드 (초고속)
바이트코드
느림
JIT
컴파일
네이티브 코드
빠름 ⚡
💡 자주 실행되는 코드(Hot Spot)를 JIT가 감지해 네이티브로 변환 → 처음엔 느리고 나중엔 빨라지는 이유
✅ 실행 결과
$ java Hello
안녕, Java!
.java
소스코드
.class
바이트코드
출력
결과

GCチューニングオプション — 実務でよく使う5つ

GCオプションが必要な理由

デフォルト設定のJVMは小メモリ + 一般ワークロード基準です。実際のサービスでは:

  • メモリを1GBから8GBに増やす必要があったり
  • レスポンス遅延が重要な場合にStop-the-world時間を短縮する必要があります

これをJVMオプション (-X, -XX)で調整します。

よく使う5つ

1. -Xms / -Xmx — ヒープメモリサイズ

bash
java -Xms512m -Xmx2g -jar app.jar
  • -Xms = 初期ヒープサイズ
  • -Xmx = 最大ヒープサイズ

実務のヒント:-Xms == -Xmx同じ値に設定してください。動的拡張のコストを避け予測可能なパフォーマンスを実現します。AWS・k8s環境では事実上の標準です。

2. -XX:+UseG1GC — G1ガベージコレクター

bash
-XX:+UseG1GC

Java 9+のデフォルト値 (Java 17もG1)。4GB以上のヒープでレスポンス遅延が短い。明示的に記載しておくと明確さの観点で良いです。

Java 11+ ZGCJava 15+ Shenandoahがより短いpause timeを提供しますがHeap 16GB以上でなければG1で十分です。

3. -XX:MaxGCPauseMillis=200 — 最大GC停止時間の目標

bash
-XX:MaxGCPauseMillis=200

JVMへの「GC1回で200ミリ秒を超えるな」というヒント。保証ではなく目標値です。レスポンス遅延SLAがあるサービスには必須。

4. -XX:+HeapDumpOnOutOfMemoryError — OOM時のダンプ自動生成

bash
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/var/log/app-heapdump.hprof

OOMが発生すると自動でヒープダンプを出力します。事後分析用 — 本番環境の必須オプション

5. -XX:+PrintGCDetails (Java 8) / -Xlog:gc* (Java 9+) — GCログ

bash
# Java 17
-Xlog:gc*:file=/var/log/gc.log:time,uptime:filecount=10,filesize=10M

GCがいつ・どのくらい・なぜ発生したかを記録します。負荷テスト時に必須

実務標準の組み合わせ (Spring Boot運用)

bash
java \
  -Xms2g -Xmx2g \
  -XX:+UseG1GC \
  -XX:MaxGCPauseMillis=200 \
  -XX:+HeapDumpOnOutOfMemoryError \
  -XX:HeapDumpPath=/var/log/heapdump.hprof \
  -Xlog:gc*:file=/var/log/gc.log:time,uptime:filecount=10,filesize=10M \
  -jar app.jar

これらのオプションをすべて暗記する必要はありません。ただしオプションが存在するという事実なぜ使うかだけ知っていれば十分です — 面接で「GCチューニングをしたことはありますか?」と聞かれたときにこの5つを挙げられるようにしておくことが大切です。

☕ 自分で試す — String Pool・== vs equals

Stringの落とし穴。リテラルと`new String()`の違いを`==`と`equals`で確認しましょう。
☕ Java
✏️ 코드 편집기
📟 출력 결과
▶ 実行ボタンを押してください
💡 코드를 직접 수정하고 실행해보세요. 변수값을 바꾸거나 println을 추가해 결과를 확인하세요!
☁️ Judge0 API로 서버에서 실행 — Java / Python / JS / C++ 지원

🤖 AIへのリクエスト例

このlessonの概念を知っていれば、AIに具体的に指示できます。漠然とした「直して」ではなく語彙を持ったリクエスト — それがトークン節約の出発点です。

  • 「このSpring Bootアプリの運用JVMオプション (Xms/Xmx/G1GC/HeapDump) を推薦してください」
  • 「このコードのOutOfMemoryErrorの原因をヒープダンプ分析の観点から診断してください」
  • 「GCログオプションをJava 17形式で追加してください」

なぜこれがトークンを減らすのか

概念を知らないとAIの回答を受け取っても「それは何ですか?」とまた聞き直す必要があります。その「聞き直し」がトークンを消費します。概念を一度習得しておけば会話が一度で終わります

JVM — アーキテクチャ・GC・String Pool・Java 17~21 - Java