主题
界面
主题
《IDEA 与项目创建》 里你已经用过一次断点。本节把调试从"会用"变成"会用来解决问题":拆解程序运行的三个阶段,掌握四种断点与四个单步操作,学会用调用栈理解递归。
调试能力是程序员的分水岭:不会调试的人靠猜和 System.out.println,会调试的人能直接看到程序内部发生了什么。
完成本节后,你应该能够:
main 方法吗?(《IDEA 与项目创建》)javac 与 java 各自的职责吗?(《JDK 与 Java 版本》)System.out.println 调试有三个硬伤:
| 问题 | 具体表现 |
|---|---|
| 看不到中间状态 | 一个表达式套三层调用,你只能打印最终结果,看不到中间值 |
| 必须改代码 | 每换一个观察点就要加一行打印、重新运行 |
| 处理不了时机问题 | 循环第 873 次出错、只有特定参数才复现——打印会淹没在输出里 |
断点调试解决这三点:随时暂停、随时看值、随时继续,而且不改动代码。
① 编译期(javac) 源码 .java ──▶ 字节码 .class 检查语法与类型
② 加载期(类加载器) .class ──▶ JVM 内存中的 Class 对象 加载、链接、初始化
③ 运行期(JVM 执行) 执行字节码,方法调用形成调用栈 异常在这里抛出这个划分能解释很多现象:
ClassNotFoundException 是②的问题,通常是依赖没打进去;NullPointerException、逻辑错误是③的问题,只有调试才能看清。| 断点类型 | 怎么下 | 解决什么问题 |
|---|---|---|
| 行断点 | 行号旁点击 | 常规暂停 |
| 条件断点 | 右键行断点 → 填 Condition | 只在 i == 873 或 userId == null 时停下 |
| 异常断点 | 调试面板 → View Breakpoints → Java Exception Breakpoint | 谁抛的异常、在哪抛的,一停就知道 |
| 方法断点 | 在方法签名行下断点 | 观察某个方法被谁调用、参数是什么 |
条件断点是最被低估的功能:调试循环问题时,它能让你直接跳到出问题的那一次迭代。
| 快捷键 | 动作 | 作用 |
|---|---|---|
F8 | 步过 / Step Over | 执行当前行,不进入被调方法(把调用当成一步) |
F7 | 步入 / Step Into | 进入被调方法内部(要看清实现时用) |
F9 | 继续 / Resume Program | 继续运行到下一个断点 |
⇧F8 | 步出 / Step Out | 从当前方法跳出,回到调用处 |
配套的常用键位:
| 快捷键 | 动作 | 作用 |
|---|---|---|
Alt+F9 | 运行到光标处 / Run to Cursor | 一路跑到光标所在行,中途遇到断点照样停 |
Alt+F8 | 求值 / Evaluate Expression | 暂停时执行任意表达式,不改代码 |
Ctrl+F8 | 切换行断点 / Toggle Line Breakpoint | 断点的开关 |
Ctrl+Shift+F8 | 查看断点 / View Breakpoints | 打开断点管理窗口(含异常断点) |
⇧F9 / ⇧F10 | 调试 / 运行启动 | 以调试 / 运行模式启动当前配置 |
Ctrl+F2 | 停止 / Stop | 结束调试会话 |
键位以你本机 IDEA 为准
上表是 Windows 默认 keymap。若你切换过 Keymap 方案(如 Eclipse)或改过键位,键位会变——按 Ctrl+Shift+A 打开动作搜索,右侧就会显示当前键位。
选择原则:关心被调方法的内部用步入(F7 / Step Into),只关心它的结果用步过(F8 / Step Over)。全都步入会在框架代码里迷路(所以 IDEA 默认会跳过 JDK 与框架的源码)。
每进入一个方法,JVM 就在调用栈上压入一个栈帧(保存参数、局部变量、返回地址);方法返回时弹出。
factorial(1) ← 栈顶(当前正在执行)
factorial(2)
factorial(3)
factorial(4)
factorial(5)
main ← 栈底递归难理解,是因为它的执行顺序是"先进后出":一路压栈到最深处,再从最深处一路返回。在 IDEA 的 Frames 面板里看这个过程,比看十遍文字描述都清楚。
int sum = 0;
for (int i = 1; i <= 5; i++) {
sum += i; // 在这里下断点,每次循环都停
}反复按 F8(步过 / Step Over),观察 i 与 sum 的同步变化。
static long factorial(int n) {
if (n <= 1) {
return 1; // 递归出口
}
return n * factorial(n - 1); // 递归调用
}调试步骤:
return n * factorial(n - 1); 下断点;factorial(5) 一层层堆到 factorial(1);n == 1 时,改按 F8(步过 / Step Over),观察 Frames 面板的栈帧从顶部逐层弹出;n 是独立的,互不干扰——这是理解"每次调用都有自己的局部变量"的最好方式。// 想排查为什么结果不是 200000
for (int i = 0; i < 10; i++) {
new Thread(() -> {
for (int j = 0; j < 20000; j++) {
System.out.println("加一前:" + counter); // 打印让时序更难复现
counter++;
}
}).start();
}打印本身会改变线程的执行时序,把"偶发问题"变成"时而复现时而不复现"。这类问题要用断点(配合 Suspend 策略设为 Thread)或日志框架,而不是 println。
文件:labs/start/src/main/java/cn/kb/learn/DebugPractice.java
package cn.kb.learn;
import java.util.ArrayList;
import java.util.List;
public class DebugPractice {
public static void main(String[] args) {
System.out.println("阶乘 5 = " + factorial(5));
System.out.println("斐波那契前 8 项 = " + fibonacci(8));
System.out.println("奇数 = " + oddNumbers(List.of(1, 2, 3, 4, 5, 6, 7)));
}
static long factorial(int n) {
if (n <= 1) {
return 1;
}
return n * factorial(n - 1);
}
static List<Integer> fibonacci(int count) {
List<Integer> result = new ArrayList<>();
int a = 0;
int b = 1;
for (int i = 0; i < count; i++) {
result.add(a);
int next = a + b;
a = b;
b = next;
}
return result;
}
static List<Integer> oddNumbers(List<Integer> numbers) {
List<Integer> odds = new ArrayList<>();
for (Integer number : numbers) {
if (number % 2 != 0) {
odds.add(number);
}
}
return odds;
}
}运行命令:
javac --release 21 -d start/target/classes start/src/main/java/cn/kb/learn/DebugPractice.java
java -cp start/target/classes cn.kb.learn.DebugPractice预期输出:
阶乘 5 = 120
斐波那契前 8 项 = [0, 1, 1, 2, 3, 5, 8, 13]
奇数 = [1, 3, 5, 7]按顺序做完这四件事,你就掌握了本节的核心:
result.add(a); 下断点,用 F8(步过 / Step Over) 走 8 次,看 a、b、next 三个变量如何"滚动"赋值;i == 5,重新调试,观察程序是否直接停在第六次迭代(不用手点 5 次步过);factorial 的方法签名行下断点,重新调试,看每次进入方法时 n 的值,配合 Frames 面板观察栈的深度变化;numbers.size() - 1 后触发求值,或者输入任意表达式(如 numbers.stream().filter(x -> x > 4).count())——调试时能执行任意代码,这是排查复杂条件的最快方式。原因:代码是旧版本(IDE 没重新编译)、断点被禁用(灰色)、或者调试启动的不是当前模块。修复:先 Build → Rebuild Project,再看断点是否为红色实心。
i = 5 // 错误:这是赋值,等于把 i 改成 5
i == 5 // 正确:这才是判断条件断点的输入框接受的是表达式,写错不报错但行为完全不对。
原因:热交换(HotSwap)只能加方法体,不能改签名。改得多了断点位置会与实际代码不匹配。修复:停止调试 → 重新编译 → 重新开始。
main 里调试,不敢进方法 修复:大胆用 F7(步入 / Step Into) 进入自己的方法;IDEA 默认会跳过 JDK 与第三方库的源码(可在 Settings → Debugger → Stepping 里调整),所以不会"迷路"到 HashMap 内部。
目标:用条件断点 + 调用栈定位一个"只在特定数据下出错"的问题。
步骤:
oddNumbers 传一个包含 null 的列表(如 Arrays.asList(1, null, 3)),运行并观察异常;NullPointerException)重新调试,让程序停在抛异常的精确位置;factorial 做一次 F7(步入 / Step Into) 连按,数一数栈最深有几层。验收方式:能说出第 4 步两个问题的答案,并解释第 5 步的层数与 n 的关系。
扩展任务:把 oddNumbers 改成流式写法(numbers.stream().filter(n -> n % 2 != 0).toList()),在 lambda 里下断点,观察 lambda 的调用栈与普通方法有什么不同。
基础题
代码阅读题
static int mystery(int n) {
if (n == 0) return 0;
return n + mystery(n - 1);
}mystery(4) 返回多少?请描述调试时 Frames 面板会依次出现哪些栈帧。编程题
int maxDepth(Node node) 求二叉树的深度,用调试器验证:单节点树返回 1,空树返回 0,并观察递归过程中调用栈的变化。record Node(int value, Node left, Node right) {}综合任务
提示 1(方向):第 5 题的深度 = 左右子树深度的较大值 + 1,空树返回 0。
提示 2(关键点):第 5 题用 F7(步入 / Step Into) 进入递归调用,注意每层栈帧里的 node 值都不同。
提示 3(接近答案):第 6 题的对比重点不在步数,而在"调试能看到中间状态,打印只能看到你事先想到的状态"。
返回 10(4 + 3 + 2 + 1 + 0)。
Frames 面板从栈顶到栈底依次是:
mystery(0) ← 最先返回
mystery(1)
mystery(2)
mystery(3)
mystery(4)
main每按一次步入(F7 / Step Into)就压入一层,到 n == 0 后开始逐层弹出,每弹出一层就完成一次加法。
package cn.kb.learn;
public class TreeDepth {
record Node(int value, Node left, Node right) {
}
static int maxDepth(Node node) {
if (node == null) {
return 0;
}
return Math.max(maxDepth(node.left), maxDepth(node.right)) + 1;
}
public static void main(String[] args) {
Node leaf = new Node(1, null, null);
Node root = new Node(2, leaf, new Node(3, null, new Node(4, null, null)));
System.out.println("空树深度 = " + maxDepth(null));
System.out.println("单节点深度 = " + maxDepth(leaf));
System.out.println("三节点链深度 = " + maxDepth(root));
}
}预期输出:
空树深度 = 0
单节点深度 = 1
三节点链深度 = 3调试要点:maxDepth 会被调用多次(每个节点一次),在方法签名下方法断点会让你看清"同一层递归里调用了两次自己"。
参考答案的对比结构:
| 维度 | 打印调试 | 断点调试 |
|---|---|---|
| 定位第 873 次迭代 | 加 if 判断再打印,改代码 + 重跑 | 条件断点 i == 873,一次到位 |
| 查看当时的所有变量 | 每个都加一行打印 | 变量面板一屏看完 |
| 临时检查新表达式 | 改代码、重编、重跑 | 求值(Alt+F8)当场算 |
| 排查异常来源 | 层层加打印 | 异常断点直接停在抛出位置 |
核心差别:调试能看"所有状态",打印只能看"你事先想到的状态"。
《读懂 Java 错误信息》 会把报错信息拆解成可操作的线索;进入 S1 后,调试将成为你学习集合、多线程时的常规武器。