Java 调试完全指南:从 IDEA 单步到 Docker 远程调试
调试是安全研究和开发的日常。本文覆盖调试的完整链路:IDEA 单步调试的断点与步进 → 本地远程调试(JDWP)→ Docker 容器远程调试 → 命令行工具(jdb / Arthas / jstack)→ 常见坑与安全提醒。
结合本博客的《Java 反序列化 Gadget 链入门》,第四节专门讲”复杂调用链(反序列化 gadget)怎么调试”——这是单步最容易卡住的地方。
一、调试的本质与前提
调试 = 让 JVM 在指定位置暂停,观察 / 修改运行中的状态。
要能调试,先满足三个前提:
| 前提 | 说明 | 不满足时的表现 |
|---|---|---|
| 编译带调试信息 | IDEA 的 Debug 模式会自动加 -g |
变量看不到、行断点不生效 |
| 有源码 | 当前类和库类都要能打开源码 | 断点打不上,报 “Source not found” |
| 断点位置被执行到 | 代码确实走到了断点行 | 断点灰色 / 一直不命中 |
最常见的”断点打不上”原因:
- 库类 / JDK 类没附加源码(见 2.4);
- 代码被
maven-shade/ 混淆 / 行号表被删(-g:none)——断点退化为方法级甚至失效; - 断点打在一个从未执行的分支里;
- 调试的是
jar而不是源码工程。
二、IDEA 单步调试基础
2.1 断点的类型
| 断点类型 | 怎么打 | 触发时机 | 典型用途 |
|---|---|---|---|
| 行断点 | 行号左侧点一下 | 执行到该行 | 最常用 |
| 方法断点 | 方法名左侧点一下 | 方法进入 / 退出 | 想知道方法何时被调、被谁调 |
| 字段断点(监听点) | 字段声明行左侧点一下 | 字段被读 / 被写 | 跟踪某个字段何时被改(如内存马的 filterConfigs) |
| 异常断点 | View Breakpoints 里添加 |
抛出指定异常时(即使被 catch) | 反序列化、反射场景最有用 |
方法断点 / 字段断点会显著拖慢调试速度(JVM 要为它插桩),用完及时删。
2.2 步进操作
| 操作 | 快捷键(Win/Linux,macOS 有差异) | 作用 |
|---|---|---|
| Step Over | F8 |
步过,不进入方法内部 |
| Step Into | F7 |
步进,进入方法内部 |
| Smart Step Into | Shift+F7 |
列出当前行所有可进入的调用,选一个进 |
| Step Out | Shift+F8 |
跳出当前方法,回到调用处 |
| Force Step Into | Alt+Shift+F7 |
强制进入(即使它常被跳过) |
| Resume Program | F9 |
继续执行到下一个断点 |
| Run to Cursor | Alt+F9 |
直接跑到光标所在行 |
| Evaluate Expression | Alt+F8 |
弹窗里执行任意表达式 / 修改变量 |
| Drop Frame | Frames 面板按钮 | 丢弃当前帧,回到调用处重新执行 |
快捷键因 IDEA 版本和 Keymap 而异(macOS 默认 Keymap 下
Resume是⌘⌥R)。记不住就记功能:在 Debug 窗口的按钮和右键菜单里都有。
2.3 Debugger 面板
| 面板 | 作用 |
|---|---|
| Variables | 查看当前帧的局部变量 / 字段,可直接改值 |
| Watches | 自定义表达式,实时跟踪(如 filterConfigs.size()) |
| Call Stack(Frames) | 当前调用栈——调试复杂链时最重要 |
| Evaluate Expression | 临时执行代码、调用方法 |
| Console | 程序输出,也支持 print 式调试 |
2.4 附加源码(解决”进不去库代码”)
JDK 源码:
File → Project Structure → SDKs → Sourcepath → 添加 src.zip
路径:/Library/Java/JavaVirtualMachines/jdk1.8.xxx/Contents/Home/src.zip
(或 JDK 目录下 lib/src.zip)
Maven 依赖源码:
Maven 面板 → 右键依赖 → Download Sources
或命令行:mvn dependency:sources
没有源码时,LazyMap、HashMap 内部断点打不上、单步进不去——这是”无法单步”的头号原因。
2.5 Debugger Stepping 设置
Settings → Build, Execution, Deployment → Debugger → Stepping
- 想把库类加进可步进的范围:从 “Do not step into the classes” 跳过列表里移除;
- 想跳过反射 / JDK 内部:把
sun.reflect.*、java.lang.reflect.Method加进跳过列表,避免F7钻到反射实现里出不来。
三、断点进阶技巧
3.1 条件断点
断点上右键 → 填条件表达式,只有条件为真才停:
// 例:只在 cmd 非空时停在 doFilter
cmd != null && !cmd.isEmpty()
// 例:只在调用栈包含某个类时停(反序列化调试常用)
Thread.currentThread().getStackTrace().toString().contains("readObject")
3.2 日志断点(不暂停,只打印)
断点右键 → 勾选 “Log message to console”,填模板。断点变成 printf,不用改代码,适合在循环 / 高频调用里插桩。
3.3 异常断点(找”被吞掉的异常”)
View Breakpoints(Ctrl+Shift+F8)→ 添加 Java Exception Breakpoints → 填类名(如 Exception)。
作用:即使异常被 catch 吞掉,也会停在抛出点。这在排查”代码静默失败但不知道错哪”时是神器(如《Java基础》里提到的吞异常审计点)。
3.4 临时断点 / 移除全部断点
- 断点上右键 → More → Remove once hit:命中一次后自动删,适合”只停一次”;
- Debug 窗口工具栏有一键移除全部断点的按钮。
四、复杂调用链(反序列化 gadget)的调试
单步反序列化 payload 是最容易卡住的场景。核心结论:别从头单步,用”入口断点 + 调用栈”。
4.1 为什么不能单步
readObject()是数据驱动的——边读字节流边递归构造对象,执行路径不是线性的;- payload 用大量反射,
F7会钻进sun.reflect.*; - 对象在栈里层层嵌套,单步几十步也理不清主干。
4.2 正确姿势:在已知 gadget 方法上打断点 + 看调用栈
以 《Java 反序列化 Gadget 链入门》 的 CC6 为例,在四个方法上分别打断点:
TiedMapEntry.hashCode() // 入口是否进来
LazyMap.get() // 是否走到懒加载
ChainedTransformer.transform() // 链是否被触发
InvokerTransformer.transform() // 是否走到反射调用
命中后看 Call Stack,从下往上就是整条链:
HashMap.readObject
→ hash → TiedMapEntry.hashCode
→ getValue → LazyMap.get
→ factory.transform → ChainedTransformer.transform
→ InvokerTransformer.transform
→ Runtime.exec
4.3 区分”构造阶段”与”反序列化阶段”
CC6 里构造 payload 时 expMap.put(entry, "value") 就会触发一次 LazyMap.get(占位 transformer,无害)。断点会先命中这一次,真正危险的在 ois.readObject() 时。
区分方法:看调用栈顶部是 HashMap.put(构造阶段)还是 HashMap.readObject(反序列化阶段)。只想断后者,给 LazyMap.get 加条件断点:
factory.getClass().getName().contains("ChainedTransformer")
4.4 序列化 / 反序列化全过程断点
想观察”还原过程中到底加载了哪些类”,在 ObjectInputStream.resolveClass 打断点;想确认集合触发,在 HashMap.readObject / putVal 打断点。记住:反序列化 = 执行链,看栈优于单步。
五、本地远程调试(JDWP)
5.1 JVM 调试参数
# 目标 JVM 启动时加上(JDK 5 ~ 8)
java -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=8000 -jar app.jar
# JDK 9+(注意 address 写法,见 5.3)
java -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:8000 -jar app.jar
| 参数 | 含义 |
|——|——|
| transport=dt_socket | 用 Socket 通信 |
| server=y | 目标 JVM 作为服务端,等调试器来连 |
| suspend=y | 启动即挂起,等调试器 attach 才继续(调试启动即崩的场景必用) |
| suspend=n | 正常运行,调试器随时可连 |
| address=8000 | 监听端口(JDK 9+ 的写法变化见 5.3) |
5.2 IDEA 连接
Run → Edit Configurations → + → Remote JVM Debug
Host: 127.0.0.1
Port: 8000
→ Debug(attach)
suspend=y时:先启动目标 JVM(会停住),再在 IDEA 里 Debug,才会开始执行;suspend=n时:目标先跑着,随时 attach。
5.3 经典坑:JDK 9+ 的 address 绑定
| JDK | address=8000 的行为 |
|---|---|
| JDK 5~8 | 监听所有网卡(0.0.0.0:8000) |
| JDK 9+ | 只监听本机回环(127.0.0.1:8000) |
JDK 9+ 想监听所有网卡必须写 address=*:8000。这个差异在 Docker 里是致命的(见 6.4)。
5.4 远程调试的适用场景
- 调试本地起的 jar / war 服务(Tomcat 加
CATALINA_OPTS); - 调试启动即崩溃的问题:
suspend=y让 JVM 停在第一行,attach 后单步看初始化流程; - 调试测试环境的线上行为(前提是对方允许且安全,见第九节)。
六、Docker 远程调试
原理:容器内的 JVM 开启 JDWP 监听 → 通过 -p 把调试端口映射到宿主机 → IDEA attach localhost:8000。
IDEA (宿主机) ── attach ──► 宿主机:8000 ── docker -p ──► 容器内 JVM:8000 (JDWP)
6.1 给容器内的 JVM 注入调试参数
不修改镜像 / entrypoint 的最省事方式:环境变量 JAVA_TOOL_OPTIONS。JVM 启动时自动读取它并拼到命令行,任何 Java 应用都生效。
docker run -d --name myapp \
-p 8080:8080 \
-p 8000:8000 \
-e JAVA_TOOL_OPTIONS="-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:8000" \
myapp-image
6.2 docker-compose 版本
services:
app:
image: myapp-image
ports:
- "8080:8080"
- "8000:8000"
environment:
JAVA_TOOL_OPTIONS: "-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:8000"
docker compose up -d
# 查看端口是否映射成功
docker port myapp
# 8000/tcp -> 0.0.0.0:8000
6.3 各类应用的参数注入位置
| 应用类型 | 注入方式 |
|---|---|
| 任意 Java 应用 | -e JAVA_TOOL_OPTIONS=...(通用,推荐) |
| Tomcat 容器 | -e CATALINA_OPTS="...jdwp..." 或 JAVA_OPTS |
| Spring Boot fat jar | JAVA_TOOL_OPTIONS 或改 Dockerfile 的 CMD |
| Kubernetes | 给 container 加 env 或 command 追加参数 |
6.4 Docker 调试必踩的坑
| 坑 | 表现 | 解决 |
|---|---|---|
| JDK 9+ 回环绑定 | IDEA 连 localhost:8000 报 Connection refused,但容器明明在跑 |
必须写 address=*:8000,address=8000 只监听容器内回环,-p 转发不进去 |
| 端口没映射 | docker port 看不到 8000 |
检查 -p 8000:8000 / compose 的 ports |
suspend=y 导致容器”起不来” |
容器一直 Running 但日志没输出、端口不通 | 这是正常的,JVM 在等 attach;attach 后才会跑 |
| 端口被占用 | 宿主机 8000 已被别的进程占 | 换端口 -p 8001:8000,IDEA 里 Port 也改 |
| 防火墙 | 宿主机放行 8000 才能从别的机器连 | 本机调试 localhost 不需要;跨机调试要放行 |
| 容器重启丢参数 | 重启后 -e 还在,但改了镜像 / k8s 的话要看配置 |
参数要固化在 compose / Deployment 里 |
6.5 一个完整的 Spring Boot 容器调试示例
# 1. 跑容器(8080 业务 + 8000 调试)
docker run -d -p 8080:8080 -p 8000:8000 \
-e JAVA_TOOL_OPTIONS="-agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=*:8000" \
my-spring-boot-app
# 2. 容器会停住(suspend=y),确认端口
docker port $(docker ps -q) | grep 8000
# 3. IDEA → Remote JVM Debug → localhost:8000 → Debug
# 连接成功后容器开始启动,即可断点调试
调试完记得移除
-e调试参数再部署(见第九节安全提醒)。
七、命令行与辅助工具
没 IDEA 或只想快速看状态时,JDK 自带工具和 Arthas 是很好的补充。
7.1 jdb(JDK 自带命令行调试器)
目标进程需带 JDWP 参数启动:
jdb -attach 8000 # 连接远程/本地调试端口
常用命令:
| 命令 | 作用 |
|---|---|
stop in com.example.App.main |
方法入口断点 |
stop at com.example.App:42 |
行断点 |
cont |
继续执行 |
where |
打印调用栈 |
print 变量 / 表达式 |
打印值 |
locals |
打印当前局部变量 |
step / next |
步进 / 步过 |
clear 断点位置 |
删除断点 |
7.2 Arthas(在线诊断神器)
不需要目标进程开 JDWP,直接 attach 运行中的进程:
java -jar arthas-boot.jar # 选目标 PID
watch com.example.Service exec '{params, returnObj}' # 观察方法入参/返回
trace com.example.Service exec # 打印方法内部调用链
jad com.example.Service # 反编译运行时字节码
sc com.example.* # 搜索类
在线枚举 / 反编译是排查内存马的常用手段,见《内存马检测与清除实操》。
7.3 jstack / jmap / jcmd
| 工具 | 用途 |
|---|---|
jstack <pid> |
打印所有线程栈——看死锁、看卡在哪 |
jmap -dump:format=b,file=heap.hprof <pid> |
堆 dump,MAT 分析 |
jcmd <pid> VM.threads |
线程信息 |
jstack 排死锁示例:
jps -l # 找 pid
jstack <pid> | grep -A2 "Found one Java-level deadlock"
jstack <pid> > thread.txt # 完整导出分析
八、常见问题排查(FAQ)
| 现象 | 原因 | 解决 |
|---|---|---|
| 断点打不上 / 灰色 | 没源码、没调试信息 | 附加源码(2.4);确认 Debug 模式编译 |
| 单步进不去库代码 | 库类在跳过列表 / 无源码 | Stepping 设置 + 附加源码 |
| 变量看不到值 | 方法被 JIT 优化 / 无 -g |
加 -Dcompiler=none 或换断点位置 |
Connection refused |
端口没监听 / 防火墙 / JDK9+ 回环 | 见 5.3、6.4 |
| attach 成功但没反应 | suspend=n 已跑过去 |
断点加在后续执行路径上 |
| 异常被吞没动静 | catch 吞了 |
异常断点(3.3) |
| 多线程断点乱 | 多个线程都命中 | 断点右键 → 勾选线程过滤 / 条件断点 |
| 调试很卡 | 方法断点 / 字段断点太多 | 删掉,改用行断点 |
| 端口被占用 | 上一个调试进程没退干净 | lsof -i:8000 找 PID kill 掉 |
九、安全提醒:调试端口是攻击面
JDWP 协议本身没有认证——这是它的设计缺陷。任何能连上调试端口的人,都可以通过 JDWP 向 JVM 下发任意代码,等同于 RCE。
攻击者 ──连接 8000 端口──► JDWP(未认证)──► 任意代码执行
真实世界已有大量通过暴露的 8000 端口打进系统的案例(含 docker 端口映射把 JDWP 暴露到公网的场景)。
安全红线:
| 事项 | 要求 |
|---|---|
| 调试端口暴露公网 | 禁止,绝不要把 8000 映射到 0.0.0.0 对外 |
| 生产环境 | 绝不带 -agentlib:jdwp 启动 |
| 调试机 | 仅监听回环(127.0.0.1),不要用 * |
| 调试完 | 移除容器 -e 调试参数、关闭端口映射,再部署 |
| 检查 | 扫描开放端口里有没有常见的 5005 / 8000 / 8001 |
# 自查本机是否暴露了 JDWP 调试端口
nc -zv 127.0.0.1 8000
# 对目标排查(授权范围内)
nmap -p 8000 --script jdwp-info <target>
总结
调试从简单到复杂的路线:
IDEA 行断点 + F7/F8 ← 日常
断点条件 / 日志断点 / 异常断点 ← 定位"被吞的异常"
断点 + 调用栈(不单步) ← 反序列化 / 复杂链
JDWP 远程调试 ← 本地 jar、启动即崩
Docker -p 映射 + attach ← 容器环境
jdb / Arthas / jstack ← 命令行、在线诊断
三条核心经验:
- 单步不是唯一手段——复杂调用链(尤其反序列化)用”断点 + 调用栈”,比纯单步快一个量级;
- “断点打不上”九成是源码没附加——先补源码,再谈调试;
- 调试端口是攻击面——用完即关,生产绝不带 JDWP 启动。
延伸阅读:《Java 反序列化 Gadget 链入门》 《Java基础》 《内存马检测与清除实操》