主题
界面
主题
报错信息是编译器与 JVM 给你的线索,不是惩罚。新手看到一屏红字会慌,老手看到一屏红字会直接翻到第三行——差别不在聪明,在于是否知道该看哪里。
本节建立一套固定的读错流程,并给出六种最常见异常的"症状 → 病因 → 处方"对照表。
完成本节后,你应该能够:
RuntimeException 与受检异常的区别吗?(不了解也没关系,《异常体系》 会系统讲)同样的报错,两种处理方式:
方式一:把整屏红字复制给 AI 或搜索引擎,等别人告诉你答案。遇到稍不同的情况就卡住。
方式二:先看类型(是哪类问题)→ 再看消息(具体是什么值不对)→ 最后看栈顶的自己的代码(哪一行)。三步下来,八成问题能自己定位。
方式二还有一个隐性收益:你会开始读懂自己写的代码。报错信息里的类名、方法名、行号,都是你自己代码的结构——读错的过程,就是熟悉自己代码的过程。
| 编译错误 | 运行时异常 | |
|---|---|---|
| 什么时候出现 | 保存/编译时,IDE 直接标红 | 程序跑起来以后 |
| 典型例子 | 少了分号、类型不匹配、方法不存在 | NullPointerException、IndexOutOfBoundsException |
| 处理方式 | 必须修掉才能运行,看 IDE 的提示即可 | 需要读栈定位,可能要调试 |
| 信息在哪 | 编辑器的悬浮提示与 Problems 面板 | 控制台输出的异常栈 |
关键差别:编译错误是"语法/类型"层面的,编译器完全知道问题在哪;运行时异常是"逻辑/数据"层面的,只有运行时才知道。
java.lang.NullPointerException: Cannot invoke "String.length()" because "text" is null
└────────── ① ───────────┘ └──────────────── ② ────────────────────────┘
at cn.kb.learn.ErrorCatalog.nullPointer(ErrorCatalog.java:41)
└─────────────────────────── ③ ───────────────────────────────┘| 段 | 内容 | 你的第一反应 |
|---|---|---|
| ① 类型 | 异常类名 | 属于哪一类问题(空值?越界?类型转换?) |
| ② 消息 | 具体的值或描述 | 哪个变量、哪个值出了问题(可能是 null,不是所有异常都有消息) |
| ③ 调用栈 | 从抛异常处向外的方法调用链 | 从上往下读,找到第一个属于你自己包的帧 |
看下面这条真实的栈(来自本节的实验代码):
类型: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 与框架的帧,直到看见自己的包名:
java.base/java.util 里——它们是触发器,不是你写错的地方;cn.kb.learn.ErrorCatalog 是你的代码,行号 66 就是出错位置。记住这个口诀:栈顶看现象,自己的第一帧看原因。
| 异常 | 一句话含义 | 最常见原因 |
|---|---|---|
NullPointerException | 对一个 null 调方法/取字段 | 对象没初始化;方法返回 null 没判断 |
ArrayIndexOutOfBoundsException | 下标越界 | 循环边界写错;集合为空却取第 0 个 |
ClassCastException | 类型转换失败 | instanceof 没判断;泛型擦除导致的隐式转换 |
NumberFormatException | 字符串转数字失败 | 输入含非数字字符;没有 trim 空格 |
IllegalStateException | 对象状态不允许这个操作 | 已注销的账户取款;连接已关闭还继续用 |
UnsupportedOperationException | 这个操作不支持 | 修改 List.of(...) 创建的不可变集合 |
ConcurrentModificationException | 遍历过程中修改了集合 | for-each 里 remove,应改用 removeIf |
IllegalArgumentException(参数不合法)与 IllegalStateException(状态不合法)的区别是高频面试点:前者是"你给我的东西不对",后者是"你给我的时机不对"。
Exception: 订单创建失败
...
Caused by: java.sql.SQLIntegrityConstraintViolationException: Duplicate entry '1001' for key 'PRIMARY'
...框架通常会把底层异常包装一层再抛出。读到 Caused by 就继续往下读——它才是根因。只看最上面一行会一直在外层打转。
String text = null;
text.length();类型: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)。
javac 编译:默认不带,消息里显示的是 <local0> 这类占位名。同一个 NullPointerException,两种消息质量差很多——这也是"团队统一用 Maven 构建"的一个实际好处。
以 ConcurrentModificationException 为例。它的消息是 null(不是所有异常都有消息),此时调用栈是唯一的线索:
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 底层用的是迭代器,遍历中直接改集合会让迭代器失效。
修复方式:
items.removeIf(item -> item.equals("a")); // 用集合自己的安全 API并发修改异常的触发位置很微妙
如果你把 remove 的目标从 "a" 改成 "b"(倒数第二个元素),这个异常可能不会抛出——因为迭代器在删完之后就结束循环了,没有机会检查。
所以"这个 bug 时有时无"往往不是环境问题,而是数据结构与遍历位置的组合问题。遇到这类"偶发",先质疑数据规模与位置,再质疑环境。
try {
processOrder(order);
} catch (Exception ignored) {
// 什么都不做
}这段代码的破坏力远超想象:问题消失了,但业务也坏了。订单没处理成功,日志里什么也没有,用户来投诉时你只能靠猜。
正确的兜底形态至少包含:记录日志(含完整栈)、决定是否继续、必要时转换为业务异常往上抛:
try {
processOrder(order);
} catch (SQLException exception) {
log.error("订单处理失败,orderId={}", order.getId(), exception); // 传异常对象,别只传 message
throw new IllegalStateException("订单处理失败", exception); // 保留因果链
}注意 throw ... (message, exception) 的写法:把原异常作为 cause 传进去,日志里才会出现 Caused by,否则原始根因就永久丢失了。
文件:labs/start/src/main/java/cn/kb/learn/ErrorCatalog.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 模块
}编译与运行:
cd labs
./mvnw -pl start compile
java -cp start/target/classes cn.kb.learn.ErrorCatalog预期输出(节选,真实运行结果):
=== 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 的第一帧就是自己的业务代码。
Java Exception Breakpoint,搜 NullPointerException。重新调试后,程序会在抛异常的精确位置停下,此时 Frames 面板就是完整的调用链——比读控制台输出更直观;catch 块里下断点,展开 exception 变量,看 stackTrace 属性包含的全部帧,数一数从你的代码到抛异常处隔了几层;showcase 方法里对 ConcurrentModificationException 下断点,确认 exception.getMessage() 返回 null——这能帮你记住"不能只依赖消息"。 at java.base/java.lang.Thread.run(Thread.java:840)这是栈底,通常是线程入口,与你的问题基本无关。真正的信息在栈顶和 Caused by 里。
Caused by 外层异常往往只是"包装"。跳过它,直接看 Caused by 链的最底层。
e.printStackTrace() 就用完事 printStackTrace 输出到标准错误流,在容器化部署、日志采集环境里可能完全丢失。生产代码要用日志框架:log.error("描述", e)。
log.error(e.getMessage()) 消息可能是 null(如 CME),也可能不含位置。一定要把异常对象本身传进去,让日志框架打印完整栈。
目标:把一条陌生的异常信息在三分钟内定位到代码行。
步骤:
ErrorCatalog,把六个异常的完整输出保存下来;concurrentModification 的 "a" 改成 "b",重新运行,观察异常是否还抛出,并解释原因。验收方式:能解释第 4 步的差异(这正是"偶发 bug"的典型来源)。
扩展任务:故意制造一次 Caused by 嵌套——在一个方法里捕获 NumberFormatException,包装成 IllegalArgumentException 抛出(用 (message, cause) 构造),观察日志里的因果链。
基础题
getMessage() 一定有值。(对 / 错)IllegalArgumentException 与 IllegalStateException 的区别是什么?报错阅读题
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)代码阅读题
Map<String, Integer> counters = new HashMap<>();
counters.put("a", 1);
int value = counters.get("b"); // ①
System.out.println(value + 1);counters 是 Map<String, String>,这里会发生什么?综合任务
提示 1(方向):第 4 题的重点是"自己代码的第一帧"——第 42 行。
提示 2(关键点):第 5 题要查 Map.get 的返回值约定:键不存在时返回 null。Integer 与 String 在这里的差别是自动拆箱。
提示 3(接近答案):第 5 题第 ① 行不抛异常(返回 null),但下一行 value + 1 会触发拆箱 → NullPointerException。如果 map 的 value 是 String,第 ① 行的赋值会触发 ClassCastException。
ConcurrentModificationException 的消息就是 null。消息是可选信息,调用栈才是必需信息。IllegalArgumentException 表示"传进来的参数不合法"(你给我的东西不对);IllegalStateException 表示"对象当前状态不允许这个操作"(时机不对,比如已注销的账户取款)。出错位置:Report.java 第 42 行(build 方法内),调用方是第 18 行的 main。
修复方向:
instanceof 判断,或用模式匹配 if (value instanceof Integer number);Map<String, Object> 取值的场景,检查上游写数据时放的类型是否一致——类型不一致往往是设计问题,不只是转换问题。第 ① 行不会抛异常:HashMap.get 对不存在的键返回 null。
但下一行 value + 1 会抛 NullPointerException——因为 value 是 Integer,参与算术运算时会自动拆箱成 int,对 null 拆箱就是 NPE。
如果 counters 是 Map<String, String>,第 ① 行就会出现 ClassCastException:泛型只是编译期的约束,若数据是通过 Object 绕进来的(比如反射或原始类型集合),运行时才会暴露类型不匹配。
实用结论:Map.get 之后没做非空判断,是 NPE 的高发区。用 getOrDefault 或 Optional.ofNullable(...) 更稳。
参考结构(以"报错是 UnsupportedOperationException,定位到对 List.of(...) 调用 add"为例):
| 项目 | 内容 |
|---|---|
| 类型 | UnsupportedOperationException:操作不被支持 |
| 消息 | 无 |
| 自己的第一帧 | Demo.main(Demo.java:21) |
| 用时对比 | 当时:搜索 + 试错约 10 分钟;按流程:1 分钟定位到"不可变集合" |
| 固化的习惯 | 先用 List.of 写测试数据,需要修改时立刻换成 new ArrayList<>(...) |
固化的习惯不必多,一条真正执行的比十条口号有用。
(message, cause) 保留因果链。《Markdown 与文档习惯》 会建立记录与整理习惯——排错的过程要留痕,否则同一个坑会踩三次。
暂无