Java 透過 Runtime.exec() 或 ProcessBuilder.start() 啟動外部程式(如 ffmpeg、notepad.exe、Shell 腳本)時,會回傳 Process 物件。開發者常呼叫 waitFor() 等待子進程結束,但若不當處理標準輸出/錯誤流,極易導致主線程永久阻塞甚至死鎖。本文從原理、陷阱、實測代碼到生產級解法完整剖析。
JVM 啟動子進程時,會在父子進程間建立三條管道:
Process.getOutputStream()):父寫、子讀。Process.getInputStream()):子寫、父讀。Process.getErrorStream()):子寫、父讀。作業系統為每條管道分配有限緩衝區(通常 4KB~64KB)。若子進程大量輸出而父進程不讀取,緩衝區寫滿 → 子進程阻塞於 write() → 父進程阻塞於 waitFor() → 雙向死鎖。
public abstract int waitFor() throws InterruptedException;
waitFor() 永不返回。Process p = Runtime.getRuntime().exec("notepad.exe");
p.waitFor(); // 必須手動關閉記事本才會繼續
System.out.println("我想被列印...");
現象:主線程卡在 waitFor(),直到使用者關閉視窗。若子程式為無介面批次處理(如 ffmpeg),且輸出量大於緩衝區,程式會永久卡死。
synchronized (Process.class) {
if (p == null) p = Runtime.getRuntime().exec(cmd);
}
p.waitFor(); // 依然會死鎖
同步只能解決多線程競態,無法解決管道緩衝區滿的問題。
核心原則:在 waitFor() 前啟動兩條守護線程,持續讀乾輸出流與錯誤流,排空緩衝區。
Runtime rt = Runtime.getRuntime();
String command = "cmd /c ffmpeg -loglevel quiet -i " + srcPath
+ " -ab " + bitrate + "k -acodec libmp3lame " + desFile;
try {
Process p = rt.exec(command, null, new File("C:\\ffmpeg\\bin"));
// 1. 取得輸出流與錯誤流
final InputStream stdout = p.getInputStream();
final InputStream stderr = p.getErrorStream();
// 2. 線程 A:消費標準輸出
new Thread(() -> {
try (BufferedReader br = new BufferedReader(new InputStreamReader(stdout))) {
String line;
while ((line = br.readLine()) != null) {
// 可選:記錄日誌、解析進度
// System.out.println("[STDOUT] " + line);
}
} catch (IOException e) {
e.printStackTrace();
}
}).start();
// 3. 線程 B:消費標準錯誤(關鍵!ffmpeg 大量資訊輸出到 stderr)
new Thread(() -> {
try (BufferedReader br = new BufferedReader(new InputStreamReader(stderr))) {
String line;
while ((line = br.readLine()) != null) {
// 可選:記錄錯誤、解析進度
// System.err.println("[STDERR] " + line);
}
} catch (IOException e) {
e.printStackTrace();
}
}).start();
// 4. 等待子進程結束
int exitCode = p.waitFor();
// 5. 可選:強制銷毀(防殭屍進程)
p.destroy();
System.out.println("轉碼完成,exitCode=" + exitCode);
} catch (IOException | InterruptedException e) {
e.printStackTrace();
}
thread.setDaemon(true),避免主程式結束時這兩條線程阻塞 JVM 退出。p.waitFor(timeout, TimeUnit.SECONDS) 避免無限等待。ProcessBuilder pb = new ProcessBuilder("ffmpeg", "-i", srcPath, "-ab", bitrate+"k", desFile);
pb.directory(new File("C:\\ffmpeg\\bin")); // 設定工作目錄
pb.redirectErrorStream(true); // 合併 stderr 到 stdout,單線程即可讀取(簡化場景適用)
Process p = pb.start();
// 單線程讀取合併後的流...
int exitCode = p.waitFor();
waitFor() 本身無毒,毒在未被讀取的管道緩衝區。掌握「啟動子進程 → 立即啟動讀取線程排空 stdout/stderr → 再呼叫 waitFor()」這套標準流程,並善用 ProcessBuilder.redirectErrorStream(true) 簡化代碼,即可徹底解決 Java 呼叫外部程式的阻塞與死鎖問題。