1. 效能剖析導論
效能剖析就像為你的程式做健康檢查:我們不只是看「體溫」(監控),而是要找出應用程式「哪裡痛」、哪些地方很慢、在哪裡耗費了過多的記憶體或資源。
效能剖析是蒐集並分析程式執行情形的過程,目的是找出瓶頸(bottlenecks)與低效的程式區段。不同於監控通常只追蹤整體指標(CPU 載入、記憶體、執行緒數量),剖析能往內部查看:了解哪些方法被呼叫最頻繁、它們花了多少時間、建立了多少物件,以及記憶體外洩究竟發生在何處。
什麼時候真的需要做效能剖析?
- 應用程式「很卡」,但不清楚原因。
- 記憶體用量突然上升。
- 更新程式碼後,某些操作變慢了。
- 需要了解為何伺服器資源不足。
順帶一提,幾乎每位開發者一生中都至少優化錯過地方一次。為什麼?因為靠感覺幾乎無法準確找出瓶頸——這正是剖析器存在的價值。
效能剖析的關鍵指標
- 方法執行時間(CPU 剖析):哪些方法耗時最多?程式在哪裡「吃」CPU?
- 記憶體使用(Memory 剖析):哪些物件最常被建立?哪些物件在記憶體中停留過久?
- 物件數量:我們是否建立了過多同類型物件?
- 執行緒:執行緒是否過多?是否存在鎖定或競爭(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 是內建於 JDK 的 JVM 事件蒐集工具。它非常輕量,幾乎不影響效能,能蒐集以下資訊:
- 方法執行時間。
- 垃圾回收。
- 執行緒、鎖定、錯誤。
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. 常見的效能問題
演算法過慢
最常見的原因:選錯演算法或資料結構。例如,用 list 搜尋而不是 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)。
執行緒鎖定
若多個執行緒競爭同一資源(例如同步化的方法),就可能導致鎖定並使效能下降。
public synchronized void doWork() {
// ...
}
如何找出?
在 VisualVM 的 Threads 分頁可以看到哪些執行緒在「卡住」,以及原因。
5. 最佳化方法
先量測,再最佳化
最佳化的首要準則:不要最佳化那些不會造成瓶頸的部分。
先做剖析,找出「熱點」,再修改程式。有時最「明顯」的區塊只佔了 1% 的時間,而真正的「怪獸」藏在函式庫或某個意想不到的地方。
用剖析器尋找熱點
熱點是指在應用程式執行中占用最大時間比例的方法或程式區段。
在 VisualVM 的 CPU Profiler 分頁可以看到:
- 依執行時間排序方法。
- 查看 stack trace:誰呼叫了誰。
- 記住,有時「罪魁禍首」不是你的程式,而是函式庫甚至是 JDK 本身。
最佳化範例
範例 1:替換演算法
如果你發現大多時間花在 list 搜尋,將 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. 剖析與最佳化的常見錯誤
錯誤 1:憑感覺就開始最佳化。 開發者常在未量測問題所在就開始改碼。結果是投入很多工,成效卻很有限。
錯誤 2:在「不真實」環境中剖析。剖析必須在接近真實流量與資料的條件下進行。空載或不具代表性的測試可能看不出真正問題。
錯誤 3:忽視記憶體洩漏。若不看 heap dump 並分析參考關係,程式可能「越來越胖」而不自知,最後直接當掉。
錯誤 4:追逐微不足道的最佳化。別把數天花在只佔 0.1% 執行時間的程式碼上。先解決主要瓶頸。
錯誤 5:忽視執行緒與同步。在多執行緒應用中,效能問題常不是演算法,而是鎖定與等待(synchronized、contention)。
錯誤 6:修改後忘了再做剖析。最佳化之後務必重新驗證結果:有時所謂的「最佳化」反而會變慢!
GO TO FULL VERSION