“代码调试”这件事,很多新手是从“代码跑起来了但结果不对”那一刻才真正开始面对的。而 Debug 能力的分水岭,往往不在于你会不会打断点,而在于你知不知道什么时候该断、断在哪里、断下之后看什么。这篇内容围绕IDEA 2023 版的调试能力展开,把断点家族、运行期观测、多线程排查、远程接入、卡死定位这些实战环节拆开讲透,目标读者是刚入行或者一直靠System.out.println打天下的同学,也适合用了几年 IDE 但只会按 F8 的老手对照补课。我不会只告诉你按钮在哪,更会解释每个功能背后“为什么这么设计”“什么时候用它才划算”,顺便把我在真实项目里踩过的坑一并摊开。
1. 先把“调试”这件事想清楚:新手最容易走偏的三个方向
1.1 调试不是“打断点看变量”这么简单
刚学调试的人通常有个固定动作:在可疑行左边点一下,出现红点,然后跑起来,一步步按 F8,盯着 Variables 面板发呆。这个流程本身没错,但它是“最低配版本”。真正的调试是一套假设 → 验证 → 收敛的过程:先根据现象推断可能出问题的位置,再设计一个能证伪这个推断的观测手段,最后根据观测结果缩小范围。断点只是观测手段之一,条件断点、日志断点、异常断点、字段监听、表达式求值,都是不同精度的“探针”。
举个特别常见的例子。接口返回的列表少了两条数据,很多人第一反应是在 Service 返回处打断点,看列表大小。看一眼发现是 8 条,预期 10 条,然后呢?就没思路了。有经验的人会往回推:数据来自数据库查询还是缓存还是远程调用?如果是数据库,SQL 的 where 条件是不是把两条过滤掉了?如果是内存里的过滤逻辑,是哪一层 filter 干的?这时应该在“进入过滤前”和“过滤后”各打一个断点,对比两个时刻的集合内容,甚至直接对集合做 Evaluate Expression 求差集,一步定位。这就是设计观测点,跟“看着变量发呆”完全是两回事。
提示:调试最大的时间浪费不是操作慢,而是观测点选错。断点打得太靠后,你只能看到“结果错了”;打得太靠前,你要穿过的无关代码太多。理想的观测点是“能区分两种假设”的那个位置。
1.2 本地调试、联调环境、远程现场:三种场景准备不一样
同样是 Debug,场景不同,准备工作差别很大。本地调试最简单,代码、依赖、数据都在本机,随便折腾,甚至可以让程序在断点处停半小时去泡杯咖啡。联调环境就麻烦一些,通常是一台共享的测试机,你的代码不是最新版,数据库里是别人造的数据,别人也可能在同一台机器上部署服务,你要考虑端口冲突和相互影响。远程现场最麻烦,程序跑在你没有图形界面、甚至没有 shell 权限的容器里,只能通过调试端口把运行时状态“引出来”。
这三类场景决定了你要用哪种调试配置。本地用普通的 Application 配置就够了;联调环境一般也是本地启动、连测试库,但要注意配置文件用哪套;远程场景则需要 JVM 启动参数里带上调试代理,再在本地 IDE 里建一个远程调试配置连上去。很多人一上来就想去连生产,我的建议是:不要在真实生产环境开调试端口。调试协议本身是没有认证机制的明文协议,任何能连到这个端口的人都能读取运行时内存、修改变量值、甚至执行代码,风险极大。真要排查线上问题,正确做法是把日志级别调细、把关键上下文打全,或者把现场数据导出一份到隔离环境里复现。
1.3 学会调试的性价比:为什么它比读代码更快
我见过不少同学,遇到问题先花两小时通读代码,试图靠“看懂”来定位 bug。对于逻辑简单、链路短的代码,这么干没问题;但只要涉及多态、注解、AOP 代理、动态代理、框架回调,光靠读源码基本是自虐。因为运行时真正执行的那个方法,很可能既不在你打开的类里,也不在你以为的类里。
调试的价值就在于它展示的是运行时的事实,而不是你脑补的流程。调用栈告诉你真实调用链,变量面板告诉你真实的取值,方法返回值告诉我这个方法到底返回了什么而不是“文档上写着返回什么”。一个熟练工定位一个空指针问题,平均可能只需要三到五分钟:在异常断点停下的那一刻,直接看栈顶那个对象的哪个字段是 null,再往上追是谁把它设成 null 的。而一个不熟悉调试的人,可能要在每个可能为 null 的字段后面加一行打印,反复重启服务,花掉一整个下午。
2. IDEA 2023 的调试面板与快捷键:上手前先认清地图
2.1 三种进入 Debug 的方式与各自的适用场合
在 IDEA 2023 里,启动调试主要有三条路子。最常用的是工具栏上那个小虫子图标,或者快捷键Shift + F9,它等价于“用当前选中的运行配置以调试模式启动”。第二种是Alt + Shift + F9,会弹出配置列表让你挑一个启动,适合一个项目里有多个入口类、你又不想手动切换下拉框的场景。第三种是“Attach to Process”,把调试器挂到一个已经在跑的进程上——本地开发时用于调试那些不是由 IDE 启动的进程,比如你用命令行java -jar起的服务,或者某个插件里跑起来的子进程。
这里有个细节值得说:调试模式和运行模式的 JVM 参数并不一样。IDEA 会在调试启动时自动注入调试代理参数,同时可能会开启一些额外的断言检查。所以偶尔会出现“运行时正常、调试时行为不同”的情况,比如断点处的代码被优化重排、finally块里对象状态被提前修改。遇到这种诡异现象,先别怀疑人生,试试关掉所有断点跑一次 Debug 模式,如果行为恢复正常,说明问题出在断点本身而不是业务代码。
还有一个很实用的功能是Run to Cursor(Alt + F9)。你不需要在目标行打断点,只要把光标停在那一行,按一下这个快捷键,程序就会一路执行到光标位置停下。它比打断点更轻量,尤其是临时想看某一行状态的时候,不用留下断点污染工程。
2.2 Debug 工具窗口里的五块区域分别解决什么问题
程序在断点停下之后,IDEA 底部或者侧边会展开 Debug 工具窗口。这块界面看着元素很多,其实按功能划分只有五块,认清它们的职责,调试效率立刻上一个台阶。
Frames 区域展示的是调用栈,最上面是当前暂停的方法,往下依次是调用者。点击任意一层栈帧,Variables 面板就会切换到那一层的作用域,你能看到那一层方法里的局部变量和参数。Variables 区域是当前栈帧的变量快照,支持展开对象、查看集合、看到继承来的字段。Watches 区域是你自己指定的关注项,可以放变量,也可以放一段表达式,每次程序停下都会重新求值。Console 区域是程序的标准输出和调试器提示信息,异常堆栈也会打在这里。调试工具栏则集中了所有步进控制、断点开关、表达式求值、Stream 追踪这些操作按钮。
很多人只盯着 Variables,忽略了 Frames 才是调试的灵魂。变量是“结果”,调用栈才是“过程”。同一份变量值,可能由完全不同的调用路径产生,而这两条路径的修复方案天差地别。养成每次停下先扫一眼调用栈的习惯,你会发现定位问题的速度明显变快。
2.3 步进家族五个动作的准确语义与对照表
步进按钮长得像一排小箭头,但它们的行为差异经常被误解。下面这张表我把每个动作的语义、快捷键和典型用途列清楚,建议对照着实际操作几遍。
| 动作 | 快捷键 | 准确语义 | 典型用途 |
|---|---|---|---|
| Step Over | F8 | 执行当前行,如果这行调用了方法,不进入方法内部 | 想跳过已确认没问题的工具方法 |
| Step Into | F7 | 执行当前行,如果是方法调用则进入被调方法 | 需要看方法内部逻辑 |
| Force Step Into | Alt + Shift + F7 | 强制进入,忽略“不进入的类”过滤规则 | 需要钻进 JDK 或第三方库源码 |
| Step Out | Shift + F8 | 执行完当前方法剩余部分,回到调用者 | 已经看完想看的,快速跳出 |
| Run to Cursor | Alt + F9 | 一直执行到光标所在行 | 临时观测某一行,不留断点 |
这里最容易踩的坑是Step Into 进不去 JDK 方法。很多新手想看看String.split到底怎么切的,按 F7 却发现直接跳过了整行。原因在于 IDEA 默认开启了“不进入的类”过滤,java.*、javax.*、sun.*这些包下的代码默认不让进,避免你无意中陷在框架源码里出不来。解决办法有两个:临时用 Force Step Into,或者去Settings → Build, Execution, Deployment → Debugger → Stepping里调整过滤规则。但要提醒一句,进 JDK 源码还有个前提,你的 SDK 配置里得关联上源码包,否则进去也只能看反编译出来的空壳。
2.4 两个容易被忽略的开关:显示方法返回值、内联显示变量值
IDEA 有两个默认关闭或者藏得比较深的选项,开启之后调试体验会有质的提升。
第一个是显示方法返回值。路径在Settings → Build, Execution, Deployment → Debugger → Data Views,勾上“Show method return values”。开启之后,当你 Step Out 出一个方法,Variables 面板里会多出一个类似Method return value = ...的条目,直接告诉你这个方法返回了什么。不用再手动加临时变量接返回值,节省大量改代码、重启的次数。
第二个是内联显示变量值(Show values inline),同样在 Data Views 下。开启后,调试暂停时,当前行涉及的变量值会直接以灰色小字显示在代码行末尾。对于那些“只想快速扫一眼几个字段”的场景,连 Variables 面板都不用展开。这个功能在 2023 版里已经相当稳定,我基本是默认开启状态。
3. 断点不止一种:把断点当“筛选器”而不是“暂停键”
3.1 行断点右键那堆选项,逐个拆开讲
在行号旁边的空白处点一下,生成一个红点,这是最基本的行断点。但很多人从来没右键点过它。右键菜单里那几个选项,才是断点真正的威力所在。
Condition和Log这两个后面单独讲。Suspend 选项决定了断点是挂起所有线程还是只挂起当前线程,默认是 All,多线程场景下必须改。Remove once hit让断点命中一次后自动删除,适合那种“我只想看第一次执行时的情况”。Disable until hitting the following breakpoint可以指定依赖断点,比如只有先命中 A 断点,B 断点才生效,这对区分“同一个方法被两种场景调用”特别有用。Breakpoint命中次数在 View Breakpoints 对话框里配置,可以设置成“在第 N 次命中时才暂停”。
注意:条件表达式里尽量不要调用有副作用的方法。比如写
list.remove(0) != null这种条件,每次虚拟机求值都会真的执行一次删除,结果就是你的数据结构被调试代码污染了,后面看到的一切都是假的。
3.2 条件断点:循环里第 N 次出事怎么抓
假设有个一万次循环,问题只在处理某个特定订单时出现,订单号是ORDER-9871。笨办法是让断点每次都停,按一万次 F9,手会废。正确的办法是右键断点,在 Condition 里写order.getId().equals("ORDER-9871"),只有这一条数据走到时才停。条件支持完整的 Java 表达式,还带代码补全和语法检查,写错了会有波浪线提示。
如果想抓“第 1000 次循环”,条件写i == 1000。如果想看“每 500 次停一次”,写i % 500 == 0,然后再配合日志断点把关键数据打出来。还有一种场景是判断集合状态,比如result.size() > 100,用于捕捉那个“数据异常增长”的瞬间。
条件断点有个性能代价必须知道:表达式的求值本身是要耗时的。如果断点位于一个每秒执行几十万次的热点方法里,而这个条件又写得比较复杂(比如里面调了数据库查询方法),程序会慢到让你怀疑是不是死锁了。这种情况下,应该把断点挪到调用频次更低的位置,或者改用“日志断点 + 事后分析”的方式。
3.3 日志断点:不暂停也能留下证据
日志断点的思路很聪明:它把断点变成一个“只记录不暂停”的观测点。配置方式是右键断点,取消勾选Suspend,然后勾上Evaluate and log,在输入框里写要打印的内容。它支持{}占位符引用变量,比如:
订单号 = {order.id}, 状态 = {order.status}, 明细数 = {order.items.size()}程序跑到这一行时不会停,直接在 Console 里输出一行格式化文本,然后继续往下跑。这个功能解决了一个长期痛点:你又想加日志,又不想改代码重启。过去为了打一行日志,改代码、等编译、重启服务、复现问题,一套下来十分钟;现在直接加个日志断点,十秒钟搞定。
日志断点还有几个实用变体。配合条件断点,可以做到“只在异常数据出现时打印”,避免刷屏;配合命中次数,可以做到“每 1000 次打印一次进度”;配合一次性断点,可以做到“只记录第一次调用”。我在排查那种“偶发、无法稳定复现”的问题时,几乎全靠日志断点把现场信息捞出来。
3.4 方法断点与字段断点:抓“谁改了我”
有些问题不是“值算错了”,而是“这个值被人偷偷改了”。比如一个配置对象的字段,明明初始化时是 30,跑到某个位置变成 0 了。这时候在读取它的地方打断点意义不大,你需要的是字段断点:在字段声明那一行的行号槽点一下,会出现一个眼睛形状的图标,右键可以配置为监听写入访问、读取访问或两者。
程序一旦执行到任何对这个字段赋值的位置,就会停下来,调用栈清楚地告诉你“是谁改的”。这一招在排查框架自动注入、反射赋值、反序列化覆盖这类问题时几乎是神技,因为这类代码往往散落在你看不见的地方。
方法断点则是打在方法签名行上的菱形图标,可以配置成方法入口停还是出口停。它的特点是能捕捉“这个方法有没有被调用过”,特别适合分析重载、多态、动态代理的场景——你以为调用的是接口方法,实际上走到了某个代理实现。但要提醒一句,方法断点的性能开销比较大,它会强制 JVM 以解释模式执行该方法,所以定位到问题后记得及时清理,不要留在代码里。
3.5 异常断点:让被吞掉的异常自己跳出来
程序跑完没报错,但结果就是不对,日志里干干净净。这种情况十有八九是异常在某个catch块里被吞掉了,或者被包装成了别的异常往上抛,原始信息丢失。异常断点就是干这个的:在 Breakpoints 窗口里新增一个 Java Exception Breakpoint,输入异常类名,程序一旦抛出这个异常就立刻停下,不管它最终会不会被捕获。
建议的做法是分两步走。第一步,先打一个NullPointerException或IllegalArgumentException这类常见的具体异常,因为“Any Exception”会抓到大量框架内部的、正常流程里就会抛的异常,比如某些框架用异常做流程控制,你会被停在完全无关的地方。第二步,如果具体异常也抓不到,再试试用java.lang.Exception加“Caught exception”选项,但要准备好面对大量干扰。
配合异常断点使用时,记得打开 Variables 面板里那个Exception对象,它携带的 message 和 cause 链往往直接指向根因。
3.6 断点分组、禁用与 Mute:批量管理不手忙脚乱
一个项目做久了,代码里可能散落着几十个断点,有调试用的,有随手留下的。这种情况很容易出事:某天你在跑一个演示,程序莫名其妙停在一个你三个月前留下的断点处,当着用户的面翻车。
两个好习惯值得养成。第一,用Ctrl + Shift + F8打开 Breakpoints 对话框,把断点按功能分组,比如“支付排查”“缓存问题”,需要的时候整组启用或禁用。第二,善用Mute Breakpoints,调试工具栏上有个小红点带斜杠的按钮,一键让所有断点失效但保留配置,演示前点一下,心里踏实。另外,断点对话框里可以直接看到每个断点所在文件和行号,定期清理一次是很好的卫生习惯。
4. 运行期观测:变量、表达式与调用栈的正确打开方式
4.1 Variables 面板里藏着的细节
Variables 面板看起来朴实无华,但里面有几个设计非常贴心。展开一个对象时,除了实例字段,IDEA 还会显示static字段(在单独的节点下),以及一些计算属性。集合类型会有专门的视图,List 会根据长度决定是否按分页显示,Map 会以键值对形式列出,数组会显示每个下标。
有个细节新手经常困惑:为什么有些对象的字段显示null,但代码逻辑上它明明有值?这通常是调试器的取值策略导致的。IDEA 默认对某些类型使用toString()或者自定义的渲染器,你可以在 Variables 面板右键选择View as → Object强制按字段展开。另一种可能是那个对象处在另一个线程的栈帧里,当前栈帧拿不到它的最新状态。
还有个实用功能是Mark Object。右键一个对象选择 Mark Object,给它起个名字比如“目标订单”,之后无论在哪个栈帧、哪个线程里看到这个对象,它后面都会跟着这个名字。排查“我传进去的和拿出来的到底是不是同一个对象”这类问题时,这个功能能省掉好几行hashCode打印。
4.2 Watches 和 Evaluate Expression 的分工
Watches是“持续关注”,Evaluate Expression是“临时算一次”。Watch 里可以放简单变量,也可以放复杂表达式,比如order.getItems().stream().filter(Item::isInvalid).count(),每次程序停下来它都会重新求值,你就能看到这个值随着执行过程怎么变化。适合追踪那些“应该在某个区间内的指标”。
Evaluate Expression 的快捷键是Alt + F8,弹出一个小输入框,可以执行任意表达式,甚至调用方法、创建对象。这个功能的强大之处在于,你可以在调试过程中临时跑一段验证代码,不需要改源码。比如你怀疑某个条件判断写错了,直接在框里输入你的版本,对比结果;或者你怀疑缓存里有没有这个 key,直接调一下缓存客户端的查询方法。
注意:Evaluate Expression 里执行的方法会真实影响程序状态。查询类方法一般安全,但涉及写操作、状态变更、远程调用的方法千万不要随手执行,否则后果自负。
4.3 调用栈、Drop Frame 与 Force Return
调用栈是调试信息的骨架。点击任意一层帧,Variables 会同步切换,Console 里也能看到该帧对应的源码位置。有一个被严重低估的功能叫Drop Frame:在 Frames 面板右键某一层栈帧,选择 Drop Frame,当前执行点会回退到那一层,相当于“让程序倒带回去重跑这一段”。
这个功能对反复验证特别有用。比如某段逻辑你在第三次调用时才发现问题,按正常流程得重启程序重新走一遍;用 Drop Frame 直接退回调用前,改一改变量值,再走一次,几秒钟就能验证一个假设。
还有个配套功能是Force Return:在 Frames 面板选中某一层方法,右键选 Force Return,可以强制让这个方法立即返回一个你指定的值,不执行剩余的代码。排查“如果这个方法返回了正常值,后续逻辑会不会正确”这类问题时,它比改代码快得多。但务必记住,被跳过的代码的副作用(比如写日志、更新计数器)也不会发生,这在某些场景下会影响你的判断。
4.4 直接改变量值:不动代码验证假设
调试时最爽的操作之一,是直接改变量的值。选中 Variables 面板里的一个变量,按F2或者右键Set Value,输入新值回车,这个变量在当前运行时就真的变成新值了。接着按 F8 往下走,观察后续逻辑的反应。
这套操作在排查边界条件时特别高效。比如你怀疑“余额为 0 时会不会除零”,不用去数据库改数据、不用重新造单,直接在断点处把余额设成 0,下一步就看到结果了。排查“列表为空时有没有兜底逻辑”,把集合clear()一下继续跑就行。
不过有个前提条件:这个变量得是可写的。被final修饰的字段、被编译器优化掉的临时变量、某些框架用反射封装的不可变对象,可能改不动。这时候可以先看类型,再决定是改对象内部字段还是换个观测点。
4.5 对象视图定制:让日志和集合看得懂
默认情况下,Variables 面板展示对象时用的是toString()。如果你的实体类没有重写toString(),看到的就是com.xxx.Order@1a2b3c这种内存地址,毫无信息量。两个解决路径:一是给实体类加上toString()(顺便说,用 Lombok 的@ToString一行搞定);二是配置调试器的类型渲染器。
类型渲染器在Settings → Build, Execution, Deployment → Debugger → Data Views → Java → Customize Data Views里配置,可以为指定类型定义显示模板,比如对Order类型显示“订单号 + 金额 + 状态”。这样做的好处是不用为了调试而给类加toString(),避免污染生产代码。对于集合类,还可以开启“Enable alternative view for Collections classes”,用更紧凑的方式展示大集合。
5. 高频疑难场景的实战排查路线
5.1 循环里的“第几次”问题
有一类 bug 只在特定位置出现,比如“批量处理里第 37 条数据开始出错”。排查思路是先加条件断点抓住那一条,再往前一层找原因。更高效的做法是组合使用:条件断点设为i >= 37,然后在该断点后面再加一个日志断点,用i和data.id输出信息,这样你既能在第 37 次停下细看,又能从日志里看到前后的数据分布。
如果循环次数巨大,比如处理百万级数据,条件断点的求值开销也会被放大。这时可以换一种策略:把断点打在“分片边界”上,比如每 1000 条一个批次,只在批次开始时停下,观察批次状态,再逐步缩小范围。本质上就是二分法:先确认问题落在哪个批次,再在批次内二分,通常三到四次就能锁定具体位置。
5.2 多线程:Suspend 选 Thread 与线程切换
多线程调试最容易犯的错误,是让断点挂起所有线程。在并发场景下,挂起所有线程会掩盖真正的问题:竞态条件往往需要“其他线程继续跑”才会暴露。所以断点的 Suspend 策略要改成Thread,只挂起触发的那个线程,其他线程继续执行。
程序停下后,Frames 面板顶部有一个下拉框,可以切换查看不同线程的调用栈。你可以对每个线程分别观察它的执行位置和局部变量,判断“它卡在哪一步”“它拿的是不是旧值”。如果某个线程一直在等锁,你会在栈里看到明确的等待方法。
另一种手段是直接点调试工具栏上的Pause Program(暂停按钮)。它不依赖任何断点,直接把当前所有线程的现场快照抓下来。这在排查“程序卡死不动”的时候最有用:按下暂停,逐个看线程栈,找出哪个线程拿着锁不干活,或者哪个线程在死循环。
5.3 Stream 与 Lambda 的调试
Java 8 之后的代码里大量使用 Stream,链式调用写起来爽,调试起来痛苦,因为 lambda 体里没法直接打断点,中间结果也看不到。IDEA 内置了一个 Stream 追踪功能(Stream Debugger),在调试暂停时,把光标放在 Stream 表达式上,点击调试工具栏里的Trace Current Stream Chain按钮,会弹出一个可视化窗口,展示数据在链路上每一步如何变化:过滤前多少个元素、过滤后多少个、映射后变成什么。
如果 Stream 很长,也可以把断点打在某一步的 lambda 体内部,用条件断点筛选特定元素,再配合 Watch 表达式stream变量。实践中我通常先用链式追踪看宏观数据流,再针对可疑的过滤或映射步骤单独下断点。
5.4 异常堆栈不指向自己代码怎么办
经常遇到这种情况:日志里一片红,堆栈最上面几行全是框架或者 JDK 的类,自己的业务代码只出现在很下面,甚至完全看不到,因为异常被包装了。处理这类问题有两个思路。
第一,用Analyze Stack Trace。把完整的堆栈文本复制下来,在 IDEA 里选择Analyze → Analyze Stack Trace,粘贴进去,它会自动生成可点击的导航链接,直接跳到对应的源码行。这比肉眼在几百行日志里找自己包名快得多。
第二,用异常断点抓原始异常。没有堆栈就说明信息被吞了,那就干脆在被吞之前拦住它。在 Breakpoints 窗口添加对应的异常类型,记得勾选“Caught exception”选项,这样即使是会被捕获的异常也能拦住。停下来之后,Variables 里那个Exception对象的cause链就是完整的根因。
5.5 远程与容器应用的接入方式
远程调试的机制很简单:JVM 启动时开启一个调试端口,IDE 通过网络连过去,之后所有断点操作都通过这个通道传输。启动参数大致是这样的:
java -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005 -jar app.jar其中suspend=n表示 JVM 不等调试器连接就直接启动,suspend=y则是等调试器连上才继续,适合排查启动阶段的问题。address=*:5005是 JDK 9 之后的写法,JDK 8 直接写端口号就行。
在 IDEA 里新建一个Remote JVM Debug配置,填上目标主机和端口,点调试就相当于把调试器“挂”到了远程进程上,断点、变量、调用栈全都能用。实际项目里,远程调试一般用于测试环境的问题复现,或者在容器编排环境里把容器端口映射到宿主机后连接。
注意:调试协议是无认证的明文协议,只要端口可达,任何人都能读写该进程的内存。所以它只能用在你完全可控的隔离环境里,绝不能让端口对外暴露。容器场景下也要确认端口映射范围,别把调试端口一并暴露出去。
顺便说一句,调试的思维方式是跨语言通用的。无论你用的是 IDEA 调试 Java,还是在别的工具链里调试嵌入式代码、数据库存储过程、移动端应用,核心都是“找到观测点、控制执行流、检查运行时状态”这三件事的组合,差别只在于工具提供的断点类型和变量查看能力不同。
5.6 卡死与假死:用 Pause 抓现场堆栈
程序不动了,日志也不打了,这时候不要急着重启。先按Pause Program,把所有线程的现场抓下来。重点看三类线程:处于WAITING或BLOCKED状态、卡在锁获取上的;处于RUNNABLE且在某个循环里反复出现的;以及持有锁又在等另一个资源的“互相等待”组合。
如果同一个方法在三次暂停的堆栈里都出现在同一个位置,那基本可以确定是死循环或者长时间阻塞。如果多个线程都在等同一把锁,就看谁持有它——栈里会直接告诉你。抓完堆栈再重启也不迟,至少你知道下次怎么复现了。
5.7 常见问题速查表
| 现象 | 可能原因 | 排查手段 |
|---|---|---|
| 断点变灰、不生效 | 代码与运行版本不一致、断点被 Mute | 检查是否勾选了 Mute Breakpoints,重新编译 |
| 断点位置有叉号 | 该行没有可执行代码、被编译优化 | 把断点移到方法体内或下一行 |
| 条件断点不触发 | 表达式求值异常、变量名不在作用域 | 去掉条件先验证断点本身能命中 |
| 变量值与预期不符 | 看错了栈帧、对象被其他线程修改 | 检查 Frames 选中的层,切到 Thread 挂起模式 |
| Debug 模式行为与 Run 模式不同 | 断点影响了时间敏感逻辑、JIT 优化差异 | 关掉全部断点再跑一遍对比 |
| 远程调试连不上 | 端口未开放、进程未带调试参数、防火墙 | 确认启动参数、端口连通性 |
| 调试时程序特别慢 | 方法断点、复杂条件表达式、大量日志断点 | 精简断点,把条件断点挪到冷路径 |
6. 从能用到好用:我的调试习惯和一些坑
6.1 我踩过的坑
第一个坑是在热点方法里留条件断点。有一次排查一个每秒调用上万次的方法,我加了个条件断点判断参数是否为空。结果程序慢得像蜗牛,我以为是并发问题查了半天,最后发现是调试器每次都在求值那个条件表达式。教训就是:条件断点的成本跟命中次数成正比,热路径上慎用。
第二个坑是用 Drop Frame 之后忘了状态的副作用。回退栈帧并不会回滚已经发生的副作用——数据库写过了、缓存更新过了、静态计数器加过了,这些都不会撤销。所以 Drop Frame 之后重跑那段逻辑,可能会因为状态已经被改过而得到完全不同的结果。用它之前先想清楚:这段代码有没有对外产生不可逆的影响?
第三个坑是过度依赖 Force Return 得到错误结论。强制返回一个“正常值”之后逻辑跑通了,不代表修复方案就对了,因为真实的调用路径可能还有前提条件。它只适合验证假设,不能当作最终结论。
第四个坑是断点太多导致的启动异常。有个项目里遗留了四十多个方法断点,某次启动直接超时,排查了很久才发现是这些断点让 JVM 走解释模式执行,性能掉了十几倍。从那以后我养成了定期Ctrl + Shift + F8清理断点的习惯。
第五个坑是改完代码忘了重新编译就调试。IDEA 默认会在启动前自动构建,但如果关掉了这个选项,或者用远程调试连接了一个旧版本的进程,就会出现“断点行号和实际执行代码不一致”的魔幻现象,调试器停在一个位置上,但你看到的源码是另一回事。遇到诡异现象先确认版本。
6.2 一套可复用的调试流程
经过这些年的反复打磨,我自己的调试流程大致固定成这样:接到问题后,先不看代码,而是把问题现象写成一句可验证的话,比如“当订单金额为 0 时,结算接口返回 500”。然后把这句话翻译成“观测目标”:金额为 0 时,进入结算逻辑的参数是什么、在哪一步抛出异常。
接着设计观测点:用异常断点抓住抛出的异常,用条件断点过滤到金额为 0 的请求,用日志断点记录关键参数。跑一次,看结果。如果假设被证伪,就换一个观测点重来。整个过程控制在三到五次循环以内,避免陷入“漫无目的地打断点”。
还有个小技巧可以分享:调试前先想清楚“我这次要看什么”,在纸上或者在注释里写下这一轮的目标。很多人调着调着就迷失了,在一个栈帧里翻半天数据结构,忘了自己最初要找的是什么。带着明确目标去打断点,效率和漫游完全不同。
另外,遇到复杂的数据结构,善用 Watch 表达式把关键指标提前算好,比如items.size()、failedCount、maxTimestamp,这样每次停下都是一眼可见的结论,而不是每次都手动展开三层对象找同一个字段。
最后再分享一个小技巧:如果你的团队用的是同一个代码仓库,可以把常用的排查断点组命名规范统一,比如“支付链路-入口”“缓存命中判断”,配合 Breakpoints 对话框的分组功能,团队成员之间可以快速共享排查思路。调试能力说到底是一种可以复制的经验,把断点配置当成一种“可传承的资产”来管理,收益比想象中大得多。