参考記事:
パフォーマンスの10倍改善は、インフラコストの大幅な削減を意味します。
クラウド時代において、CPU時間は直接的な経費です。月額100万円のAWSインフラコストが10万円になれば、年間1,080万円の利益改善。これは中小企業なら社員を1名雇用できる金額であり、大企業ならコスト削減となります。
しかし、本当の価値はコスト削減だけではありません。
- レスポンスタイムの改善。ユーザー体験が向上し、コンバージョン率が上昇
- スケーラビリティの向上。同じリソースでより多くのユーザーに対応可能
- 競争優位性の確立。競合より高速なサービスは、それだけで差別化要因
計算量が同じでも実行時間は10倍違う
多くのエンジニアは「高速化=アルゴリズムの改良」と考えがちです。確かに、O(n²)をO(n log n)に改善することは重要です。しかし、同じO(n)のアルゴリズムでも、実装次第で10倍以上の性能差が生まれることをご存知でしょうか。
// CPUに優しくないコード(キャッシュミスが頻発)
for (int i = 0; i < size; i++) {
for (int j = 0; j < size; j++) {
sum += matrix[j][i]; // 列方向のアクセス
}
}
// CPUフレンドリーなコード(キャッシュ効率が良い)
for (int i = 0; i < size; i++) {
for (int j = 0; j < size; j++) {
sum += matrix[i][j]; // 行方向のアクセス
}
}
どちらも計算量はO(n²)で同じですが、行列がL3キャッシュを超えるサイズでは実測で後者が4~7倍高速になります(本記事の実測では4096×4096で6.5倍。詳細はメモリ帯域幅の節を参照)。これは、メモリアクセスパターンがCPUキャッシュの動作原理に合致しているからです。
「動けばいい」では通用しない時代へ
仕様通りに動くコードをクリーンに書くのは当たり前。その上で、高速化してビジネス的な優位性をもたらせるかどうかが、次の差別化要因になる。
実務の開発では、納期やコストの制約から「まず動くコード」を優先せざるを得ない場面が多くあります。また、競技プログラミングでは「最悪計算量さえ良ければ定数倍は無視」が合理的ですが、実務ではこの前提がそのまま通用しません。
しかし実務では、以下のような問題が性能を大きく左右します。
- 無駄なオブジェクト生成によるGCプレッシャー
- 不適切なデータ構造選択による性能劣化(LinkedListの罠など)
- CPUキャッシュを考慮しないメモリアクセスパターン
- 並列処理の未活用によるマルチコアCPUの無駄遣い
競プロでは「定数倍は無視」が基本ですが、実務では2倍の高速化でもインフラコストが半減するのです。
高度人材の必要性とAI時代の新たな課題
パフォーマンス最適化は、CPUアーキテクチャ、JVMの内部動作、アルゴリズムの計算量、並行処理の理論など、複数の専門分野にまたがる知識が必要です。日常の業務開発で常に要求される知識ではないからこそ、身につければ大きな差別化要因になる専門性です。
さらに、Claude CodeのようなAIツールを使う場合でも、専門的な指示と適切な観点での誘導が不可欠です。
AIは強力なツールですが、何を最適化すべきか、どのような手法が適切かを判断するには、人間の専門知識が不可欠です。
この記事で得られるもの
本記事では、GitHub Codespaces環境で取得した実測データを交えながら、以下を解説します。
- CPUの動作原理から理解する、なぜその最適化が効くのか
- JITコンパイラの挙動を確認し、意図した最適化が適用されているかの検証方法
- 実際のコード例と実行結果で、10倍以上の高速化を実現する手法
「Javaは十分速い」という言葉で満足していませんか?
実は、適切な最適化技術を使えば、Javaコードでも10倍以上の高速化は十分可能なのです。そして、それは直接的にビジネスの競争力につながります。
測定環境について
今回の実測は、以下の環境で行いました。
| 項目 | 詳細 |
|---|---|
| プラットフォーム | GitHub Codespaces(standardLinux32gb) |
| CPU | 4 vCPU(AMD EPYC 7763、物理2コア×SMT2) |
| メモリ | 16GB RAM |
| OS | Ubuntu Linux |
| Java | Microsoft OpenJDK 21.0.11 |
| JVMオプション | デフォルト(追加オプションが必要なベンチマークには個別に記載) |
なぜJavaでも高速化が必要なのか?
ソフトウェアの性能とCPUの関係
CPUがソフトウェアを高速に実行する原理は、突き詰めると「命令の流れ(命令流)を高密度に処理する」ことに集約されます。現代のCPUは、パイプライン処理1やスーパースカラ2といった技術を駆使し、1クロックサイクルあたりに複数の命令を並列で実行しようとします。
しかし、この命令流を滞らせる要因がコード内に存在すると、CPUはその性能を全く発揮できなくなります。Javaは高速な言語ですが、以下のような実装はCPUレベルで性能劣化を引き起こします。
-
不適切なデータ構造の選択。メモリアクセスパターンが悪化し、CPUのキャッシュミス3を頻発させます。キャッシュミスは数十~数百サイクルのペナルティを発生させ、命令流を大きく停滞させます。
-
過度なオブジェクト生成。GCプレッシャーだけでなく、データがメモリ上で散在し、空間局所性4が低下します。これもキャッシュ効率を悪化させる一因です。
-
並列処理の未活用。マルチコアCPUの性能を半分も引き出せていません。これは複数の命令流を同時に実行できるというマルチプロセッサの最大の利点を放棄しているのと同じです。
-
予測しにくい分岐。
ifやswitch、ループ内の条件分岐が不規則だと、CPUの分岐予測5が外れやすくなります。予測ミスが発生すると、パイプラインを一旦空にして命令を再取得する必要があり、数十サイクルのペナルティが発生します。 -
データ依存関係による並列性の阻害。ある計算の結果を次の計算で使う(データ依存関係)と、CPUはその待ち時間(レイテンシ)でパイプラインが停滞し、アウトオブオーダー実行6による並列化の恩恵を受けられません。
これらの「CPUにとって優しくないコード」を「CPUにとって実行しやすいコード」に変えることが、高速化の本質です。
JITコンパイラの最適化を検証する
Javaの高速化を語る上で欠かせないのが、JITコンパイラ7が生成する機械語コードの確認です。私たちが書いたJavaコードが、実際にCPU上でどのような命令に変換されているかを知ることで、最適化の効果を実証できます。
# JITコンパイル結果の逆アセンブル出力を有効化(hsdisライブラリが必要)
# hsdisはJDKに同梱されないため、binutils/Capstone等をバックエンドに別途ビルドして配置する
java -XX:+UnlockDiagnosticVMOptions \
-XX:+PrintAssembly \
-XX:PrintAssemblyOptions=intel \
YourProgram
# hsdisがない場合の代替手段
# PrintCompilation: コンパイル対象 / PrintInlining: インライン化判断 / LogCompilation: XMLログ
# (注意: 行継続 \ の後ろにコメントは書けない)
java -XX:+UnlockDiagnosticVMOptions \
-XX:+PrintCompilation \
-XX:+PrintInlining \
-XX:+LogCompilation \
YourProgram
なお、hsdisはJDKに同梱されていませんが、Capstoneバックエンドなら数分でビルドできます(本記事のアセンブリ出力もこの方法で取得しました)。
# hsdis(Capstoneバックエンド)のビルドと導入例(Ubuntu / x86-64)
sudo apt-get install -y libcapstone-dev
curl -sO https://raw.githubusercontent.com/openjdk/jdk/jdk-21-ga/src/utils/hsdis/hsdis.h
curl -sO https://raw.githubusercontent.com/openjdk/jdk/jdk-21-ga/src/utils/hsdis/capstone/hsdis-capstone.c
gcc -O2 -shared -fPIC -DCAPSTONE_ARCH=CS_ARCH_X86 -DCAPSTONE_MODE=CS_MODE_64 \
-I. -I/usr/include/capstone hsdis-capstone.c -lcapstone -o hsdis-amd64.so
sudo cp hsdis-amd64.so "$JAVA_HOME/lib/"
# 特定メソッドだけ逆アセンブルする(出力が膨大になるのを防ぐ)
java -XX:+UnlockDiagnosticVMOptions -XX:PrintAssemblyOptions=intel \
-XX:CompileCommand=print,YourClass::yourMethod YourProgram
高速化の基本戦略
データ依存関係の解消によるCPU並列性の向上
依存関係がCPUの並列実行を阻害する
CPUは複数の演算器を持ち、データ依存関係がない命令を同時に実行できます。しかし、前の計算結果を次の計算で使う場合、その結果が出るまで待つ必要があり、CPUの並列実行能力が活かせません。
データ依存関係の実行シーケンス
このシーケンス図が示すように、依存関係がある場合は1つの演算器しか使用できませんが、複数のアキュムレータを使用することで、CPUの複数の演算器を同時に活用できます。これにより、理論上は演算器の数に比例した高速化が可能となります。
public class DataDependencyOptimization {
// 遅い例:強いデータ依存関係
public static double slowAccumulation(double[] data) {
double sum = 0.0;
for (int i = 0; i < data.length; i++) {
// 各反復でsumの更新を待つ必要がある(レイテンシ3-4サイクル)
sum += data[i] * 0.1;
}
return sum;
}
// 速い例:複数のアキュムレータで依存関係を緩和
public static double fastAccumulation(double[] data) {
double sum1 = 0.0, sum2 = 0.0, sum3 = 0.0, sum4 = 0.0;
// 4つの独立した累算を並列実行可能
int i;
for (i = 0; i < data.length - 3; i += 4) {
sum1 += data[i] * 0.1;
sum2 += data[i + 1] * 0.1;
sum3 += data[i + 2] * 0.1;
sum4 += data[i + 3] * 0.1;
}
// 残りの要素を処理
for (; i < data.length; i++) {
sum1 += data[i] * 0.1;
}
return sum1 + sum2 + sum3 + sum4;
}
// JITコンパイルの確認用メソッド
public static void verifyJIT() {
double[] testData = new double[1000];
for (int i = 0; i < testData.length; i++) {
testData[i] = i * 0.5;
}
// ウォームアップ(JITコンパイルをトリガー)
for (int i = 0; i < 10000; i++) {
slowAccumulation(testData);
fastAccumulation(testData);
}
// この時点でJITコンパイルが完了
System.out.println("JIT compilation completed. Check assembly output.");
}
public static void main(String[] args) {
// テストデータ生成
int size = 10_000_000;
double[] data = new double[size];
for (int i = 0; i < size; i++) {
data[i] = Math.random();
}
// JIT最適化の確認
verifyJIT();
// 測定
long start = System.nanoTime();
double result1 = slowAccumulation(data);
long slowTime = System.nanoTime() - start;
start = System.nanoTime();
double result2 = fastAccumulation(data);
long fastTime = System.nanoTime() - start;
System.out.printf("Dependent accumulation: %.4f seconds%n",
slowTime / 1_000_000_000.0);
System.out.printf("Independent accumulation: %.4f seconds%n",
fastTime / 1_000_000_000.0);
System.out.printf("Speedup: %.1fx%n",
(double) slowTime / fastTime);
System.out.printf("Results match: %b%n",
Math.abs(result1 - result2) < 0.01);
}
}
実測結果とJITコンパイル後の検証
# コンパイル・実行コマンド
javac DataDependencyOptimization.java && java DataDependencyOptimization
JIT compilation completed. Check assembly output.
Dependent accumulation: 0.0095 seconds
Independent accumulation: 0.0039 seconds
Speedup: 2.5x
Results match: true
※注: 実測値は環境(CPU、JVM、負荷状況)により変動します。この例では4 vCPU環境での測定結果です。
hsdis(Capstoneバックエンド、導入方法は前述)で取得した、C2コンパイル後の実際の逆アセンブルです(抜粋、アドレス省略)。実出力からは教科書以上のことがわかります。
依存関係がある場合(遅い)
; slowAccumulation のループ本体(実出力・抜粋)
; 乗算(data[i] * 0.1)はSIMD化され8倍アンロールまでされているが…
vmulpd ymm1, ymm14, ymmword ptr [rsi + rdx*8 + 0x10]
vmulpd ymm6, ymm14, ymmword ptr [rsi + rdx*8 + 0x30]
; …加算は順序保存のため、1要素ずつ xmm0 への直列チェーンになる
vaddsd xmm0, xmm0, xmm1 ; sum += 要素1
vpshufd xmm3, xmm1, 0xe
vaddsd xmm0, xmm0, xmm3 ; sum += 要素2
vextractf128 xmm3, ymm1, 1
vaddsd xmm0, xmm0, xmm3 ; sum += 要素3
vpshufd xmm2, xmm3, 0xe
vaddsd xmm0, xmm0, xmm2 ; sum += 要素4
興味深いことに、C2は乗算をベクトル化・アンロールする一方で、加算だけはすべてxmm0を経由する直列チェーンのままです。Javaは浮動小数点加算の順序を勝手に入れ替えられない(丸め結果が変わるため)ので、加算チェーンがボトルネックとして残ります。
依存関係を緩和した場合(速い)
; fastAccumulation のループ本体(実出力・抜粋)
vaddsd xmm1, xmm1, xmm0 ; sum1 への加算チェーン
vaddsd xmm12, xmm12, xmm4 ; sum2 への加算チェーン(独立)
vaddsd xmm14, xmm14, xmm3 ; sum3 への加算チェーン(独立)
vaddsd xmm13, xmm13, xmm2 ; sum4 への加算チェーン(独立)
; ...
vaddsd xmm0, xmm12, xmm14 ; 最後に4本を合算(return sum1+sum2+sum3+sum4)
vaddsd xmm0, xmm0, xmm13
vaddsd xmm0, xmm0, xmm1
アキュムレータを4つに分けると、ソースコードの意図どおり加算チェーンが**4本の独立したレジスタ(xmm1, xmm12, xmm14, xmm13)**に分かれ、CPUのアウトオブオーダー実行で並列に処理できるようになります。
CPUは4つの浮動小数点加算を同時に実行でき、理論上4倍の高速化が可能です。実測で2.5倍となったのは、メモリ帯域幅などの他の要因も影響しているためです。
文字列連結の最適化
まずは、基本的な例から見てみましょう。+演算子による文字列連結は、ループのたびに新しいStringオブジェクトが生成されます。Java 8以前では内部でStringBuilderも生成されていましたが、Java 9以降ではinvokedynamicとStringConcatFactoryを使用する実装に変更されました。いずれにせよ、ループ内での使用は避けるべきです。これはGCの負荷を高めるだけでなく、データがメモリの離れた領域に確保され、キャッシュ効率を悪化させます。
文字列連結の内部動作シーケンス
このシーケンス図は、+演算子が各ループで新しいオブジェクトを生成し、メモリの異なる場所にアクセスすることでキャッシュミスを引き起こす様子を示しています。一方、StringBuilderは事前に確保した連続メモリ領域に追記するため、キャッシュ効率が高く、GCの負荷も最小限に抑えられます。
import java.util.concurrent.TimeUnit;
public class StringOptimization {
// 遅い例:文字列の+演算子による連結
public static String slowConcat(int n) {
String result = "";
for (int i = 0; i < n; i++) {
result += "item" + i + ",";
}
return result;
}
// 速い例:StringBuilderを使用
public static String fastConcat(int n) {
StringBuilder sb = new StringBuilder(n * 10); // 適切な初期容量
for (int i = 0; i < n; i++) {
sb.append("item").append(i).append(",");
}
return sb.toString();
}
public static void main(String[] args) {
int n = 10000; // 10,000回の文字列連結
// ウォームアップ
for (int i = 0; i < 10; i++) {
slowConcat(100);
fastConcat(100);
}
// 遅い方法の測定
long start = System.nanoTime();
String result1 = slowConcat(n);
long slowTime = System.nanoTime() - start;
// 速い方法の測定
start = System.nanoTime();
String result2 = fastConcat(n);
long fastTime = System.nanoTime() - start;
System.out.printf("String concatenation: %.4f seconds%n",
slowTime / 1_000_000_000.0);
System.out.printf("StringBuilder: %.4f seconds%n",
fastTime / 1_000_000_000.0);
System.out.printf("Speedup: %.1fx%n",
(double) slowTime / fastTime);
}
}
実測結果
# コンパイル・実行コマンド
javac StringOptimization.java && java StringOptimization
String concatenation: 0.1134 seconds
StringBuilder: 0.0021 seconds
Speedup: 53.8x
文字列連結で53.8倍の高速化を実現できました。
注意: この結果はGitHub Codespaces(4 vCPU、16GB RAM、Microsoft OpenJDK 21.0.11)環境での実測値です。
- 高速化率は文字列の長さとループ回数に大きく依存します(この例では10,000回の連結)
- 少ない回数の連結では、差はより小さくなります
- JDK 9以降では、
invokedynamicとStringConcatFactoryを使用した実装に変更されました- この変更により単純な文字列連結の性能は向上しましたが、ループ内での連結は最適化されません
- 大量の連結やループ内ではStringBuilderが依然として圧倒的に高速です(今回の実測で約54倍。連結回数を増やすと差はさらに開きます)
これは、不適切な実装がCPUのキャッシュやメモリ管理にどれほど大きな負担をかけているかを示す好例です。
データ構造の選択による劇的な高速化
適切なコレクションを選んでいますか?
データ構造の選択は、計算量だけでなく、CPUのキャッシュ効率に絶大な影響を与えます。
データ構造のメモリアクセスパターン
このシーケンス図は、ArrayListが連続したメモリ領域を使用することで、一度のメモリアクセスで複数の要素をキャッシュに読み込める様子を示しています。一方、LinkedListは各ノードが離れたメモリ位置にあるため、要素アクセスのたびにキャッシュミスが発生し、大きなペナルティを受けます。
-
ArrayList:内部的には連続した配列でデータを保持します。これにより、ある要素にアクセスすると、その周辺の要素も一緒にキャッシュライン8に読み込まれる可能性が高まります(空間局所性)。結果として、連続した要素へのアクセスはキャッシュにヒットし続け、メモリアクセスの待ち時間(レイテンシ)がほぼゼロになります。 -
LinkedList:各要素がNodeオブジェクトとしてヒープ上のバラバラな位置に確保されます。get(i)で要素を辿るたびに、CPUはメモリの全く異なる場所へアクセスするため、キャッシュミスが頻発します。キャッシュアクセスのレイテンシはL1ヒットで約4〜5サイクル、L2で十数サイクル、L3で40〜70サイクル、メインメモリまで行くと100〜300サイクルにもなり、CPUは主記憶からのデータロードを待つ間、パイプラインをストール(停滞)9させざるを得ません。 -
int[](プリミティブ配列):ArrayListよりもさらに高速です。これは、Integerオブジェクトへの参照ではなく、int値そのものが連続したメモリに格納されるためです。オブジェクトのヘッダ情報などがなくデータ密度が高いため、一度にキャッシュに載る情報量が増え、さらにオートボクシング/アンボクシング10のオーバーヘッドもありません。
アセンブリレベルで見るArrayList vs. LinkedList
ArrayListとLinkedListで合計値を計算するループは、CPUから見ると全く異なる「命令流」となります。
ArrayListの場合:
sum += list.get(i); は、CPUにとって非常に予測しやすく、最適化しやすい処理です。内部的には配列アクセスなので、アセンブリレベルでは以下のような単純な命令に展開されます。
-
iからオフセットアドレスを計算する (例:i * 4) - ベースアドレスにオフセットを加算して、目的のデータのアドレスを直接求める (例:
mov rax, [base + i*4])
この処理は、命令間のデータ依存関係11が少なく、CPUはアウトオブオーダー実行6によって複数のループ反復を並行して処理しようとします。
LinkedListの場合:
sum += list.get(i); は、CPUにとって非常に非効率な処理です。i番目の要素を取得するには、先頭からi個のノードを辿る必要があります。
-
nodeのアドレスから.nextフィールドのアドレスをロードする - ロードしたアドレスから次の
nodeのアドレスをロードする - これを
i回繰り返す
この処理は、「前のノードをロードしないと、次のノードのアドレスがわからない」という強い真のデータ依存関係の連鎖になっています。
命令依存関係の図解
この違いを命令の依存関係グラフで見てみましょう。スーパースカラCPUは依存関係のない命令を同時に実行できますが、LinkedListの場合はその恩恵を全く受けられません。
ArrayListでは各要素へのアクセスが独立しているため、CPUは複数のロード命令を同時に発行できます。一方、LinkedListでは先行のロード命令が完了するまで後続のロード命令を開始できず、CPUの並列実行ユニットが遊んでしまいます。これが絶望的な性能差の根源です。
import java.util.*;
public class DataStructureOptimization {
// 最悪例:LinkedListでランダムアクセス(アンチパターン)
public static long sumLinkedListRandom(int n) {
List<Integer> list = new LinkedList<>();
for (int i = 0; i < n; i++) {
list.add(i);
}
long sum = 0;
for (int i = 0; i < n; i++) {
sum += list.get(i); // O(n)のアクセス、全体でO(n²)
}
return sum;
}
// LinkedListの正しい使い方:イテレータアクセス
public static long sumLinkedListIterator(int n) {
List<Integer> list = new LinkedList<>();
for (int i = 0; i < n; i++) {
list.add(i);
}
long sum = 0;
for (Integer value : list) {
sum += value; // O(1)のアクセス、全体でO(n)
}
return sum;
}
// 速い例:ArrayListで要素アクセス
public static long sumArrayList(int n) {
List<Integer> list = new ArrayList<>(n); // 初期容量を指定
for (int i = 0; i < n; i++) {
list.add(i);
}
long sum = 0;
for (int i = 0; i < n; i++) {
sum += list.get(i); // O(1)のアクセス
}
return sum;
}
// さらに速い例:プリミティブ配列
public static long sumArray(int n) {
int[] array = new int[n];
for (int i = 0; i < n; i++) {
array[i] = i;
}
long sum = 0;
for (int i = 0; i < n; i++) {
sum += array[i];
}
return sum;
}
public static void main(String[] args) {
int n = 100000;
// 測定(アンチパターン)
long start = System.nanoTime();
long result1 = sumLinkedListRandom(n);
long linkedRandomTime = System.nanoTime() - start;
// 測定(正しい使い方)
start = System.nanoTime();
long result2 = sumLinkedListIterator(n);
long linkedIteratorTime = System.nanoTime() - start;
start = System.nanoTime();
long result3 = sumArrayList(n);
long arrayListTime = System.nanoTime() - start;
start = System.nanoTime();
long result4 = sumArray(n);
long arrayTime = System.nanoTime() - start;
System.out.println("=== アンチパターン: LinkedListのランダムアクセス ===");
System.out.printf("LinkedList (random access): %.4f seconds%n",
linkedRandomTime / 1_000_000_000.0);
System.out.printf("ArrayList: %.4f seconds%n",
arrayListTime / 1_000_000_000.0);
System.out.printf("Slowdown: %.1fx%n",
(double) linkedRandomTime / arrayListTime);
System.out.println("\n=== 公平な比較: 順次アクセス ===");
System.out.printf("LinkedList (iterator): %.4f seconds%n",
linkedIteratorTime / 1_000_000_000.0);
System.out.printf("ArrayList: %.4f seconds%n",
arrayListTime / 1_000_000_000.0);
System.out.printf("Array: %.4f seconds%n",
arrayTime / 1_000_000_000.0);
System.out.printf("ArrayList vs LinkedList: %.1fx faster%n",
(double) linkedIteratorTime / arrayListTime);
System.out.printf("Array vs LinkedList: %.1fx faster%n",
(double) linkedIteratorTime / arrayTime);
}
}
実測結果
# コンパイル・実行コマンド
javac DataStructureOptimization.java && java DataStructureOptimization
=== アンチパターン: LinkedListのランダムアクセス ===
LinkedList (random access): 3.9796 seconds
ArrayList: 0.0073 seconds
Slowdown: 547.6x
=== 公平な比較: 順次アクセス ===
LinkedList (iterator): 0.0091 seconds
ArrayList: 0.0073 seconds
Array: 0.0021 seconds
ArrayList vs LinkedList: 1.2x faster
Array vs LinkedList: 4.4x faster
注意: これらの結果はGitHub Codespaces環境での実測値です。
- アンチパターン:LinkedListのget(i)によるランダムアクセスはO(n²)となり、実用的ではありません。この性能差は主に計算量の違い(O(n²) vs O(n))によるものです
- 公平な比較:イテレータを使った順次アクセスでは、LinkedListとArrayListの差は1.2倍程度に縮まります
- 残りの性能差は、メモリ局所性とキャッシュ効率の違いによるものです
- なお、この計測は各メソッド内のリスト構築(ボクシング含む)と走査の合計時間です。純粋なアクセス性能だけを比較したい場合は、構築と走査を分けて測定してください
データ構造選択のガイドライン
| 用途 | 推奨データ構造 | 理由 (CPUの視点) |
|---|---|---|
| ランダムアクセス | ArrayList |
空間局所性が高く、キャッシュヒット率が向上する |
| 頻繁な挿入・削除 |
ArrayDeque(両端)/ ArrayList
|
実測では配列ベースが勝つことが多い。LinkedListが有利なのはイテレータで挿入位置を保持している場合に限られる |
| キー検索 | HashMap |
O(1)の平均検索時間。ただしノードはポインタ参照のため、キャッシュ効率は配列ほど良くない |
| 順序付きキー | TreeMap |
O(log n)だが順序保証。ツリー構造の辿りがキャッシュミスを誘発しやすい |
| 重複なし集合 | HashSet |
O(1)の平均存在確認時間。HashMapと同様の利点 |
分岐予測の最適化
分岐予測とパイプラインフラッシュ
ループ内のif文は、CPUレベルでは条件分岐命令に変換されます。現代のCPUは、パイプラインを止めないために、この分岐命令がどちらの経路(ifの中に入るか、入らないか)に進むかを分岐予測器12というハードウェアで予測し、予測した側の命令を投機的に実行し始めます。
- 予測が成功した場合:パイプラインは止まることなく、スムーズに命令が流れ続けます。
- 予測が失敗した場合(分岐予測ミス):投機的に実行していた命令はすべて無駄になります。CPUはパイプラインを空にし(これをパイプラインフラッシュ13と呼びます)、正しい分岐先の命令をフェッチし直す必要があります。このペナルティは非常に大きく、数十サイクルに及びます。
分岐予測ミスのシーケンス図
このシーケンス図は、分岐予測ミスが発生した際のCPU内部の動作を示しています。予測ミスが判明すると、投機的に実行していたすべての命令が無駄になり、正しい分岐先から命令を再取得する必要があります。この処理には数十サイクルかかり、CPUの実効性能を大きく低下させます。
ブランチレス手法の動作原理
このシーケンス図は、ブランチレス手法が条件分岐を算術演算に置き換える様子を示しています。通常のif文では条件によって異なる命令パスを実行しますが、ブランチレス手法では常に同じ命令列を実行するため、分岐予測ミスが発生しません。
実測:条件分岐の影響と最適化
import java.util.*;
public class BranchPredictionOptimization {
// 遅い例:予測困難な分岐(ランダムデータ)
public static int countWithBranch(int[] array) {
int count = 0;
for (int value : array) {
if (value >= 128) { // 50%の確率で分岐
count++;
}
}
return count;
}
// 速い例1:データをソートして予測しやすくする
public static int countWithSortedData(int[] array) {
// ソート済みデータは分岐予測が容易
int count = 0;
for (int value : array) {
if (value >= 128) {
count++;
}
}
return count;
}
// 速い例2:分岐を算術演算に置き換える(ブランチレス)
public static int countBranchless(int[] array) {
int count = 0;
for (int value : array) {
// 条件演算を使わず、ビット演算で0か1を生成
// value >= 128 なら 0、そうでなければ -1
int mask = (value - 128) >> 31;
count += ~mask & 1;
}
return count;
}
// 参考:Streamでも書ける(簡潔だが、このケースではループより速くはならない)
public static int countWithStream(int[] array) {
return (int) Arrays.stream(array)
.filter(v -> v >= 128)
.count();
}
// より厳密な測定のための別メソッド
public static void preciseBenchmark() {
int size = 10_000_000;
int[] randomArray = new int[size];
int[] sortedArray = new int[size];
Random rand = new Random(42);
for (int i = 0; i < size; i++) {
int value = rand.nextInt(256);
randomArray[i] = value;
sortedArray[i] = value;
}
Arrays.sort(sortedArray);
// より多くのウォームアップを実施
for (int i = 0; i < 20000; i++) {
countWithBranch(Arrays.copyOf(randomArray, 1000));
countWithBranch(Arrays.copyOf(sortedArray, 1000));
countBranchless(Arrays.copyOf(randomArray, 1000));
}
// JITコンパイルの安定化のため少し待機
try { Thread.sleep(100); } catch (InterruptedException e) {}
// 測定(5回実行して最速値を採用)
long bestRandomTime = Long.MAX_VALUE;
long bestSortedTime = Long.MAX_VALUE;
long bestBranchlessTime = Long.MAX_VALUE;
long checksum = 0;
for (int run = 0; run < 5; run++) {
long start = System.nanoTime();
int result1 = countWithBranch(randomArray);
long randomTime = System.nanoTime() - start;
bestRandomTime = Math.min(bestRandomTime, randomTime);
start = System.nanoTime();
int result2 = countWithSortedData(sortedArray);
long sortedTime = System.nanoTime() - start;
bestSortedTime = Math.min(bestSortedTime, sortedTime);
start = System.nanoTime();
int result3 = countBranchless(randomArray);
long branchlessTime = System.nanoTime() - start;
bestBranchlessTime = Math.min(bestBranchlessTime, branchlessTime);
checksum += result1 + result2 + result3;
}
if (checksum == -1) System.out.println(); // 結果を使用(デッドコード除去を防止)
System.out.println("=== Branch Prediction Impact (Best of 5 runs) ===");
System.out.printf("Random array (branch): %.4f seconds%n",
bestRandomTime / 1_000_000_000.0);
System.out.printf("Sorted array (branch): %.4f seconds%n",
bestSortedTime / 1_000_000_000.0);
System.out.printf("Branchless: %.4f seconds%n",
bestBranchlessTime / 1_000_000_000.0);
System.out.printf("Sorted speedup vs Random: %.1fx%n",
(double) bestRandomTime / bestSortedTime);
System.out.printf("Branchless speedup vs Random: %.1fx%n",
(double) bestRandomTime / bestBranchlessTime);
}
public static void main(String[] args) {
// より厳密な測定を実行
preciseBenchmark();
}
}
実測結果とJIT最適化の確認
# コンパイル・実行コマンド(JITコンパイルを安定させるオプション付き)
javac BranchPredictionOptimization.java && java -XX:-TieredCompilation BranchPredictionOptimization
=== Branch Prediction Impact (Best of 5 runs) ===
Random array (branch): 0.0391 seconds
Sorted array (branch): 0.0063 seconds
Branchless: 0.0016 seconds
Sorted speedup vs Random: 6.2x
Branchless speedup vs Random: 24.4x
測定結果の解釈
- ソート済みデータが6.2倍高速:分岐予測が機能している典型的な結果です
- ブランチレス手法が24.4倍高速:分岐予測ミスの排除に加え、分岐が消えたことでJITの自動ベクトル化(SIMD化)が効くため、差は分岐予測単独の効果より大きくなります
- この測定はJITの世代・フラグの影響を強く受けます。新しいJDKではJITが分岐をcmov命令等に自動変換し、ソートの有無による差がほぼ消えることもあります。結論を一般化せず、対象環境で必ず実測してください
- ブランチレス版の
(value - 128) >> 31というテクニックは、value - 128がintでオーバーフローしない値域(この例では0〜255)でのみ正しい点にも注意してください
JITコンパイル後の逆アセンブルは、概念的には次のとおりです。
分岐がある場合(予測困難)
; countWithBranch のループ内部
cmp eax, 0x80 ; value と 128 を比較
jl skip ; 条件ジャンプ(予測ミスの可能性)
inc edx ; count++
skip:
ブランチレスの場合(高速)
; countBranchless のループ内部
mov ecx, eax
sub ecx, 0x80 ; value - 128
sar ecx, 0x1f ; 算術右シフト(符号拡張)
not ecx ; ビット反転
and ecx, 0x1 ; 最下位ビットのみ取得
add edx, ecx ; count に加算(分岐なし)
分岐を算術演算に置き換えることで、分岐予測ミスを完全に回避し、さらに分岐が消えたことで自動ベクトル化も効くようになり、24.4倍の高速化を実現しました。
メモリ効率の最適化とキャッシュの活用
オブジェクトを作りすぎていませんか?
過度なオブジェクト生成は、GCプレッシャーだけでなく、CPUのキャッシュ効率を著しく低下させます。
オブジェクト生成とメモリレイアウトの違い
このシーケンス図は、オブジェクト生成による性能低下の仕組みを示しています。Pointオブジェクトはヒープ上の離れた位置に配置され、各オブジェクトにはヘッダ情報も付随します。一方、プリミティブ配列は連続メモリに配置され、一度のメモリアクセスで複数の要素をキャッシュに読み込めます。
-
大量のオブジェクト生成:
new Point()をループ内で繰り返すと、各Pointオブジェクトはヒープ上の離れたメモリ領域に確保される可能性があります。これによりデータがメモリ上で散在し、空間局所性が失われます。CPUが1つのPointオブジェクトにアクセスしても、その近くのキャッシュラインには次に関係するデータが存在しないため、キャッシュミスが多発します。 -
プリミティブ配列の使用:
double[]のようなプリミティブ配列を使うと、データは物理的に連続したメモリ領域に配置されます。これにより空間局所性が最大化され、CPUは一度のメモリアクセスで複数のデータをキャッシュに読み込むことができます。結果としてメモリアクセスの待ち時間が大幅に削減されます。
import java.util.*;
public class MemoryOptimization {
// 遅い例:大量のオブジェクト生成
static class Point {
final double x, y;
Point(double x, double y) {
this.x = x;
this.y = y;
}
double distance(Point other) {
double dx = this.x - other.x;
double dy = this.y - other.y;
return Math.sqrt(dx * dx + dy * dy);
}
}
public static double calculateDistancesSlow(int n) {
List<Point> points = new ArrayList<>();
Random rand = new Random(42);
// 大量のPointオブジェクトを生成
for (int i = 0; i < n; i++) {
points.add(new Point(rand.nextDouble(), rand.nextDouble()));
}
double totalDistance = 0;
for (int i = 0; i < points.size() - 1; i++) {
totalDistance += points.get(i).distance(points.get(i + 1));
}
return totalDistance;
}
// 速い例:プリミティブ配列を使用
public static double calculateDistancesFast(int n) {
double[] x = new double[n];
double[] y = new double[n];
Random rand = new Random(42);
// 配列に直接格納
for (int i = 0; i < n; i++) {
x[i] = rand.nextDouble();
y[i] = rand.nextDouble();
}
double totalDistance = 0;
for (int i = 0; i < n - 1; i++) {
double dx = x[i] - x[i + 1];
double dy = y[i] - y[i + 1];
totalDistance += Math.sqrt(dx * dx + dy * dy);
}
return totalDistance;
}
public static void main(String[] args) {
int n = 1000000;
System.gc(); // 測定前にGCを実行し、ヒープの状態を揃える
// 遅い方法の測定
long start = System.nanoTime();
double result1 = calculateDistancesSlow(n);
long slowTime = System.nanoTime() - start;
System.gc(); // メモリクリア
// 速い方法の測定
start = System.nanoTime();
double result2 = calculateDistancesFast(n);
long fastTime = System.nanoTime() - start;
System.out.printf("Object creation: %.4f seconds%n",
slowTime / 1_000_000_000.0);
System.out.printf("Primitive arrays: %.4f seconds%n",
fastTime / 1_000_000_000.0);
System.out.printf("Speedup: %.1fx%n",
(double) slowTime / fastTime);
System.out.printf("Results match: %b%n",
Math.abs(result1 - result2) < 0.0001);
}
}
実測結果
# コンパイル・実行コマンド
javac MemoryOptimization.java && java MemoryOptimization
Object creation: 0.0982 seconds
Primitive arrays: 0.0367 seconds
Speedup: 2.7x
Results match: true
この2.7倍の高速化は、GC負荷の軽減効果に加え、メモリアクセスパターンの改善によるキャッシュヒット率の劇的な向上が大きく貢献しています。
フォールスシェアリング14の回避(マルチスレッド環境)
マルチスレッド環境では、異なるスレッドが更新する変数が偶然同じキャッシュライン(通常64バイト)に乗ってしまうと、互いのキャッシュを無効化し合い、激しい性能劣化を引き起こします。
フォールスシェアリングの発生メカニズム
このシーケンス図は、フォールスシェアリングが発生する仕組みを示しています。異なるCPUコアが同じキャッシュライン上の別の変数を更新すると、キャッシュコヒーレンシプロトコルにより、互いのキャッシュを無効化し合います。パディングを使用して変数を別のキャッシュラインに配置することで、この問題を回避できます。
import java.util.concurrent.*;
public class FalseSharingDemo {
// 遅い例:フォールスシェアリングが発生(パディングなし)
static class BadCounter {
volatile long value1 = 0; // スレッド1が更新
volatile long value2 = 0; // スレッド2が更新(同じキャッシュラインに乗りやすい)
}
// JDK 9以降:jdk.internal.vm.annotation.Contended を使用
// コンパイル・実行に --add-exports java.base/jdk.internal.vm.annotation=ALL-UNNAMED が必要
static class ContendedCounter {
@jdk.internal.vm.annotation.Contended
volatile long value1 = 0;
@jdk.internal.vm.annotation.Contended
volatile long value2 = 0;
}
// 参考:手動パディング(ただしJVMはフィールドの並び順を保証しない点に注意)
static class ManualPaddingCounter {
volatile long value1 = 0;
long p1, p2, p3, p4, p5, p6, p7; // 56バイトのパディング
volatile long value2 = 0;
}
static final int ITERATIONS = 100_000_000;
// スレッドごとに実行するタスクを明示的に渡す
static void benchmark(String name, Runnable t1, Runnable t2) throws Exception {
ExecutorService executor = Executors.newFixedThreadPool(2);
CyclicBarrier barrier = new CyclicBarrier(3);
CountDownLatch latch = new CountDownLatch(2);
for (Runnable task : new Runnable[]{t1, t2}) {
executor.submit(() -> {
try { barrier.await(); } catch (Exception e) { throw new RuntimeException(e); }
task.run();
latch.countDown();
});
}
barrier.await(); // 2スレッドの開始を揃える
long start = System.nanoTime();
latch.await();
long elapsed = System.nanoTime() - start;
System.out.printf("%-16s: %.4f seconds%n", name, elapsed / 1_000_000_000.0);
executor.shutdown();
}
public static void main(String[] args) throws Exception {
// 1周目はウォームアップ、2周目の結果を採用する
for (int round = 1; round <= 2; round++) {
System.out.println("--- round " + round + " ---");
BadCounter bad = new BadCounter();
benchmark("False sharing",
() -> { for (int i = 0; i < ITERATIONS; i++) bad.value1++; },
() -> { for (int i = 0; i < ITERATIONS; i++) bad.value2++; });
ContendedCounter cont = new ContendedCounter();
benchmark("@Contended",
() -> { for (int i = 0; i < ITERATIONS; i++) cont.value1++; },
() -> { for (int i = 0; i < ITERATIONS; i++) cont.value2++; });
ManualPaddingCounter manual = new ManualPaddingCounter();
benchmark("Manual padding",
() -> { for (int i = 0; i < ITERATIONS; i++) manual.value1++; },
() -> { for (int i = 0; i < ITERATIONS; i++) manual.value2++; });
}
}
}
実測結果
# コンパイル・実行コマンド(内部APIのContendedを使うため --add-exports が必要)
javac --add-exports java.base/jdk.internal.vm.annotation=ALL-UNNAMED FalseSharingDemo.java
java -XX:-RestrictContended --add-exports java.base/jdk.internal.vm.annotation=ALL-UNNAMED FalseSharingDemo
--- round 1 ---
False sharing : 0.2031 seconds
@Contended : 0.2075 seconds
Manual padding : 0.2178 seconds
--- round 2 ---
False sharing : 2.0200 seconds
@Contended : 0.2027 seconds
Manual padding : 0.2012 seconds
round 2(ウォームアップ後)では、フォールスシェアリングの回避により約10倍の高速化になりました。一方、round 1で「False sharing」が速いのは、オブジェクトがたまたまキャッシュライン境界を跨いで配置され、2つの変数が偶然別ラインに分かれたためです。パディングなしの性能はこのようにメモリ上の配置運に左右されるため、再現性のある回避にはパディングか@Contendedが必要です(この不安定さ自体が、メモリレイアウトの重要性を示す好例です)。
重要な注意
@Contendedについて15:JDK 8ではsun.misc.Contendedでしたが、JDK 9以降はjdk.internal.vm.annotation.Contended(内部API)に移動しており、上記のとおり--add-exportsと-XX:-RestrictContendedの指定が必要です- 手動パディングの限界:JVMはフィールドの宣言順どおりの配置を保証しないため、並べ替えでパディングが無効になる可能性があります。実際のレイアウトはJOL(jol-core)で確認できます
- 構造体のようなメモリレイアウトの厳密な制御が必要な場合は、オフヒープメモリ(DirectByteBuffer)の使用を検討
フォールスシェアリング発生時の遅さの正体は、volatile書き込みのたびにキャッシュコヒーレンシプロトコルがキャッシュラインの所有権を2コア間で奪い合い、ラインの無効化と再取得が往復し続けることです。
並列処理による高速化
複数の命令流を同時に処理する
CPUはソフトウェアを「命令を順番に並べたもの」、すなわち命令流16として見ています。シングルスレッドのプログラムは、1本の命令流を1つのCPUコアが処理することを意味します。
並列処理は、この命令流を複数用意し、別々のCPUコアに割り当てることに他なりません。これにより、コンピュータシステム全体として、単位時間あたりに処理できる命令の総数が物理的なコア数に比例して増加します。
シングルコア vs マルチコアの命令流
並列処理の実行フロー
このシーケンス図は、Fork/Joinフレームワークがタスクを分割し、複数のCPUコアで並列実行する様子を示しています。各ワーカースレッドは独立した命令流として異なるCPUコアで実行され、理論上はコア数に比例した性能向上が期待できます。
実測:並列処理による素数判定の高速化
import java.util.concurrent.*;
import java.util.stream.*;
import java.util.*;
public class ParallelProcessing {
// 遅い例:逐次処理で素数判定
public static List<Integer> findPrimesSequential(int max) {
List<Integer> primes = new ArrayList<>();
for (int i = 2; i <= max; i++) {
if (isPrime(i)) {
primes.add(i);
}
}
return primes;
}
// 速い例:並列ストリームで素数判定
public static List<Integer> findPrimesParallel(int max) {
return IntStream.rangeClosed(2, max)
.parallel()
.filter(ParallelProcessing::isPrime)
.boxed()
.collect(Collectors.toList());
}
// さらに速い例:Fork/Joinフレームワーク
static class PrimeTask extends RecursiveTask<List<Integer>> {
private final int start, end;
private static final int THRESHOLD = 10000;
PrimeTask(int start, int end) {
this.start = start;
this.end = end;
}
@Override
protected List<Integer> compute() {
if (end - start <= THRESHOLD) {
// 直接計算
List<Integer> primes = new ArrayList<>();
for (int i = start; i <= end; i++) {
if (isPrime(i)) {
primes.add(i);
}
}
return primes;
} else {
// 分割統治
int mid = (start + end) / 2;
PrimeTask left = new PrimeTask(start, mid);
PrimeTask right = new PrimeTask(mid + 1, end);
left.fork();
List<Integer> rightResult = right.compute();
List<Integer> leftResult = left.join();
leftResult.addAll(rightResult);
return leftResult;
}
}
}
public static List<Integer> findPrimesForkJoin(int max) {
ForkJoinPool pool = new ForkJoinPool();
return pool.invoke(new PrimeTask(2, max));
}
private static boolean isPrime(int n) {
if (n <= 1) return false;
if (n <= 3) return true;
if (n % 2 == 0 || n % 3 == 0) return false;
for (int i = 5; i * i <= n; i += 6) {
if (n % i == 0 || n % (i + 2) == 0) {
return false;
}
}
return true;
}
public static void main(String[] args) {
int max = 5000000; // 500万まで素数を探索
// 逐次処理の測定
long start = System.nanoTime();
List<Integer> result1 = findPrimesSequential(max);
long sequentialTime = System.nanoTime() - start;
// 並列ストリームの測定
start = System.nanoTime();
List<Integer> result2 = findPrimesParallel(max);
long parallelTime = System.nanoTime() - start;
// Fork/Joinの測定
start = System.nanoTime();
List<Integer> result3 = findPrimesForkJoin(max);
long forkJoinTime = System.nanoTime() - start;
System.out.printf("Sequential: %.4f seconds (found %d primes)%n",
sequentialTime / 1_000_000_000.0, result1.size());
System.out.printf("Parallel Stream: %.4f seconds%n",
parallelTime / 1_000_000_000.0);
System.out.printf("Fork/Join: %.4f seconds%n",
forkJoinTime / 1_000_000_000.0);
System.out.printf("Parallel speedup: %.1fx%n",
(double) sequentialTime / parallelTime);
System.out.printf("Fork/Join speedup: %.1fx%n",
(double) sequentialTime / forkJoinTime);
}
}
実測結果
# コンパイル・実行コマンド
javac ParallelProcessing.java && java ParallelProcessing
Sequential: 0.4354 seconds (found 348513 primes)
Parallel Stream: 0.2570 seconds
Fork/Join: 0.2244 seconds
Parallel speedup: 1.7x
Fork/Join speedup: 1.9x
注意: この結果はGitHub Codespaces(4 vCPU)環境での実測値です。500万までの素数判定で測定しました。
測定環境が4 vCPUであるため、単純には最大4倍の性能向上が期待できそうですが、実測では並列ストリームが1.7倍、Fork/Joinが1.9倍に留まりました。これは実際のアプリケーションでよく見られる現象で、以下の要因が影響しています:
理論値と実測値の差の原因
- SMT(同時マルチスレッディング) - この環境の4 vCPUは物理2コア×2スレッドです。CPUバウンドな処理の実効上限は物理コア数(約2倍)に近く、1.7〜1.9倍はほぼ上限に達しています
- メモリ帯域の競合 - 複数コアが同時にメモリアクセスすることでボトルネックが発生
- タスク分割のオーバーヘッド - タスクの分割・統合・スレッド間通信のコスト
- 不均等な負荷分散 - 素数判定は数値が大きくなるほど計算量が増加するため、タスクの負荷が不均等
- CPUのターボブースト - シングルスレッド時はCPUがより高いクロックで動作する可能性
- 仮想環境のオーバーヘッド - GitHub Codespacesのような仮想環境では、物理コアへのマッピングが最適でない場合がある
実用的な指針
- 並列化による2倍の高速化でも、インフラコストの大幅削減につながる
- タスクの性質によって並列化の効果は大きく変動する
- CPU集約的な処理ほど並列化の恩恵を受けやすい
マルチスレッドでの同期コスト
並列処理を行う際、スレッド間の同期は避けて通れませんが、同期処理にはCPUレベルで大きなコストが伴います。
同期処理のコスト比較
このシーケンス図は、異なる同期手法のコストを比較しています。synchronizedは無競合時はユーザ空間で高速に処理されますが、競合時にOSレベルのロック機構を使用します。AtomicLongはCAS(Compare-And-Swap)17操作でリトライが必要になる可能性があります。LongAdderはスレッドローカルなセルを使用することで競合を最小化し、最も軽量な同期を実現します。
import java.util.concurrent.*;
import java.util.concurrent.atomic.*;
public class SynchronizationCostDemo {
private static final int THREADS = 4;
private static final int ITERATIONS = 10_000_000;
// 同期なし(スレッドセーフではない)
static class UnsafeCounter {
long value = 0;
void increment() { value++; }
long get() { return value; }
}
// synchronizedを使用
static class SynchronizedCounter {
private long value = 0;
synchronized void increment() { value++; }
synchronized long get() { return value; }
}
// volatile変数を使用
static class VolatileCounter {
private volatile long value = 0;
void increment() { value++; } // 実際にはスレッドセーフではない
long get() { return value; }
}
// AtomicLongを使用(CAS操作)
static class AtomicCounter {
private final AtomicLong value = new AtomicLong(0);
void increment() { value.incrementAndGet(); }
long get() { return value.get(); }
}
// LongAdderを使用(競合を減らす設計)
static class AdderCounter {
private final LongAdder value = new LongAdder();
void increment() { value.increment(); }
long get() { return value.sum(); }
}
private static void benchmark(String name, Runnable task) throws Exception {
ExecutorService executor = Executors.newFixedThreadPool(THREADS);
CountDownLatch latch = new CountDownLatch(THREADS);
long start = System.nanoTime();
for (int i = 0; i < THREADS; i++) {
executor.submit(() -> {
task.run();
latch.countDown();
});
}
latch.await();
long elapsed = System.nanoTime() - start;
System.out.printf("%-20s: %.4f seconds%n",
name, elapsed / 1_000_000_000.0);
executor.shutdown();
}
public static void main(String[] args) throws Exception {
// 各カウンタのベンチマーク
SynchronizedCounter sync = new SynchronizedCounter();
benchmark("Synchronized", () -> {
for (int i = 0; i < ITERATIONS; i++) {
sync.increment();
}
});
AtomicCounter atomic = new AtomicCounter();
benchmark("AtomicLong", () -> {
for (int i = 0; i < ITERATIONS; i++) {
atomic.increment();
}
});
AdderCounter adder = new AdderCounter();
benchmark("LongAdder", () -> {
for (int i = 0; i < ITERATIONS; i++) {
adder.increment();
}
});
// JIT最適化の確認
System.out.println("\n=== JIT Optimization Check ===");
System.out.println("Synchronized final: " + sync.get());
System.out.println("AtomicLong final: " + atomic.get());
System.out.println("LongAdder final: " + adder.get());
}
}
実測結果
# コンパイル・実行コマンド
javac SynchronizationCostDemo.java && java SynchronizationCostDemo
Synchronized : 2.3256 seconds
AtomicLong : 0.5669 seconds
LongAdder : 0.0707 seconds
=== JIT Optimization Check ===
Synchronized final: 40000000
AtomicLong final: 40000000
LongAdder final: 40000000
各手法をCPU命令レベルで見ると、概念的には次のようになります。
synchronized(競合時は低速)
; 無競合時は薄いロック(thin lock)で高速
; 競合時はモニターの取得と解放
test [monitor], owner ; ロック所有者チェック
jne slow_path ; 競合時は遅いパスへ
; クリティカルセクション
inc qword ptr [rbx+10h]
AtomicLong(中速)
; incrementAndGetはJITのintrinsicでアトミックな加算命令になる
mov rdx, 1
lock xadd [rbx+10h], rdx ; アトミックな加算(全コアで直列化される)
LongAdder(高速)
; スレッドごとに分散したセルへのCAS(命令自体はlock cmpxchg)
; セルが分かれているため競合とリトライがほぼ発生しない
lock cmpxchg [cell], rdx
LongAdderは競合を減らす設計により、synchronizedの32.9倍の高速化を実現しました。ただし、これは高競合条件下での結果であり、低競合時はsynchronizedも十分高速です。AtomicLongと比較しても8.0倍、LongAdderの効果は著しく高いことが確認できました。
並列処理の使い分け
| 処理の種類 | 推奨手法 | 適用場面 |
|---|---|---|
| 単純な変換・フィルタ | Parallel Stream | 関数型の処理 |
| 分割統治アルゴリズム | Fork/Join | 再帰的な処理 |
| 非同期I/O | CompletableFuture | I/Oバウンドな処理 |
| 大量の並行I/O | 仮想スレッド(JDK 21+) | ブロッキング型のコードのまま数万並行にスケール |
| 定期実行 | ScheduledExecutorService | バッチ処理 |
| 高競合カウンタ | LongAdder | 書き込みが多い統計処理 |
| 低競合の共有状態 | AtomicReference | 読み込みが多い設定値 |
JVMチューニングによる高速化
JVMパラメータを理解していますか?
JVMチューニングは、CPUの動作に直接介入するものではありませんが、アプリケーションの振る舞いを大きく変えることで、間接的にCPU性能を引き出します。
GCの動作とアプリケーションへの影響
このシーケンス図は、GCアルゴリズムの違いがアプリケーションの実行にどのような影響を与えるかを示しています。デフォルトのGCでは長時間のStop-The-Worldが発生しますが、G1GCなどの最適化されたGCでは、並行処理とインクリメンタルな回収により、アプリケーションの停止時間を最小限に抑えます。
-
-Xmx4g -Xms4g:ヒープサイズを固定することで、実行中のヒープ拡張/縮小に伴うオーバーヘッドをなくします。 -
-XX:+UseG1GC:G1GCは予測可能な停止時間を目標とし、大きなヒープでも効率的に動作 -
-XX:+UseZGC:ZGCは常時数十マイクロ秒レベルの極小STW18を実現(JDK 15で正式化。11〜14では実験的機能)
JVMパラメータの効果
GCの選択やヒープ設定の効果は、アロケーション頻度・オブジェクト寿命・ヒープサイズなど、ワークロードの性質に強く依存します。注意点として、JDK 9以降はG1GCがデフォルトなので-XX:+UseG1GCの明示は通常不要です。また、ZGCは停止時間(レイテンシ)の最小化に特化したGCで、スループットはG1より低下する場合があります。「どのGCが速いか」は一般化できないため、必ず自分のアプリケーションで実測してください。
# GC動作の確認
java -Xlog:gc YourApplication
# ヒープ固定+G1GC(リサイズによる揺らぎを排除)
java -Xmx4g -Xms4g -XX:+UseG1GC YourApplication
# 低レイテンシ重視のZGC(JDK 15で正式化、JDK 21では世代別ZGCも選択可能)
java -XX:+UseZGC YourApplication
推奨JVMパラメータ
| 用途 | パラメータ | 効果 | 注意点 |
|---|---|---|---|
| ヒープサイズ固定 | -Xmx4g -Xms4g |
GCによるリサイズを回避 | - |
| 低レイテンシ | -XX:+UseZGC |
停止時間を最小化 | JDK 15で正式(11〜14は実験的) |
| 高スループット | -XX:+UseParallelGC |
全体的な処理速度向上 | 停止時間は長め |
| バランス型 | -XX:+UseG1GC |
レイテンシとスループットのバランス | デフォルト(JDK 9以降) |
| NUMA対応 | -XX:+UseNUMA |
NUMAアーキテクチャで性能向上 | ParallelGC/G1GCで有効(ZGCは自動でNUMA対応) |
JDK 11以降の変更点
-XX:+UnlockCommercialFeaturesは不要(JFRがOSS化)- ZGCとShenandoahGCが利用可能に
場面別の最適化手法選択ガイドとCPUの視点
| 問題の種類 | 推奨手法 | 期待効果 | CPUレベルでの根拠 |
|---|---|---|---|
| 文字列の大量連結 | StringBuilder | 10-100倍(JDK 9以降は差が縮小) | メモリ局所性の向上、オブジェクト生成の抑制 → キャッシュ効率向上 |
| データ依存のあるループ | 複数アキュムレータ | 2-4倍 | データ依存関係の解消によるアウトオブオーダー実行の活用 |
| コレクションの検索 | HashSet/HashMap | 10-200倍(平均O(1)) | 計算量削減による命令数削減 |
| 予測困難な分岐 | ブランチレス手法 | 2〜25倍(自動ベクトル化の可否で大きく変動) | 分岐予測ミスの完全回避によるパイプラインストールの排除 |
| フォールスシェアリング | @Contended使用 | 5-10倍 | キャッシュライン競合の回避によるキャッシュ効率の改善 |
| 大量データ処理 | 並列ストリーム | 1.5-3倍(コア数と処理内容に依存) | マルチコア活用による命令流の並列実行 |
| メモリ使用量過多 | プリミティブ配列 | 2-10倍 | メモリ局所性の最大化によるキャッシュヒット率向上 |
| 高競合の共有変数 | LongAdder | 10〜35倍(高競合時) | 同期コスト削減とキャッシュライン競合の分散 |
| I/O処理 | NIO/非同期処理 | 5-50倍 | I/O待ちでCPUをブロックせず、他の処理を実行させる |
| 数値計算 | ベクトルAPI(Java 16+) | 2-8倍(AVX2/AVX-512依存) | SIMD命令19によるデータレベルの並列実行 |
プロファイリングと最適化戦略
ボトルネックを推測で判断していませんか?
最適化の鉄則は**「測定なくして最適化なし」**20です。CPUの動作原理を理解することは、測定結果の「なぜ」を解明し、より効果的な打ち手を考えるための強力な武器となります。
マイクロベンチマークにはJMHを
本記事のコード例は、仕組みを読みやすくするためにSystem.nanoTime()による素朴な計測を使っていますが、この方式にはウォームアップ不足、デッドコード除去(結果を使わない計算がJITに丸ごと消される)、実行ごとのばらつきといった落とし穴があります。実務でマイクロベンチマークを取る場合は、これらを設計レベルで解決している標準ツール**JMH(Java Microbenchmark Harness)**の使用を強く推奨します。
@Benchmark
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
public double dotProduct(BenchState s) {
double sum = 0.0;
for (int i = 0; i < s.a.length; i++) {
sum += s.a[i] * s.b[i];
}
return sum; // 戻り値はJMHが消費するため、デッドコード除去されない
}
プロファイリングツールの活用
# JDK付属のプロファイラを使用
java -XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining ProfilingExample
# JITコンパイル結果の確認
java -XX:+UnlockDiagnosticVMOptions \
-XX:+PrintAssembly \
-XX:PrintAssemblyOptions=intel \
ProfilingExample
# JDK 11以降でのJFR使用(UnlockCommercialFeaturesは不要)
java -XX:StartFlightRecording=duration=60s,filename=profile.jfr ProfilingExample
JIT最適化の確認ポイント
- インライン化:メソッド呼び出しのオーバーヘッドが削除されているか
- ループの最適化:ループアンローリングやベクトル化が適用されているか
- エスケープ解析:オブジェクトがスタック割り当てに最適化されているか
-
分岐の最適化:条件分岐が
cmov命令に置き換えられているか
最適化の優先順位
HPC(High Performance Computing)の観点での高度な最適化
NUMAアーキテクチャへの対応
大規模なマルチソケットシステムでは、NUMA(Non-Uniform Memory Access)アーキテクチャによるメモリアクセスの非対称性が性能に大きな影響を与えます。
NUMAアーキテクチャのメモリアクセスパターン
このシーケンス図は、NUMAシステムにおける3つの典型的なメモリアクセスパターンを示しています:
- ローカルメモリアクセス(緑):CPUが同じソケット内のメモリにアクセスする場合、最も高速(約100ナノ秒)
- リモートメモリアクセス(赤):CPUが別ソケットのメモリにアクセスする場合、インターコネクト経由で遅延が発生(150-200ナノ秒)
- CPU間競合(青):複数のCPUが同じメモリ領域にアクセスする場合、キャッシュコヒーレンシプロトコルにより最も遅延が大きい
// NUMAを意識したJVMオプション
// Java自体にはNUMA制御APIがないため、JVMオプションで対応
// -XX:+UseNUMA -XX:+UseParallelGC
// 注意:UseNUMAはParallelGCとG1GCで有効(ZGCは自動的にNUMAを考慮する)
SIMD(ベクトル化)による並列化
Java 16以降のVector APIを使用することで、CPU の SIMD 命令を活用した高速化が可能です。しかし、通常のJavaコードでもJITコンパイラによる自動ベクトル化が行われます。
SIMD命令による並列演算の仕組み
このシーケンス図は、通常のスカラー演算とSIMD演算の違いを示しています:
- スカラー演算(赤):各要素を1つずつ処理するため、n要素に対して4n回の命令実行が必要
- SIMD演算(緑):複数要素を同時に処理するため、命令数が大幅に削減(この例では4分の1)
AVX2では256ビット(double型4要素)、AVX-512では512ビット(double型8要素)を一度に処理できます。
JVMでのSIMD活用
# AVX2を明示的に有効化(通常は自動検出)
java -XX:UseAVX=2 YourApplication
# AVX命令セットの使用を確認
java -XX:+PrintFlagsFinal -version | grep AVX
注意:
- AVX-512は高い電力を消費するため、周波数が低下する可能性があります
- GitHub Codespacesなどの仮想環境では、AVX2までしか利用できない場合が多い
- 通常のJavaコードでも、JITコンパイラが自動的にSIMD命令を生成します
SIMD最適化の実測結果
実際にJVMが自動ベクトル化を行った場合の性能を測定しました。
public class AVXBenchmark {
public static double arraySum(double[] a, double[] b, double[] c) {
for (int i = 0; i < a.length; i++) {
c[i] = a[i] + b[i];
}
return c[0];
}
public static double dotProduct(double[] a, double[] b) {
double sum = 0.0;
for (int i = 0; i < a.length; i++) {
sum += a[i] * b[i];
}
return sum;
}
public static double fmaOperation(double[] a, double[] b, double[] c) {
double sum = 0.0;
for (int i = 0; i < a.length; i++) {
sum += a[i] * b[i] + c[i]; // FMA候補
}
return sum;
}
// 計測ハーネス
public static void main(String[] args) {
int n = 1 << 20;
double[] a = new double[n], b = new double[n], c = new double[n];
java.util.Random r = new java.util.Random(42);
for (int i = 0; i < n; i++) {
a[i] = r.nextDouble(); b[i] = r.nextDouble(); c[i] = r.nextDouble();
}
double sink = 0;
for (int i = 0; i < 300; i++) { // ウォームアップ
sink += arraySum(a, b, c) + dotProduct(a, b) + fmaOperation(a, b, c);
}
long bestSum = Long.MAX_VALUE, bestDot = Long.MAX_VALUE, bestFma = Long.MAX_VALUE;
for (int run = 0; run < 10; run++) {
long t = System.nanoTime();
sink += arraySum(a, b, c);
bestSum = Math.min(bestSum, System.nanoTime() - t);
t = System.nanoTime();
sink += dotProduct(a, b);
bestDot = Math.min(bestDot, System.nanoTime() - t);
t = System.nanoTime();
sink += fmaOperation(a, b, c);
bestFma = Math.min(bestFma, System.nanoTime() - t);
}
System.out.printf("Array sum: %.4f ms%n", bestSum / 1e6);
System.out.printf("Dot product: %.4f ms%n", bestDot / 1e6);
System.out.printf("FMA operation: %.4f ms%n", bestFma / 1e6);
System.out.println();
System.out.println("Performance (Million operations/sec):");
System.out.printf("Sum: %.2f MOPS (1演算/要素)%n", n * 1.0 / (bestSum / 1e9) / 1e6);
System.out.printf("Dot: %.2f MOPS (2演算/要素)%n", n * 2.0 / (bestDot / 1e9) / 1e6);
System.out.printf("FMA: %.2f MOPS (3演算/要素)%n", n * 3.0 / (bestFma / 1e9) / 1e6);
if (sink == 0) System.out.println(); // sinkを使用(デッドコード除去の防止)
}
}
# コンパイル・実行
javac AVXBenchmark.java && java AVXBenchmark
Array sum: 0.4176 ms
Dot product: 1.0062 ms
FMA operation: 1.0042 ms
Performance (Million operations/sec):
Sum: 2510.78 MOPS (1演算/要素)
Dot: 2084.30 MOPS (2演算/要素)
FMA: 3132.59 MOPS (3演算/要素)
結果の読み方
- 要素ごとの独立した加算(arraySum)が最速です。ループ間に依存関係がないため、JITの自動ベクトル化(SuperWord最適化)が効きます
- dotProductとfmaOperationはほぼ同速です。どちらも
sumへの逐次的な依存チェーンを持ち、浮動小数点の加算順序を変える最適化はJavaでは許されないため、リダクション部分はベクトル化されず、FP加算のレイテンシに律速されます- Javaは
a[i] * b[i] + c[i]を暗黙にFMA命令へ融合しません(融合すると丸め結果が変わるため)。FMAが必要な場合はMath.fma()を明示的に使います
つまり、自動ベクトル化の恩恵を受ける鍵は「依存関係のないループ」にすることです。これは本記事冒頭の「データ依存関係の解消」と同じ原理です。
JITコンパイルによるSIMD命令の生成
arraySumのC2コンパイル結果をhsdisで確認すると、実際にAVX2のベクトル命令が生成されています(実出力・抜粋、アドレス省略):
# arraySum のループ本体(実出力・抜粋)
vmovdqu ymm0, ymmword ptr [rsi + rdi*8 + 0x10] # a[i..i+3] を256bitロード
vaddpd ymm0, ymm0, ymmword ptr [rdx + rdi*8 + 0x10] # b[i..i+3] と4要素同時加算
vmovdqu ymmword ptr [rcx + rdi*8 + 0x10], ymm0 # c[i..i+3] へストア
vmovdqu ymm0, ymmword ptr [rsi + rdi*8 + 0x30] # (以降、アンロールされて続く)
vaddpd ymm0, ymm0, ymmword ptr [rdx + rdi*8 + 0x30]
vmovdqu ymmword ptr [rcx + rdi*8 + 0x30], ymm0
AVX2では256ビット(double型4要素)を同時に処理できます。一方、dotProductの実出力では、乗算こそvmulpdでSIMD化されるものの、sumへの加算はvaddsdで1要素ずつ直列に実行されており、リダクションは前述のとおりベクトルの形になりません。
メモリ帯域幅の最適化
HPCアプリケーションでは、演算性能よりもメモリ帯域幅がボトルネックになることが多いです。
メモリアクセスパターンによる帯域幅の違い
このシーケンス図は、メモリアクセスパターンが帯域幅効率に与える影響を示しています:
- 行優先アクセス(緑):連続したメモリ領域にアクセスするため、キャッシュライン全体を有効活用
- 列優先アクセス(赤):ストライドアクセスにより、転送したデータの大部分が無駄になる
C言語やJavaの2次元配列は行優先で格納されるため、内側ループで列インデックスを変化させることが重要です。
public class MemoryBandwidthOptimization {
// 非効率:ストライドアクセスによりメモリ帯域を無駄にする
public static double sumMatrix2DColumnWise(double[][] matrix) {
double sum = 0;
int rows = matrix.length;
int cols = matrix[0].length;
for (int j = 0; j < cols; j++) {
for (int i = 0; i < rows; i++) {
sum += matrix[i][j]; // 列方向のアクセス
}
}
return sum;
}
// 効率的:連続メモリアクセスで帯域幅を最大活用
public static double sumMatrix2DRowWise(double[][] matrix) {
double sum = 0;
for (double[] row : matrix) {
for (double value : row) {
sum += value; // 行方向のアクセス
}
}
return sum;
}
// 計測ハーネス
public static void main(String[] args) {
java.util.Random r = new java.util.Random(42);
for (int n : new int[]{1024, 2048, 4096}) {
double[][] m = new double[n][n];
for (int i = 0; i < n; i++)
for (int j = 0; j < n; j++)
m[i][j] = r.nextDouble();
double sink = 0;
for (int w = 0; w < 3; w++) { // ウォームアップ
sink += sumMatrix2DRowWise(m) + sumMatrix2DColumnWise(m);
}
long bestRow = Long.MAX_VALUE, bestCol = Long.MAX_VALUE;
for (int run = 0; run < 5; run++) {
long t = System.nanoTime();
sink += sumMatrix2DRowWise(m);
bestRow = Math.min(bestRow, System.nanoTime() - t);
t = System.nanoTime();
sink += sumMatrix2DColumnWise(m);
bestCol = Math.min(bestCol, System.nanoTime() - t);
}
System.out.printf("%dx%d (%.0f MB): row-wise %.4f s / column-wise %.4f s -> 列アクセスは %.1f倍 遅い%n",
n, n, n * (double) n * 8 / 1e6, bestRow / 1e9, bestCol / 1e9, (double) bestCol / bestRow);
if (sink == 42.0) System.out.println(); // sinkの利用(DCE防止)
}
}
}
実測結果(行アクセス vs 列アクセス)
# コンパイル・実行コマンド
javac MemoryBandwidthOptimization.java && java MemoryBandwidthOptimization
1024x1024 (8 MB): row-wise 0.0010 s / column-wise 0.0016 s -> 列アクセスは 1.6倍 遅い
2048x2048 (34 MB): row-wise 0.0040 s / column-wise 0.0172 s -> 列アクセスは 4.3倍 遅い
4096x4096 (134 MB): row-wise 0.0159 s / column-wise 0.1035 s -> 列アクセスは 6.5倍 遅い
行列がL3キャッシュ(この環境では32MB)に収まるサイズでは差は1.6倍程度ですが、L3を超えたとたんに4〜7倍まで差が拡大します。アクセスパターンの問題は「データがキャッシュに収まらない規模」で初めて本格的に顕在化することがわかります。
ループタイリング(ブロッキング)
大規模な行列演算では、キャッシュサイズに合わせたブロッキングが重要です。
ループタイリングによるキャッシュ効率化
このシーケンス図は、ループタイリングがキャッシュ効率を改善21する仕組みを示しています:
-
通常の行列積(赤):
- 行列が大きいとキャッシュ容量を超える
- 頻繁なキャッシュミスによりメモリアクセスが増加
- メモリ帯域幅がボトルネックになる
-
タイリング行列積(緑):
- 小さなタイル(ブロック)単位で処理
- タイルサイズをL1キャッシュに収まるよう設計
- 同じデータを何度も再利用(時間的局所性)
- キャッシュヒット率90%以上を達成可能
public class LoopTiling {
// キャッシュ効率の悪い行列積
public static void matrixMultiplyNaive(double[][] A, double[][] B, double[][] C) {
int n = A.length;
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
for (int k = 0; k < n; k++) {
C[i][j] += A[i][k] * B[k][j];
}
}
}
}
// ループタイリングによる最適化
public static void matrixMultiplyTiled(double[][] A, double[][] B, double[][] C) {
int n = A.length;
int TILE_SIZE = 64; // L1キャッシュに収まるサイズ
for (int ii = 0; ii < n; ii += TILE_SIZE) {
for (int jj = 0; jj < n; jj += TILE_SIZE) {
for (int kk = 0; kk < n; kk += TILE_SIZE) {
// タイル内の計算
for (int i = ii; i < Math.min(ii + TILE_SIZE, n); i++) {
for (int j = jj; j < Math.min(jj + TILE_SIZE, n); j++) {
double sum = C[i][j];
for (int k = kk; k < Math.min(kk + TILE_SIZE, n); k++) {
sum += A[i][k] * B[k][j];
}
C[i][j] = sum;
}
}
}
}
}
}
// 計測ハーネス
static double[][] randomMatrix(int n, java.util.Random r) {
double[][] m = new double[n][n];
for (int i = 0; i < n; i++)
for (int j = 0; j < n; j++)
m[i][j] = r.nextDouble();
return m;
}
public static void main(String[] args) {
java.util.Random r = new java.util.Random(42);
// ウォームアップ(JITコンパイルを促す)
double[][] wa = randomMatrix(192, r), wb = randomMatrix(192, r);
for (int i = 0; i < 5; i++) {
matrixMultiplyNaive(wa, wb, new double[192][192]);
matrixMultiplyTiled(wa, wb, new double[192][192]);
}
for (int n : new int[]{256, 512, 1024}) {
double[][] A = randomMatrix(n, r), B = randomMatrix(n, r);
double[][] C1 = new double[n][n], C2 = new double[n][n];
long t = System.nanoTime();
matrixMultiplyNaive(A, B, C1);
long naive = System.nanoTime() - t;
t = System.nanoTime();
matrixMultiplyTiled(A, B, C2);
long tiled = System.nanoTime() - t;
double maxDiff = 0;
for (int i = 0; i < n; i++)
for (int j = 0; j < n; j++)
maxDiff = Math.max(maxDiff, Math.abs(C1[i][j] - C2[i][j]));
System.out.printf("Matrix size: %dx%d%n", n, n);
System.out.printf("Naive: %.4f seconds%n", naive / 1e9);
System.out.printf("Tiled: %.4f seconds%n", tiled / 1e9);
System.out.printf("Speedup: %.2fx (max diff %.2e)%n%n", (double) naive / tiled, maxDiff);
}
}
}
ループタイリングの実測結果
# コンパイル・実行
javac LoopTiling.java && java LoopTiling
Matrix size: 256x256
Naive: 0.0179 seconds
Tiled: 0.0121 seconds
Speedup: 1.48x (max diff 0.00e+00)
Matrix size: 512x512
Naive: 0.2239 seconds
Tiled: 0.1351 seconds
Speedup: 1.66x (max diff 0.00e+00)
Matrix size: 1024x1024
Naive: 1.8715 seconds
Tiled: 1.0785 seconds
Speedup: 1.74x (max diff 0.00e+00)
注意: GitHub Codespaces環境での実測値です。ループタイリングによる高速化は1.5~1.7倍程度でした。
キャッシュ効率化の効果
- 行列サイズが大きくなるほど、キャッシュミスの影響が顕著になる
- 今回のタイル(64×64×8B=32KB)はそれ単体でL1データキャッシュ(32KB)と同サイズのため、A・B・Cの3タイルはL1に収まらず、実質的にはL2レベルのブロッキングとして効いている。L1を狙うなら32×32などより小さいタイルとの比較も有効
- より大きな行列では、L2/L3キャッシュも考慮した多段階タイリングが有効
HPCでのJava活用時の注意点
- GCの影響を最小化 - 大規模計算ではGCによる停止が致命的。オフヒープメモリの活用も検討
- JNIによるネイティブライブラリ活用 - 極限の性能が必要な部分はC/C++で実装し、JNIで呼び出す
- MPI for Java - 分散メモリ環境での並列計算にはMPJExpressなどを活用
- GPUコンピューティング - JCudaやAparapi経由でGPUを活用
注意: Vector APIはまだインキュベーター機能のため、本番環境での使用には注意が必要です。また、HPCレベルの最適化は、一般的なビジネスアプリケーションでは過剰な場合が多いため、必要性を慎重に判断してください。
まとめ
主な学び
- データ依存関係の解消:複数のアキュムレータを使用することで2.5倍の高速化
-
文字列連結:
StringBuilderを使用することで53.8倍の高速化(JDK 9以降は差が縮小傾向) -
データ構造の選択:
-
LinkedListのランダムアクセスは避ける(O(n²)の計算量) - イテレータ使用時でも
ArrayListが1.2倍、プリミティブ配列で4.4倍高速
-
-
分岐予測の最適化:
- ソート済みデータで6.2倍の高速化(分岐予測の効果)
- ブランチレス手法で24.4倍の高速化(自動ベクトル化の効果を含む)
-
メモリ効率とキャッシュ:
- オブジェクト生成を避けプリミティブ配列を使用することで2.7倍の高速化
- フォールスシェアリング回避で約10倍の高速化(パディング/@Contended)
-
並列処理と同期:
- 並列処理で1.7〜1.9倍の高速化(物理コア数とタスク性質に依存)
- LongAdderによる同期コスト削減で32.9倍の高速化(高競合時)
- JVMチューニング:効果はワークロード依存。GC選択とヒープ設定は必ず自環境で実測する
- SIMD/ベクトル化:依存関係のないループはJITが自動ベクトル化(リダクションは対象外)
これらの最適化が効果的な理由は、すべてCPUの命令パイプラインをスムーズに流し続けることに集約されます。
JITコンパイル結果の検証の重要性
最適化の効果を確実にするためには、JITコンパイラが生成する機械語コードを確認することが重要です。
# 基本的な逆アセンブル出力
java -XX:+UnlockDiagnosticVMOptions \
-XX:+PrintAssembly \
-XX:PrintAssemblyOptions=intel \
YourProgram
# より詳細な最適化情報
java -XX:+UnlockDiagnosticVMOptions \
-XX:+PrintCompilation \
-XX:+PrintInlining \
YourProgram
# 安定した測定のためのオプション
java -XX:-TieredCompilation YourProgram # 段階的コンパイルを無効化
実装の優先順位
効果的な最適化を行うための優先順位:
- アルゴリズムとデータ構造 - 最も効果的(計算量の改善)
- データ依存関係の解消 - CPUの並列実行能力を活用
- 分岐予測の考慮 - 予測しやすいコード構造またはブランチレス
- 並列処理の活用 - マルチコアCPUを活かす
- メモリ効率の改善 - キャッシュ効率とGC負荷の削減
- 同期コストの最小化 - 適切な同期手法の選択
- I/O処理の最適化 - CPUのアイドル時間を削減
- JVMチューニング - 最後の仕上げ
重要な注意点
-
環境依存性を認識する
- 測定結果はCPU、JVM、OS、負荷状況により大きく変動
- 本番環境での測定が重要
-
過度な最適化を避ける
- 可読性とのバランスを考慮
- プロファイリングで特定したボトルネックに集中
-
JDKバージョンの影響
- JDK 9以降:文字列連結の最適化が改善
- JDK 11以降:ZGC利用可能、JFRがOSS化
- JDK 21以降:世代別ZGCや仮想スレッドが利用可能
最適化の心得
「測定なくして最適化なし」
この原則は普遍的です。しかし、その測定結果の裏でCPUがどのように動作しているかを理解することで、より本質的で効果的な最適化への道筋が見えてきます。
「CPUの命令流を滞らせない」
アルゴリズム、データ構造、並列処理、メモリ管理。これらの最適化はすべて、最終的には「CPUの命令パイプラインをスムーズに流し続ける」という目的に集約されます。
「JITコンパイラの最適化を信頼しつつ、検証する」
JITコンパイラは優秀ですが、意図した最適化が実際に適用されているかを逆アセンブル出力で確認することで、より確実な高速化を実現できます。
この記事で紹介したテクニックと、その背後にあるCPUの動作原理を理解することで、皆さんのJavaコードも10倍以上の高速化を実現できるはずです。まずは、プロファイリングから始め、ボトルネックの裏にあるCPUレベルの原因を探ってみてください。
参考記事
-
パイプライン処理:CPUが命令を複数の段階に分割し、各段階を並列に実行する技術。例えば、命令フェッチ、デコード、実行、メモリアクセス、書き戻しの5段階に分割することで、理想的には5つの命令を同時に処理できる。 ↩
-
スーパースカラ:1クロックサイクルに複数の命令を同時に実行できるCPUアーキテクチャ。複数の実行ユニットを持ち、依存関係のない命令を並列に処理する。 ↩
-
キャッシュミス:CPUが必要とするデータがキャッシュメモリに存在せず、より遅い主記憶(RAM)からデータを読み込む必要が生じること。L1ヒットは約4〜5サイクルで済むが、L1ミス→L2アクセスで十数サイクル、L3で40〜70サイクル、メインメモリアクセスでは100〜300サイクルのペナルティが発生する。 ↩
-
空間局所性:あるメモリ位置にアクセスしたとき、その近くのメモリ位置も近い将来アクセスされる可能性が高いという性質。配列のような連続したデータ構造はこの性質を活かしやすい。 ↩
-
分岐予測:CPUが条件分岐命令の結果を予測し、予測した方向の命令を投機的に実行する技術。予測が外れるとパイプラインをフラッシュする必要があり、大きなペナルティが発生する。 ↩
-
アウトオブオーダー実行:プログラムの命令順序と異なる順序で命令を実行する技術。データ依存関係がない命令を先に実行することで、CPUの実行ユニットの稼働率を向上させる。 ↩ ↩2
-
JIT(Just-In-Time)コンパイラ:Javaバイトコードを実行時に機械語に変換するコンパイラ。頻繁に実行されるコード(ホットスポット)を検出し、最適化された機械語に変換することで高速化を実現する。 ↩
-
キャッシュライン:CPUキャッシュの最小単位。通常64バイト。メモリからキャッシュにデータを読み込む際は、この単位でまとめて読み込まれる。 ↩
-
パイプラインストール:パイプライン処理が何らかの理由(データ依存、キャッシュミス、分岐予測ミスなど)で停止すること。CPUの実行効率が大幅に低下する。 ↩
-
オートボクシング/アンボクシング:プリミティブ型(int)とラッパークラス(Integer)の間の自動変換。変換処理のオーバーヘッドとオブジェクト生成コストが発生する。 ↩
-
データ依存関係:ある命令の実行結果が別の命令の入力となる関係。依存関係がある命令は並列実行できず、逐次実行する必要がある。 ↩
-
分岐予測器:CPUが条件分岐の結果を予測するハードウェア機構。過去の分岐履歴を学習し、パターンを見つけて予測精度を向上させる。現代のCPUでは90%以上の予測精度を達成することもある。 ↩
-
パイプラインフラッシュ:分岐予測ミスなどにより、パイプライン内の投機実行された命令をすべて破棄する処理。CPUは正しい分岐先から命令を再フェッチする必要があり、大きな性能ペナルティとなる。 ↩
-
フォールスシェアリング:マルチスレッド環境で、異なるスレッドが更新する変数が同じキャッシュラインに乗ることで、互いのキャッシュを無効化し合う現象。パフォーマンスを著しく低下させる。 ↩
-
@Contended:フィールドの周囲にパディングを挿入してフォールスシェアリングを防ぐアノテーション。JDK 8では
sun.misc.Contended、JDK 9以降はjdk.internal.vm.annotation.Contended(内部API)。JDK外のコードで有効にするには--add-exportsと-XX:-RestrictContendedが必要。 ↩ -
命令流:CPUが実行する命令の連続した流れ。プログラムカウンタによって次に実行する命令が決定され、順次または分岐によって進行する。 ↩
-
CAS(Compare-And-Swap):現在の値と期待値を比較し、一致した場合のみ新しい値に更新するアトミック操作。Javaでは
AtomicIntegerなどで使用される。 ↩ -
Stop-The-World(STW):ガベージコレクション実行中にアプリケーションのすべてのスレッドが停止すること。ZGCやShenandoahGCでは数十マイクロ秒レベルまで短縮される。 ↩
-
SIMD(Single Instruction, Multiple Data)命令:1つの命令で複数のデータを同時に処理する並列演算命令。ベクトル演算とも呼ばれ、数値計算の高速化に効果的。 ↩
-
「測定なくして最適化なし」:性能最適化の基本原則。ドナルド・クヌースの格言「早すぎる最適化は諸悪の根源である」とセットで語られることが多い。 ↩
-
キャッシュ効率の改善:データセットを小さく分割することで、各CPUコアのL1/L2キャッシュに収まりやすくなり、キャッシュミスが減少する現象。 ↩
