一个偶发的空指针,用单步调试跑了二十分钟,最后还是靠猜。因为单步的本质是"逐行确认你怀疑的地方",而你怀疑的地方错了——真正的问题在八层调用之前,那里传进来一个 null。
调试器最常见的误用是"把断点当放大镜用":到处下断点,然后单步。这两种用法的成本差很大:放大镜式调试的时间随"代码行数"增长,筛选器式随"假设质量"增长。而好的假设通常来自"读日志"而不是"读代码"。
而高效的用法是把断点当筛选器用:先用条件断点把"有问题的那一次"从一万次调用里筛出来。
1. 四种断点的分工
| 类型 | 适用 |
|---|---|
| 行断点 | 定位"执行到哪" |
| 条件断点 | 从大量调用中筛出异常样本 |
| 异常断点 | 抓"第一次抛出的地方" |
| 字段/方法断点(watch) | 抓"谁改了它" |
第二项是被低估的:一个方法被调 5000 次,只有 3 次参数是 null——条件断点让你只在那 3 次停下。
第四项在"值莫名其妙变了"时最有效:给字段下"访问/修改"断点,能直接抓出谁改的第四项还有个"范围"问题:字段 watch 在多线程修改时可能停在"不相关的那次"。所以它适合"单线程或低并发"的代码,高并发下要配合线程过滤。
,而不是逐行读代码。
// 条件断点示例(IDE 里填 condition)
id == null || id.isEmpty() // 只在异常样本上停
amount > 1_000_000 // 只在异常大额时停
retry >= 3 // 只在重试第 3 次时停
// 关键:条件表达式必须"无副作用",否则会改变被测行为
2. 异常断点为什么比"单步"快
| 方式 | 信息 |
|---|---|
| 单步到出错行 | 只看到"现在崩了" |
| 异常断点在抛出处 | 看到第一次抛出的位置 |
| 断在 catch 处 | 看到"被谁接了" |
第二项还有个"策略"设置:IDE 通常可以选"只在这个类/包里抛出时暂停"。把范围缩小到自己代码,能避免在框架内部反复停下。
第二项的价值在于"区分源头与末端":一个异常被包装三次,最终在顶层报错,而真正的源头只有一处。异常断点直接停在源头。
第三项在"异常被吞"的场景里必要:很多问题表现为"功能没生效",而实际是某个 catch 把异常记录成了 debug 级别。第三项的排查方式是"在 catch 处下断点,看它是否被命中"——命中说明异常发生了但被处理掉,于是"日志里没有"不等于"没发生"。
3. 条件断点的三个坑
| 坑 | 后果 |
|---|---|
| 条件有副作用 | 改变被测行为 |
| 条件太复杂(调方法) | 每次命中都执行,极慢 |
| 条件依赖未初始化数据 | 条件本身抛异常 |
第一项最危险:在条件里写 list.remove(0) 或 counter++,会让"调试时正常、去掉断点就错"。
第二项的量化影响很直观:5000 次命中 × 每次一次 DB 查询(5ms)= 25 秒,于是"调试变慢 25 秒",而人会以为是"断点太多"。
第二项在"调用了业务方法"时:条件表达式在每个命中点都求值,于是 5000 次调用变成 5000 次方法调用——而那个方法可能查数据库。
// 条件断点的写法建议
好:id == null
好:status == "FAILED" && retryCount > 2
差:service.isInvalid(x) // 会在每次命中时真的调一次
差:list.size() > 0 // 通常没问题,但注意不是修改操作
4. "谁改了这个值"怎么查
| 手段 | 效果 |
|---|---|
| 字段 watchpoint | 抓到修改的第一现场 |
| 只读字段(final) | 从设计上排除 |
| 反查日志 | 事后追查 |
| 二分注释 | 粗暴但有效 |
第一项最直接:在变量上设"watch",程序在它被修改时停下,于是"值什么时候变的"变成"哪一行改的"。
第四项在"无法调试的环境(生产/预发)"里常用:逐段注释掉可疑逻辑,看症状是否消失。第四项要配合"最小改动"原则:一次只注释一处,且记录注释前的行为。否则会陷入"改了三处,症状消失但不知道是哪一处起了作用"。
它慢但不需要调试器。
还有一个"日志断点"(logpoint)的技巧值得常用:不暂停、只打印。它把"断点"与"加日志"合成一步,于是"需要重启/改代码"这个过程被省掉了。
// logpoint(IDE 里把断点设为"不暂停 + 求值并打印")
"id=" + id + " status=" + status + " retry=" + retryCount
// 优势:改造一行日志要重新部署;logpoint 立即生效
// 代价:只在本地产出,无法长期留存(生产还是要靠真日志)
5. 远程调试的边界
| 场景 | 可行性 |
|---|---|
| 本地/开发环境 | 完全可用 |
| 预发(单实例) | 可用,需端口与安全策略 |
| 生产(多实例) | 不建议(会 suspend 线程) |
| 容器内 | 需端口映射与 JDWP 参数 |
第三项的风险是"断点即停顿":命中断点时 JVM 可以 suspend 所有线程(默认策略),于是健康检查失败、负载均衡摘除、用户超时。
第三项(生产多实例)还有个"路由"问题:你必须知道"出问题的请求打到了哪个实例",否则断点下在 A 而请求在 B,永远等不到。所以 traceId 与实例 ID 的关联是前提。
所以生产上更合适的是"不 suspend 的诊断":堆直方图、线程栈、火焰图、jcmd 的各类采样——它们不中断执行。
// 生产排查的"不暂停"工具箱
jstack / jcmd Thread.print 线程栈(瞬时)
jcmd GC.class_histogram 对象分布
async-profiler / JFR 采样火焰图
arthas(阿里开源) 在线诊断(可 watch 方法入参/返回)
调试"偶发问题"还有一条更省时间的路:先把"复现条件"写下来,再开始调试。因为"能复现"与"不能复现"决定了完全不同的手段——前者用断点,后者只能靠采样与日志。
// 复现条件的四项(写清再动手)
频率:每 200 次请求 1 次
触发面:只有带优惠券的订单
环境:仅生产(预发不复现)
时间:集中在 14:00–15:00(高峰)
// 结论:这是"高并发下的时序问题",断点必然改变它 → 改用采样
6. 排错:五种"调试效率低"
| 现象 | 成因 | 处理方向 |
|---|---|---|
| 单步很久找不到 | 怀疑点错了 | 用异常断点定源头 |
| 断点太多互相干扰 | 无筛选 | 用条件断点 |
| 去掉断点就复现 | 条件有副作用 | 检查条件表达式 |
| 调试时正常、跑起来错 | 时序/并发 | 用日志与采样 |
| 生产无法调试 | suspend 风险 | 用不暂停工具 |
第四项是"调试器盲区":并发与时序问题在断点下往往不复现第五项(生产无法调试)的替代不只有"采样":arthas 这类在线诊断工具能"watch 方法入参与返回"而不暂停进程,在"想知道某个方法的实际参数"时比采样更直接。
,因为断点改变了时序。这类问题只能靠日志、采样与压力复现。
最后一条"团队"层面的实践:把"怎么复现这个 bug"写进工单,而不只是"现象"。因为下一个人接手时,最有价值的不是"它坏了",而是"三步能让它坏"——而这通常正是调试过程中最费时的部分。
// 工单里的"复现"段(三行)
步骤:① 用账号 A 加购 ② 用优惠券 C 下单 ③ 刷新订单页
期望:显示应付 100 元
实际:显示应付 0 元(不一致)
7. 边界:什么情况不该用调试器
| 场景 | 为什么 | 做法 |
|---|---|---|
| 并发/时序问题 | 断点改变时序 | 日志 + 采样 |
| 生产环境 | suspend 风险 | 不暂停工具 |
| 性能问题 | 断点让耗时失真 | profiler |
| 依赖外部系统的 bug | 断点看不到对端 | 抓包/日志 |
最后一条与"成本"有关的边界:调试器的启动成本低(下个断点就行),但它的时间成本高(单步)。所以它适合"假设明确、范围小"的问题;假设不明确时,先花五分钟读日志比下断点划算。
判断口径一句话:这个问题的"信息"在"某一行的状态"里,还是在"一段时间的行为"里。前者用调试器;后者(并发、性能、跨系统)用采样与日志。
8. 自测
- 四种断点分别适用什么?为什么条件断点被低估?
- 异常断点为什么比单步快?它如何区分"源头与末端"?
- 条件断点的三个坑里,哪一个会造成"去掉断点就错"?
- "谁改了这个值"怎么查?"二分注释"适合什么环境?
- 生产上为什么不能随便下断点?"不 suspend 的诊断"有哪些?
- 判断"该不该用调试器"的那个问题是什么?