Skip to content

读懂 Java 错误信息 ​

S0-006必学入门约 45 分钟JDK 21学习准备与环境 · 学习方法与 AI 协作

1. 本节定位 ​

报错信息是编译器与 JVM 给你的线索,不是惩罚。新手看到一屏红字会慌,老手看到一屏红字会直接翻到第三行——差别不在聪明,在于是否知道该看哪里。

本节建立一套固定的读错流程,并给出六种最常见异常的"症状 → 病因 → 处方"对照表。

2. 学习目标 ​

完成本节后,你应该能够:

  • 区分编译错误与运行时异常,分别用不同方式处理;
  • 把一条异常信息拆成"类型 + 消息 + 调用栈"三段,并说清每段的作用;
  • 从调用栈里定位到自己代码的第一帧,而不是被 JDK 内部帧带偏;
  • 认出六种最常见异常的成因,并知道对应的修复方向;
  • 说出"吞异常"为什么比报错更可怕。

3. 前置知识检查 ​

4. 为什么需要这个知识 ​

同样的报错,两种处理方式:

方式一:把整屏红字复制给 AI 或搜索引擎,等别人告诉你答案。遇到稍不同的情况就卡住。

方式二:先看类型(是哪类问题)→ 再看消息(具体是什么值不对)→ 最后看栈顶的自己的代码(哪一行)。三步下来,八成问题能自己定位。

方式二还有一个隐性收益:你会开始读懂自己写的代码。报错信息里的类名、方法名、行号,都是你自己代码的结构——读错的过程,就是熟悉自己代码的过程。

5. 核心概念 ​

5.1 两类错误:编译错误与运行时异常 ​

编译错误运行时异常
什么时候出现保存/编译时,IDE 直接标红程序跑起来以后
典型例子少了分号、类型不匹配、方法不存在NullPointerException、IndexOutOfBoundsException
处理方式必须修掉才能运行,看 IDE 的提示即可需要读栈定位,可能要调试
信息在哪编辑器的悬浮提示与 Problems 面板控制台输出的异常栈

关键差别:编译错误是"语法/类型"层面的,编译器完全知道问题在哪;运行时异常是"逻辑/数据"层面的,只有运行时才知道。

5.2 异常信息的三段结构 ​

text
java.lang.NullPointerException: Cannot invoke "String.length()" because "text" is null
└────────── ① ───────────┘  └──────────────── ② ────────────────────────┘
	at cn.kb.learn.ErrorCatalog.nullPointer(ErrorCatalog.java:41)
	└─────────────────────────── ③ ───────────────────────────────┘
段内容你的第一反应
① 类型异常类名属于哪一类问题(空值?越界?类型转换?)
② 消息具体的值或描述哪个变量、哪个值出了问题(可能是 null,不是所有异常都有消息)
③ 调用栈从抛异常处向外的方法调用链从上往下读,找到第一个属于你自己包的帧

5.3 读调用栈的正确姿势 ​

看下面这条真实的栈(来自本节的实验代码):

text
类型:java.util.ConcurrentModificationException
消息:null
调用栈前 3 帧:
  1. java.base/java.util.ArrayList$Itr.checkForComodification(ArrayList.java:1096)
  2. java.base/java.util.ArrayList$Itr.next(ArrayList.java:1050)
  3. cn.kb.learn.ErrorCatalog.concurrentModification(ErrorCatalog.java:66)

读法是从上往下扫,跳过 JDK 与框架的帧,直到看见自己的包名:

  • 第 1、2 帧都在 java.base/java.util 里——它们是触发器,不是你写错的地方;
  • 第 3 帧 cn.kb.learn.ErrorCatalog 是你的代码,行号 66 就是出错位置。

记住这个口诀:栈顶看现象,自己的第一帧看原因。

5.4 常见异常词典 ​

异常一句话含义最常见原因
NullPointerException对一个 null 调方法/取字段对象没初始化;方法返回 null 没判断
ArrayIndexOutOfBoundsException下标越界循环边界写错;集合为空却取第 0 个
ClassCastException类型转换失败instanceof 没判断;泛型擦除导致的隐式转换
NumberFormatException字符串转数字失败输入含非数字字符;没有 trim 空格
IllegalStateException对象状态不允许这个操作已注销的账户取款;连接已关闭还继续用
UnsupportedOperationException这个操作不支持修改 List.of(...) 创建的不可变集合
ConcurrentModificationException遍历过程中修改了集合for-each 里 remove,应改用 removeIf

