多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

从断点到线上排查:Java 调试完整进阶指南

从断点到线上排查:Java 调试完整进阶指南 干这行久了你会发现一个挺残酷的事实很多人写了几年 Java遇到 Bug 还是只会System.out.println或者干脆靠猜。IDE 里那个 Debug 按钮对他们来说就是“下一步、下一步、继续跑”三个动作的循环。Debug 远远不止是“打断点看变量”这么简单它是一套完整的“现场还原”方法论。断点怎么打、打在哪个位置、条件断点怎么写效率最高、多线程下怎么让断点不互相干扰、线上出问题没有调试环境时又该怎么办——这些才是区分“会写代码”和“会查代码”的分水岭。这篇内容我从五个层面展开Debug 的底层原理与断点体系、调试窗口里那些高频但不一定用得好的功能、多线程调试的难点、以及离开 IDE 之后怎么用日志和相关工具辅助定位问题。文章覆盖从入门到进阶的完整路径不管你是刚接触 Debug 的新手还是想查漏补缺的资深开发都能在其中找到对应的实用技巧和排坑经验。1. Debug 的本质不是按钮是“现场还原”1.1 先理解 JVM 在 Debug 模式下做了什么先花一分钟把底层原理讲明白因为理解了原理后面所有操作你都能触类旁通。Java 的调试能力不是 IDE 给的而是 JVM 本身就带的一套体系标准叫 JPDAJava Platform Debugger Architecture。这里面分三层JVM TI提供调试能力、JDWP定义调试者和 JVM 之间的通信协议、JDI给 IDE 用的 Java 接口。你点了 Debug 按钮之后JVM 会以调试模式启动并额外开启一个调试端口IDE 通过 JDWP 协议连上去双方建立起一条调试通道。当你设置的断点被命中的那一刻目标线程会被立即挂起JVM 把当前线程的调用栈、栈帧里的局部变量、对象的引用关系等状态全部暴露给调试器。这就是为什么断点停住之后你可以在 IDE 里看到那个变量名对应的值、可以把鼠标悬停在表达式上查看内容、甚至可以实时计算一段任意表达式。理解了这套机制你就明白三件重要的事。第一断点为什么只能在 Debug 模式下生效。因为普通 Run 模式不会开启 JPDA 的调试通道没有调试器在监听JVM 根本不会在断点位置停下来。有人开发时用 Run 跑代码发现断点变灰或者根本没效果多半就是没切到 Debug 模式。第二被挂起的是当前线程而不是整个 JVM。默认情况下 IDEA 里挂起策略是 All也就是任意一个线程命中断点所有线程都会暂停。这个行为在多线程场景里会导致一种奇特的“时序冻结”后面讲多线程调试的时候再详细说。第三调试器跟你“看到”的并不一定完全相同。JVM 层面有 JIT即时编译优化有些局部变量在编译后的字节码里根本不存在或者被优化到寄存器里了这个时候 IDE 里那个变量栏会显示“Variable is not available”或者直接不显示。这不是工具坏了是 JVM 的主动优化行为。你会看到有些老司机调试时在变量值上右键选择“Force Return”这本质上也是在利用调试接口对目标线程的栈帧做手工干预没有 JPDA 这层能力任何 IDE 都做不出这些功能。1.2 Debug 的正确打开姿势验证假设而不是漫无目的地乱走新手最容易犯的错误是一上来就打一堆断点然后断点停下来就盯着变量看看完 F8 一步一步走走到哪看到哪最后也没搞清楚问题出在哪里。Debug 的正确用法是做“假设验证”先根据报错信息和代码逻辑提出一个或几个明确的怀疑点再针对性地打断点去验证让现场告诉你“情况和你想象的是否一致”。举个例子说明。线上突然有接口偶发返回超时你本地跑了一下发现没法稳定复现。不要上来就在 Controller 第一行打断点而是先顺着代码想超时最可能在哪个环节调用下游 HTTP 耗时过长线程池排队数据库连接获取等待根据这些假设你决定在调用下游之前打印当前时间在获取连接之后打印消耗时间甚至可以在关键方法的临界点加条件断点去观察某个状态的取值。这种带着假设去验证的方式比无差别撒网高一个量级。再讲一个我常用的习惯Debug 之前先在源码里把怀疑的位置标出来相当于写“调试计划”。比如怀疑某个集合遍历时出现了不该存在的元素那么断点就打在对该集合做 remove 的地方并加上条件断点匹配到目标元素时才停住。这样每命中一次你都能看到 remove 发生的上下文和触发条件问题的边界会快速收缩到一个非常小的范围里。做好准备之后带着明确的问题去 Debug效率会有质的提升。而这个过程是否顺手很大程度上取决于你对断点体系的掌控程度。2. 断点体系Java 调试的立身之本2.1 行断点与条件断点最常用的两板斧行断点是基础中的基础在 IDEA 里点一下行号右侧的空白区域一个红点就出现了然后以 Debug 模式运行代码程序执行到这一行就会挂起。这个操作人人都会但很多人没注意过行断点上右键以后那些选项的用处。最重要的就是条件断点。右键断点在弹出的框里填写一个布尔表达式那么只有该表达式结果为 true 时断点才会生效。举个例子某个for循环要遍历 5000 条数据只有处理到第 25 条时会出现空指针你在循环体里打断点然后 F8 走了 24 次才到目标位置一次两次还能忍每次调试都这样那纯粹是折磨自己。正确做法是在断点条件里写i 25程序会直接跳过前 24 次停在目标下标的那次循环上。条件表达式里还可以写更复杂的逻辑比如判断一个对象是否满足某个状态ERROR.equals(log.getLevel())、order.getStatus() 2 order.getAmount() 1000。需要注意的是条件表达式是在目标线程的上下文中执行的要确保表达式不会抛异常一旦异常会导致调试会话出错。另一个实用技巧是“临时断点”。有些时候你只想知道某个点走没走过、或者只需要在这个位置停一次传统做法是打断点、走到、然后手动把断点删掉。IDEA 里可以右键断点勾选“Remove once hit”这样断点在被命中一次之后自动消失非常省事。我在追踪那种“理论上只会执行一次但实际执行了很多次”的代码时特别爱用这个功能断点第一次命中就自动下线不会反复打断调试节奏。条件断点还有一个高频场景是排查集合中的脏数据。比如你怀疑某个 list 里混进了一个 id 为 9999 的对象打断点在遍历处条件写item.getId() 9999命中的瞬间直接看这个元素是怎么构造出来的一目了然。关于行断点还有一个技巧值得提断点位置尽量打在“边界”上。什么叫边界方法入口、方法出口、循环条件的判断处、分支的切换点。这些位置的共同特点是变量状态刚刚发生变化或者即将发生变化信息量最大。打在毫无意义的中间赋值语句上往往还要多走几步才能看到关键变量变化纯属浪费时间。2.2 方法断点、字段断点与异常断点面试和实战都爱问的隐藏技巧行断点只能停在具体某一行但有时候你根本不关心具体哪一行你关心的是“这个方法到底有没有被调用”。这时候方法断点就派上用场了。在 IDEA 里断点可以直接打在方法名那一行的左侧。方法断点有两种命中时机方法进入时、方法退出时。默认是两者都会命中你可以右键选择只断 Entry 或者只断 Exit。方法断点的好处是当你面对一个陌生接口想知道哪些调用方会触发这个方法时打个方法断点然后看栈帧Frames里的调用链所有上游调用方一目了然。它在追踪那些“调了但没反应”“值传错了”“返回值不对”的问题时特别高效不需要在方法内部翻来覆去找打断点的位置。但要提醒一下方法断点的性能开销很大远超行断点。因为 JVM 处理此类断点需要通过 JVMTI 的特定机制底层可能要经过字节码改写或 step 模式来实现实测项目里如果方法断点用得多整个调试会明显卡顿。所以我的建议是方法断点只用来做快速定位确认调用方之后立刻删除换成行断点继续深入。字段断点又是一个冷门好手。它的使用场景是一个字段的值被某些代码莫名其妙地改了但你不知道是谁改的。在字段定义那行的左侧打断点可以让程序在字段被读或被写时暂停。右键可以设置只关注“Field access”读或“Field modification”写修改场景下调试器还会显示旧值和新值。多线程环境下排查共享变量被篡改的问题这个功能是神器。异常断点值得每个 Java 开发者掌握。它甚至不需要你提前知道异常会在哪一行抛出它要做的是当 JVM 抛出指定类型的异常时无论有没有被 try-catch 捕获都自动暂停。在 IDEA 里打开 Breakpoints 面板点击加号选择“Java Exception Breakpoints”输入比如NullPointerException然后运行代码。一旦任何地方抛出空指针调试器会直接停在抛异常的那一行栈帧和变量现场完整保留。对于那种“异常被吞掉了但系统行为诡异的场景”这招几乎一击致命。3. 调试窗口的每一个按钮都不是摆设3.1 Variables、Watches、Frames 三大面板的使用心得断点命中之后你面对的是 IDE 底部那一排调试窗口。默认最常看的三个是 Frames调用栈、Variables变量、Watches观察点。很多人只看 Variables其他基本不管这其实浪费了大量信息。先讲 Frames。它展示的是当前线程从起点到当前断点位置的完整方法调用链。双击任意一层帧就可以跳到那个方法调用处的源码位置同时 Variables 里的内容也会切换成那一帧的局部变量。这意味着你可以通过点击栈帧“穿越”回调用现场的上游去看方法之间传递的参数到底是什么。比如前端传过来的 userId 到了后端某个 service 变成了 null你用 Frames 一层一层往上点就能找出是哪一层的逻辑把这个参数搞丢或者覆盖了不用来回加日志。再讲 Variables。它展示当前帧的局部变量和参数。对象类型的变量可以逐层展开看字段这在定位 JSON 反序列化结果是否符合预期、看某个实体类的字段是否有脏数据时非常管用。IDEA 里还可以直接在变量上右键选择“View Text”查看完整的字符串内容避免在控制台被日志截断影响判断。如果某次调试的对象是字符串但长度特别长这个功能比复制到文本编辑器里翻找要快得多。Watches 面板适合用来“固定观察某个表达式”。比如你对一串嵌套计算的结果感兴趣但局部变量是一步步算出来的中间值你直接选中那个计算表达式右键“Add to Watches”它就会一直出现在 Watches 面板里并且每次走到不同帧或者不同断点时这个表达式都会实时重新计算。调试二分查找这类算法时我习惯把mid、left、right和array[mid]全部加到 Watches一边单步走一边看柱子一样的列表变化逻辑哪里出問題一眼就能看出来。3.2 Evaluate Expression 与 Drop Frame调试中的“后悔药”Evaluate Expression计算表达式绝对是被低估的功能之一。断点停住之后你可以打开这个窗口在里面执行任意代码片段。它不只是能让你查看表达式结果更强大的是可以直接修改变量的值。举个例子。你的代码走到某一步时发现一个外部接口返回的数据有问题导致后面的逻辑全部走偏。传统的做法是把代码改掉、重启、再走一遍一来一回十几分钟没了。有了 Evaluate Expression你可以在断点处直接调用修复方法、或者用setValue把有问题的对象替换成构造好的假数据然后继续往下一步步执行。这在调试复杂流程时相当于给了你一次“篡改现场”的机会极大节省了复现问题的时间。我在实际项目中用它模拟过这样一种场景某方法内部依赖了一个很难触发的条件分支常规入口根本进不去。我在外部调用的断点处用 Evaluate Expression 直接调用了目标方法并传入了精确构造的参数绕过了实际的入口强校验快速验证了分支逻辑正确性。这种操作在生产环境代码里没有任何改动纯粹通过调试器的能力完成了验证。再一个功能叫 Drop Frame中文版可能叫“丢帧”。它的作用是当前断点停住后你可以选择丢弃当前栈帧让程序回到调用当前方法之前的状态然后重新进入这个方法再走一遍。非常适合那种“一个方法要反复调试、不想一次次手动重启”的场景。不过要留意 Drop Frame 的限制。它并不是真正的时间回退已经发生的 IO、网络调用、数据库写入等等外部副作用是没法撤销的。比如你已经执行了userDao.insert(user)你把帧 dropped 回去再次调用方法数据会再插入一次可能引发主键冲突。所以涉及到写操作的方法用这个功能要非常谨慎。另外不是所有帧都可以 Drop只对非 native 方法中的非最外层帧生效如果按钮是灰的那就说明当前场景不支持。Force Return 也是一个特别实用的小功能。在断点停住时你可以在方法执行的任意位置强制指定一个返回值让方法直接返回不再执行后续代码。多用于调试“方法返回结果对上层的影响”的场景。例如某个方法内部的复杂计算耗时太长但你只是想看看上一层在这个结果下的行为就可以用 Force Return 伪造一个返回值跳过中间逻辑。这个功能对分析那种“偶发异常但现场已丢失”的问题有奇效。4. 多线程调试Java 程序员的必修课4.1 线程断点与 Suspend 策略的精确控制单线程调试已经很顺了多线程一上来各种匪夷所思的问题就出现了。比如你在一个断点处停住切到另一个线程发现它不走了或者整个程序里所有线程都卡住了问题定位瞬间变得复杂。这其实是 Suspend 策略在起作用。在 IDEA 的断点属性面板中有一个 Suspend 选项可以设置为“All”或者“Thread”。如果设置为 All那么任何线程命中该断点整个 JVM 所有线程都会被暂停。这意味着一旦有线程 A 在断点停住线程 B、C、D 也全被冻住整个程序的运行状态完全停滞在那一刻。大部分场景下我们是希望一次只观察一个线程的运行状态其他线程该怎么跑就怎么跑。这时候就应该把断点的 Suspend 改成 Thread。这样只有命中断点的那个线程会挂起其他线程照常执行。典型的应用场景是一个线程池在处理任务任务处理过程中偶发异常你希望等到第 5 个任务执行时停下来而其他任务不受影响继续执行。配合线程过滤功能更佳。在断点属性中可以设置“Filter by thread name”比如规定只有名字匹配worker-*的线程停住其他线程一律放行。当你有多个线程在跑同一个方法但只关心某一个特定线程的动作时这个功能能避免调试时不断被打断的烦恼。多线程调试的一个核心思路是越精确地控制“哪个线程在哪个时刻停下”越容易复现和定位并发问题。你在 IDE 里可以看到每个断点命中的线程列表点击不同的线程名Variables 区域会切换到对应线程的栈帧和变量。排查多线程共享数据竞争时我经常交替切换两个线程的栈帧锁定它们各自持有的锁资源和正在访问的共享对象比对操作顺序。4.2 死锁识别与 Thread Dump 实战死锁是多线程调试里最典型也最难肉眼直接发现的问题之一。表现是程序卡住不动CPU 占用率很低没有任何报错输出。新手遇到这种情况第一反应是“我代码是不是死循环了”排查半天才想到会不会是死锁。区分死循环和死锁最直接的手段是看线程状态。IDEA 底部有一个“Dump Threads”按钮点击后会把当前 JVM 中所有线程的栈信息全部输出到一个新窗口。打开这份线程快照Thread Dump搜索处于 BLOCKED 状态的线程看看它阻塞在什么地方、在等待哪把锁。如果再往下看发现另一把锁被另一个线程持有而那个线程也在 BLOCKED等待前一个线程释放锁这就形成了经典的循环等待。举个例子线程 A 持有锁 L1等待锁 L2线程 B 持有锁 L2等待锁 L1。jstack 输出里你会看到 A 在等- waiting to lock 0x...而这个锁的持有者正是 B同时 B 也在等 A 持有的锁。看到这种相互等待的链路死锁就实锤了。不用 IDE 的场景下用命令行也能做。先jps -l找到目标 Java 进程的 PID然后jstack PID把线程栈打印出来。线上环境没有图形界面时这是最可靠的死锁排查手段。输出内容中有一个专门的Found one Java-level deadlock区块会直接帮你梳理出死锁拓扑。排查完死锁以后更重要的是明确产生死锁的代码路径。线程栈里会显示每一个 BLOCKED 线程阻塞在哪个类哪个方法沿着这个路径回到源码审视你的锁获取顺序是否一致——死锁的经典根源就是“多条路径以不同顺序获取同一组锁”。理顺锁顺序或者使用tryLock加超时机制是两种常见解法。5. Debug 之外的第二战场日志与线上问题排查5.1 远程调试能连就别瞎猜本地联调了半天觉得一切正常结果代码一上测试环境就出问题。这时候远程调试就是你的重要手段。Java 远程调试的原理并不复杂就是在目标 JVM 启动时带上调试参数让它可以被远程的 IDE 连接并控制。比较基础的启动参数如下java -agentlib:jdwptransportdt_socket,servery,suspendn,address*:5005 -jar your-app.jar其中transportdt_socket表示通过网络 socket 通信servery表示当前 JVM 作为调试服务端suspendn表示 JVM 启动时不暂停等待调试器连接address*:5005表示监听所有网卡的 5005 端口。如果你是 Java 9 以上的高版本address的写法可能会有所不同比较稳妥的写法是address0.0.0.0:5005显式指定网卡。启动好目标进程之后在你本地 IDEA 里添加一个 Remote JVM Debug 配置填上目标机器的 IP 和端口然后以 Debug 模式启动这个配置就能像调试本地代码一样远程打断点、看变量、评估表达式了。远程调试有两个极其重要的注意事项。第一生产环境不要随意开启调试端口。 JDWP 协议本身没有强加密开启调试端口等于把代码执行能力暴露在网络上这会带来严重的安全隐患。我个人的底线是调试端口只在开发或测试环境开并且只对特定 IP 开放用安全组或防火墙做白名单。第二远程调试会把双方的网络连接跑满如果目标机器和本地之间延迟很高调试体验会非常差每走一步都要等网络往返这种情况不如先拉日志定位。远程调试只是一个手段不是万能的。5.2 线上问题排查工具箱:jstack、Arthas 与 MAT线上环境通常不能重启、不能打断点这就要依赖日志和诊断工具了。先说日志日志用的好Debug 也不用远程连。我一直建议在关键链路上打上结构化日志请求进来了、参数是什么、走到哪个分支、关键操作耗时多少、异常堆栈是什么。如果有链路追踪编号TraceId那就更好了能把一次请求的所有日志串起来看。说到工具第一梯队是 JDK 自带的命令行工具。jps查看本地 Java 进程jstack打印线程栈能看出线程处于什么状态是 WAITING、BLOCKED 还是 RUNNABLE这对于“卡住不动”的问题定位极有价值jmap -dump:formatb,fileheap.bin pid能导出堆内存快照然后用 MATMemory Analyzer Tool打开通过直方图或支配树找到大对象、可疑引用链排查内存泄漏或频繁 Full GC 的问题。第二梯队是阿里开源的 Arthas这是一个可以在线诊断 Java 进程的利器。它最常用的一组命令是watch。比如我想看某个方法每次被调用时入参和返回值直接执行watch com.example.OrderService createOrder {params, returnObj} -x 2它会在不重启应用的前提下动态输出每次调用的参数和返回值效果等同于“现场打断点”。trace命令还能打印方法内部每一行的调用耗时定位性能瓶颈眼都不眨。还有stack命令可以查看当前方法被谁调用的完整调用链效果和 IDE 里的 Frames 面板一样但在线上环境可以零侵入地使用。对于线上排查我更推荐一个思路先用日志定位时间点和可疑方法再用 Arthas 做动态观测实在需要堆内部分析再导出堆快照。这几个工具组合起来效果几乎等同于在线上环境里“远程 Debug”但风险更可控。6. 高频问题排查速查手册最后这一节我把这些年实际调试过程中踩过的、指导别人时反反复复遇到的典型问题整理成了速查表。每个问题都给出原因和解决思路方便你在现场对照着查。问题现象常见原因解决思路断点完全不生效代码一路跑完启动方式不是 Debugclass 文件与源码不一致断点打在接口或抽象方法上确认用 Bug 图标启动先 Build 再启动把断点移到实现类条件断点从不命中条件表达式语法错误表达式抛异常条件写成了赋值语句检查表达式返回值是否为布尔用equals代替比较字符串确认没有写单等号调试窗口里看不到局部变量JIT 优化导致变量不可见正在查看的帧不对切换 Frames 里不同的帧关闭调试后用-Djava.compilerNONE限制 JIT 再试断点停住后整个程序全部卡住Suspend 策略设置成了 All右键断点把 Suspend 改为 Thread方法断点调试非常卡方法断点本身性能开销大确认调用链后立刻删除改成行断点远程调试连接失败端口没开address参数写法不对防火墙禁止先 telnet 验证端口调整 JDWP 参数检查安全组字段断点在修改时是灰色不可用JDK 版本过高或调试器限制升级 IDE/切换 debugger 版本改用条件行断点配合 Watch修了代码但没有热更新生效方法签名变化、类结构变化等不支持热更新重构后重启调试会话小范围改动尽量只改方法体调试时 Evaluate Expression 报错表达式内部引用了不可见变量调用了有副作用的代码影响状态检查当前栈帧的上下文避免在表达式里执行写操作再展开讲两个最容易掉进去的坑。一个是条件断点写错。Java 的语法里是比较是赋值。调试器里条件表达式中写if (user.status 2)编译器可能还不会立刻报错因为它是合法的赋值表达式但它返回的是 int 而不是 booleanIDE 可能会提示类型不匹配如果这个提示被忽略最终结果就是断点永远不命中。推荐在写条件断点时明确使用比较操作符并且对于字符串比较务必写出xxx.equals(str)的形式避免反过来调用可能为空的引用。另一个是“断点不生效”最隐蔽的版本你改了代码但程序加载的还是旧的 class 文件。这种情况在复杂的 Maven 多模块项目里很常见你以为 Build 过了实际上某个模块没有被重新编译。处理办法是先用mvn clean compile重新编译模块再启动 Debug。排查时可以通过断点位置的代码和源码是否一致来判断如果 IDEA 断点行号跟源码对不上基本都是编译产物和源码不同步的问题。最后的几点体会Debug 这个东西看似是 IDE 里的一个小按钮真用熟了之后它会内化成一种思维方式。我在实际项目中 Debug 之前都会先问自己三个问题我现在怀疑什么这个断点能验证我的怀疑吗命中之后我该观察哪些变量想清楚这三个问题再动手效率比凭感觉乱打断点高得多。调试不顺利的时刻我的经验是先看数据再看代码。很多时候代码逻辑看起来没问题但数据流里的某个值在某一环被悄无声息地改写了这恰恰是 Debug 最擅长捕捉的场景。数据流理顺了问题的边界就出来了。还有一个小习惯想分享每次修完一个棘手的 Bug我都会在源码里留下一两行注释说明这个空指针是为什么、这个多线程竞争是怎么解决的。几个月之后这些注释帮了我很多次——同样的坑绝不让它绊倒我第二次。
返回列表