读完这篇,你能做什么
能在 IDE 和终端得到一致输出,并用断点找到变量首次偏离预期的位置。
学习理由、准备要求与知识点
为什么学它
调试器让执行状态可见,是定位代码错误而不反复猜测的基本工具。
先读这些内容
知识点清单
- IDE、终端、JDK 分工
- 项目 SDK 与运行配置
- 工作目录和参数
- 断点与条件断点
- 单步进入和越过
- 变量与调用栈
- 调试信息 javac -g
- 可重复排错记录
运行前先确认Java 17;源码在本机 Microsoft OpenJDK 17.0.20.1 src.zip 核验,在线链接为上游 jdk-17.0.20-ga。深入模块可在掌握基础后回读。
01先弄懂它是什么
IDE 能把写代码、编译、运行和调试放在一个界面,但每一步仍有各自的设置。打开 Hello.java 所在项目后,先核对 Project SDK 是 JDK 17,再检查运行配置中的主类和工作目录。IDE 内的终端通常继承启动时的环境变量。一个窗口能运行、另一个不能时,先比较 JDK 路径和启动参数,再判断是不是代码问题。
调试让你在执行途中停下来查看真实数据。断点决定在哪里暂停;变量窗口显示当前这次方法调用里的变量;调用栈显示是谁一路调用到这里。单步跳过通常执行完当前语句涉及的方法,单步进入则进入可调试的方法调用,继续运行会走到下一次暂停。先预测下一步变量应该是什么,再逐步核对,比反复猜着改更容易找到偏差。
练习时可以把整数求和的循环条件故意写错,在循环体设置断点,观察下标最后一次取值。不要在未理解原因时直接修改正在运行的变量,因为这样得到的路径可能无法复现。断点命中也不等于找到了根因:被观察的异常状态可能由更早的输入造成。记录最小输入、期望结果、实际结果和第一处偏差,再从偏差向上追溯。
文中这几个词,先认一认
- 栈帧
- 某一次方法调用自己的执行记录,包含这次调用的局部变量等信息。
- 断点
- 告诉调试器在某行或某个条件满足时暂停,让你检查当时的状态。
- 工作目录
- 程序解释相对路径时使用的起点,不一定是 Java 源文件所在的文件夹。
02看看它怎么工作
运行配置决定启动哪个类、传哪些参数、使用哪个运行时,并可能指定环境变量。编辑器显示的源文件还需与实际加载的字节码对应;如果运行的是旧构建产物,光看代码会产生错觉。可以清楚地重新编译,再检查输出目录与运行目标,避免把配置差异误判成语言行为。不同 IDE 版本的菜单可能移动,但这些配置概念不会因此合并。
暂停程序后,先确认停住的是哪条线程,再选要看的那一次方法调用;调试器把每次调用显示为一个栈帧。调用者和被调用者可能都有名叫 count 的变量,选错栈帧就会看错值。观察表达式也可能改变程序:调用 getter 可能临时读取数据库等尚未加载的数据,这叫懒加载;自定义 toString 也可能执行额外逻辑。初学时先看简单字段和局部变量,避免在调试器里调用会修改数据的方法。
异常堆栈通常是 Throwable 创建时保存的调用现场,不是 catch 捕获异常那一刻的完整现场。沿着堆栈可以找出当时的方法调用关系,但它不是整个业务的时间线;异常对象后来再次被抛出,创建位置与抛出位置也可能不同。
包装异常时保留 cause,才能继续查看原来的原因。代码还可以重新填充或设置堆栈,少数异常也可能不记录堆栈。因此断点用来观察当前暂停状态,异常记录用来追查它保存下来的现场,两者应一起看。
正在加载流程图…
查看流程图源码(Mermaid)
flowchart TD
A["运行配置"] --> B["加载字节码并执行"]
B --> C["断点暂停"]
C --> D["选择线程和栈帧"]
D --> E["比较变量与预期"]
B --> F["异常记录栈快照"]
F --> G["阅读 cause 与业务调用位置"]按顺序看,每一步做了什么
- 固定输入和预期输出。
- 核对运行配置与实际编译产物。
- 在偏差发生前设置断点。
- 选择正确线程和调用栈帧。
- 逐步比较状态变化,修复后离开调试器重新运行。
03对照实现代码
Microsoft OpenJDK 17.0.20.1;java.base/java/lang/Throwable.java · java.lang.Throwable#getStackTrace()
以下为原创 Java 17 教学实现,不是原样 JDK 源码;只保留文中明确讨论的机制,省略优化、兼容及内部接口。
public final class SourceModel {
private final StackTraceElement[] snapshot;
public SourceModel(StackTraceElement[] frames) {
// 保存副本,调用者后续修改输入数组不会替换内部帧。
snapshot = frames.clone();
}
public StackTraceElement[] getStackTrace() {
// 返回新数组,防止观察者直接修改内部快照。
return snapshot.clone();
}
}本机 Throwable#getStackTrace 的实现先取得内部栈表示,再返回克隆数组。内部访问还包含按需构造栈元素的逻辑;教学模型直接接受已有帧,不模拟虚拟机如何捕获调用栈。理解这段代码的关键是区分数组容器与数组中的帧对象,而不是把模型当成一个真实调试器。
克隆数组会创建独立的槽位集合,所以调用者把返回数组的某项设为 null,不会通过这次赋值替换 Throwable 内部保存的项。这是防御性复制的一种用法。复制的目的并不是重新采集一次当前调用栈,也不意味着对所有可能嵌套对象都做了递归深复制。
真实实现的内部栈访问带有同步和特殊状态处理,以支持尚未初始化或不可写栈等情形。模型没有这些状态,因此只能解释普通数组隔离。阅读源码时应明确自己关注的是公开方法返回值的封装边界,不能由几行模型推断异常对象的全部线程安全性质。
调试器所显示的活动调用栈通过调试接口获取,不是简单调用每个方法的 getStackTrace。把两者类比可以帮助理解帧的排列,却不能认为异常快照会随程序继续执行而自动更新。真实系统里日志栈、追踪链路和调试现场应相互印证,单一快照无法还原全部异步调用。
代码先调用谁,再调用谁
- 异常构造等流程记录栈信息
getStackTrace请求公开栈表示getOurStackTrace按需准备元素- clone 隔离返回数组与内部数组
04动手写一个例子
在线检查调用进入与退出是否匹配
事件使用 +方法 表示进入,-方法 表示退出。检查同步调用日志是否满足后进先出,返回 OK、首个错误位置或未退出状态。空日志合法;这只是单线程理想日志模型。
- 输入 1
+main,+sum,-sum,-main- 输出 1
OK
- 输入 2
+main,-sum- 输出 2
ERROR_2
- 输入 3
+main- 输出 3
UNCLOSED
嵌套方法调用像一摞盘子,最后进入的方法应先退出。用双端队列的头部作栈顶,进入时压入名字,退出时检查名字是否与栈顶一致。只计数无法发现 main 与 sum 退出顺序颠倒,因此状态必须保留尚未退出的方法次序。
处理退出前先检查栈是否为空,避免多余退出事件变成队列异常。然后比较名称,发现首个不匹配就返回事件位置,不继续猜测如何修复。所有事件处理完后,栈为空才表示闭合;仍有名字说明日志缺失了退出事件,或者输入代表尚在执行的片段。
每个事件至多压栈或弹栈一次。方法名字比较还取决于名字长度,所以复杂度用总字符数描述更准确。真实线程日志可能交错,需要先按线程及执行编号分组;异常展开可能没有普通退出日志,这时应改变事件协议,不能直接指责业务调用违反了栈纪律。
为什么这样写是对的栈从顶到底恰好保存已进入但尚未匹配退出的方法,顺序为由内向外。
每个事件读取一次,每个名字最多入栈出栈一次;substring 与相等比较的字符成本计入 L,最大栈深度不超过事件数。
保存为 Main.java,运行 javac --release 17 -encoding UTF-8 Main.java,再运行 java Main。程序包含固定输入自测,断言失败时会抛出错误。
展开收起代码 · Main.java · 完整可运行程序
长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。
import java.util.ArrayDeque;
public class Main {
static String verify(String... events) {
if (events == null) throw new IllegalArgumentException("events");
// 栈顶始终是最后进入、尚未退出的方法。
ArrayDeque<String> stack = new ArrayDeque<>();
for (int i = 0; i < events.length; i++) {
String e = events[i];
if (e == null || e.length() < 2) return "ERROR_" + (i + 1);
// 名字只有在标记后存在内容时才可提取。
String name = e.substring(1);
if (e.charAt(0) == '+') stack.push(name);
else if (e.charAt(0) == '-') {
// 多余退出与退出名称错位都属于协议错误。
if (stack.isEmpty() || !stack.pop().equals(name))
return "ERROR_" + (i + 1);
} else return "ERROR_" + (i + 1);
}
return stack.isEmpty() ? "OK" : "UNCLOSED";
}
static void check(String want, String... events) {
String got = verify(events);
if (!want.equals(got)) throw new AssertionError(want + " != " + got);
}
public static void main(String[] args) {
// 正常、空输入、错位、未闭合与格式错误逐一自测。
check("OK", "+main", "+sum", "-sum", "-main");
check("OK");
check("ERROR_1", "-main");
check("ERROR_2", "+main", "-sum");
check("UNCLOSED", "+main");
check("ERROR_1", "?main");
check("ERROR_1", (String)null);
System.out.println("PASS 7");
}
}这些特殊情况也要试一试
- 空日志合法。
- 退出先于进入时立即报错。
- 递归方法同名可重复压栈。
- 异步跨线程调用不符合本模型前提。
进阶选读 · 可选深入:跨线程调用链重建
场景异步任务在其它线程继续执行,单个调用栈无法覆盖整个请求。
做法使用请求与任务关联标识构建因果图,把线程内栈与任务边相连;对缺失或乱序事件标注不确定性。
复杂度建图 O(V+E),排序取决于时间或序列键。
好处与代价观察关联需要传播上下文并付出采集成本,不能仅用墙上时间判断因果。
05项目里怎么用
生产排错先保留可复现证据:版本、请求标识、输入摘要和异常链。日志中不要记录完整凭证或大量个人数据;字段必须足以关联一次失败,又不能把排错便利等同于无限采集。常规故障优先使用有结构的日志与指标,必要时才在受控环境进行更强的现场观察。
断点会改变线程的运行和等待时间,所以一调试就不再出现的并发问题,不能据此认定已修复。条件断点、复杂表达式和大量异常断点也可能拖慢程序。改完后要用正常运行方式复测原输入;涉及并发或计时的,还要保留对应复现和负载条件。
IDE 的便利应服务于明确假设。搜索引用用来确认调用范围,跳转定义用来核实实际实现,重命名重构用来维护符号关系;它们不能替代测试。对于日志调用模型,教学算法只判断格式与嵌套,实际排错还需核对采集是否完整、线程是否混合以及日志对应哪个版本。模型成功只能证明这些输入符合约定,不能说明全部业务执行正确。
容易踩的坑
- 只看变量值而未确认线程和栈帧。
- 在观察表达式中调用会修改状态的方法。
- 忽略 cause,只复制最外层异常名称。
- 用断点下的时序推断正常并发行为。
几种做法,怎么选
表格有四列,窄屏可左右滑动查看代价和适用条件。
| 方案 | 优势 | 代价与限制 | 适用条件 |
|---|---|---|---|
| 日志 | 低侵入且可保留历史 | 需预先设计字段并控制数据量 | 线上持续观察 |
| 断点调试 | 能检查局部状态与调用上下文 | 暂停改变时序且需要可调试目标 | 本地可复现逻辑错误 |
| 最小测试 | 可自动重复并固定期望 | 需构造相关前提,不能覆盖所有环境 | 回归验证与边界检查 |
06检验是否读懂了
先自己想、自己写,再展开答案核对。场景题是假设的练习情境;遇到还没学过的内容,可以做完基础后再回来。
先用自己的话解释
01工作目录与源码目录有什么区别?
展开参考答案与推导
工作目录决定相对路径如何解析,源码目录决定编译输入位置。IDE 可将二者配置为不同位置;读取失败时应输出或检查解析后的路径,不能只凭源文件所在位置猜测。
02异常栈为什么不是完整执行录像?
展开参考答案与推导
它记录特定时刻的调用关系,不保留此前所有变量变化,也不天然跨越异步线程。要还原历史还需日志、输入与关联标识,不能从一条栈推断全部业务过程。
03为什么 getStackTrace 返回克隆数组?
展开参考答案与推导
为了隔离内部容器,调用者修改返回数组槽位不会直接替换内部快照。它不重新采集当前栈,也不表示对整个对象图深复制;复制范围必须按代码和类型解释。
换个情况,还会处理吗
01断点里变量正常,继续运行结果却错了怎么办?
展开参考答案与推导
检查观察位置是否早于错误,是否还有其它线程修改数据,以及断点是否改变了时序。缩小到首次偏差并在不暂停的运行方式下复现,不能把一次观察当成全程保证。
02修改源码后断点显示行不匹配怎么办?
展开参考答案与推导
确认已重新编译、实际启动的是该模块产物,且附加的源码与加载字节码版本一致。先核对运行目标,再考虑缓存问题;直接删除全部环境通常会丢失更有用的证据。
03调用日志包含多个线程为什么误报?
展开参考答案与推导
一个栈只能描述一条同步调用序列。先按线程和执行实例分组,再检查各自嵌套;跨线程派生任务用因果边关联,不能把全局时间排序后的日志当成单栈。
写代码试一试
01为递归调用添加自测
- 输入 / 条件
+f,+f,-f,-f- 输出 / 预期
OK
提示 相同名字仍代表不同层。
展开参考答案与推导
调用 check("OK", "+f", "+f", "-f", "-f")。每次进入都压栈,因此同名不需要去重;去重会破坏递归深度,导致第二次退出误报。
02限制最大调用深度
- 输入 / 条件
+a,+b;上限 1- 输出 / 预期
DEPTH_LIMIT
提示 压栈前检查当前大小。
展开参考答案与推导
进入分支先判断 stack.size() 是否已达到上限,达到则拒绝事件;参数还应拒绝负数。空日志与零上限可以合法,第一项进入时才超过限制。
03返回尚未退出方法的数量
- 输入 / 条件
+a,+b- 输出 / 预期
2
提示 处理完成后读取栈大小。
展开参考答案与推导
成功扫描后返回 stack.size() 能表示当前嵌套深度,但不能用这个数代替完整匹配验证,因为同样深度可能含有错误退出顺序。保留中途格式与名称检查。
读完之后,独立完成
课后作业:自己做一遍
本篇共 10 道作业,先完成 8 道基础与综合任务,再按前置知识挑战进阶题。其中包含 1 道思维拓展,试着换个条件,看看原来的办法是否还管用。不附参考答案。
能在 IDE 和终端得到一致输出,并用断点找到变量首次偏离预期的位置。
本篇重点加练
重点加练运行参数与复现记录,使同一故障能够从终端和 IDE 被重新定位。
- 工作目录和参数
- 可重复排错记录
重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。
先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。
基础必做
- 作业 01原理问答
把“编辑文件、编译、调试、执行程序”分配给 IDE、终端与 JDK 中的实际参与者;记录项目 SDK 和运行配置使用的 JDK,说明它们是否由同一设置决定。
- 提交内容
- 职责图和本机有效设置记录。
- 怎样算完成
- 区分界面与执行工具
- 使用实际设置作证据
练到的知识点IDE、终端、JDK 分工 · 项目 SDK 与运行配置
- 作业 02排错实验
编写打印 args 与当前工作目录的最小程序,在 IDE 和终端分别传入两个参数运行;调整一个运行配置后再次记录,不改变程序代码。
- 提交内容
- 源文件、自写参数检查和两种入口运行记录。
- 怎样算完成
- 参数与工作目录分别观察
- 变化可追溯到单一配置
练到的知识点工作目录和参数
综合应用
- 作业 03代码推演
使用本篇已有的调试示例,在一个已学过的变量取指定值时触发条件断点,分别使用单步进入与越过;暂停前先预测当前位置和下一条语句,记录实际行为,无需自行添加循环。
- 提交内容
- 断点条件、预测表与调试观察记录。
- 怎样算完成
- 能解释暂停位置
- 调试按钮操作与实际执行位置对应
练到的知识点断点与条件断点 · 单步进入和越过
- 作业 04排错实验
用三层方法调用传递金额,故意在中间层使用错误变量;分别以普通编译和 javac -g 编译调试,记录变量和调用栈可见信息的差异。
- 提交内容
- 最小故障程序、自写失败测试及调试记录。
- 怎样算完成
- 从输入追踪至错误产生点
- 调试信息差异由真实观察支持
练到的知识点变量与调用栈 · 调试信息 javac -g
- 作业 05排错实验
复制本篇已有的最小调试示例,只改一个传入参数或已有表达式制造行为偏差。自行记录两组输入应有的表现与实际行为,再仅凭排错笔记重新定位和修复;不要求新增数组、循环或框架。
- 提交内容
- 故障版与修复版、自写输入检查表、运行证据和排错笔记。
- 怎样算完成
- 记录足以独立复现
- 修复后重新执行两组检查
- 代码改动与问题原因对应
练到的知识点断点与条件断点 · 变量与调用栈 · 可重复排错记录
- 作业 06排错实验
使用本篇参数打印示例,传入“Java SE”这个含空格的单个参数,程序文件也放在含空格目录中;比较 IDE 与终端的配置表达,记录实际参数个数和内容。
- 提交内容
- 两种入口配置、自写参数核对表与运行证据。
- 怎样算完成
- 程序路径和参数边界分别核验
- 记录足以独立复现而无需猜引号
练到的知识点工作目录和参数 · 可重复排错记录
- 作业 07设计评审
两个同名项目在不同目录,IDE 的运行配置可能指向另一份输出。制定核验项目位置、主类和输出目录的最少步骤,不新增程序功能。
- 提交内容
- 运行对象核验清单。
- 怎样算完成
- 记录能识别具体副本
- 源文件与运行产物关系明确
练到的知识点工作目录和参数
- 作业 08排错实验
对已有示例选择一个可重复错误,先仅写“程序不对”的记录,再改成包含环境、步骤、观察和范围的记录;隔一段时间仅凭第二份重现。
- 提交内容
- 两版问题记录、自写复现核对表与运行证据。
- 怎样算完成
- 问题能被另一人独立重现
- 未观察到的信息明确留空
练到的知识点可重复排错记录
进阶挑战 · 掌握前置后选做
- 作业 09设计评审
完成文件读取后:程序在 IDE 正常、终端异常。设计收集路径、参数、环境与异常信息的诊断报告模板,要求不记录凭据或整份个人文件。
- 提交内容
- 诊断模板与一份脱敏实例。
- 怎样算完成
- 环境差异可比较
- 信息范围与排错目标相关
练到的知识点IDE、终端、JDK 分工 · 工作目录和参数 · 可重复排错记录
思维拓展
先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。
- 作业 10思维拓展 · 开放探究
如果不能截图 IDE,也不能发送整个项目,如何用最少文本让别人重现“终端正常、IDE 异常”?给出受信息预算限制的报告并验证是否足够。
- 提交内容
- 不超过二十行的报告和独立复现反馈。
- 怎样算完成
- 信息与故障判断有关
- 缺少前置时能明确指出而非猜测
练到的知识点工作目录和参数 · 可重复排错记录
完成后,再检验一次
给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。
继续阅读
先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。
- 必读实践指南英文
IntelliJ IDEA:创建与运行 Java 项目 (新标签页)↗
按创建项目、选择 JDK、运行 main 的流程操作;将文档较新 JDK 示例调整为 Java 17。
- 实践实践指南英文
IntelliJ IDEA:调试代码 (新标签页)↗
练习断点、单步执行、变量面板与调用栈,核对观察到的执行顺序。
- 深入参考文档英文
Java 17:javac 命令 (新标签页)↗
查阅 -encoding、-d、--release 和 classpath,用于解释编译器实际输入与输出。
- 实践实践指南中文
命令行入门:找文件、传递结果和写小脚本 (新标签页)↗
先读到“在程序间创建连接”,再做文件和脚本练习。小练习:在自己的临时练习目录写三行文字,用管道只取最后一行并保存到新文件。系统硬件和管理员权限示例先作为阅读材料。 讲义公开;中文站是社区翻译,原课程官网也注明翻译未由课程团队验证。练习面向类 Unix shell,不能直接把所有命令粘贴到
PowerShell。 - 实践实践指南中文
用断点找 Java 错误:观察变量怎样一步步变化 (新标签页)↗
先完成设置断点与单步执行。小练习:在自己的求平均数方法中故意漏掉除法,用 1、2、3 作输入,记录每一步变量值,再修复并复查空输入情况。 文档公开;当前示例使用 Java 25 的入口语法,不能原样放进 JDK 17 项目。可保留方法逻辑,改用自己项目已有的标准 main 入口练习。
- 深入参考文档英文
OpenJDK 17u java.lang.Throwable (新标签页)↗
定位
getStackTrace与getOurStackTrace,理解返回副本及按需构造的边界。 - 深入参考文档英文
Java17 Throwable API (新标签页)↗
查看栈快照、cause 及栈可写性约定。