Java 调试完全指南:从 IDEA 单步到 Docker 远程调试

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 为什么不能单步

  1. readObject() 是数据驱动的——边读字节流边递归构造对象,执行路径不是线性的;
  2. payload 用大量反射,F7 会钻进 sun.reflect.*;
  3. 对象在栈里层层嵌套,单步几十步也理不清主干。

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           ← 命令行、在线诊断

三条核心经验:

  1. 单步不是唯一手段——复杂调用链(尤其反序列化)用”断点 + 调用栈”,比纯单步快一个量级;
  2. “断点打不上”九成是源码没附加——先补源码,再谈调试;
  3. 调试端口是攻击面——用完即关,生产绝不带 JDWP 启动。
延伸阅读:《Java 反序列化 Gadget 链入门》 《Java基础》 《内存马检测与清除实操》
改变就是好事。