概要
- Javaパフォーマンス最適化シリーズ第1弾の要点解説
- DevNexusで使用した注文処理アプリの改善事例
- 主要なアンチパターン8つを実例で紹介
- 最適化前後で5倍のスループット、87%のヒープ削減、GC停止79%減
- コード変更はアーキテクチャ非依存、同一JDK・同一テストで検証
Javaパフォーマンス最適化事例:アンチパターン8選
-
DevNexus講演用に開発した 注文処理Javaアプリ でのパフォーマンス改善事例
- 初期状態: 1,198ms (経過時間)、 85,000注文/秒、ヒープ 1GB超、GC停止 19回
- 最適化後: 239ms、 419,000注文/秒、ヒープ 139MB、GC停止 4回
- 同一アプリ・同一テスト・同一JDK・アーキテクチャ変更なし
-
本記事では パフォーマンス低下を招く8つのアンチパターン を紹介
- これらは 実際のコードベース で頻出し、 コードレビューやコンパイルでは見逃されやすい
- プロファイリングデータがなければ気づきにくい
- 次回記事で プロファイリング結果 や 具体的な修正内容 を解説予定
1. ループ内のString連結
-
String report = ""; for (String line : logLines) { report = report + line + "\n"; }
- Stringはイミュータブル なため、連結ごとに新規オブジェクト生成
- ループごとに O(n²)の文字列コピー が発生し、大規模データで深刻な遅延
- JMHベンチマークで nが4倍になると処理時間は7倍超 に悪化
-
修正例: StringBuilder sb = new StringBuilder(); for (String line : logLines) { sb.append(line).append("\n"); } String report = sb.toString();
- StringBuilderは1つの可変バッファ でappendごとに追記、最後にtoString()
- JDK9以降でも ループ内連結は自動最適化されない ため明示的にStringBuilderを用意
2. ループ内ストリームのO(n²)反復
-
for (Order order : orders) { int hour = ...; long countForHour = orders.stream().filter(...).count(); ordersByHour.put(hour, countForHour); }
- 全注文リストを各要素ごとにストリーム処理、10,000件で1億回比較
- JFRプロファイルで CPUサンプルの71%を消費
-
修正例: for (Order order : orders) { int hour = ...; ordersByHour.merge(hour, 1L, Long::sum); }
- 一度のパスでカウント集計、O(n)で効率化
- stream()のループ内使用は 冗長処理のシグナル
3. ホットパスでのString.format()多用
-
return String.format("Order %s for %s: $%.2f", orderId, customer, amount);
- String.format()は毎回フォーマット解析・正規表現処理 で最も遅い
- ベンチマークで StringBuilderが最速、format()が最遅
-
修正例: return "Order " + orderId + " for " + customer + ": $" + String.format("%.2f", amount);
- 数値整形のみformat()、他は連結でコンパイラ最適化
- format()は 設定読み込み・エラー表示等の低頻度用途 に限定
4. ホットパスでのオートボクシング
-
Long sum = 0L; for (Long value : values) { sum += value; }
- 毎回ボックス化/アンボックス化 でLongオブジェクト大量生成
- 1,000,000要素で 16MBのヒープ消費
-
修正例: long sum = 0L; for (long value : values) { sum += value; }
- プリミティブ型利用 でGC負荷を回避
- List<Long>やMap<String, Integer>が 頻繁に使われる場合は注意
5. 例外による制御フロー
-
public int parseOrDefault(String value, int defaultValue) { try { return Integer.parseInt(value); } catch (NumberFormatException e) { return defaultValue; } }
- 頻繁な例外発生時にfillInStackTrace()でコールスタック全走査
- 例外発生で 数百倍遅くなる ことも
-
修正例: public int parseOrDefault(String value, int defaultValue) { if (value == null || value.isBlank()) return defaultValue; for (int i = 0; i < value.length(); i++) { char c = value.charAt(i); if (i == 0 && c == '-') continue; if (!Character.isDigit(c)) return defaultValue; } try { return Integer.parseInt(value); } catch (NumberFormatException e) { return defaultValue; } }
- 事前バリデーションで例外発生率を大幅低減
- 例外は 予期しない事象 のみで利用、通常フローには使わない
6. 過度な同期化(synchronized)
-
public synchronized void increment(String key) { ... }
- 全メソッドをロック し、スレッド間でボトルネック発生
-
修正例: private final ConcurrentHashMap<String, LongAdder> counts = new ConcurrentHashMap<>(); public void increment(String key) { counts.computeIfAbsent(key, k -> new LongAdder()).increment(); }
- ConcurrentHashMapで細粒度ロック、LongAdderで高並列性
- Collections.synchronizedMap()も 全体ロック型 なので非推奨
7. 再利用可能オブジェクトの都度生成
-
return new ObjectMapper().writeValueAsString(order);
- ObjectMapperやDateTimeFormatter、Gson等の毎回生成は高コスト
- 構築時に モジュール検出・キャッシュ初期化等の重い処理
-
修正例: private static final ObjectMapper MAPPER = new ObjectMapper(); public String serializeOrder(Order order) throws JsonProcessingException { return MAPPER.writeValueAsString(order); }
- スレッドセーフなオブジェクトはstatic finalで共有
- DateTimeFormatter.ISO_LOCAL_DATE等は 組み込みシングルトン
8. Virtual Threadピニング(JDK 21–23)
- synchronizedやブロッキングI/Oで 仮想スレッドのキャリアスレッドがピン留め
- 仮想スレッドの並列性低下、スケーラビリティ阻害
- これらアンチパターンの修正で 5倍のスループット、ヒープ87%削減、GC停止79%減 を実現
- 次回は プロファイリングデータ解析 や 詳細な修正内容 を紹介予定