概要
「処理が遅い」という報告を受けたとき、まず必要になるのが正確な計測です。しかし Java のベンチマークには落とし穴が多く、System.currentTimeMillis で測ると OS の時刻調整に影響を受けたり、JIT コンパイルの有無で結果が大きく変わったりします。この記事では、System.nanoTime を使った相対時間計測の基本、JIT コンパイルの影響を除くウォームアップの考え方、そして現場でよく比較される文字列結合(+ 演算子 vs StringBuilder)とコレクション操作(ArrayList vs LinkedList)の性能差を、動作するコードで確認します。本格的なベンチマークには JMH が適しますが、まずは標準 API だけで計測の勘所を押さえておくことが実務での判断を速くします。
使いどころ
バッチ処理の各ステップにかかる時間を計測し、ボトルネック箇所を特定する
コードレビューで「StringBuilder に変えるべきか」と指摘されたとき、実際の性能差を数値で確認する
API のレスポンスタイムを nanoTime で計測し、SLA の閾値を超えていないかログに記録する
コード例
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();
}
}Version Coverage
record で計測結果(ラベル・所要時間)を不変オブジェクトとして表現できる。var による型推論でコードが簡潔に。文字列結合は invokedynamic ベース(Java 9+)。
// 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
注意点
System.currentTimeMillis は壁時計時間を返すため、NTP による時刻補正の影響を受ける。処理時間の計測には必ず System.nanoTime を使うこと
JIT コンパイルが走る前と後で処理速度が大きく異なる。ウォームアップなしの1回計測では正確な結果が得られない
小さな処理(数マイクロ秒以下)の計測では nanoTime 自体の呼び出しコストが相対的に大きくなる。ループで繰り返して平均を取ること
文字列結合の + 演算子は Java 9 以降で invokedynamic ベースに最適化されたが、ループ内での大量結合では依然 StringBuilder が有利
ベンチマーク結果は JVM バージョン、GC アルゴリズム、ハードウェアに依存する。環境を明記しないと再現性がない
FAQ
処理時間の計測には nanoTime を使います。nanoTime は相対時間で OS の時刻調整に影響されません。currentTimeMillis は「いつ実行されたか」を記録するときに使います。
一般的には 3〜5 回で JIT コンパイルが安定します。その後 5〜10 回計測して平均を取ると、ばらつきの少ない結果が得られます。処理の重さに応じて調整してください。
ループ内で文字列を繰り返し連結するケースでは StringBuilder が有利です。数回の結合なら + 演算子のほうが読みやすく、Java 9 以降の最適化もあるため差は小さくなります。