1. プロファイリング入門
プロファイリングは、プログラムに対する健康診断のようなものです。単に「体温」(モニタリング)を見るのではなく、アプリの「どこが痛いのか」、何が遅いのか、どこでメモリやリソースを使い過ぎているのかを探ります。
プロファイリングとは、プログラムの動作に関する情報を収集・分析し、ボトルネック(bottlenecks)や非効率なコード部位を特定するプロセスです。モニタリングが一般的な指標(プロセッサ負荷、メモリ、スレッド数など)を追跡するのに対し、プロファイリングは内部を覗き込みます。どのメソッドが最も頻繁に呼ばれているか、どれだけ時間を消費しているか、どのオブジェクトがどれくらい生成され、どこでメモリリークが起きているのかを把握できます。
プロファイリングが本当に必要なとき
- アプリが「重い/遅い」が、理由が分からない。
- 突然メモリ消費が増えた。
- コードを更新したあと、処理が長くかかるようになった。
- サーバーのリソースが足りない理由を知りたい。
ちなみに、ほとんどの開発者は少なくとも一度は的外れな箇所を最適化してしまいます。なぜか?目視だけでボトルネックを特定するのはほぼ不可能だからです——そのためにプロファイラが必要なのです。
プロファイリングの主なメトリクス
- メソッドの実行時間(CPU プロファイリング):どのメソッドが最も時間を占めているか?どこで CPU を消費しているか?
- メモリ使用量(メモリ・プロファイリング):どのオブジェクトが最も頻繁に生成されているか?必要以上に長くメモリに残っていないか?
- オブジェクト数:同種のオブジェクトを作り過ぎていないか?
- スレッド:スレッド数が多過ぎないか?ブロッキング(deadlock, contention)はないか?
- メソッド呼び出し:スタックの深さは適切か?出口のない再帰が起きていないか?
2. プロファイリングツール
Java の世界には、プロファイリングを行うためのクラシック(しかも無料!)なツールがいくつかあります。主要なものを見ていきましょう。
VisualVM
VisualVM は、JDK(JDK 6 以降)に同梱されている無料ツールです。以下が可能です。
- ローカルおよびリモートの JVM へ接続。
- メモリ、スレッド、CPU、ガーベジコレクションの可視化。
- heap dump の取得と分析。
- CPU とメモリのプロファイリング。
VisualVM の起動方法
通常は JDK のフォルダにあります: <path_to_JDK>/bin/jvisualvm
起動し、Java アプリのプロセスを選べば、そのライフサイクルを水槽の魚を眺めるように観察できます(ただしここでの魚はオブジェクトとスレッドです)。
JProfiler, YourKit
商用ですが非常に強力なツールです。以下が可能です。
- メモリ、CPU、スレッドのプロファイリング。
- メモリの「スナップショット」(heap dump)の分析。
- リーク、長いロック、遅いメソッドの特定。
- IDE や CI/CD との統合。
まずは VisualVM で十分ですが、より大きなプロジェクトに「成長」したら、これらのツールも検討してみてください。
Java Flight Recorder (JFR)
JFR は、JVM の動作に関するイベントを収集するための JDK 組み込みツールです。非常に軽量で、パフォーマンスへの影響がほとんどなく、次の情報を収集できます。
- メソッドの実行時間。
- ガーベジコレクション。
- スレッド、ロック、エラー。
JFR は、本番環境でアプリを遅くできない場合に最適です。
3. 実践:シンプルなアプリをプロファイルする
長い計算を実行し、操作履歴を保存できるミニ電卓を作ってみましょう(ループ、コレクション、メモリ操作の題材になります)。
コード例:「遅い電卓」
import java.util.ArrayList;
import java.util.List;
public class SlowCalculator {
private final List<String> history = new ArrayList<>();
public int add(int a, int b) {
simulateHeavyOperation();
int result = a + b;
history.add(a + " + " + b + " = " + result);
return result;
}
public int multiply(int a, int b) {
simulateHeavyOperation();
int result = a * b;
history.add(a + " * " + b + " = " + result);
return result;
}
public List<String> getHistory() {
return history;
}
// 「重い」処理のシミュレーション
private void simulateHeavyOperation() {
for (int i = 0; i < 5_000_000; i++) {
Math.sqrt(i);
}
}
}
ではメインクラスです:
public class Main {
public static void main(String[] args) {
SlowCalculator calc = new SlowCalculator();
for (int i = 0; i < 10; i++) {
calc.add(i, i * 2);
calc.multiply(i, i + 5);
}
System.out.println("操作履歴:");
for (String entry : calc.getHistory()) {
System.out.println(entry);
}
}
}
このアプリをどうプロファイルするか?
- アプリをコンパイルして実行します。
- VisualVM(jvisualvm)を開きます。
- 自分のプロセスを探します(通常はクラス名 Main で分かります)。
- CPU Profiler タブに移動して Start をクリックします。
- プログラムをしばらく動かします(または遅いボタンをもう一度押します)。
- どのメソッドが最も長い時間を占めているかを確認します。
質問: 最も「重い」メソッドはどれだと思いますか?
答え: もちろん simulateHeavyOperation() です。5_000_000 回の巨大なループを回し、Math.sqrt を呼び出しているからです。
4. 典型的なパフォーマンス問題
遅いアルゴリズム
最も多い原因は、アルゴリズムやデータ構造の選択ミスです。例えば、リストで探索する代わりに HashMap を使うべき場面や、バブルソートではなくクイックソートを使うべき場面です。
例:
// 遅い探索
for (String s : list) {
if (s.equals("target")) {
// 見つかった
}
}
Set や Map を使って高速に探索しましょう。
メモリリーク
メモリリークとは、不要になっているのに参照が残って「生き続ける」オブジェクトがある状態です。これはメモリ消費の増加を招き、最終的には OutOfMemoryError につながります。
public class MemoryLeakDemo {
private static List<byte[]> leakyList = new ArrayList<>();
public static void main(String[] args) {
while (true) {
leakyList.add(new byte[1_000_000]); // 1 MB
try { Thread.sleep(100); } catch (InterruptedException ignored) {}
}
}
}
リークはどう見つける?
VisualVM で heap dump を取得し、どのオブジェクトが最も多くメモリを占め、なぜ参照が残っているのかを確認します。
過剰なオブジェクト生成
ループで多数の同種オブジェクトを生成すると、ガーベジコレクタの負荷が増すだけでなく、アプリを遅くする可能性があります。
for (int i = 0; i < 1_000_000; i++) {
String s = new String("hello"); // 悪い!
}
定数や文字列プール(String pool)を使う方が良いでしょう。
スレッドのロック
複数のスレッドが同じ資源(例えば synchronized メソッド)を取り合うと、ロックが発生してパフォーマンスが低下することがあります。
public synchronized void doWork() {
// ...
}
どう見つける?
VisualVM の Threads タブで、どのスレッドが「固まっている」のか、その理由を確認できます。
5. 最適化のアプローチ
まず測定、そして最適化
最適化の鉄則:遅くないものは最適化しない。
まずプロファイルして「ホットスポット」を見つけてからコードを変更しましょう。しばしば、最も「明らか」に見える箇所は全体の時間のわずか 1% に過ぎず、本当の「怪物」はライブラリや思いもよらない場所に潜んでいます。
プロファイラを用いたホットスポットの特定
ホットスポットとは、アプリの実行時間の大半を占めるメソッドやコード断片のことです。
VisualVM の CPU Profiler タブで以下を確認します。
- メソッドを実行時間でソートする。
- スタックトレースを見て、誰が誰を呼んでいるかを追う。
- 原因が自分のコードではなく、ライブラリや JDK にある場合もあることを覚えておく。
最適化の例
例 1: アルゴリズムの置き換え
探索に最も時間がかかっていることが分かったら、List を HashSet に置き換えます。
Set<String> set = new HashSet<>(list);
if (set.contains("target")) {
// 高速!
}
例 2: 割り当て回数の削減
ループ内で新しいオブジェクトを作る代わりに、再利用や StringBuilder を使いましょう。
// 悪い例:
for (int i = 0; i < 10000; i++) {
String s = "結果: " + i;
}
// 良い例:
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 10000; i++) {
sb.setLength(0);
sb.append("結果: ").append(i);
String s = sb.toString();
}
例 3: キャッシュ化
重いメソッドが同じ引数で何度も呼ばれているなら、キャッシュを使いましょう。
Map<Integer, Double> sqrtCache = new HashMap<>();
public double cachedSqrt(int x) {
return sqrtCache.computeIfAbsent(x, Math::sqrt);
}
6. デモ:電卓を高速化する
問題: simulateHeavyOperation() が多くの時間を消費している
ステップ 1. プロファイルする
VisualVM で、時間のほぼすべてが Math.sqrt(i)(5_000_000 回のループ内)に費やされているのが分かります。
ステップ 2. 最適化する
単なる負荷のシミュレーションなら、削除するか反復回数を減らしましょう。
実際のビジネスロジックなら、次のことを検討します。
- 結果をキャッシュする。
- より高速なアルゴリズムを使う。
- (ユーザーにとってクリティカルでないなら)計算を別スレッドに移す。
最適化例:
private void simulateHeavyOperation() {
// 以前は 5_000_000、今は 100_000
for (int i = 0; i < 100_000; i++) {
Math.sqrt(i);
}
}
ステップ 3. 結果を検証する
再度プロファイリングすると、プログラムが速くなり、CPU 負荷が下がっていることが分かります。
7. 可視化:最適化プロセス
flowchart TD
A[アプリ起動]
B["プロファイリング(VisualVM)"]
C[ボトルネックの特定]
D[コードの最適化]
E[再プロファイリング]
F[性能改善]
A --> B --> C --> D --> E --> F
E --> C
8. プロファイリングと最適化のよくある誤り
誤り No.1: 勘に頼った最適化。 多くの場合、実際に問題がどこにあるかを測らずにコードを変更し始めてしまいます。その結果、作業量は多いのに効果は最小限になります。
誤り No.2: 非現実的な条件でのプロファイリング。 本番に近いデータや負荷でプロファイルすべきです。空っぽの状態でのプロファイリングでは、本当の問題が見つからないことがあります。
誤り No.3: メモリリークの無視。 heap dump を確認せず参照を分析しないと、プログラムが「膨らみ」続け、やがて落ちることに長い間気づけません。
誤り No.4: 微小な最適化へのこだわり。 アプリ全体の時間の 0.1% しか占めないコードを速くするために何日も費やすべきではありません。まずは主なボトルネックから。
誤り No.5: スレッドと同期の軽視。 マルチスレッドアプリでは、性能問題はアルゴリズムではなくロックや待機(synchronized、contention)に起因することがよくあります。
誤り No.6: 変更後にプロファイルしない。 最適化のあとには必ず結果を再確認しましょう。「最適化」によって逆に遅くなることすらあります。
GO TO FULL VERSION