
写代码这么多年IDEA 的 Debug 功能是我用得最多的功能之一。很多时候我看同事调试还停留在点一下 Breakpoint、按一下 F8 的地步断点条件不会设表达式求值没用过多线程一调试就乱套。这篇东西就是把我实际排查问题过程中沉淀下来的 IDEA Debug 调试技巧做一个系统梳理会从调试前的准备工作讲起逐步深入到条件断点、表达式求值、方法断点这几个核心功能再配合一个接口参数异常的真实案例演示完整排查链路最后把多线程调试、远程调试、热部署这几个高频场景一起覆盖到。适合刚接触 Debug 的新手建立完整认知也适合有一定经验但只停留在基础用法的同学查漏补缺。1. 调试前的准备把工具调顺后面少踩一半坑很多人忽略这一步拿到项目直接开调。实际上 IDEA 默认配置和项目环境有不少会影响 Debug 体验的地方我这里按优先级从高到低讲。1.1 调试的本质不是“看代码跑没跑”而是“观察代码为什么这么跑”调试的核心价值不在于确认程序有没有执行到某一行而在于回答“数据在这里变成了什么”“调用链为什么走了这条路”“这个异常是在哪一步被抛出来的”。我用一个生活化的类比解释一下Debug 相当于你在开车时给发动机装了一套仪表盘你不仅要看到车子有没有熄火还要看到转速、油温、进气量在哪个瞬间出现了异常。Step Over、Step Into 这些操作就是你在不同仪表之间切换视角的手段。理解这一点之后你会意识到 Debug 的关键不是按钮怎么按而是怎么选择观察点。观察点选错了后面按一万次 F8 也定位不到问题。选择观察点的核心思路是先通过日志、报错堆栈、数据返回结果圈定几个可疑区域再在可疑区域的关键变量处下断点而不是从头到尾一行一行跟过去。1.2 环境与初始配置快捷键、编译和断点设置IDEA 的 Debug 相关默认配置大部分够用但有三个设置我建议进场前先确认。第一是快捷键方案。IDEA 默认的 Keymap 是 Windows 或 macOS 自带方案Debug 最常用的几个快捷键建议先背下来操作Windows 快捷键macOS 快捷键作用打断点/取消断点CtrlF8CommandF8在光标行切换断点查看所有断点CtrlShiftF8CommandShiftF8打开断点管理窗口Step Over单步跳过F8CommandF8执行当前行不进入方法内部Step Into单步进入F7CommandF7进入当前行调用的方法内部Force Step IntoAltShiftF7OptionShiftF7强制进入方法可进入 JDK 内部方法Step Out跳出方法ShiftF8ShiftCommandF8跑完当前方法并返回调用处Run to Cursor运行到光标处AltF9OptionCommandF9从当前断点直接运行到光标位置Evaluate Expression表达式求值AltF8OptionCommandF8在调试会话中执行任意表达式Resume Program继续运行F9CommandOptionR运行到下一个断点这个表值得你花两分钟过一遍调试过程中 90% 的操作都落在这些键上。记不住没关系调试工具窗口右侧的按钮也有同样的功能鼠标操作也能跑通只不过效率差一些。第二是 Build 配置。IDEA 默认在启动 Debug 前会先编译变化过的代码这个行为在大多数情况是正确的。但如果项目特别大编译耗时很痛苦可以在 Settings - Build, Execution, Deployment - Compiler 里把Build project automatically选项打开配合CtrlShiftF9手动编译当前文件或模块这样启动调试时就不会全局编译只编译你改过的东西。这里要注意Build project automatically打开之后IDEA 会在代码改动后自动编译但这个编译结果不会自动热加载到运行中的 JVM需要配合第 4 节讲的热部署方案才能真正“改完即生效”。第三是断点默认行为。在断点上右键打开设置面板有一个Suspend选项默认是All意思是线程执行到这个断点时所有线程都会暂停。这个默认值在单线程调试时没问题但调试 Web 应用这种天生多线程的程序时会很烦你在断点处观察数据其他请求线程也一起被挂起整个应用像死了一样。如果只想挂起当前线程可以把断点的 Suspend 改为Thread。后面第 4 节讲多线程调试时还会细聊。1.3 认识 IDEA 的 Debug 工具窗口开启一次 Debug 会话后IDEA 底部会出现 Debug 工具窗口整个布局分五个核心区域Frames 区域位于左上角显示当前线程的调用栈。从栈顶到栈底就是方法调用的从近到远排查问题时经常要在这里切换栈帧看调用来源。Variables 区域位于 Frames 下方实时显示当前栈帧内的局部变量、参数、静态变量的值。这是调试时视线停留最久的区域。Watches 区域默认和 Variables 放一起右上角可以切换 Tab。这里可以手动添加你要持续观察的表达式。Console 区域左下角显示 System.out 输出和日志。如果你打了 Log 类型的断点输出也在这里查看。Threads 区域在 Frames 上方的下拉列表里可以切换当前线程。多线程调试时主要靠它切换视角。我见过很多新手把目光死死钉在 Console 上等着看输出其实调试时信息量最大的是 Variables 和 Frames。真正定位问题的过程基本就是“盯着 Variables 里的数据变化 在 Frames 里向上追调用来源”的组合打法。先把这几个区域认清楚后面的技巧才有落点。2. 核心断点技术从入门到进阶的断点玩法断点是 Debug 的基石但“打断点”这件事很多人从入门到工作几年都在用最基础的方式在某一行左侧单击出现红点然后 Run - Debug。这种方式实际项目中根本不够用。下面我把断点按使用场景拆开讲。2.1 行断点和条件断点的正确用法行断点是 IDEA 默认支持的断点类型单击行号右侧空白处即可创建。问题在于你断在一个循环体里这个循环可能跑一万次难道手动按一万次 F9 吗显然不现实。这时候就该条件断点上场。条件断点的使用方式是在断点上右键macOS 是双指点击在Condition输入框里写一个布尔表达式。比如下面这段代码for (OrderItem item : order.getItems()) { // 想在商品 ID 是 10086 的那次循环停下来 BigDecimal amount item.getPrice().multiply(item.getQuantity()); total total.add(amount); }直接在断点 Condition 里写item.getId() 10086程序运行到这一行时会先计算这个表达式只有表达式结果为 true 时才挂起。这里有几个注意点Condition 表达式里可以调用当前作用域内的方法比如item.getQuantity() 10、error.equals(log.getLevel())都行但不要写有副作用的方法调用。比如在 Condition 里调用order.remove(item)每经过一次断点就会改一次数据调试完数据已经被污染了这个 bug 会被误判为业务 bug。表达式写错不会报编译错误而是会抛运行时异常IDEA 会弹窗提示。如果你写的是item1.getName().equals(test)但当前变量的实际类型没有 getName 方法会直接抛出异常并中断调试。条件断点比普通断点慢一些因为每次执行到该行都要计算条件表达式。在性能敏感的大循环里批量设置太多条件断点会让程序明显变卡。我的习惯是先用普通断点确认代码确实执行到了再改成条件断点缩小范围。2.2 方法断点关注“这个方法被谁调用了”调试时经常遇到的情况是某个方法的返回值不对但是你又不知道这个方法被谁调用的。如果只在方法内部第一行打断点每次调用都会停而且调用来源要从调用栈里翻有点绕。IDEA 的方法断点可以直接打在方法定义行上图标是两个小圆点叠在一起。当程序执行到这个方法时IDEA 会先停一次进入方法前再停一次方法结束后调试窗口会显示Method entry和Method exit两种状态。这个方法断点的价值在于可以快速查看所有调用方。断点命中后 Frames 区域会显示完整的调用链向上翻就能找到调用点。可以在方法退出时检查返回值。在断点设置面板里勾选Method exit然后配合表达式求值查看 return value。但说实话方法断点的性能开销远大于行断点因为 JVM 对方法断点需要额外的调试支持。我在线上项目里一般不会挂太多方法断点通常先用调用链搜索CtrlAltF7查看方法被谁调用缩小范围确认几个候选入口之后再决定要不要在方法边界打断点。2.3 字段断点监听某个变量被修改的瞬间字段断点更加隐蔽。场景是这样的一个订单状态字段orderStatus多个地方都在更新它。你觉得某处把它从“待支付”改成了“已取消”但找不到是在哪里改的。这时候字段断点就非常有用。把断点打在字段声明行上比如private Integer orderStatus;断点默认是字段访问和修改时都会暂停。一般我会关掉Field access只保留Field modification这样只有赋值操作发生时才会暂停。字段断点有个好处它天然带调用栈信息一旦命中你能立刻看到是谁在哪个方法的哪一行改了它。这个功能在排查并发环境下“共享变量被莫名修改”的问题时特别好用。我之前排查过一个库存超卖问题就是靠字段断点在stock字段上监听修改结果发现是两个不同线程先后写了同一个变量很快定位了加锁范围。2.4 异常断点不放过任何异常抛出的瞬间异常断点的意思是不指定具体在哪一行断而是指定一个异常类型比如NullPointerException或Exception程序运行过程中一旦抛出这种类型的异常就会自动暂停在抛出位置。设置方式Run - View Breakpoints - Add Exception Breakpoint填入异常类型。这个功能非常适合排查那种“日志里看到异常但就是从日志里找不到根因”的场景。比如我们遇到过一个ClassCastException日志里只打印了一行栈信息没有上下文。用异常断点挂上ClassCastException之后重跑一次IDEA 直接把我们带到抛出异常的那行代码配合 Variables 一眼看出是哪个对象被强转成了不匹配的类型。之后再顺着调用链查就发现是某个接口返回的数据结构在版本升级后改了字段类型导致反序列化后的对象类型不一致。异常断点比普通断点更耗性能因为 JVM 每次抛出异常时都要判断类型是否匹配。建议只在你正排查问题的那几分钟打开查完立刻移除。3. 实战案例一次接口参数异常的完整排查过程在这节里我完整复现一次我之前排查真实线上问题的过程。这个案例基本涵盖了 Debug 调试的常规操作组合从问题复现到定位根因再到验证修复方案整个链路走完你就能理解前面讲的各种断点到底在什么场景下用得上。3.1 问题复现与初步定位当时的情况是这样的线上有一个订单查询接口调用方反馈说传同一个orderId有时候能查到数据有时候返回“订单不存在”。因为同一参数不同结果第一反应是缓存、多数据源或并发问题。但这个问题不一定是必现的本地拿同一个参数调了好几次都正常。我先把问题缩小既然本地调都通问题可能出在数据状态差异上。于是我连了预发环境的数据库把线上那条订单数据拉到本地用同样的参数跑一遍果然复现了。接下来我打了一个普通断点在订单查询 Service 的入口方法第一行。启动 Debug 之后发现参数里的orderId确实传进来了但 Service 内部又根据用户 ID 组装了一个查询条件问题出在这个查询条件上。于是我把断点往下移移到查询条件组装完成的那一行把 Variables 窗口里组装出来的条件对象展开发现用户 ID 和订单 ID 没有对应关系。到这里已经定位到不是查询逻辑的问题而是调用方在组装请求参数时把用户 ID 传错了。但这个定位过程如果一开始就在 Service 入口打断点一行一行跟着走效率会很低——因为业务链路很长前面十几行都是参数校验和转换逻辑。3.2 逐步跟踪Step Over 和 Step Into 的组合打法复现问题之后我打了一个条件断点让程序在orderService.getOrderDetail(userId, orderId)这一行且orderId等于特定值时才停下来OrderDetail detail orderService.getOrderDetail(userId, orderId);断点条件写的是orderId.equals(20250115001)。命中之后我先用 Step Over 执行当前行看看正常流程走完会返回什么。结果返回对象为 null确认问题出在 Service 内部。这时我想进入getOrderDetail方法内部看查询条件按 F7 进入。进入之后发现方法内部还有一个远程接口调用通过restTemplate.exchange()去拿订单扩展信息。我怀疑是不是扩展信息接口导致订单查不到于是我在远程调用的返回结果上加了 Watch选中表达式后右键 - Add to Watches观察返回内容。这里有一个我在实际项目里反复踩过的坑Step Into 有时会进入你不想看的方法比如OrderDetail的构造函数、toString()方法甚至进入 JDK 的底层代码。如果你不想钻研 JDK 源码就别用普通 Step Into而是用Force Step Into有选择地进入或者先用 Step Over 跳过那些非关键调用等走到真正关键的方法入口时再按住Shift键点击调用行IDEA 会提示你选择要进入的具体方法。这个Smart Step Into在方法嵌套很深时非常好用。3.3 用 Frames 回看调用来源找到参数源头我进入getOrderDetail之后在查询订单主表之前发现参数userId已经是错的。也就是说问题根本不在 Service 内部而在 Controller 层组装参数时就已经错了。在 Frames 区域当前调试停留在OrderServiceImpl.getOrderDetail这一帧上面一层是OrderController.getDetail。点击栈帧可以直接切换到 Controller 层那一层Variables 也跟着切换成 Controller 的局部变量。我在 Controller 局部变量里看到userId是从登录态对象里取出来的而orderId是从请求参数里解析的。这时问题就变成登录态对象里的 userId 为什么不是当前请求用户的继续往上翻发现 Controller 层之上还有一个拦截器拦截器在通过UserContext.setUserId()设置用户上下文。我因此怀疑是多线程导致的ThreadLocal上下文串号。为了验证我在UserContext.setUserId()上打了一个字段断点监听 userId 字段的修改再跑一次发现第一次是正常用户 A第二次变成用户 B 的 ID。这就坐实了这是线程并发时上下文覆盖导致的问题。从这里你看出来Frames 区域不只是给你看的它还可以直接点击切换。排查“数据源头在哪里”这类问题时从栈顶一层一层往回点等于在反向追踪调用链比从入口正向跟要快得多。3.4 修复与验证把调试结果转化为代码改动定位到根因之后修复方案是给这个请求链路启用独立的上下文传递不让子线程继续复用父线程的ThreadLocal数据。修好之后重新 Debug在同一个条件断点处停下查看userId已经是正确的值再 Step Over 往下执行发现订单返回正常。在整个验证过程中我没有重新启动应用只改了代码IDEA 在 Debug 模式下会自动编译配合Build - Reload Changed Classes完成了类替换。这是 Java 调试的一个隐藏福利在 Debug 会话里修改代码IDEA 会把改动后的类热替换到正在运行的 JVM 中。大多数业务方法修改是支持的但要注意修改类的结构添加/删除方法、字段时热替换会失败IDEA 会提示需要重启应用。后面第 4 节我会讲替代方案。4. 多线程和远程场景的调试技巧很多同学在单线程调试里很熟练一到多线程就懵。IDEA 的多线程调试并不是无解核心是搞懂线程切换和挂起策略两个概念。4.1 多线程调试的线程切换与挂起策略默认情况下断点 Suspend 是All意味着任意线程命中断点所有线程都会暂停。这在多线程并发场景下有两个问题有些线程可能阻塞在其他线程持有的锁上全部挂起会导致互相等待看起来像死锁一样停住不动。你想观察两个线程各自的执行顺序但 All 模式把所有线程都冻结了观察不到真实的交错执行。解决方式是把断点的 Suspend 改成Thread。改完之后线程 A 命中断点时只有 A 暂停其他线程继续跑。在 Debug 工具窗口左上方的 Threads 下拉框里你可以手动切换到线程 B 查看它的变量和执行位置再切回线程 A 继续走。这种“只冻结当前观察对象、放行其他线程”的模式才是多线程调试的正确打开方式。有一个调试并发问题的实用技巧把两个关键断点的 Suspend 都设为 Thread然后选择合适的时机切换线程手动模拟出“线程 A 执行到一半、线程 B 进来”的交错场景。比如排查 ArrayList 在多线程 add 时抛 ConcurrentModificationException 的问题你就可以让线程 A 停在 add 之后、遍历之前再切到线程 B 执行 add切回线程 A 继续遍历异常就稳定复现了。4.2 Debug 模式下的热部署改完代码不需要重启传统调试流程是“改代码 - 重启 - 复现 - 再改”一来一回很伤。IDEA 在 Debug 模式下可以通过以下几种方式减少重启次数。第一种是 Debug 模式下直接改方法体内部代码编译后 IDEA 会自动热替换。这个机制基于 JVM 的 RedefineClasses只对方法体内部修改有效不支持增加或删除方法、字段、修改类签名。第二种是引入 Spring Boot DevTools它会监听 classpath 变化并自动重启应用上下文。这里的“重启”不是 JVM 重启而是 Spring 容器刷新速度比完整重启快很多。配置方式是在pom.xml里加依赖dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-devtools/artifactId scoperuntime/scope /dependency启动 Debug 后修改代码IDEA 编译完成时 DevTools 会自动触发上下文刷新你只需要在页面重新请求一次改动就生效了。注意 DevTools 默认不会监听静态资源变化可以忽略不看。第三种是使用 IDEA 自带的HotSwap快捷键CtrlF9macOS 是 CommandF9只重新编译并热替换当前文件。这个方法轻量、不引入额外依赖适合没有 Spring Boot 的老项目。我实际使用时最常用的就是改完代码直接按一下快捷键完成类替换不需要等 Spring 上下文重启。4.3 远程调试连接服务器上的 JVM本地能复现的问题都好解决真正棘手的是“本地跑不出来只有服务器上报错”。IDEA 的远程调试功能可以让你在本地上代码、看变量、设断点实际操作的是服务器上的 Java 进程。启动远程调试需要两步。第一步在 IDEA 中选择Run - Edit Configurations点击左上角加号选Remote JVM Debug把 IDEA 自动生成的 JVM 参数复制下来-agentlib:jdwptransportdt_socket,servery,suspendn,address*:5005第二步把这串参数加到服务器上应用启动命令里然后重启应用。本地点一下 Debug 按钮IDEA 就会通过 socket 连接到服务器 JVM后面所有调试操作和本地一致。这里有几个注意点。首先是端口问题保证服务器防火墙放行了调试端口同时最好只在预发环境或内网环境使用不要直接暴露在公网。其次是suspendn表示不阻塞应用启动应用先正常起来之后连上调试器就能用。如果suspendy应用会一直等待调试器连接没连上就不启动适合定位启动阶段的问题。最后是连接之后你的断点会影响线上请求——有请求经过断点就会被暂停所以在生产环境做远程调试这件事本身风险极高我一般只建议在测试或预发环境操作。ITjack的博客提到远程调试时有时 IDE 会显示“Connection refused”排除防火墙之后多半是地址绑定问题。JDK 9 以上版本默认监听 IPv6地址需要写成*:5005而不是localhost:5005否则会出现连接超时或者根本无法连接的诡异现象。5. 常见问题与排查技巧实录这一节把我在各种项目里反复遇到、也看到同事经常踩的坑集中说一下。每一条都是我实际踩过或者看到别人深陷其中之后总结出来的直接对应真实场景。5.1 断点不生效的几种典型原因断点明明打了但程序跑过却不停这种情况排查优先级从高到低如下编译版本和源码不一致。改了代码但没触发编译打断点的位置是旧 class 文件的位置。解决方式是Build - Rebuild Project强制重新编译。启动模式选错。如果你启动的是 Run 而不是 Debug那断点就是纯摆设。确认启动按钮旁边的小虫子图标是否点亮。多模块项目里依赖了其他模块的 jar 包。断点在依赖模块源码里但运行时实际用的 class 来自 jarIDEA 没法在 jar 里断行。解决方式是把依赖模块改成 project 依赖或者用Maven - Reload All Projects重新导入。代码被 JIT 优化。JVM 在运行一段时间后会对热点代码做编译优化可能导致某些断点位置不再命中。Debug 模式下 JIT 优化会部分禁用所以这种情况少见但遇到莫名不生效时可以试试在 Run 配置的 VM options 里加-Djava.compilerNONE。检查断点是否被整体禁用。在Run - View Breakpoints窗口里如果断点被勾选为Enabled但左边有斜线说明被 mute 了工具栏有一个“Mute Breakpoints”的按钮点击后所有断点变成灰色再点一下恢复。5.2 Debug 启动速度慢的提速方案项目大了之后Debug 启动比 Run 慢是必然的因为 IDEA 还需要初始化 JDI 调试通道。如果慢到无法忍受可以考虑几个方向在 Run/Debug 配置里的VM options中加上-Xdebug -Xrunjdwp:transportdt_socket,servery,suspendn,address5005手动指定调试端口和参数有时候能规避 IDEA 自动生成调试参数时的额外开销。关闭不需要在启动阶段执行的断点。如果你有大量命中频繁的断点JVM 在启动阶段就要检查断点启动速度会雪上加霜。全部断点可以在Run - View Breakpoints里按 CtrlA 全选后取消。确认 IDE 索引没有在后台跑到一半。IDEA 在做索引时本来就很吃 CPUDebug 启动慢不一定是调试本身的问题。看右下角进度条等索引完成再跑。5.3 调试过程中滥用 System.out 的坏习惯说实话用System.out.println打日志来排查问题不丢人但问题是打完日志之后这些代码不会自己删除。一旦提交带调试输出到共享分支别人跑起来满屏输出体验极差。更麻烦的是在并发场景下 println 的输出顺序不一定反映真实顺序因为多线程打印时会互相穿插甚至因为缓冲区刷新时机导致顺序错乱。再严重点在 JDK 7 中并发调用System.out.println可能存在性能问题。更好的替代方案是使用日志框架如 slf4j logback输出到控制台并设置 logger 级别为 DEBUG排查完把 level 调回来。如果你只是临时想看某个值Do Use IDEA 自带的表达式求值功能选中变量按 AltF8 执行结果直接显示代码里一行都不用加。这个习惯我已经保持了四年多排查问题的效率和代码整洁度都受益明显。5.4 条件断点表达式写错的“隐形坑”条件断点的表达式写错不报编译错误而是运行时抛异常IDEA 会在断点窗口给出错误提示。这种情况往往发生在修改了变量名、字段名却没有同步修改条件表达式的场景。另一个隐蔽的问题是空指针。比如你写了order.getItems().size() 5作为条件但某条数据的getItems()返回 null每次运行到这个断点就会抛空指针程序却不会真正挂起在断点处而是报错提示。解决方式是给表达式加空判断写成order ! null order.getItems() ! null order.getItems().size() 5我建议大家把“条件表达式一律判空”当成写条件断点的默认规范养成条件反射能省掉不少排查时间。5.5 调试快捷键速查与使用时机对照最后附一个小表把不同场景对应的钥匙组合整理一下截图存在手机里也行用一段时间自然就记住了。使用场景推荐操作原因只想看当前这行的结果F8 单步跳过速度快不进入细节想进入方法看内部逻辑F7 单步进入跟踪数据流转过程方法内部还有第三方调用Smart Step IntoShift单击有选择地进入特定方法在嵌套循环里调试条件断点避免手动循环很多次想知道异常从哪抛出异常断点直接停在抛异常位置想知道字段在哪里被改字段断点监听修改行为想从底层方法快速跳出ShiftF8 跳出方法回到上层调用点想直接跑到指定行AltF9 运行到光标省去中间断点这个表还有一层使用意义上的递进普通断点解决“有没有执行到”的问题方法断点和字段断点解决“什么时候被执行”“被谁修改”的问题异常断点解决“异常从哪来”的问题条件断点解决“数据在什么状态停”的问题。把这几个维度串起来你基本就能覆盖日常开发 90% 以上的调试需求。我个人的习惯是每到一个新项目都会花半小时把 IDEA 的 Debug 设置和快捷键过一遍确认当前团队有没有约定俗成的调试配置。有些配置在多人协作时会影响大家比如断点输出日志Log message to console和断点计数Pass count这类配置通常会“跟随项目”保存你加了之后同事也会看到所以操作完最好及时清除。调试就像看病先问诊、再检查、最后做手术直接上来就开刀往往在浪费时间和制造新问题。这个顺序是这些年踩坑踩出来的最实在的经验。