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 如何阅读堆栈信息

我个人的习惯是「三步定位法」:

  1. 看异常类型:先搞清楚是什么异常。NullPointer?还是数组越界?这决定了排查方向。
  2. 看第一行栈帧:异常发生在哪个类的哪一行。这是最直接的线索。
  3. 看业务代码层:忽略框架和容器的栈帧,重点关注你自己的代码。

举个例子,上面那个栈里,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 在你的业务代码里。多问自己一句:「这个参数是从哪来的?」

好了,异常栈追踪的核心内容就这些。记住:异常栈是你的朋友,不是敌人。读懂了它,你就能快速定位问题,而不是在代码里瞎猜。