面試體驗(yàn)避坑指南含完整示例)
3個(gè)面試體驗(yàn)避坑指南含完整示例
報(bào)錯(cuò)一堆看不懂 StackTrace 是新人常態(tài),別慌。
很多人卡在第一步:日志滿屏紅,腦子直接死機(jī)。
其實(shí)只要看懂調(diào)用棧順序,問題就解決了一半。
本文給一套完整示例,從現(xiàn)象到根因拆解。
你不需要背下所有異常類型,只需要掌握“體驗(yàn)”背后的執(zhí)行邏輯。
這能幫你面試時(shí)不被基礎(chǔ)題問懵,也能讓你上線后少背鍋。
一句話原理
異常處理的核心是“控制流轉(zhuǎn)移”,不是“程序崩潰”。
Java 虛擬機(jī)在遇到未捕獲異常時(shí),會(huì)沿著調(diào)用棧向上回溯。
這個(gè)過程叫“棧展開”,每一步都會(huì)執(zhí)行 finally 塊或 try-catch 中的邏輯。
你看到的 StackTrace 不是錯(cuò)誤本身,而是路徑記錄。
它告訴你:代碼從哪進(jìn)來,在哪一步斷了,帶著什么參數(shù)。
RFC 規(guī)范雖不直接定義 Java 異常,但 HTTP 協(xié)議中 4xx/5xx 錯(cuò)誤碼的設(shè)計(jì)思想與之同源:明確狀態(tài)、傳遞上下文、支持重試或降級(jí)。
Java 的 Exception 類繼承自 Throwable,再分 Error 和 Exception 兩大分支。
Error 是 JVM 級(jí)別問題,如 OutOfMemoryError,你不該捕獲它。
Exception 才是業(yè)務(wù)層該處理的,分 Checked 和 Unchecked。
Checked 異常必須在編譯期處理,如 IOException。
Unchecked 異常如 NullPointerException,運(yùn)行時(shí)才拋,容易漏。
面試常問:“為什么 RuntimeException 不需要 try-catch?”
答:因?yàn)樗粡?qiáng)制捕獲,允許“快速失敗”,符合現(xiàn)代框架如 Spring 的設(shè)計(jì)哲學(xué)。
但“允許”不等于“推薦”,生產(chǎn)環(huán)境應(yīng)全局捕獲并記錄。
類比解釋
把方法調(diào)用想象成俄羅斯套娃。
最外層是 main 方法,里面套著 service 方法,再套著 dao 方法。
當(dāng) dao 層數(shù)據(jù)庫(kù)連接斷開,拋出一個(gè) SQLException。
這個(gè)異常就像一顆石子扔進(jìn)最內(nèi)層套娃。
套娃不會(huì)碎,但會(huì)一層層“彈開”,直到被某個(gè) catch 塊接住。
StackTrace 就是記錄“彈開路徑”的監(jiān)控?cái)z像頭。
它按時(shí)間倒序排列:最底下是 main,最上面是拋異常的 dao。
新人常犯錯(cuò)誤:只看第一行,忽略上下文。
比如看到 NullPointerException at UserService.java:42,就只查第42行。
但真正原因可能是第38行傳了 null,第42行只是“受害者”。
所以讀 StackTrace 要“從下往上”讀,找到第一個(gè)屬于你代碼的行。
框架代碼如 Spring 或 Hibernate 的棧幀可以跳過。
重點(diǎn)看包名是你項(xiàng)目前綴的那幾行。
一個(gè)完整示例能幫你建立肌肉記憶。
假設(shè)你寫了一個(gè)用戶注冊(cè)接口,流程如下:
@RestController
public class UserController {@Autowiredprivate UserService userService;@PostMapping(/register)public ResponseEntityString register(@RequestBody UserDto dto) {try {userService.register(dto);return ResponseEntity.ok(注冊(cè)成功);} catch (IllegalArgumentException e) {return ResponseEntity.badRequest().body(參數(shù)錯(cuò)誤: + e.getMessage());} catch (Exception e) {log.error(注冊(cè)未知異常, e);return ResponseEntity.status(500).body(系統(tǒng)繁忙);}}
}如果 dto 為 null,userService.register(null) 會(huì)拋 NullPointerException。
但你在 Controller 層只捕獲了 IllegalArgumentException 和 Exception。
NullPointerException 屬于 RuntimeException,會(huì)被 Exception 捕獲。
日志里會(huì)看到:
java.lang.NullPointerExceptionat com.example.service.UserService.register(UserService.java:15)at com.example.controller.UserController.register(UserController.java:22)at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)...注意:第一個(gè)業(yè)務(wù)行是 UserService.java:15,不是 Controller。
說明問題出在 service 層,而不是參數(shù)校驗(yàn)層。
你本應(yīng)在 Controller 入口加 @Valid 注解,讓 Hibernate Validator 提前攔截。
這就是“體驗(yàn)”的關(guān)鍵:異常不是終點(diǎn),而是提示你該在哪一層加防御。
源碼/偽代碼片段
下面看 JVM 如何“彈開”套娃。
簡(jiǎn)化版?zhèn)未a:
// 模擬調(diào)用棧
void main() {try {service();} catch (Exception e) {printStackTrace(e); // 輸出完整棧}
}void service() {dao();
}void dao() {throw new SQLException(連接超時(shí));
}執(zhí)行流程:main 調(diào)用 service,壓入棧幀。
service 調(diào)用 dao,再壓棧。
dao 拋異常,棧頂彈出。
檢查 service 是否有 catch?沒有,繼續(xù)彈。
main 有 catch,接住異常,執(zhí)行 printStackTrace。printStackTrace 內(nèi)部調(diào)用 Throwable.printStackTrace()。
它遍歷 cause 鏈,逐層輸出棧幀。
每個(gè)棧幀包含:類名、方法名、文件名、行號(hào)。
行號(hào)來自字節(jié)碼中的 LineNumberTable 屬性。
這是編譯時(shí)由 javac 寫入 class 文件的元數(shù)據(jù)。
如果你用 -g:none 編譯,行號(hào)會(huì)丟失,棧里顯示 -1。
所以生產(chǎn)環(huán)境不要關(guān)調(diào)試信息,否則排障難度翻倍。
另一個(gè)常見誤區(qū):catch 塊里再拋異常。
比如:
try {riskyOperation();
} catch (Exception e) {throw new RuntimeException(包裝異常, e);
}這樣原始異常被保留在 cause 鏈中。
StackTrace 會(huì)顯示兩層:
java.lang.RuntimeException: 包裝異常at com.example.Service.risky(Service.java:20)
Caused by: java.sql.SQLException: 連接超時(shí)at com.example.Dao.query(Dao.java:50)面試官喜歡問:“為什么保留原始異常?”
答:為了保留現(xiàn)場(chǎng)。包裝異常丟失上下文,后續(xù)排查無從下手。
Spring 的 @ExceptionHandler 也遵循此原則:不要吞掉原始堆棧。
一個(gè)完整示例的進(jìn)階用法是自定義異常層次。
建議項(xiàng)目?jī)?nèi)建 base/exception 包,定義:BizException:業(yè)務(wù)異常,帶錯(cuò)誤碼
SysException:系統(tǒng)異常,記錄日志但不暴露細(xì)節(jié)這樣 Controller 層可統(tǒng)一處理:
@ExceptionHandler(BizException.class)
public ResponseEntityMapString, String handleBiz(BizException e) {return ResponseEntity.status(e.getHttpStatus()).body(Map.of(code, e.getCode(), msg, e.getMessage()));
}避免每個(gè)接口寫重復(fù) catch,提升“體驗(yàn)”一致性。
流程描述
整個(gè)異常處理流程可拆為四步:
第一步:觸發(fā)
運(yùn)行時(shí)檢測(cè)到非法狀態(tài),如 null 解引用、數(shù)組越界、類型轉(zhuǎn)換失敗。
JVM 創(chuàng)建異常對(duì)象,填充消息和棧幀。
第二步:傳播
當(dāng)前方法未捕獲,異常向上拋。
每層方法檢查是否有匹配的 catch 子句。
匹配規(guī)則:精確類型優(yōu)先,父類次之。
注意:catch 順序必須從子類到父類,否則編譯報(bào)錯(cuò)。
第三步:捕獲
找到第一個(gè)能處理的 catch 塊,執(zhí)行其邏輯。
可以記錄日志、返回默認(rèn)值、轉(zhuǎn)換異常類型。
第四步:終止或繼續(xù)
如果捕獲后不拋新異常,程序從 catch 塊結(jié)束后繼續(xù)執(zhí)行。
如果重新拋出,則繼續(xù)向上找。
若到達(dá) main 方法仍未捕獲,JVM 打印棧并終止線程。
對(duì)于 Web 應(yīng)用,Tomcat 有全局錯(cuò)誤頁(yè),返回 500。
但日志里仍有完整棧,這是你排障的唯一依據(jù)。
所以務(wù)必配置好日志框架,如 Logback 或 Log4j2。
日志格式建議包含:時(shí)間、線程、級(jí)別、類名、方法、行號(hào)、消息。
MDC(Mapped Diagnostic Context)可注入 traceId,串聯(lián)分布式調(diào)用。
微服務(wù)時(shí)代,一個(gè)請(qǐng)求跨多個(gè)服務(wù),Stack Trace 不再完整。
此時(shí)需結(jié)合鏈路追蹤系統(tǒng),如 SkyWalking 或 Zipkin。
但單機(jī)內(nèi),StackTrace 仍是第一手資料。
面試中若問“如何優(yōu)化異常處理性能”,答:避免在循環(huán)中創(chuàng)建異常對(duì)象
用 try-with-resources 管理資源
預(yù)分配日志緩沖區(qū),減少 I/O
對(duì)高頻路徑用條件日志:if (log.isDebugEnabled())異常處理不應(yīng)成為性能瓶頸。
一個(gè)完整示例的性能對(duì)比:
// 差:每次循環(huán)都創(chuàng)建異常
for (int i = 0; i 10000; i++) {try {if (i % 10 == 0) throw new Exception(test);} catch (Exception e) {// 忽略}
}// 好:先判斷再進(jìn)入 try
for (int i = 0; i 10000; i++) {if (i % 10 == 0) {throw new Exception(test);}
}前者慢 3-5 倍,因?yàn)楫惓?duì)象創(chuàng)建成本高。
JIT 編譯器對(duì)異常路徑優(yōu)化有限,避免頻繁觸發(fā)。
實(shí)戰(zhàn)驗(yàn)證
現(xiàn)在用真實(shí)場(chǎng)景驗(yàn)證上述原理。
假設(shè)你開發(fā)一個(gè)電商訂單系統(tǒng),下單時(shí)調(diào)用庫(kù)存服務(wù)。
庫(kù)存服務(wù)是遠(yuǎn)程 HTTP 調(diào)用,可能超時(shí)或返回 500。
你的代碼:
public Order createOrder(OrderDto dto) {StockResponse resp = stockClient.checkStock(dto.getSkuId());if (resp.getCode() != 200) {throw new BizException(5001, 庫(kù)存不足);}return orderRepo.save(new Order(dto));
}stockClient 是 Feign 客戶端,底層是 OkHttp。
如果網(wǎng)絡(luò)超時(shí),F(xiàn)eign 拋 FeignException。
你未捕獲,直接向上拋。
Controller 層全局異常處理器捕獲 FeignException,返回 500。
但日志里只有 FeignException 棧,看不到底層 SocketTimeoutException。
為什么?因?yàn)?Feign 包裝了原始異常。
你需要在 Feign 配置中保留 cause:
@Bean
public ErrorDecoder feignErrorDecoder() {return (methodKey, response) - {try (InputStream is = response.body().asInputStream()) {String body = new String(is.readAllBytes());throw new FeignException.FeignServerException(response.status(),服務(wù)異常,response.headers(),body,response);} catch (IOException e) {throw new FeignException.ErrorStatus(response.status(), 讀取響應(yīng)失敗, e);}};
}這樣 StackTrace 會(huì)包含 Caused by: java.net.SocketTimeoutException。
你就能定位是網(wǎng)絡(luò)問題,還是庫(kù)存服務(wù)內(nèi)部錯(cuò)誤。
另一個(gè)實(shí)戰(zhàn)技巧:用 AOP 統(tǒng)一記錄異常。
@Aspect
@Component
public class ExceptionLogAspect {@Around(execution(* com.example..*.*(..)))public Object logException(ProceedingJoinPoint pjp) throws Throwable {try {return pjp.proceed();} catch (Throwable e) {log.error(方法 {} 異常: {}, pjp.getSignature().toShortString(), e.getMessage(), e);throw e;}}
}注意:不要攔截 getter/setter 等高頻方法,否則性能下降。
用 @Pointcut 限定包路徑或注解。
面試中??迹骸叭绾卧O(shè)計(jì)一個(gè)全局異常處理器?”
參考 Spring 的 @RestControllerAdvice:
@RestControllerAdvice
public class GlobalExceptionHandler {@ExceptionHandler(BizException.class)public MapString, String handleBiz(BizException e) {return Map.of(code, e.getCode().toString(), message, e.getMessage());}@ExceptionHandler(Exception.class)public MapString, String handleAll(Exception e) {log.error(未處理異常, e);return Map.of(code, 500, message, 系統(tǒng)內(nèi)部錯(cuò)誤);}
}關(guān)鍵點(diǎn):BizException 在前,Exception 在后。
否則 BizException 會(huì)被 Exception 先捕獲,失去語(yǔ)義。
一個(gè)完整示例的避坑點(diǎn):不要返回敏感信息。
比如把 SQL 異常堆棧直接吐給前端,可能被注入攻擊探測(cè)表結(jié)構(gòu)。
生產(chǎn)環(huán)境只返回友好提示,詳細(xì)日志存后端。
這個(gè)知識(shí)點(diǎn)你面試被問過嗎?留言說說