IllegalArgumentException(参数不合法)与 IllegalStateException(状态不合法)的区别是高频面试点:前者是"你给我的东西不对",后者是"你给我的时机不对"。

5.5 Caused by:异常的因果链 ​

text
Exception: 订单创建失败
    ...
Caused by: java.sql.SQLIntegrityConstraintViolationException: Duplicate entry '1001' for key 'PRIMARY'
    ...

框架通常会把底层异常包装一层再抛出。读到 Caused by 就继续往下读——它才是根因。只看最上面一行会一直在外层打转。

6. 递进示例 ​

6.1 最小示例:读懂一个空指针 ​

java
String text = null;
text.length();
text
类型:java.lang.NullPointerException
消息:Cannot invoke "String.length()" because "text" is null
调用栈前 3 帧:
  1. cn.kb.learn.ErrorCatalog.nullPointer(ErrorCatalog.java:41)

三段信息读下来:调 length() 时 text 是 null,位置在第 41 行。修复动作只有两个可能——要么让 text 不为 null,要么在调用前判断。

为什么你的报错里可能显示 <local0> 而不是 text

JDK 15 之后,NPE 消息会告诉你"哪个变量是 null",但前提是编译时带上了局部变量调试信息(javac -g)。

  • 用 Maven 或 IDE 编译:默认就带,消息里能看到变量名;
  • 用裸 javac 编译:默认不带,消息里显示的是 <local0> 这类占位名。

同一个 NullPointerException,两种消息质量差很多——这也是"团队统一用 Maven 构建"的一个实际好处。

6.2 业务示例:从栈到自己的代码 ​

以 ConcurrentModificationException 为例。它的消息是 null(不是所有异常都有消息),此时调用栈是唯一的线索:

java
List<String> items = new ArrayList<>(List.of("a", "b", "c"));
for (String item : items) {
    if (item.equals("a")) {
        items.remove(item);       // 第 66 行
    }
}

栈的第 3 帧指向第 66 行——正是 items.remove(item)。原因也很明确:for-each 底层用的是迭代器,遍历中直接改集合会让迭代器失效。

修复方式:

java
items.removeIf(item -> item.equals("a"));   // 用集合自己的安全 API

并发修改异常的触发位置很微妙

如果你把 remove 的目标从 "a" 改成 "b"(倒数第二个元素),这个异常可能不会抛出——因为迭代器在删完之后就结束循环了,没有机会检查。

所以"这个 bug 时有时无"往往不是环境问题,而是数据结构与遍历位置的组合问题。遇到这类"偶发",先质疑数据规模与位置,再质疑环境。

6.3 反例:把异常吞掉 ​

java
try {
    processOrder(order);
} catch (Exception ignored) {
    // 什么都不做
}

这段代码的破坏力远超想象:问题消失了,但业务也坏了。订单没处理成功,日志里什么也没有,用户来投诉时你只能靠猜。

正确的兜底形态至少包含:记录日志(含完整栈)、决定是否继续、必要时转换为业务异常往上抛:

java
try {
    processOrder(order);
} catch (SQLException exception) {
    log.error("订单处理失败,orderId={}", order.getId(), exception);   // 传异常对象,别只传 message
    throw new IllegalStateException("订单处理失败", exception);         // 保留因果链
}

注意 throw ... (message, exception) 的写法:把原异常作为 cause 传进去,日志里才会出现 Caused by,否则原始根因就永久丢失了。

7. 完整可运行代码 ​

文件:labs/start/src/main/java/cn/kb/learn/ErrorCatalog.java

java
package cn.kb.learn;

import java.util.ArrayList;
import java.util.List;

public class ErrorCatalog {
    public static void main(String[] args) {
        showcase("NullPointerException", ErrorCatalog::nullPointer);
        showcase("ArrayIndexOutOfBoundsException", ErrorCatalog::arrayIndex);
        showcase("ClassCastException", ErrorCatalog::classCast);
        showcase("NumberFormatException", ErrorCatalog::numberFormat);
        showcase("IllegalStateException", ErrorCatalog::illegalState);
        showcase("ConcurrentModificationException", ErrorCatalog::concurrentModification);
    }

    private static void showcase(String label, Runnable action) {
        System.out.println("=== " + label + " ===");
        try {
            action.run();
            System.out.println("(未抛出异常)");
        } catch (RuntimeException exception) {
            System.out.println("类型:" + exception.getClass().getName());
            System.out.println("消息:" + exception.getMessage());
            System.out.println("调用栈前 3 帧:");
            StackTraceElement[] frames = exception.getStackTrace();
            for (int i = 0; i < Math.min(3, frames.length); i++) {
                System.out.println("  " + (i + 1) + ". " + frames[i]);
            }
        }
        System.out.println();
    }

