Javaは速いが、コードはそうとは限らない
概要
- 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%減を実現
- 次回はプロファイリングデータ解析や詳細な修正内容を紹介予定