Java 例外處理:用編譯器強迫你正視錯誤
從例外階層、try/catch/finally 到 try-with-resources,理解 Java 招牌的 checked 例外設計與它二十年的爭議
當檔案讀到一半,硬碟突然被拔掉
想像你寫了一個程式,要從磁碟讀一份學生名單、逐行解析、再寫進資料庫。一切都很順——直到某天有人在程式跑到一半把隨身碟拔掉。檔案讀取會失敗,但你的程式接下來該怎麼辦?是直接整個崩潰、印出一堆看不懂的訊息,還是優雅地告訴使用者「檔案讀取中斷,已回復到安全狀態」?
這就是「例外處理(exception handling)」要解決的問題:把「正常邏輯」和「出錯時怎麼辦」這兩件事分開來寫,讓程式碼既清楚又穩健。Java 在這件事上做了一個其他主流語言幾乎都沒做的激進決定——它用編譯器強迫你正視某些錯誤。這個設計到今天還在被熱烈爭論,我們會在最後深入聊。
如果你讀過本專區的 Python 篇,會發現 Java 的例外觀念在「try/catch」層面神似,但 Java 多了一層 Python 沒有的東西:checked 例外。這正是 Java 作為「靜態強型別、編譯期就想抓住你錯誤」的語言哲學體現。