    // 省略:六个演示方法分别触发一类异常,完整代码见仓库 labs/start 模块
}

编译与运行:

powershell
cd labs
./mvnw -pl start compile
java -cp start/target/classes cn.kb.learn.ErrorCatalog

预期输出(节选,真实运行结果):

text
=== NullPointerException ===
类型:java.lang.NullPointerException
消息:Cannot invoke "String.length()" because "text" is null
调用栈前 3 帧:
  1. cn.kb.learn.ErrorCatalog.nullPointer(ErrorCatalog.java:41)
  2. cn.kb.learn.ErrorCatalog.showcase(ErrorCatalog.java:23)
  3. cn.kb.learn.ErrorCatalog.main(ErrorCatalog.java:12)

=== NumberFormatException ===
类型:java.lang.NumberFormatException
消息:For input string: "12a"
调用栈前 3 帧:
  1. java.base/java.lang.NumberFormatException.forInputString(NumberFormatException.java:67)
  2. java.base/java.lang.Integer.parseInt(Integer.java:565)
  3. java.base/java.lang.Integer.parseInt(Integer.java:662)

=== IllegalStateException ===
类型:java.lang.IllegalStateException
消息:账户已注销,无法取款:100
调用栈前 3 帧:
  1. cn.kb.learn.ErrorCatalog$ClosedAccount.withdraw(ErrorCatalog.java:80)
  2. cn.kb.learn.ErrorCatalog.illegalState(ErrorCatalog.java:61)
  3. cn.kb.learn.ErrorCatalog.showcase(ErrorCatalog.java:23)

=== ConcurrentModificationException ===
类型:java.util.ConcurrentModificationException
消息:null
调用栈前 3 帧:
  1. java.base/java.util.ArrayList$Itr.checkForComodification(ArrayList.java:1096)
  2. java.base/java.util.ArrayList$Itr.next(ArrayList.java:1050)
  3. cn.kb.learn.ErrorCatalog.concurrentModification(ErrorCatalog.java:66)

对照着看这三段栈,你能体会到本节的核心技巧:NumberFormatException 的前三帧全是 JDK 内部,需要继续往下翻才能看到自己的代码;IllegalStateException 的第一帧就是自己的业务代码。

8. Debug 观察点 ​

  1. 异常断点:在调试面板的 View Breakpoints 里添加 Java Exception Breakpoint,搜 NullPointerException。重新调试后,程序会在抛异常的精确位置停下,此时 Frames 面板就是完整的调用链——比读控制台输出更直观;
  2. 看完整栈:在 catch 块里下断点,展开 exception 变量,看 stackTrace 属性包含的全部帧,数一数从你的代码到抛异常处隔了几层;
  3. 验证消息为 null:在 showcase 方法里对 ConcurrentModificationException 下断点,确认 exception.getMessage() 返回 null——这能帮你记住"不能只依赖消息"。

9. 常见错误 ​

错误 1:只看最后一行 ​

text
	at java.base/java.lang.Thread.run(Thread.java:840)

这是栈底,通常是线程入口,与你的问题基本无关。真正的信息在栈顶和 Caused by 里。

错误 2:忽略 Caused by ​

外层异常往往只是"包装"。跳过它,直接看 Caused by 链的最底层。

错误 3:e.printStackTrace() 就用完事 ​

printStackTrace 输出到标准错误流,在容器化部署、日志采集环境里可能完全丢失。生产代码要用日志框架:log.error("描述", e)。

错误 4:只 log.error(e.getMessage()) ​

消息可能是 null(如 CME),也可能不含位置。一定要把异常对象本身传进去,让日志框架打印完整栈。

10. 动手实验 ​

目标:把一条陌生的异常信息在三分钟内定位到代码行。

步骤:

  1. 运行 ErrorCatalog,把六个异常的完整输出保存下来;
  2. 对每一个异常,写下:类型属于哪类问题、消息告诉了你什么、出错的是你代码的第几行(找不到就写"JDK 内部");
  3. 用异常断点重新跑一遍,验证第 2 步的结论;
  4. 把 concurrentModification 的 "a" 改成 "b",重新运行,观察异常是否还抛出,并解释原因。

验收方式:能解释第 4 步的差异(这正是"偶发 bug"的典型来源)。

