| name | jvm-live-debug |
| description | 在无 GUI 调试器的 CLI 环境中,对运行中的 JVM 服务(Spring Boot bootRun 启动)做交互式断点调试。通过 JDWP + jdb + tmux 组合:bootRun 暴露 5005 端口,jdb 跑在持久 tmux session 里,由 send-keys/capture-pane 跨调用读写 REPL 状态。适用于:(1) 设断点观察请求现场;(2) 在断点处求值/调用方法;(3) 排查只能在运行时复现的问题。 |
适用场景
当需要在运行中的 server 进程上设断点、看变量、调方法,而没有 IDEA 等 GUI 可用时使用本流程。不适合长时间多线程 step 调试——那种场景效率远不如让用户开 IDEA。
完整流程
1. 启动后端(带 JDWP)
cd server && ./gradlew bootRun --debug-jvm > /tmp/unirhy-debug/server.log 2>&1
工具调用上用 run_in_background: true。然后用 Monitor 等待 Listening for transport dt_socket at address: 5005 出现,再做下一步。
如果不想让 JVM suspend(让服务先正常起来、调试器随时来去),改用:
./gradlew bootRun -PjvmArgs='-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005'
但 --debug-jvm 的 suspend=y 模式在本流程中其实更好用——延迟断点能在类加载前就挂上。
2. 用 tmux 启动 jdb
tmux kill-session -t jdb 2>/dev/null
tmux new-session -d -s jdb -x 200 -y 50 'jdb -attach localhost:5005'
sleep 3
tmux capture-pane -t jdb -p -S -50
attach 成功后看到 VM 已启动 和 main[1] 提示符,说明 JVM 已经从 suspend 状态接管。
3. 设断点 → cont
tmux send-keys -t jdb 'stop at com.example.Foo:30' Enter
sleep 1
tmux send-keys -t jdb 'cont' Enter
类还没加载时 jdb 会显示 正在延迟断点 ... 将在加载类后设置,正常现象。
4. 等待 Spring 启动完成
用 Monitor 等服务端日志出现 Started UnirhyApplicationKt in ... 即可。
5. 命中后取现场
请求把断点打中后,tmux pane 会出现 Breakpoint hit: 和当前栈,提示符变成 <thread-name>[1]。
tmux send-keys -t jdb 'locals' Enter
tmux send-keys -t jdb 'print someVar' Enter
tmux send-keys -t jdb 'where' Enter
sleep 2
tmux capture-pane -t jdb -p | grep -v '^$' | tail -30
6. 放行 + 清理
tmux send-keys -t jdb 'cont' Enter
tmux kill-session -t jdb
jdb 常用命令速查
| 命令 | 作用 |
|---|
stop at FQCN:LINE | 在某文件某行设断点 |
stop in FQCN.method | 在方法入口设断点 |
clear | 列出所有断点 |
clear FQCN:LINE | 删除断点 |
cont | 继续执行 |
step / next | 单步进入 / 跳过 |
step up | 执行到当前方法返回 |
locals | 当前栈帧的局部变量和方法参数 |
print EXPR / eval EXPR | 求值表达式(可调用方法,见下) |
where | 当前线程调用栈 |
up / down | 切换栈帧 |
threads / thread ID | 列线程 / 切线程 |
exit | 退出 jdb(不杀被调试 JVM) |
关键技巧
Kotlin 顶层 / 扩展函数的类名
文件 PasswordConfig.kt 中的顶层函数或扩展函数会被编译成 <FileName>Kt 类的静态方法:
package com.foo.bar 下的 PasswordConfig.kt 中的扩展函数
- 设断点用
com.foo.bar.PasswordConfigKt:行号,不是 PasswordConfig
在断点处调用方法
print / eval 可以求值任意表达式,包括调用方法。在 Spring 注入的 service 里:
print this.passwordEncoder.encode(password)
this.field 必须显式带 this.——jdb 不会自动解析隐式 receiver。返回值会原样打印。
延迟断点
JVM 是 lazy class loading:服务刚启动时大量类还没加载。stop at 一个未加载类的位置时 jdb 会 defer,等类加载时再激活。所以任何时候都可以下断点,不必担心时序。
tmux capture-pane 看不到东西时
capture-pane -p 默认只看可见区域。pane 越大空白行越多,输出可能被 tail 掉。两个办法:
tmux capture-pane -t jdb -p -S -50 | grep -v '^$' | tail -30(带 scrollback、过滤空行)
- 创建 session 时给小一点的尺寸:
tmux new-session -d -s jdb -x 200 -y 50 ...
踩坑记录
bootRun --debug-jvm 默认 suspend=y
JVM 启动后会卡在 Listening for transport ...,不附加 jdb 永远不会进入 Spring 启动流程。所以必须:(1) 启动 bootRun → (2) 等 JDWP listener → (3) attach jdb → (4) JVM 才开始真正启动 Spring。
zsh glob 空目录会让整条命令失败
mkdir -p /tmp/x && rm -f /tmp/x/*.log
zsh 默认 nomatch 开启。要么先创建占位文件,要么把 rm 分开写,要么避免用 glob。
monitor where 副作用很吵
monitor where 会让每次断点命中都打印整个栈(Spring/Tomcat 通常有 60+ 帧)。一旦开了就用 unmonitor 1 关掉。命中后真要看栈用一次性的 where。
jdb 中文版 where N 不工作
where 5 在中文版 jdb 里会被解释成「线程 ID 5」并报错。直接用 where 看全栈,或者用 up/down 切帧。
命中后请求会挂住
JVM 被 jdb 暂停了,发起这次请求的 HTTP 连接也卡在那里。前端可能报超时。处理完务必 cont 放行,或者前端开发自己接受这个等待。
Spring Bean 是 BCryptPasswordEncoder
PasswordEncoder 是接口,注入进来的实际对象是 BCryptPasswordEncoder。print this.passwordEncoder.encode(x) 能直接工作是因为 jdb 走的是运行时多态。
完整模板(供 Claude 调用时粘贴)
mkdir -p /tmp/unirhy-debug && cd /Users/yang/Documents/code/UniRhy/server && \
./gradlew bootRun --debug-jvm > /tmp/unirhy-debug/server.log 2>&1
until grep -qE "Listening for transport|BUILD FAILED|FAILURE:" /tmp/unirhy-debug/server.log; do sleep 1; done
tmux kill-session -t jdb 2>/dev/null
tmux new-session -d -s jdb -x 200 -y 50 'jdb -attach localhost:5005'
sleep 3
tmux send-keys -t jdb 'stop at com.coooolfan.unirhy.service.AccountService:30' Enter
sleep 1
tmux send-keys -t jdb 'cont' Enter
cd /Users/yang/Documents/code/UniRhy/web && yarn dev > /tmp/unirhy-debug/web.log 2>&1
until grep -q "Started .* in [0-9]" /tmp/unirhy-debug/server.log && \
grep -q "Local:.*http" /tmp/unirhy-debug/web.log; do sleep 2; done
tmux send-keys -t jdb 'locals' Enter
sleep 1
tmux capture-pane -t jdb -p | grep -v '^$' | tail -30
tmux send-keys -t jdb 'cont' Enter
tmux kill-session -t jdb