3个面试体验避坑指南含完整示例
报错一堆看不懂 StackTrace 是新人常态,别慌。
很多人卡在第一步:日志满屏红,脑子直接死机。
其实只要看懂调用栈顺序,问题就解决了一半。
本文给一套完整示例,从现象到根因拆解。
你不需要背下所有异常类型,只需要掌握“体验”背后的执行逻辑。
这能帮你面试时不被基础题问懵,也能让你上线后少背锅。
一句话原理
异常处理的核心是“控制流转移”,不是“程序崩溃”。
Java 虚拟机在遇到未捕获异常时,会沿着调用栈向上回溯。
这个过程叫“栈展开”,每一步都会执行 finally 块或 try-catch 中的逻辑。
你看到的 StackTrace 不是错误本身,而是路径记录。
它告诉你:代码从哪进来,在哪一步断了,带着什么参数。
RFC 规范虽不直接定义 Java 异常,但 HTTP 协议中 4xx/5xx 错误码的设计思想与之同源:明确状态、传递上下文、支持重试或降级。
Java 的 Exception 类继承自 Throwable,再分 Error 和 Exception 两大分支。
Error 是 JVM 级别问题,如 OutOfMemoryError,你不该捕获它。
Exception 才是业务层该处理的,分 Checked 和 Unchecked。
Checked 异常必须在编译期处理,如 IOException。
Unchecked 异常如 NullPointerException,运行时才抛,容易漏。
面试常问:“为什么 RuntimeException 不需要 try-catch?”
答:因为它不强制捕获,允许“快速失败”,符合现代框架如 Spring 的设计哲学。
但“允许”不等于“推荐”,生产环境应全局捕获并记录。
类比解释
把方法调用想象成俄罗斯套娃。
最外层是 main 方法,里面套着 service 方法,再套着 dao 方法。
当 dao 层数据库连接断开,抛出一个 SQLException。
这个异常就像一颗石子扔进最内层套娃。
套娃不会碎,但会一层层“弹开”,直到被某个 catch 块接住。
StackTrace 就是记录“弹开路径”的监控摄像头。
它按时间倒序排列:最底下是 main,最上面是抛异常的 dao。
新人常犯错误:只看第一行,忽略上下文。
比如看到 NullPointerException at UserService.java:42,就只查第42行。
但真正原因可能是第38行传了 null,第42行只是“受害者”。
所以读 StackTrace 要“从下往上”读,找到第一个属于你代码的行。
框架代码如 Spring 或 Hibernate 的栈帧可以跳过。
重点看包名是你项目前缀的那几行。
一个完整示例能帮你建立肌肉记忆。
假设你写了一个用户注册接口,流程如下:
@RestController
public class UserController {@Autowiredprivate UserService userService;@PostMapping("/register")public ResponseEntity<String> register(@RequestBody UserDto dto) {try {userService.register(dto);return ResponseEntity.ok("注册成功");} catch (IllegalArgumentException e) {return ResponseEntity.badRequest().body("参数错误:" + e.getMessage());} catch (Exception e) {log.error("注册未知异常", e);return ResponseEntity.status(500).body("系统繁忙");}}
}
如果 dto 为 null,userService.register(null) 会抛 NullPointerException。
但你在 Controller 层只捕获了 IllegalArgumentException 和 Exception。
NullPointerException 属于 RuntimeException,会被 Exception 捕获。
日志里会看到:
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)...
注意:第一个业务行是 UserService.java:15,不是 Controller。
说明问题出在 service 层,而不是参数校验层。
你本应在 Controller 入口加 @Valid 注解,让 Hibernate Validator 提前拦截。
这就是“体验”的关键:异常不是终点,而是提示你该在哪一层加防御。
源码/伪代码片段
下面看 JVM 如何“弹开”套娃。
简化版伪代码:
// 模拟调用栈
void main() {try {service();} catch (Exception e) {printStackTrace(e); // 输出完整栈}
}void service() {dao();
}void dao() {throw new SQLException("连接超时");
}
执行流程:
- main 调用 service,压入栈帧。
- service 调用 dao,再压栈。
- dao 抛异常,栈顶弹出。
- 检查 service 是否有 catch?没有,继续弹。
- main 有 catch,接住异常,执行 printStackTrace。
printStackTrace 内部调用 Throwable.printStackTrace()。
它遍历 cause 链,逐层输出栈帧。
每个栈帧包含:类名、方法名、文件名、行号。
行号来自字节码中的 LineNumberTable 属性。
这是编译时由 javac 写入 class 文件的元数据。
如果你用 -g:none 编译,行号会丢失,栈里显示 -1。
所以生产环境不要关调试信息,否则排障难度翻倍。
另一个常见误区:catch 块里再抛异常。
比如:
try {riskyOperation();
} catch (Exception e) {throw new RuntimeException("包装异常", e);
}
这样原始异常被保留在 cause 链中。
StackTrace 会显示两层:
java.lang.RuntimeException: 包装异常at com.example.Service.risky(Service.java:20)
Caused by: java.sql.SQLException: 连接超时at com.example.Dao.query(Dao.java:50)
面试官喜欢问:“为什么保留原始异常?”
答:为了保留现场。包装异常丢失上下文,后续排查无从下手。
Spring 的 @ExceptionHandler 也遵循此原则:不要吞掉原始堆栈。
一个完整示例的进阶用法是自定义异常层次。
建议项目内建 base/exception 包,定义:
- BizException:业务异常,带错误码
- SysException:系统异常,记录日志但不暴露细节
这样 Controller 层可统一处理:
@ExceptionHandler(BizException.class)
public ResponseEntity<Map<String, String>> handleBiz(BizException e) {return ResponseEntity.status(e.getHttpStatus()).body(Map.of("code", e.getCode(), "msg", e.getMessage()));
}
避免每个接口写重复 catch,提升“体验”一致性。
流程描述
整个异常处理流程可拆为四步:
第一步:触发
运行时检测到非法状态,如 null 解引用、数组越界、类型转换失败。
JVM 创建异常对象,填充消息和栈帧。
第二步:传播
当前方法未捕获,异常向上抛。
每层方法检查是否有匹配的 catch 子句。
匹配规则:精确类型优先,父类次之。
注意:catch 顺序必须从子类到父类,否则编译报错。
第三步:捕获
找到第一个能处理的 catch 块,执行其逻辑。
可以记录日志、返回默认值、转换异常类型。
第四步:终止或继续
如果捕获后不抛新异常,程序从 catch 块结束后继续执行。
如果重新抛出,则继续向上找。
若到达 main 方法仍未捕获,JVM 打印栈并终止线程。
对于 Web 应用,Tomcat 有全局错误页,返回 500。
但日志里仍有完整栈,这是你排障的唯一依据。
所以务必配置好日志框架,如 Logback 或 Log4j2。
日志格式建议包含:时间、线程、级别、类名、方法、行号、消息。
MDC(Mapped Diagnostic Context)可注入 traceId,串联分布式调用。
微服务时代,一个请求跨多个服务,Stack Trace 不再完整。
此时需结合链路追踪系统,如 SkyWalking 或 Zipkin。
但单机内,StackTrace 仍是第一手资料。
面试中若问“如何优化异常处理性能”,答:
- 避免在循环中创建异常对象
- 用 try-with-resources 管理资源
- 预分配日志缓冲区,减少 I/O
- 对高频路径用条件日志:if (log.isDebugEnabled())
异常处理不应成为性能瓶颈。
一个完整示例的性能对比:
// 差:每次循环都创建异常
for (int i = 0; i < 10000; i++) {try {if (i % 10 == 0) throw new Exception("test");} catch (Exception e) {// 忽略}
}// 好:先判断再进入 try
for (int i = 0; i < 10000; i++) {if (i % 10 == 0) {throw new Exception("test");}
}
前者慢 3-5 倍,因为异常对象创建成本高。
JIT 编译器对异常路径优化有限,避免频繁触发。
实战验证
现在用真实场景验证上述原理。
假设你开发一个电商订单系统,下单时调用库存服务。
库存服务是远程 HTTP 调用,可能超时或返回 500。
你的代码:
public Order createOrder(OrderDto dto) {StockResponse resp = stockClient.checkStock(dto.getSkuId());if (resp.getCode() != 200) {throw new BizException(5001, "库存不足");}return orderRepo.save(new Order(dto));
}
stockClient 是 Feign 客户端,底层是 OkHttp。
如果网络超时,Feign 抛 FeignException。
你未捕获,直接向上抛。
Controller 层全局异常处理器捕获 FeignException,返回 500。
但日志里只有 FeignException 栈,看不到底层 SocketTimeoutException。
为什么?因为 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(),"服务异常",response.headers(),body,response);} catch (IOException e) {throw new FeignException.ErrorStatus(response.status(), "读取响应失败", e);}};
}
这样 StackTrace 会包含 Caused by: java.net.SocketTimeoutException。
你就能定位是网络问题,还是库存服务内部错误。
另一个实战技巧:用 AOP 统一记录异常。
@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 限定包路径或注解。
面试中常考:“如何设计一个全局异常处理器?”
参考 Spring 的 @RestControllerAdvice:
@RestControllerAdvice
public class GlobalExceptionHandler {@ExceptionHandler(BizException.class)public Map<String, String> handleBiz(BizException e) {return Map.of("code", e.getCode().toString(), "message", e.getMessage());}@ExceptionHandler(Exception.class)public Map<String, String> handleAll(Exception e) {log.error("未处理异常", e);return Map.of("code", "500", "message", "系统内部错误");}
}
关键点:BizException 在前,Exception 在后。
否则 BizException 会被 Exception 先捕获,失去语义。
一个完整示例的避坑点:不要返回敏感信息。
比如把 SQL 异常堆栈直接吐给前端,可能被注入攻击探测表结构。
生产环境只返回友好提示,详细日志存后端。
这个知识点你面试被问过吗?留言说说