扩展任务:故意制造一次 Caused by 嵌套——在一个方法里捕获 NumberFormatException,包装成 IllegalArgumentException 抛出(用 (message, cause) 构造),观察日志里的因果链。

11. 练习题 ​

基础题

  1. 判断:运行时异常的 getMessage() 一定有值。(对 / 错)
  2. 填空:异常信息三段是 、、______。
  3. 简答:IllegalArgumentException 与 IllegalStateException 的区别是什么?

报错阅读题

text
Exception in thread "main" java.lang.ClassCastException: class java.lang.String cannot be cast to class java.lang.Integer
	at cn.kb.learn.Report.build(Report.java:42)
	at cn.kb.learn.Report.main(Report.java:18)
  1. 出错位置在哪一行?修复方向有哪几种可能?

代码阅读题

java
Map<String, Integer> counters = new HashMap<>();
counters.put("a", 1);
int value = counters.get("b");          // ①
System.out.println(value + 1);
  1. 第 ① 行会不会抛异常?为什么?如果 counters 是 Map<String, String>,这里会发生什么?

综合任务

  1. 复盘你最近一次遇到(或看到)的报错:
    • 抄下完整错误信息;
    • 按"三段结构"逐段标注;
    • 写出你实际花的时间、以及用本节流程本应花的时间;
    • 总结一条你以后要固化的排查习惯。
分级提示

提示 1(方向):第 4 题的重点是"自己代码的第一帧"——第 42 行。

提示 2(关键点):第 5 题要查 Map.get 的返回值约定:键不存在时返回 null。Integer 与 String 在这里的差别是自动拆箱。

提示 3(接近答案):第 5 题第 ① 行不抛异常(返回 null),但下一行 value + 1 会触发拆箱 → NullPointerException。如果 map 的 value 是 String,第 ① 行的赋值会触发 ClassCastException。

12. 参考答案 ​

基础题
  1. 错。ConcurrentModificationException 的消息就是 null。消息是可选信息,调用栈才是必需信息。
  2. 类型、消息、调用栈。
  3. IllegalArgumentException 表示"传进来的参数不合法"(你给我的东西不对);IllegalStateException 表示"对象当前状态不允许这个操作"(时机不对,比如已注销的账户取款)。
报错阅读题(第 4 题)

出错位置:Report.java 第 42 行(build 方法内),调用方是第 18 行的 main。

修复方向:

  1. 检查第 42 行做了哪种强制转换,确认源对象的实际类型;
  2. 转换前加 instanceof 判断,或用模式匹配 if (value instanceof Integer number);
  3. 如果是从 Map<String, Object> 取值的场景,检查上游写数据时放的类型是否一致——类型不一致往往是设计问题,不只是转换问题。
代码阅读题(第 5 题)

第 ① 行不会抛异常:HashMap.get 对不存在的键返回 null。

但下一行 value + 1 会抛 NullPointerException——因为 value 是 Integer,参与算术运算时会自动拆箱成 int,对 null 拆箱就是 NPE。

如果 counters 是 Map<String, String>,第 ① 行就会出现 ClassCastException:泛型只是编译期的约束,若数据是通过 Object 绕进来的(比如反射或原始类型集合),运行时才会暴露类型不匹配。

实用结论:Map.get 之后没做非空判断,是 NPE 的高发区。用 getOrDefault 或 Optional.ofNullable(...) 更稳。

综合任务(第 6 题)

参考结构(以"报错是 UnsupportedOperationException,定位到对 List.of(...) 调用 add"为例):

项目内容
类型UnsupportedOperationException:操作不被支持
消息无
自己的第一帧Demo.main(Demo.java:21)
用时对比当时:搜索 + 试错约 10 分钟;按流程:1 分钟定位到"不可变集合"
固化的习惯先用 List.of 写测试数据,需要修改时立刻换成 new ArrayList<>(...)

固化的习惯不必多,一条真正执行的比十条口号有用。

13. 掌握标准 ​

  • [ ] 能把一条异常信息拆成三段并说清每段的作用;
  • [ ] 能跳过 JDK 帧、定位到自己代码的第一帧;
  • [ ] 记住六种常见异常的成因与修复方向;
  • [ ] 知道"吞异常"的危害,并会用 (message, cause) 保留因果链。

14. 扩展资源 ​

15. 下一步学习 ​

《Markdown 与文档习惯》 会建立记录与整理习惯——排错的过程要留痕,否则同一个坑会踩三次。

后续知识

暂无

进度只保存在本机浏览器,可随时清除

个人学习项目:AI 生成内容均经人工复核,第三方资源仅作索引