世界を動かす技術を、日本語で。

Javaは速いが、コードはそうとは限らない

2026年3月20日原文(jvogel.me)

概要

  • Javaパフォーマンス最適化シリーズ第1弾の要点解説
  • DevNexusで使用した注文処理アプリの改善事例
  • 主要なアンチパターン8つを実例で紹介
  • 最適化前後で5倍のスループット、87%のヒープ削減、GC停止79%減
  • コード変更はアーキテクチャ非依存、同一JDK・同一テストで検証

Javaパフォーマンス最適化事例:アンチパターン8選

  • DevNexus講演用に開発した 注文処理Javaアプリ でのパフォーマンス改善事例

    • 初期状態: 1,198ms (経過時間)、 85,000注文/秒、ヒープ 1GB超、GC停止 19回
    • 最適化後: 239ms419,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%減 を実現
  • 次回は プロファイリングデータ解析詳細な修正内容 を紹介予定

Hackerたちの意見

最初のリクエストのレイテンシが、JavaではホットパスのコードがC2コンパイラを通るまで本当にひどいことになることがあるんだよね。起動時にそのコードを実行してホットパスを温めることはできるけど、それがめっちゃ面倒なんだよ。C++、Go、Rustを使えば、その問題を回避できるし、コードパスのウォームアップの手間も省ける。Javaにちゃんとしたコンパイラがあればいいのに。

JVMはそれをやってないの?GraalVMはどう?

C2が静的なAOTコンパイル、例えばLLVMに対してどれだけのメリットをもたらすのか、直接比較してみたいな。もし価値があるなら、状態をフリーズして再開できて、瞬時にワークロード最適化されたスタートアップができるのに驚くよ。

GraalVMを使えばネイティブ実行可能ファイルを作れるよ。もしJVMを維持したいなら、進行中のプロジェクトLeydenで、JVMのウォームアップの一部を「事前トレーニング」できるし、完全なAOTコードコンパイルは将来的に実現する予定だよ。

長時間実行するプロセスにはJavaを使う理由はこれなんだ。もし起動が早い小さなバイナリが大事なら、起動は速いけど実行時は遅いPythonみたいな別のものを使うよ。

Excelsior JETは今はなくなっちゃったけど、GraalVMとOpenJ9があるからね。組み込み系の人たちはPTCやAicasを使って遊んでるよ。AndroidはちゃんとしたJavaじゃないけど、dex2oatがあるからね。

最近のJDKがあればほとんど問題ないよ。Leydenはウォームアップをかなり短縮していて、今後もさらに改善される見込みだよ。 https://foojay.io/today/how-is-leyden-improving-java-perform... https://quarkus.io/blog/leyden-1/

最初のリクエストのレイテンシが言語の選択によってボトルネックになるって考えには異議あり。確かにそれはあり得ると思うけど、ほとんどの開発者にとってそれが問題になるのかな?

AOTは起動時間にはいいけど、プロダクションの長期的なパフォーマンス問題には逆のトレードオフがあるよね。動的プロファイルガイド最適化を使うJITもあって、実際のワークロードに合わせてランタイムでバイナリを調整できるんだ。普通のPGOみたいに事前にプロファイルを用意する必要はない。Javaはまだこれがない(私の知る限りでは)けど、.NETにはあって、大規模なウェブアプリケーションには大きなメリットだよね。

かなり前にJVMの開発に関わってた(もう20年近く前)。その頃のJavaの使い方は、長時間稼働するサーバーがほとんどだった。ランタイムチームは、できるだけ長い間AOTキャッシングの実装を拒否してたんだ。これはJavaにとって大きなチャンスを逃したことになる。クライアントの起動時間は常にひどかったからね。ここ3〜5年でようやく状況が変わり始めた気がするのは、Graalネイティブイメージの推進が一因だと思う。私はずっと前に、JVMのメンテナがクライアントやシステムプログラミング言語としてのJavaを優先していないと結論づけた。優先順位と言っていることに注意してほしい。彼らは非常に優秀なエンジニアで、異なるユースケースに焦点を当てているけど、クライアントエコシステムからはあまりお金が得られないんだ。

JITを駆使してスタートアップ時間を合わせるのは、Javaの「速さ」がプロダクショングラフに見えないアスタリスクを付けてる良いサインだよ。どこかでアプリじゃなくてランタイムを管理してるってこと。GraalVM Native ImageみたいなAOTオプションはコールドスタートにはかなり役立つけど、そうするとお気に入りのフレームワークの半分が壊れちゃうし、別の問題に悩まされることになる。どの痛みを選ぶかだね。

アルゴリズムの複雑さを理解すること(特にループ内の再作業を避けること)は、どの言語でも役立つし、賢いアドバイスだよ。ただ、実際にはほとんどのエンタープライズウェブサービスでは、外部サービス(データベースを含む)をどれだけ効率的に呼び出すかが、実際のパフォーマンスに大きく影響するんだ。クエリのループをバルク処理に変えるだけでも大きな助けになるし(それからインデックスをうまく使うようにクエリを調整したり、アップサートを行ったり、不必要なデータを削除したりすることも重要)。LLMの改善によって、ORMを使わずにSQLをうまく活用できるようになることを期待してるよ。

Hacker Newsで議論の続きを見る