Engineering
Glossvation Engineering
Web・モバイル

Java パフォーマンスチューニングの実務ノート — JVM / コレクション / GC

プリミティブ型・不変オブジェクト・コレクション選択・ストリーム vs for ループ・GC チューニングまで、現場で効くチューニングの型。計測してからチューニングする原則を軸に。

Glossvation ·

まずは計測から

パフォーマンスチューニングの最大のアンチパターンは「推測で直す」こと。以下の計測基盤を整えてから、本稿のテクニックを適用してください。

  • JMH (Java Microbenchmark Harness) — メソッド単位のベンチマーク
  • async-profiler — プロダクション環境でも使える低オーバーヘッド CPU / メモリプロファイラ
  • JFR (Java Flight Recorder) + JMC (Mission Control) — JDK 11+ に標準同梱
  • GC ログ (-Xlog:gc*=info,heap=debug)

「どこが遅いか」を事実で特定してから、以下のテクニックを狙い撃ちで使います。

1. メモリ使用量を減らす

プリミティブ型を優先

オブジェクト型 (Integer, Long) はヒープに別オブジェクトを作るぶんメモリ消費が大きい。特に大量データで顕著:

// NG: 1M 要素で ~24MB + オブジェクトヘッダ (16B × 1M = 16MB) = ~40MB
List<Integer> ids = new ArrayList<>(1_000_000);

// OK: 1M 要素で int 配列 ~4MB
int[] ids = new int[1_000_000];

判断軸:

  • サイズが小さい (数百要素程度) → どちらでもOK、可読性優先
  • サイズが大きい or ホットパス → プリミティブ配列 / IntStream を検討

不変オブジェクトは毎回 new しない

// NG: ループで毎回オブジェクト生成
for (int i = 0; i < 1_000_000; i++) {
    String s = new String("constant");
}

// OK: 定数は静的に 1 回だけ
private static final String CONSTANT = "constant";

Java 9+ では String の内部表現が UTF-16 → Latin-1 可変に最適化され、ASCII 文字だけなら半分のメモリで済む。

BigDecimal vs double

金額計算は BigDecimal だが、ホットパスで多用すると遅い。大量データなら long でサブ単位 (円 → 銭) に変換して計算、最後に BigDecimal に戻すのも手。

2. コレクションの選び方

用途推奨理由
順序保持、高速な末尾追加ArrayListほぼ常にこれで良い
高頻度 add/remove が先頭・中間ArrayDequeLinkedList より速い
キー検索HashMapO(1) 平均
ソート順で取り出しTreeMapO(log n) ソート済
スレッド安全ConcurrentHashMap同期化せず済む
読み取り多数 + 変更稀CopyOnWriteArrayListロックフリー Read
初期サイズが分かるnew ArrayList<>(expectedSize)再 allocate を防ぐ

LinkedList はほぼ使わない。現代の CPU キャッシュ効率を考えると、ArrayList の方がランダムアクセス・イテレーションとも速い。連結リスト記事 で詳細に比較しています。

3. ストリーム vs for ループ

// Stream API (可読性○)
int sum = list.stream().mapToInt(Integer::intValue).sum();

// 拡張 for (超ホットパスで速い)
int sum = 0;
for (int x : list) sum += x;
  • JIT が最適化しきれないケース: Stream は for より 2〜3 倍遅いことがある (シンプルな集計で特に)
  • 並列化が必要: parallelStream は for より速くなる (が Fork/Join 固有のオーバーヘッドあり)
  • 可読性と保守性を優先: 迷ったら Stream、プロファイリングで遅ければ for に書き換え

4. GC チューニング

JDK 21 時点の推奨:

GC向く用途設定例
G1GC (既定)一般用途、停止時間重視-XX:+UseG1GC -XX:MaxGCPauseMillis=200
ZGC大ヒープ (数十 GB〜) + 超短停止-XX:+UseZGC
ShenandoahZGC と同様、Red Hat 系で選択-XX:+UseShenandoahGC
ParallelGCバッチ処理、スループット重視-XX:+UseParallelGC

チューニング前の確認:

# GC ログを詳細に
-Xlog:gc*=info:file=/tmp/gc.log:time,uptime,level,tags

# Heap ダンプ (OOM 時)
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp/heap.hprof

GC ログは GCViewer や GCEasy で可視化すると、どの世代が逼迫しているか一目で分かる。

5. その他の実務 Tips

DB アクセス

  • N+1 クエリを疑え。JPA なら @EntityGraph / fetch join
  • コネクションプールは HikariCP、最大接続数は CPU × 2〜4 程度から
  • 大量 INSERT は batchSize を 500〜1000 でまとめる

I/O

  • Files.lines(path) で遅延読み込み (大きいファイルを全部メモリに乗せない)
  • BufferedReader / BufferedWriter を忘れずに

仮想スレッド (JDK 21+)

  • I/O ブロッキング多数のワークロードで威力
  • Spring Boot 3.2+ は spring.threads.virtual.enabled=true で自動有効化

文字列結合

  • ループ内の += は NG、StringBuilder を使う
  • 大量ログは「ログレベルガード + ラムダ」(log.debug("x={}", () -> expensive()))

落とし穴

1. 早すぎる最適化

書いたばかりのコードを最適化するのは大半が無駄。まず動くもの → プロファイリング → 遅い所だけチューニング が鉄則。

2. マイクロベンチマーク誤解

System.nanoTime() 手書き計測は JIT warmup / dead code elimination で嘘つく。必ず JMH を使う。

3. GC アルゴリズムの変更で勘違い

GC を ZGC にしたら「速くなった」のではなく「停止時間が短くなった」が正しい理解。スループットは下がることもある。

まとめ

  • 計測が先、推測は捨てる (JMH / async-profiler / JFR)
  • メモリとオブジェクト生成を抑える → GC 圧を下げる
  • コレクションは ArrayList + HashMap を基本に、特殊ケースで差し替え
  • GC は G1 から始め、大ヒープ短停止が要るなら ZGC
  • 仮想スレッド (JDK 21+) は I/O 多数ワークロードで試す価値あり

関連記事