例外其實是「物件」:先看階層
在 Java 裡,例外不是某種神秘的訊號,它就是一個普通的物件,繼承自 Throwable。這一點和 Java「OOP-first(物件導向優先)」的本性完全一致——連「錯誤」都是類別。整個階層長這樣:
Throwable
├── Error (JVM 層級的嚴重問題,例如 OutOfMemoryError,通常不該 catch)
└── Exception
├── RuntimeException (unchecked:執行期才會爆,編譯器不強制處理)
│ ├── NullPointerException
│ ├── ArrayIndexOutOfBoundsException
│ └── IllegalArgumentException
└── IOException、SQLException… (checked:編譯器強制你處理)
記住兩條分界線:
ErrorvsException:Error是 JVM 自己出事(記憶體爆了、堆疊溢位),一般應用程式不該去 catch 它,因為你也救不了。我們平常處理的都是Exception這一支。RuntimeExceptionvs 其他Exception:這條線就是 unchecked vs checked 的分界,是 Java 例外處理最核心、也最有 Java 特色的概念。
相較於 Python——Python 的例外也是物件、也有階層(BaseException → Exception),但 Python 完全沒有 checked/unchecked 的區分,所有例外都不需要事先宣告。Java 偏要在型別系統裡把這件事標記出來,這是兩種語言哲學的根本差異。
try / catch / finally:三段式的安全網
最基本的語法。我們把「可能出錯的程式碼」放進 try,把「出錯後的善後」放進 catch,把「不管成功失敗都要做的收尾」放進 finally:
public class DivideDemo {
public static void main(String[] args) {
try {
int[] data = {10, 20, 30};
int result = data[0] / 0; // 這行會丟 ArithmeticException
System.out.println(result); // 永遠不會執行到
} catch (ArithmeticException e) {
System.out.println("數學錯誤:" + e.getMessage());
} finally {
System.out.println("收尾:不管有沒有出錯,我都會跑。");
}
System.out.println("程式繼續往下。");
}
}
// 輸出:
// 數學錯誤:/ by zero
// 收尾:不管有沒有出錯,我都會跑。
// 程式繼續往下。
幾個關鍵點:
- 一旦
try區塊裡某行丟出例外,該行之後的程式碼會被跳過,直接跳到對應的catch。 finally幾乎保證執行(連try裡有return都擋不住它先跑),所以傳統上用來釋放資源(關檔案、關連線)。catch的型別有多型(polymorphism):寫catch (Exception e)可以接住所有Exception子類,但這通常太寬鬆——你會把真正該往上拋的錯也吃掉。
多重 catch 與順序陷阱
可以針對不同例外分別處理。注意:子類別的 catch 必須寫在父類別之前,否則編譯不過。
try {
process(args);
} catch (NumberFormatException e) { // 較具體的子類,放前面
System.out.println("數字格式錯誤");
} catch (IllegalArgumentException e) { // 父類,放後面
System.out.println("參數錯誤");
} catch (Exception e) { // 最寬鬆的,墊底
System.out.println("其他未預期錯誤");
}
Java 7 後也支援用 | 合併不會相互衝突的多個型別:
try {
risky();
} catch (IOException | SQLException e) { // 兩種都用同一段處理
log.error("外部資源失敗", e);
throw new ServiceException("服務暫時不可用", e);
}
checked vs unchecked:Java 最有個性的設計
這是 Java 區別於 Python、JavaScript、C# 的標誌性設計,務必搞懂。
checked 例外:繼承自 Exception 但不繼承 RuntimeException(例如 IOException、SQLException)。編譯器強制你要嘛 catch 它、要嘛在方法簽章用 throws 宣告往外丟。漏掉就無法編譯。它代表「可預期、呼叫端理應有能力應對」的錯誤,例如檔案不存在、網路斷線。
unchecked 例外:繼承自 RuntimeException(例如 NullPointerException、IllegalArgumentException)。編譯器不強制處理,通常代表「程式設計上的 bug」——是你該在開發時修掉的,而不是在執行期 catch 的。
看一段對照就懂:
import java.io.*;
public class CheckedDemo {
// checked:readFile 內部可能丟 IOException,不處理就必須用 throws 宣告
static String readFirstLine(String path) throws IOException {
BufferedReader br = new BufferedReader(new FileReader(path));
try {
return br.readLine();
} finally {
br.close();
}
}
// unchecked:age 不合法是「呼叫者的 bug」,不必宣告 throws
static void setAge(int age) {
if (age < 0) {
throw new IllegalArgumentException("年齡不可為負:" + age);
}
System.out.println("年齡設為 " + age);
}
public static void main(String[] args) {
// 呼叫 checked 方法,編譯器強迫我們處理 IOException
try {
System.out.println(readFirstLine("notes.txt"));
} catch (IOException e) {
System.out.println("讀檔失敗:" + e.getMessage());
}
// 呼叫 unchecked 方法,編譯器不管,但執行期會爆
setAge(-5); // 丟出 IllegalArgumentException
}
}
如果你把 readFirstLine 的 throws IOException 拿掉、又不在內部 catch,程式根本編譯不過——這就是 checked 的「強迫正視」威力。相較之下,setAge 丟的 unchecked 例外,編譯器完全不囉嗦。
一句話心法:checked = 「外部世界可能出事,請你預先想好對策」;unchecked = 「你自己寫錯了,去把 bug 修掉」。
throws 宣告:把責任往上推
當一個方法自己不想(或不該)處理某個 checked 例外時,可以用 throws 在簽章上宣告,把責任「往呼叫它的人」推。這是一種契約:方法在告訴所有呼叫者「我可能丟這種例外,你得準備好」。
// 第一層:只負責讀,不負責決定出錯怎麼辦 → 往上拋
static String loadConfig(String path) throws IOException {
return Files.readString(Path.of(path)); // 可能丟 IOException
}
// 第二層:同樣不決定,繼續往上拋
static Config parseConfig(String path) throws IOException {
String raw = loadConfig(path);
return Config.from(raw);
}
// 最上層(main):在這裡才真正決定怎麼辦
public static void main(String[] args) {
try {
Config cfg = parseConfig("app.conf");
System.out.println("載入成功:" + cfg);
} catch (IOException e) {
System.out.println("設定載入失敗,改用預設值。");
}
}
這展現了一個重要的設計原則:例外應該在「有足夠資訊與權力做決定」的那一層被處理。底層的讀檔函式不知道「失敗了該不該用預設值」,所以它不處理、只宣告往上拋;到了 main 才知道整體策略,於是在那裡 catch。
注意 throws 和 throw 是兩個不同的東西,初學者很常搞混:
throw(無 s):在程式碼裡實際丟出一個例外物件,例如throw new IllegalArgumentException(...)。throws(有 s):寫在方法簽章上,宣告這個方法可能丟出哪些 checked 例外。
try-with-resources:自動關門的現代寫法
前面用 finally 手動 close() 其實很容易出錯:忘了關、或者 close() 本身又丟例外把原本的錯蓋掉。Java 7 引入 try-with-resources 徹底解決這件事。只要資源實作了 AutoCloseable 介面,放進 try(...) 的括號裡,離開 try 區塊時 JVM 自動呼叫 close(),不論正常結束還是丟例外。
對照一下舊寫法和新寫法:
// 舊寫法:囉嗦又易錯
static String oldWay(String path) throws IOException {
BufferedReader br = new BufferedReader(new FileReader(path));
try {
return br.readLine();
} finally {
br.close(); // 要記得寫;若 br 是 null 還會 NPE
}
}
// 新寫法:簡潔且保證關閉
static String newWay(String path) throws IOException {
try (BufferedReader br = new BufferedReader(new FileReader(path))) {
return br.readLine();
} // ← 離開這裡時自動 br.close(),不用寫 finally
}
可以一次宣告多個資源,用分號隔開,關閉順序與宣告順序相反(後開先關,符合資源依賴的直覺):
try (FileInputStream in = new FileInputStream("src.dat");
FileOutputStream out = new FileOutputStream("dst.dat")) {
in.transferTo(out);
} // 先關 out,再關 in
相較於 Python 的 with 陳述式,try-with-resources 是非常類似的概念(兩者都靠一個「進場/離場」協定,Python 用 __enter__/__exit__,Java 用 AutoCloseable.close())。差別在 Java 把它整合進既有的 try/catch 語法,可以無縫接上 catch 與 finally。
動手寫一段:把所有觀念串起來
以下是一個完整可執行的程式。它模擬「解析一串使用者輸入的數字、計算平均」,故意混入會丟 unchecked 例外的壞資料,示範 try-with-resources、多重 catch、finally、以及自訂例外往外拋的完整流程。複製貼上就能跑。
import java.io.*;
import java.util.*;
public class AverageCalculator {
// 自訂 checked 例外:代表「資料品質不合格」這種可預期的業務錯誤
static class InvalidDataException extends Exception {
InvalidDataException(String msg) { super(msg); }
}
static double average(String line) throws InvalidDataException {
String[] tokens = line.split(",");
if (tokens.length == 0) {
throw new InvalidDataException("沒有任何數字");
}
long sum = 0;
for (String t : tokens) {
try {
sum += Integer.parseInt(t.trim()); // 壞資料會丟 NumberFormatException(unchecked)
} catch (NumberFormatException e) {
throw new InvalidDataException("不是合法整數:'" + t.trim() + "'");
}
}
return (double) sum / tokens.length;
}
public static void main(String[] args) {
// 用 try-with-resources 模擬讀取一串輸入
String input = "10, 20, 30, x, 40";
try (BufferedReader br = new BufferedReader(new StringReader(input))) {
String line = br.readLine();
System.out.printf("平均 = %.2f%n", average(line));
} catch (InvalidDataException e) {
System.out.println("資料錯誤:" + e.getMessage());
} catch (IOException e) {
System.out.println("讀取錯誤:" + e.getMessage());
} finally {
System.out.println("計算流程結束。");
}
}
}
// 輸出:
// 資料錯誤:不是合法整數:'x'
// 計算流程結束。
把 input 改成 "10, 20, 30, 40" 再跑一次,輸出會變成:
平均 = 25.00
計算流程結束。
留意這支程式的設計:底層 Integer.parseInt 丟的是 unchecked 的 NumberFormatException,但我們在 average 裡把它「翻譯」成一個有業務意義的 checked InvalidDataException 再往外拋。這個「例外轉譯(exception translation)」是很實務的技巧——不要讓底層的技術性例外原封不動地穿透到高層,那會讓上層程式碼依賴底層細節。
常見錯誤:初學者最常踩的五個雷
-
吞掉例外(swallowing):寫了
catch (Exception e) {}卻什麼都不做。錯誤就這樣無聲消失,日後 debug 會崩潰。至少要 log 出來,包含完整的e(堆疊軌跡),不要只印e.getMessage()。 -
用 catch 控制正常流程:例外是給「例外狀況」用的,不是 if-else 的替代品。例如「檢查 key 在不在 Map 裡」應該用
containsKey,而不是 catchNullPointerException。例外的建立與堆疊軌跡填充成本不低,濫用會拖慢效能。 -
catch 範圍太寬:第一行就寫
catch (Exception e),把連同NullPointerException這種「你自己的 bug」都一起接住,等於把 bug 藏起來。只 catch 你真的有辦法處理的具體例外。 -
在 finally 裡 return 或丟例外:
finally裡的return會覆蓋try裡的return,finally裡丟的例外會吞掉try裡原本的例外。這會造成極難追查的詭異行為,務必避免。 -
混淆
throw與throws:throw是動作(丟一個物件出去),throws是宣告(在簽章上標記)。寫錯通常編譯就過不了,但有時會誤用throw new IOException卻忘了該方法沒有throws IOException宣告而卡關。
重點回顧
- 例外是繼承自
Throwable的物件;Error別碰,Exception才是我們處理的對象。 - checked(強制處理,代表外部可預期錯誤)vs unchecked /
RuntimeException(不強制,多半代表程式 bug)是 Java 的招牌設計。 try/catch/finally三段式;finally幾乎必跑,傳統用於收尾。throws把處理責任往呼叫端推;應在「有能力做決定」的那層才 catch。- 開資源優先用 try-with-resources,讓
AutoCloseable自動關閉,告別手寫finally close()。
深入探討(研究所視角)
checked 例外:一個被熱烈爭論了二十年的設計
Java 是主流語言中幾乎唯一堅持 checked 例外的。C#、Python、JavaScript、Kotlin、Scala 全部都放棄了它。為什麼這個看似「貼心地強迫你處理錯誤」的設計,會變成 Java 社群最大的爭議點之一?
支持方的論點:checked 例外把「這個方法可能失敗、以及怎麼失敗」寫進了型別簽章,成為編譯期可驗證的契約。呼叫者無法「假裝沒看到」一個 IOException,這對企業級系統的健壯性有實質幫助——你不會因為忘了處理網路逾時而讓整個交易系統默默損毀。它把錯誤處理從「文件上的約定」提升成「編譯器執行的約束」,這與 Java「靜態強型別、編譯期抓住盡可能多錯誤」的整體哲學一脈相承。
反對方的論點(這也是業界主流意見):
- 破壞抽象與可組合性。當你在某個介面方法上宣告
throws SQLException,這個底層細節就「洩漏」到了所有上層介面。日後想把儲存層從 SQL 換成 NoSQL,整條呼叫鏈的簽章都得改。checked 例外讓介面與實作細節耦合。 - 誘發「吞例外」的反模式。當編譯器逼著你處理一個你當下不知道怎麼處理的例外,最省事的做法就是寫個空的
catch {},反而比沒有 checked 例外更危險——錯誤被靜默吃掉了。 - 與 lambda、Stream 等函數式特性嚴重衝突。Java 8 的
Function、Stream.map等函數式介面不允許丟 checked 例外。一旦你想在stream().map(this::readFile)裡呼叫會丟 checked 例外的方法,就得寫一堆醜陋的 try/catch 包裝,或自訂 unchecked 包裝介面。這是 checked 例外與現代 Java 風格格格不入的最痛點。
業界目前的主流實務(例如 Spring 框架的設計哲學)是:盡量少用 checked 例外,傾向把它們在邊界處轉譯成 unchecked 的 runtime exception。Spring 著名地把 JDBC 的 checked SQLException 統一包成 unchecked 的 DataAccessException 階層,正是因為「呼叫端對一個 SQL 錯誤通常無能為力,強迫處理只會製造樣板程式碼」。
例外處理的最佳實務
綜合主流經驗,可以歸納幾條跨越語言的原則:
-
Throw early, catch late(早丟、晚接):在偵測到問題的最早一刻就丟出例外(例如方法入口處驗證參數),但在擁有足夠情境能做決定的最高層才 catch。中間層一律往上拋。
-
保留因果鏈(exception chaining):轉譯例外時,務必把原始例外當作
cause傳進去:throw new ServiceException("...", e),而不是throw new ServiceException("...")。否則原始堆疊軌跡會遺失,debug 時你會只看到「服務失敗」卻不知道根因是磁碟滿了。Java 的Throwable(String, Throwable)建構子就是為此設計,列印時會顯示Caused by:鏈。 -
別用例外控制流程。承前所述,例外建立時要捕捉完整堆疊軌跡,這是有成本的操作(雖然 JIT 會優化,但相對於普通條件判斷的 $O(1)$ 仍貴上許多)。正常的「找不到」「不存在」狀況,優先用回傳值(如 Java 的
Optional<T>)表達。 -
catch 具體型別,不要 catch
Exception或Throwable。catchThrowable連Error(如OutOfMemoryError)都接住,會掩蓋 JVM 的致命問題。
底層機制:例外處理「零成本」嗎?
從 JVM 實作角度,現代 Java 的例外處理採用例外表(exception table)機制。每個方法在編譯後的 bytecode 裡附帶一張表,記錄「哪段位元組碼範圍由哪個 handler 處理」。關鍵在於:只要不丟例外,這張表完全不影響正常執行路徑的速度——也就是說,包一層 try 本身在「沒出錯」時幾乎是零開銷,這和早期某些用 setjmp/longjmp 的 C 風格錯誤處理很不同。
真正昂貴的是丟出例外的那一刻:JVM 要建立例外物件、並透過 fillInStackTrace() 走訪當前呼叫堆疊把每一層的類別名、方法名、行號填進去。這步驟的成本與堆疊深度成正比。這也再次解釋了為什麼「不要用例外控制流程」——在熱路徑(hot path)上頻繁丟例外,fillInStackTrace 的開銷會非常顯著。(進階技巧:在確定不需要堆疊軌跡的高頻例外上,可以覆寫 fillInStackTrace() 回傳 this 來省下這筆成本,但這是要極度謹慎使用的優化。)
理解到這一層,你就能明白 Java 例外處理設計背後的每一個取捨:它把錯誤當物件、用型別系統做契約、用 JVM 機制讓正常路徑零成本——這一切都是「靜態強型別、企業級健壯性優先」這套價值觀的具體實踐。而 checked 例外的爭議,本質上是「編譯期安全」與「程式碼簡潔可組合」這兩個美德之間,至今仍未有定論的拉鋸。