Java パフォーマンスチューニングの実務ノート — JVM / コレクション / GC
プリミティブ型・不変オブジェクト・コレクション選択・ストリーム vs for ループ・GC チューニングまで、現場で効くチューニングの型。計測してからチューニングする原則を軸に。
まずは計測から
パフォーマンスチューニングの最大のアンチパターンは「推測で直す」こと。以下の計測基盤を整えてから、本稿のテクニックを適用してください。
- 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 が先頭・中間 | ArrayDeque | LinkedList より速い |
| キー検索 | HashMap | O(1) 平均 |
| ソート順で取り出し | TreeMap | O(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 |
| Shenandoah | ZGC と同様、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 多数ワークロードで試す価値あり
関連記事
- Web・モバイル·
Spring Boot プロジェクト構成 — 10 人規模で崩れないパッケージ設計
Controller / Service / Repository の責務境界、ドメイン層の切り方、テストの並べ方まで。Spring Boot API 開発で「後から直せなくなるコード」を防ぐためのパッケージ構成の指針。
- Web・モバイル·
Spring AOP 実装パターン — トランザクション・ログ・権限を本流から剥がす
横断的関心事 (クロスカッティングコンサーン) を綺麗に分離する Spring AOP の使い所と Aspect/JoinPoint/Advice/Pointcut の全体像、プロダクションで効くユースケース 4 選。
- Web・モバイル·
Spring Bean ライフサイクル — スコープ・初期化順・落とし穴
BeanDefinition のロードから @PostConstruct まで、Spring Bean がどの順序で生成されるかを 7 ステップで整理。Singleton/Prototype/Request/Session スコープの違いと実務的な使い分けも。