概要

「処理が遅い」という報告を受けたとき、まず必要になるのが正確な計測です。しかし Java のベンチマークには落とし穴が多く、System.currentTimeMillis で測ると OS の時刻調整に影響を受けたり、JIT コンパイルの有無で結果が大きく変わったりします。この記事では、System.nanoTime を使った相対時間計測の基本、JIT コンパイルの影響を除くウォームアップの考え方、そして現場でよく比較される文字列結合(+ 演算子 vs StringBuilder)とコレクション操作(ArrayList vs LinkedList)の性能差を、動作するコードで確認します。本格的なベンチマークには JMH が適しますが、まずは標準 API だけで計測の勘所を押さえておくことが実務での判断を速くします。

使いどころ

バッチ処理の各ステップにかかる時間を計測し、ボトルネック箇所を特定する

コードレビューで「StringBuilder に変えるべきか」と指摘されたとき、実際の性能差を数値で確認する

API のレスポンスタイムを nanoTime で計測し、SLA の閾値を超えていないかログに記録する

コード例

PerformanceMeasure.java
import java.util.ArrayList;
import java.util.LinkedList;

public class PerformanceMeasure {

    // 計測結果を record で保持(Java 17+)
    record MeasureResult(String label, long nanos) {
        double toMillis() { return nanos / 1_000_000.0; }
        void print() {
            System.out.printf("%s: %,d ns (%.3f ms)%n", label, nanos, toMillis());
        }
    }

    /** System.nanoTime で処理時間を計測 */
    static long measureNanoTime(Runnable task) {
        var start = System.nanoTime();
        task.run();
        return System.nanoTime() - start;
    }

    /** ラベル付きで計測 */
    static MeasureResult measure(String label, Runnable task) {
        return new MeasureResult(label, measureNanoTime(task));
    }

    /** ウォームアップ付き計測(JIT の影響を除く) */
    static MeasureResult measureWithWarmup(
            String label, Runnable task, int warmup, int runs) {
        for (int i = 0; i < warmup; i++) { task.run(); }
        var total = 0L;
        for (int i = 0; i < runs; i++) { total += measureNanoTime(task); }
        return new MeasureResult(label, total / runs);
    }

    public static void main(String[] args) {
        int n = 10_000;

        // 文字列結合 vs StringBuilder
        System.out.println("=== 文字列結合 vs StringBuilder ===");
        var r1 = measure("+ 結合(" + n + "回)", () -> {
            var s = "";
            for (int i = 0; i < n; i++) { s = s + i; }
        });
        var r2 = measure("StringBuilder(" + n + "回)", () -> {
            var sb = new StringBuilder();
            for (int i = 0; i < n; i++) { sb.append(i); }
            sb.toString();
        });
        r1.print();
        r2.print();
        if (r2.nanos() > 0) {
            System.out.printf("StringBuilder は約 %.1f 倍高速%n",
                (double) r1.nanos() / r2.nanos());
        }

        // ArrayList vs LinkedList
        System.out.println("\n=== ArrayList vs LinkedList ランダムアクセス ===");
        var al = new ArrayList<Integer>();
        var ll = new LinkedList<Integer>();
        for (int i = 0; i < n; i++) { al.add(i); ll.add(i); }
        measure("ArrayList.get(中間) 1000回",
            () -> { for (int i = 0; i < 1000; i++) al.get(n / 2); }).print();
        measure("LinkedList.get(中間) 1000回",
            () -> { for (int i = 0; i < 1000; i++) ll.get(n / 2); }).print();

        // ウォームアップ付き
        System.out.println("\n=== ウォームアップ付き計測 ===");
        measureWithWarmup("100000回ループ平均", () -> {
            var sum = 0L;
            for (int i = 0; i < 100_000; i++) { sum += i; }
        }, 5, 10).print();
    }
}

Java 8 / 17 / 21 の完全なサンプルコードは GitHub リポジトリ で確認できます。

Version Coverage

record で計測結果(ラベル・所要時間)を不変オブジェクトとして表現できる。var による型推論でコードが簡潔に。文字列結合は invokedynamic ベース(Java 9+)。

Java 17
// Java 17: ラムダ + record で簡潔に
record MeasureResult(String label, long nanos) {
    double toMillis() { return nanos / 1_000_000.0; }
    void print() {
        System.out.printf("%s: %,d ns (%.3f ms)%n", label, nanos, toMillis());
    }
}
var r = new MeasureResult("文字列結合", measureNanoTime(() -> {
    var result = "";
    for (int i = 0; i < 10000; i++) { result = result + i; }
}));
r.print();

Library Comparison

標準 API(System.nanoTime)簡易的な処理時間計測やボトルネックの切り分けに。外部依存なしですぐに組み込める。JIT・GC の影響を手動で考慮する必要がある。統計的な信頼性を担保するには工夫がいる。
JMH(Java Microbenchmark Harness)マイクロベンチマークの精度が求められるとき。ウォームアップ、フォーク、統計処理を自動化してくれる。Maven / Gradle への依存追加が必要。学習コストがあり、手軽さでは nanoTime に劣る。
Spring Boot Actuator / Micrometerアプリケーション全体のメトリクス(レスポンスタイム、スループット)を収集したいとき。Spring Boot が前提。マイクロベンチマーク目的には不向き。

注意点

System.currentTimeMillis は壁時計時間を返すため、NTP による時刻補正の影響を受ける。処理時間の計測には必ず System.nanoTime を使うこと

JIT コンパイルが走る前と後で処理速度が大きく異なる。ウォームアップなしの1回計測では正確な結果が得られない

小さな処理(数マイクロ秒以下)の計測では nanoTime 自体の呼び出しコストが相対的に大きくなる。ループで繰り返して平均を取ること

文字列結合の + 演算子は Java 9 以降で invokedynamic ベースに最適化されたが、ループ内での大量結合では依然 StringBuilder が有利

ベンチマーク結果は JVM バージョン、GC アルゴリズム、ハードウェアに依存する。環境を明記しないと再現性がない

FAQ

nanoTime と currentTimeMillis はどう使い分けますか。

処理時間の計測には nanoTime を使います。nanoTime は相対時間で OS の時刻調整に影響されません。currentTimeMillis は「いつ実行されたか」を記録するときに使います。

ウォームアップは何回実行すればよいですか。

一般的には 3〜5 回で JIT コンパイルが安定します。その後 5〜10 回計測して平均を取ると、ばらつきの少ない結果が得られます。処理の重さに応じて調整してください。

文字列結合はいつ StringBuilder に変えるべきですか。

ループ内で文字列を繰り返し連結するケースでは StringBuilder が有利です。数回の結合なら + 演算子のほうが読みやすく、Java 9 以降の最適化もあるため差は小さくなります。

関連書籍

この記事のテーマをさらに深く学びたい方へ。

※ Amazon アソシエイトリンクを含みます