1. 调试思路先纠正:别再从第一行开始按 F8
我在带团队做 Code Review 的时候,经常看到有人遇到 bug 的第一反应是“把断点打在 main 方法第一行,然后一路 F8 往下按”。这种调试方式不能说没用,但效率实在太低。项目小的时候还能靠肉眼跟完整个流程,项目一旦上了规模,一个请求从 Controller 进来,经过 Service、Mapper、MQ、第三方接口,再用这个方法去追,光让程序走到“案发现场”就要花掉半天。
真正高效的调试思路,是让代码在“你觉得会出问题的位置”停下来,然后通过栈帧、变量、表达式去验证你的怀疑,而不是像读小说一样从头到尾把代码翻一遍。调试的本质是“制造可控停顿”,不是“逐行观赏程序运行”。
1.1 打断点之前,先想清楚三件事
很多人在 Debug 之前没有做过任何预判,断点打在哪里全凭直觉。我的习惯是,动手打断点前先问自己三个问题:
- 哪个模块最可疑?是入参解析、业务判断,还是数据落库?
- 什么时候会触发这段代码?是第一次调用、循环第十次,还是某个特殊数据进来之后?
- 我希望它在什么条件下停下来?比如
order.getStatus() == 3,还是list.size() == 0的时候?
这三个问题想清楚,断点位置基本就浮出来了。比如用户反馈“上传一个 2GB 的文件,界面卡死了”,你大概率不用在文件读取的第一行断点,而应该直接去读文件分片循环、内存分配、异步任务提交这几个可疑位置。带着预判去调试,才能避免在无关代码上浪费时间。
1.2 几个天天在用的“导航型操作”
除了最基础的 Resume Program(F9)和 Stop,真正能提高日常调试效率的是这几个操作:
- Run to Cursor(光标定位运行):断点停在 A,你怀疑 B 行有问题,不想再新起一个断点,直接把光标放在 B 行,按 Alt+F9,程序会继续执行到光标所在行并停住。
- Show Execution Point(回到当前行):在 Debugger 窗口里翻看变量、堆栈时不小心把界面拖乱了,点一下这个按钮,可以立刻回到当前暂停的代码行。
- Force Step Into(强制进入):普通 Step Into 遇到某些三方库的方法不会进入,或者进了 JDK 底层却不想慢慢跟,用 Force Step Into(Alt+Shift+F7)可以强制进入任意方法,包括很多被 IDEA 默认过滤掉的类。
- Step Out(跳出当前方法):跟到一个工具方法内部,发现里面逻辑没问题,直接 Shift+F8 跳出,回到调用方继续看。
这几个操作我建议直接记住快捷键。Debug 时鼠标来回点工具栏,会打断你思考的连贯性。尤其是 Show Execution Point,你会发现它在多方法嵌套、什么时候丢失当前行时,特别能救命。
1.3 栈帧里到底在看什么
程序停在断点后,不要急着往下走。先看 Debugger 窗口里的 Frames(调用栈)。调用栈能告诉你“当前方法是谁调过来的”“调用链上有哪些中间层”。很多隐蔽 bug 不是你当前这行写错了,而是传入的参数从源头就不对。
我调试时有个固定动作:先把调用栈从上到下扫一遍,找到第一层业务入口,比对入参在每一层的变换情况。如果中间层有个方法把参数值改了,断点当前位置的变量看起来就很奇怪。这时候你再顺着 Frames 往回点,逐层查看方法参数值,就能快速画出“数据是怎么变成这样的”。这比盲目 F8 更接近问题的真相。
2. 断点类型拆解:行断点只是开始,剩下三种更值钱
IDEA 里最常见的断点是行断点,在行号右侧点一下,程序运行到这一行就会暂停。但实际开发中,真正烦人的问题往往和“方法入口”“字段被篡改”“异常被吞”有关,这三种场景分别对应方法断点、字段断点、异常断点。
| 断点类型 | 添加位置 | 典型场景 | 说明 |
|---|---|---|---|
| 行断点 | 任意可执行代码行 | 看某个具体位置的变量值 | 最常用 |
| 方法断点 | 方法声明行 | 想知道方法被调用、参数是什么 | 性能有额外开销 |
| 字段断点 | 字段声明行(成员变量) | 字段值被莫名修改 | 每次读写都会触发 |
| 异常断点 | Debugger 面板配置 | 异常被捕获后还想定位抛出点 | 很强大但需要注意过滤 |
2.1 方法断点:不侵入方法内部,先看谁在调它
方法断点不是让你在方法第一行打行断点,而是在方法名那一行直接打断点。IDEA 会把方法断点标记成一个特殊的图标。它的作用是:只要方法被调用就会中断,你可以直接看到入参,再决定要不要进入方法体。
有个很典型的场景:一个public void updateStock(Stock stock)方法被好几个地方调用,每次调用后库存都对不上。你在方法内部打行断点,只能看到“进来的时候值是多少”,但看不到“是谁把它调进来的”。如果改成在方法名上打方法断点,IDEA 会帮你在方法进入和退出时都有机会暂停,你可以看调用栈找到真正的调用方。
不过方法断点有个坑:它会让方法调用变得很慢,尤其是高并发、高频率调用的方法。所以它适合“低频方法”或“需要快速定位调用方”的场景,排查完记得及时删除。
2.2 字段断点:抓住那个偷改数据的人
字段断点是在成员变量的声明行上打的断点。它的神奇之处在于,字段被读取或者被修改的时候,都会触发暂停。你可以设置为只在“字段值发生变化”时暂停,这样一旦有代码修改了这个字段,就能立刻定位到修改点。
举一个我印象很深的案例:一个订单对象里有个status字段,明明代码里没有直接调setStatus(),但界面上显示的状态老是变。用字段断点设置status字段“字段访问修改”都断,然后重新触发一次业务,程序直接停在某个第三方工具类里,发现是反射调用Field.set()把值改了。如果不是字段断点,仅靠业务代码走查根本发现不了这个隐藏修改点。
字段断点也有限制:如果你需要捕捉非常高频的字段修改,程序会一直暂停,体验会很差。建议配合条件表达式,比如this.status != oldStatus之类的条件再停。
2.3 异常断点:让“被吞掉的异常”现出原形
Java 里常见的坑是异常被 catch 住后只打了一行日志,甚至 catch 后没做任何输出。你在业务代码里打断点根本等不到异常,因为异常已经被人为拦截了。这时候用Exception Breakpoints就很合适。
在 Debugger 窗口打开断点管理(Ctrl+Shift+F8),点击加号,选择“Java Exception Breakpoints”,填入你怀疑的异常类型,比如NullPointerException。然后程序运行过程中,只要这个异常被抛出,无论有没有被 catch,IDEA 都会在抛出位置暂停。
我第一次用这个功能解决的是一个线上偶发的空指针。日志里只有一句log.error("处理失败"),没有任何堆栈。我在 IDEA 里加了 NPE 异常断点,运行测试用例,程序直接停在了真正为 null 的那一行,前后版本比对后,发现是上游订单状态值映射漏了一个枚举。如果没有异常断点,这种问题很难快速锁定。
异常断点的缺点是范围太大会乱停,比如 JDK 内部也经常抛出一些可预期异常。这时候可以在异常断点上设置过滤条件,或者打开“捕获异常时也停”/“只在未捕获时停”的开关,控制暂停时机。
2.4 断点分组与批量管理
项目大了之后,断点会越加越多,最后你会发现自己根本分不清哪些是旧的、哪些是当前排查需要的。IDEA 的 Breakpoints 窗口支持给断点分组、重命名,还能一键静音(Mute Breakpoints)。我的一般做法是:
- 给不同业务模块的断点分到不同组,比如“订单问题”“登录问题”。
- 临时验证用的断点用完之后立刻删掉。
- 不需要暂停但想保留的断点,先取消勾选 Enable,留给下一次排查。
尤其是“Mute Breakpoints”这个功能,有时候你不想真的执行到断点,但又不想一个个删,直接右键断点图标临时禁用全部断点。程序跑完了再恢复,特别省事。
3. 条件断点、日志断点和命中次数:三个“精准打击”的实战场
行断点虽好用,但总不能每次遇到列表循环都要在循环体里停几百次。IDEA 的断点本身支持非常丰富的属性配置,右键断点就能设置条件、日志、命中次数等。这一节聊的,就是让断点“长眼睛”的方法。
3.1 条件断点的正确写法与坑点
打断点后右键断点红点(或 Ctrl+Shift+F8 进入断点管理),在 Condition 栏输入一个 Java 布尔表达式。例如:
// 循环处理订单时,只停在订单编号包含特殊前缀的订单上 order.getOrderNo().startsWith("RUSH") && order.getAmount() > 1000条件断点会在每次执行到这一行时先计算条件,条件为 true 才暂停,否则继续放行。这样假设一个列表有 10 万条数据,你只需要在当前第 87 条停下查看,就不用每一条数据都人工判断一次。
写条件断点有几个容易踩的坑:
- 别在条件里调用重量级方法。每次到这里都要执行一次,如果条件里写了个远程 RPC 调用,程序会卡到怀疑人生。
- 注意局部变量是否在当前作用域内。Lambda 表达式里,某些循环变量访问方式和普通 for 循环不一样,条件里看不到对应的变量时,可以改成用外部类的字段来判断。
- 条件表达式本身抛异常时,IDEA 会按“条件不成立”处理,程序不会暂停。所以条件里尽量用 null-safe 的写法,比如
"ok".equals(obj.getStatus()),而不是obj.getStatus().equals("ok")。
我平时定位“某一个特定数据引发的 bug”时,最常用的动作就是在循环体断点加一个id == xxx的条件。这比一遍遍改断点位置高效太多。
3.2 日志断点:让线上代码悄悄说话
日志断点在 IDEA 里的学名叫“Breakpoint properties”中的 Log message / Log evaluated expression。它和普通断点的区别是:命中断点时不会暂停程序,而是把指定信息输出到控制台,然后继续运行。
场景是这样的:一个批量任务要处理 10 万个文件,你怀疑某个文件中某个字段格式导致后续计算全错。如果用行断点,程序会在第一个文件就暂停,你需要手动点“继续”十万次,这显然不现实。改成日志断点以后,你可以在断点处设置表达式,把当前文件路径、字段值打印出来,程序全程不暂停,最后只需要看控制台日志就能分析哪些文件有异常。
日志断点的配置也很简单:
- 右键断点,勾选 “Log message to console”。
- 在 message 里写一句话,用
{}占位符引用变量。 - 如果想要详细堆栈,就再加一个 “Log stacktrace to console”。
我特别喜欢把日志断点和条件断点结合起来用:先设置一个条件,只有满足条件才打印日志。这样连“大量重复日志”的问题都避开了。调试完毕后,日志断点保留不保留看情况,如果控制台日志已经够用,直接删除断点也不心疼,因为它本来就不影响流程。
3.3 命中次数的场景:只看第 N 次
Pass count 这个配置常用于“问题只在前 N 次不会出现,后面才出现”的循环或递归场景。比如一个分页查询接口,第 3 页返回的数据异常,但前两页都正常。这时候断点加条件page == 3就可以了。但如果是在一个没有明确条件的递归逻辑里,你只想在递归深度到 100 的时候停下来看看,那 Pass count 就派上用场了。
设置 Pass count 后,断点会在第 N 次命中时才真正暂停。这里注意,IDEA 里有些版本把 Pass count 写为“会跳过前几次”,不同版本的文案略有差异,总之“命中 N 次才真正触发”这个概念能对上。
3.4 断点条件计算对性能的影响
条件断点好用,但代价也不小。程序每执行到这一行,都需要额外做一次表达式计算。如果一个条件表达式本身很慢,比如遍历一个大集合、调用数据库、做字符串匹配复杂正则,那整体性能会明显下降。
我实测过一个大集合的循环中,条件里写了个list.contains(),结果 100 万条数据整整跑了十几分钟。后来改成在循环外面用一个 Set 判断,瞬间就快了。这类性能问题不是 Debugger 的锅,而是条件表达式写得不够高效。
调试场景下也要养成“用完就清理”的习惯:临时条件断点改过的代码、加过的表达式,排查完尽量还原,否则下次再跑性能问题就会重现。
4. 多线程与异步代码调试:线程视图、挂起策略与并发套路
多线程代码是调试里的老大难。单线程下断点停住,整个世界都静止;多线程下断点停住,往往只是一个线程停了,其他线程还在继续跑,状态互相影响,很容易把问题看乱。IDEA 的 Debugger 对多线程调试提供了不少支持,但很多人没有认真用过。
4.1 先分清 Threads 与 Frames
IDEA 的 Debugger 窗口顶部一般有两个下拉/列表区域。左侧或者上方的 Threads 列表显示当前所有线程,右侧是当前选中线程的调用栈 Frames。
很多初学者会忽略 Threads 列表,只盯着当前 Frames 看。但多线程问题恰恰需要你不断切换线程视角:
- 先看哪个线程处于
RUNNABLE、哪个线程处于BLOCKED、哪个线程处于WAITING。 - 选中一个线程后,下面 Frames 会切换成它自己的调用栈。
- 如果多个线程都停在同一个锁上,你能看到它们在等谁释放锁。
有一次我排查一个死锁问题,两个线程互相持有对方的锁。我就是在 Threads 列表里切换查看两个线程的栈帧,分别看到各自停在synchronized方法上,又检查了 Monitor 对象信息,很快锁定了加锁顺序不一致的问题。
4.2 Suspend All 与 Suspend Thread 的区别
断点属性里有一项是 Suspend policy,分为两个选项:
- Suspend All:所有线程都暂停,整个 JVM 停在断点处。
- Suspend Thread:只有命中断点的当前线程暂停,其他线程继续跑。
默认是 Suspend All,适合大多数场景,因为暂停整个应用状态比较稳定,方便查看全局数据。但在调试“某几个线程在竞争,其他线程只是背景噪声”的场景时,Suspend Thread 会更好用。
举个例子,一个线程池跑了 20 个任务,其中有一个任务处理异常。如果你用 Suspend All,断点一命中,20 个线程全停了。你其实只关心出问题那个线程,其他线程停不停对你排查没有帮助,反而会掩盖竞争条件。这时候改成 Suspend Thread,只把那个任务线程停住,其他线程继续执行,你能更贴近真实运行状态。
Suspend Thread 也让调试变得更复杂:因为其他线程还在跑,你查看的共享变量可能一直在变。所以我的建议是:默认 Suspend All,只有明确发现“其他线程继续跑才会复现 bug”时再改 Suspend Thread。
4.3 用条件断点按线程过滤
如果你只想在某一个特定线程上停下来,最粗暴的办法是在断点条件里写线程名:
Thread.currentThread().getName().contains("order-task")这样无论多少个线程执行到这一行,都只有线程名带 “order-task” 的那个会真正暂停。这个技巧在处理线程池、异步任务、定时任务时非常实用。
还有一个注意点:Spring 的 @Async、定时器线程池默认都会有命名的线程前缀,比如SimpleAsyncTaskExecutor-1、scheduled-1等。你可以先停一次,在条件里输出Thread.currentThread().getName()确认线程名,再补上精确过滤。
4.4 多线程断点导致的“假死”和虚假等待
很多人调试并发代码时会发现:一旦断点命中某个线程,程序就卡住不动了,点击 Resume 也没反应。这往往是因为被暂停的线程持有锁,而其他线程正在等待这个锁;你恢复了主线程,发现下游还得继续走;但停住的那个线程还没释放锁,导致整个流程推进不下去。
遇到这种情况,先别慌。先看当前断点命中的线程是不是锁的持有者,再看等待方线程卡在哪里。一个常用的变通方案是:在断点处用 Evaluate Expression 主动执行“解锁”或者修改状态,或者干脆先把断点禁掉,让线程跑完。否则你会在“死锁调试”里越陷越深。
排查并发问题的核心是“看线程状态、看锁对象、看调用栈”,IDEA 的多线程视图把这些信息都摊开了,剩下的就是你的逻辑判断。
5. 集合与 Stream 调试:用 Trace Current Stream Chain 看懂每一步
Java 8 之后,集合和 Stream 的链式调用越来越常见,但链式表达式调试起来特别别扭。你想知道一个map之后的数据变成什么样,通常的做法是在 Stream 中间某一行打断点,然而 Lambda 里没有明显的行号,一个链式表达式往往写在一两行内,断点一停就是整条链式调用的中间某步。IDEA 专门为这种场景提供了 Stream 调试能力。
5.1 集合里到底装了什么:展开与自定义视图
程序停在断点处,Variables 面板会显示当前集合对象。List、Map、Set 都可以展开,看到内部元素。对于 List,默认显示的是元素列表和长度;对于 Map,显示的是键值对。这一块很多人都会用,但很少有人会去点集合对象左侧的“View”下拉,或者邮件选择“Evaluate Expression”。
实际工作中,我经常遇到集合对象已经展开,但元素太多、找到目标元素很费劲的情况。这时候我会直接在集合对象上右键,使用 Evaluate Expression,写一段临时表达式来过滤:
// 在 Evaluate Expression 中执行,找出某个用户的订单 orders.stream().filter(o -> o.getUserId().equals("u_10086")).collect(Collectors.toList())调试时用 Evaluate Expression 跑一段临时逻辑,不会影响程序本身,却能帮你在巨大集合中快速缩小范围。
5.2 Trace Current Stream Chain:把每一步都拉出来看
这是 IDEA 2018 之后加入的功能,对我来说算是“救命级”的调试工具。当断点停在 Stream 链式调用上时,Debugger 面板会多出一个按钮,通常叫Trace Current Stream Chain(图标看起来像一串小圆点加箭头)。点击后,IDEA 会打开一个新的 Stream 调试窗口,把整条 Stream 的每个中间操作拆成列展示,每列显示当前步骤的元素内容和变化过程。
比如下面这段代码:
List<String> result = orderList.stream() .filter(o -> o.getAmount() > 100) .map(Order::getOrderNo) .distinct() .collect(Collectors.toList());点开 Trace Current Stream Chain 后,你能看到 filter 前有多少订单、filter 后留下来多少、map 之后变成哪些订单号、distinct 之后又剩多少。这样一来,如果 final result 不对,你一眼就能看出是 filter 条件过严,还是 map 转换时字段取错。
我之前遇到过一个问题:用户下单数量统计总比实际少几条。业务代码里对订单流做了三次 filter,我一行行执行太麻烦,用 Trace Current Stream Chain 直接看到第二次 filter 把一批“订单金额为 0 但确实下单成功”的记录全过滤掉了。如果不用这个功能,我要么拆代码加日志,要么手动算一遍中间值,排查成本高得多。
5.3 用 Evaluate Expression 拆解长 Lambda
Stream 链式表达式如果很长,比如一个流水线套了三层 map、两层 filter,中间还引用了一个外部工具类,光靠读代码很难看出每一步的真实输入输出。我通常会在断点处,用 Evaluate Expression 分步执行整条流水线的一个片段。
例如先执行:
orderList.stream().filter(o -> o.getAmount() > 100).collect(Collectors.toList())查看暂时的过滤结果,再继续在上一步结果上追加 map 操作。这会非常直观地展示 Stream 的执行路径。
另外,Evaluate Expression 还有一个很实用的场景:临时调用某个 getter 方法。比如你看到变量user上有 id、name 字段,但你不知道user.getAddress()是否为空,直接在 Evaluate Expression 里输入user.getAddress(),回车就能看到结果,不用再去浏览器或测试工具里拼接口。
5.4 不重启就调整 Lambda 里的局部变量
在 Lambda 表达式内打断点,有时候会遇到一个问题:lambda 内部引用的外部局部变量会被拷贝成一个新变量,你在 Variables 面板里修改外部变量的值,可能不会影响 lambda 里的那份拷贝。这种情况下,直接在断点处的 Evaluate Expression 里改 lambda 内可见的变量,效果更可控。
比如循环里有个map操作,把字符串转成大写,你想看看如果把输入改成“TEST”会发生什么。断点在 lambda 内部停住后,直接在 Evaluate Expression 里把参数变量的值设置成"TEST",再继续往下走,就能验证你的猜想。但这种修改只对当前执行帧有效,别指望它能回写外部集合。
6. 不重启也能改代码:Drop Frame、Set Value、Force Return 与热加载
调试的终极效率,是在不停应用、不重新部署的情况下验证你的修改是否有效。IDEA 里 Drop Frame、Set Value、Force Return 这几个操作组合起来,几乎可以在运行时“实时改剧本”,前提是你得理解它们的边界。
6.1 Drop Frame 到底干了什么
Drop Frame 在 Debugger 工具栏上,点击后当前栈帧会“弹出”,程序会重新回到该方法的调用处,再次进入这个方法。很多人以为它是“回滚代码”,其实不完全是。
- 它能让当前方法重新执行,但不会重置局部变量,如果你的方法内部在进入之前修改了某个对象的字段,重新进入时会保留这个修改。
- 它只能往前放弃当前栈帧,不能反向跳过已经执行完的调用栈。
- 它不能跨过递归、不能跨过 native 方法、不能跨过某些第三方代理对象。
我最常拿它来反复走同一段逻辑。比如有一个方法想把订单状态从“待支付”改成“已支付”,但每次改完又希望重新走一遍同一段逻辑验证不同分支,用 Drop Frame 就能避免重新发起一次完整的接口调用。
6.2 Set Value:在断点处“篡改”数据
在 Variables 面板里,选中一个变量,按 F2 或者右键 Set Value,可以直接修改它的值。这个功能在调试时非常实用。
举个例子,你在循环里想测试order.getStatus() == 3和order.getStatus() == 5两种分支。如果每次都改代码里的入参然后重启,效率太低。直接在断点处把status字段的值改成 5,继续执行,就能进入另一个分支验证逻辑。
我在真实项目里还会用它来绕过一些不好构造的入参。比如第三方接口返回的对象,在本地很难模拟一个带 100 个字段的响应,这时候断点停在解析处,直接 Set Value 把关键字段改成期望值,后面流程正常执行即可。
注意:Set Value 修改的是 JVM 内存中的对象引用或值,不是修改源码。如果代码逻辑后续又对这个变量赋值了,修改会被覆盖。
6.3 Force Return 和 Throw Exception
这两个操作都在右键当前栈帧的菜单里。
- Force Return:把当前方法强制返回一个指定值,方法后面的代码不会执行。
- Throw Exception:在当前位置抛出一个指定异常,用来模拟异常路径。
正常开发中,这两个操作主要用于“测试异常分支”和“跳过耗时操作”。比如调用一个第三方查询接口,本地环境连不上,你可以 Force Return 一个 mock 结果,然后继续往下调试业务逻辑。或者你想看 catch 块里的告警日志是否正确,直接在 try 块里 Throw Exception,程序就会进入你期望的异常处理路径。
6.4 热加载的范围与限制
Debug 模式下,IDEA 支持对已运行的 JVM 做热加载。当你修改了方法体、字段值、变量名等“不改变结构”的内容,点击 Build Project 或使用 JBR 的热替换,新代码一般能直接生效。我之前调一个工具类的排序规则,改了 compare 方法里的一个条件,不用重启 Debug 会话,运行中的程序下一次调用就是新逻辑。
但热加载有硬性边界:
- 新增方法、新增类、修改方法签名、修改继承关系,基本都不能热加载,IDEA 会提示你需要重启。
- 修改 Lambda 表达式时,情况比较复杂,有的版本支持,有的版本会提示“类结构已变化,需要重启”。
- 修改注解、泛型、静态变量类型也有可能不生效。
所以我的经验是:Debug 过程中小改直接用热加载,大改还是重启稳妥,别为了省那几十秒浪费更多排查时间。
7. 断点失效、卡顿、找不到源码——常见翻车现场逐个排雷
调试工具本身也会出问题。最让人血压升高的事情莫过于:断点在行号上明明打了红点,程序也执行到了这一行,但就是不暂停。这里我把实际中常见的“断点翻车”梳理一遍。
7.1 断点不命中的几类原因
- 断点被 Mute(静音)了。如果断点图标上有一条斜线,右键检查一下是不是点了 Mute Breakpoints。这个问题最常见,最容易忽略。
- 代码没有重新编译。IDEA 里改了代码,但 Debug 运行时用的还是旧的 class。尤其是 Maven/Gradle 多模块项目,某个子模块的源码改了,如果没有触发编译,断点就落在旧 class 上。我一般会先 Build Project 再重新 Debug。
- 没有走 Debug 模式。听起来像废话,但真的有人用 Run 模式启动程序,然后再从 Debugger 窗口加断点,发现不生效。
- 断点打在接口上或抽象方法上。这类位置没有实际的执行代码,需要打在实现类或具体方法上。
- 断点打在 Spring 代理类上。项目中大量使用 @Transactional、@Async 时,实际执行的是代理对象里的逻辑,IDEA 有时无法将断点精确映射到目标行。可以尝试在事务方法实现类上加断点,或者关闭部分代理配置。
7.2 调试时程序卡死或特别慢
- 条件断点太频繁。每次执行到断点都计算一次,条件表达式又很重,程序自然慢。
- 方法断点太多。方法断点开销远高于行断点,尽量不要在循环内的方法上打方法断点。
- 日志断点打印了海量日志。如果条件范围太宽,控制台的输出本身就能把 IDE 拖垮。
- 调试器变量计算副作用。IDEA 的 Variables 面板默认会显示变量值,某些对象在显示时会调用 toString()。如果 toString() 实现里有网络请求或复杂计算,调试就会非常慢。遇到这种情况,可以在断点属性中关闭自动预览,或者把该类的 toString() 临时注释掉。
7.3 变量看不到、源码找不到
断点停了,但 Variables 面板里只有this,看不到局部变量。有一种可能是当前停在类加载阶段或者 JIT 优化过的代码上,IDEA 没法采样局部变量。还有一种可能是停在了反编译出来的第三方类上,源码缺失。
- 遇到源码找不到,点击“Download Sources”下载对应 jar 的源码。
- 遇到局部变量不显示,可以先用 Evaluate Expression 手动输入变量名,看能不能取到值。如果 Evaluate 也没有,那多半是代码被 JIT 编译优化了,可以尝试在 Debug 配置里关闭 JIT 相关优化(其实 IDEA 默认已经做了很多处理,个别江湖偏方是调整 -Xint 参数,不推荐在生产环境干这种事)。
7.4 我个人的一个习惯:每轮排查只保留“怀疑链条”上的断点
最后分享一个我自己的调试习惯:每轮排查只保留“怀疑链条”上的断点。不要一口气打 20 个断点,程序停来停去,最后自己都不知道看到的是哪一层的数据。
正确做法是先打一个入口断点,确认数据进入时是对的,再往下走一步,在下一个分叉点加断点,逐步缩小范围。每验证完一个环节,就把旧断点删掉。这样调试链路清晰,也不容易出现“断点太多互相干扰”的问题。
Debug 这种东西,技巧学再多,不练永远没感觉。你平时可以先拿自己项目里一个慢接口练手,从“去掉 verbose 日志”开始,把断点、条件、Evaluate Expression 这几个基本功用熟。等哪天遇到一个莫名其妙的并发问题,你会发现下意识就打开了线程视图,加了线程条件断点,十分钟内就把问题圈出来了。到那个时候,Debug 对你来说就不再是“试错”,而是一种顺手的习惯。