4. 异常栈追踪:异常栈的构成、如何阅读堆栈信息、常见异常类型
异常栈追踪,说白了就是程序崩溃时留下的「案发现场」。
我刚开始做运维那会儿,看到一长串堆栈信息就头大。后来踩的坑多了,慢慢就练出了「一眼定位」的本事。今天咱们就把这个技能拆开揉碎了讲。
4.1 异常栈的构成
一个完整的异常栈,通常包含三部分:
- 异常类型:比如 NullPointerException、IndexOutOfBoundsException
- 异常消息:具体描述,比如 "Cannot invoke "String.length()" because "str" is null"
- 调用栈帧:从异常发生点一路回溯到入口方法的调用链
举个例子,你看这个栈:
java.lang.NullPointerException
at com.example.service.UserService.getUser(UserService.java:25)
at com.example.controller.UserController.query(UserController.java:12)
at org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:1050)
at javax.servlet.http.HttpServlet.service(HttpServlet.java:660)
at org.apache.catalina.core.StandardEngineValve.invoke(StandardEngineValve.java:74)
最上面那行是异常类型和消息。往下每一行都是一个栈帧,格式是:类名.方法名(文件名:行号)。
关键点:异常栈是从上往下读的。最上面是异常发生的位置,越往下越接近程序的入口。
4.2 如何阅读堆栈信息
我个人的习惯是「三步定位法」:
- 看异常类型:先搞清楚是什么异常。NullPointer?还是数组越界?这决定了排查方向。
- 看第一行栈帧:异常发生在哪个类的哪一行。这是最直接的线索。
- 看业务代码层:忽略框架和容器的栈帧,重点关注你自己的代码。
举个例子,上面那个栈里,UserService.java:25 就是第一现场。我一般会直接打开这行代码看看。
小技巧:如果栈特别长,可以从下往上扫一眼。有时候真正的根因藏在中间某个调用里,而不是最上面那行。
为什么会这样?因为有些异常是被包装过的。比如 Spring 的事务管理,它会把底层异常包成 TransactionSystemException。这时候你得看 Caused by 那一节。
org.springframework.transaction.TransactionSystemException
at ...
Caused by: java.lang.NullPointerException
at com.example.service.UserService.getUser(UserService.java:25)
... 10 more
看到 Caused by 没?这才是真正的根因。我遇到过不少新手,盯着最上面的异常看了半天,结果发现是下面那个 Caused by 才是真凶。
4.3 常见异常类型
| 异常类型 | 常见原因 | 排查方向 |
|---|---|---|
| NullPointerException | 调用了 null 对象的方法或属性 | 检查变量是否初始化,或者方法返回值是否为 null |
| IndexOutOfBoundsException | 访问了数组或列表的越界索引 | 检查循环边界,或者集合的 size() 是否小于索引 |
| ClassCastException | 类型转换失败 | 检查 instanceof 判断,或者泛型使用是否正确 |
| IllegalArgumentException | 方法参数不合法 | 检查传入的参数值是否在预期范围内 |
| ArithmeticException | 数学运算异常,比如除零 | 检查除数是否可能为 0 |
4.3.1 NullPointerException
这个异常,我敢说每个 Java 程序员都遇到过。它出现的场景太多了:
- 从 Map 里 get 了一个不存在的 key,返回 null 然后直接调用方法
- 数据库查询结果为空,没做判空处理
- 方法参数传入了 null
我曾经排查过一个线上问题,用户反馈某个页面白屏了。一看日志,全是 NullPointerException。最后发现是缓存里某个字段没初始化,导致后续的链式调用全崩了。
避坑指南:我曾经因为偷懒没做判空,结果线上出了 P0 事故。从那以后,我养成了两个习惯:一是方法返回值尽量用 Optional,二是链式调用前先判空。
4.3.2 IndexOutOfBoundsException
这个异常常见于循环遍历时。你想想看,如果你用 for (int i = 0; i <= list.size(); i++),那最后一次循环 i 等于 list.size(),肯定越界。
正确的写法是 i < list.size()。嗯,这个坑我见过太多次了。
4.3.3 ClassCastException
这个异常通常发生在向下转型时。比如:
Object obj = "hello";
Integer num = (Integer) obj; // 这里会抛 ClassCastException
我建议在转型前先用 instanceof 判断一下。或者用泛型来避免这种问题。
4.4 实战:从异常栈到根因
咱们来看一个真实案例。假设你看到这样的日志:
java.lang.IndexOutOfBoundsException: Index 5 out of bounds for length 5
at java.base/java.util.ArrayList.get(ArrayList.java:435)
at com.example.service.OrderService.calcTotal(OrderService.java:88)
at com.example.controller.OrderController.submit(OrderController.java:32)
第一步,看异常类型:IndexOutOfBoundsException。第二步,看第一行:ArrayList.get() 的第 435 行。第三步,看业务代码:OrderService.java:88。
打开 OrderService.java 第 88 行,发现是:
BigDecimal total = items.get(index).getPrice();
嗯,问题出在 index 这个变量上。它等于 5,但 items 列表只有 5 个元素(索引 0-4)。所以 index = 5 就越界了。
再往上查,发现 index 是从前端传过来的。前端没做校验,后端也没做边界检查。这就是根因。
我的经验:排查异常栈时,别只看第一行。有时候异常是在框架层抛出的,但真正的 bug 在你的业务代码里。多问自己一句:「这个参数是从哪来的?」
好了,异常栈追踪的核心内容就这些。记住:异常栈是你的朋友,不是敌人。读懂了它,你就能快速定位问题,而不是在代码里瞎猜。