JDWP 与 JPDA:Java 调试的优势在运行时,不在线协议
最近在对照 Java 远程调试和其他语言的调试通道,想先把「JPDA」的整体架构摆清楚,再看它为什么算运行时的一等公民。挂上之后也会用 JDK 自带的「jdb」走一遍。在这里记录一下。
一、整体架构
「JPDA」(Java Platform Debugger Architecture)的目标写在规范里:让调试工具可以跨平台、跨 VM 实现、跨 JDK 版本移植1。它不是一条孤立的线协议,而是三层叠在一起12:
flowchart TB
UI["IDE / jdb / 监控工具"]
JDI["JDI front-end"]
JDWP["JDWP 线协议"]
BE["jdwp agent back-end"]
TI["JVM TI"]
VM["HotSpot VM"]
UI --> JDI
JDI --> JDWP
JDWP --> BE
BE --> TI
TI --> VM
style JDWP fill:#87CEEB,stroke:#333,stroke-width:3px
style TI fill:#90EE90,stroke:#333,stroke-width:2px
style JDI fill:#FFD700,stroke:#333,stroke-width:2px
- 「JVM TI」:VM 必须提供的本地调试服务。规格是 C 本地接口,定义在
jvmti.h;agent 可以用任何支持 C 调用约定的语言写。参考实现里,HotSpot 用 C++ 实现这份接口,客户端是随 JDK 发布的本地库jdwp.so/jdwp.dll234。 - 「JDWP」:debuggee 进程和 debugger 前端之间,请求与调试信息的格式。规范只规定格式和布局,不规定用 socket 还是共享内存来传51。
- 「JDI」:调试器进程里、纯 Java 的高层接口。官方建议工具写在这一层,而不是直接啃 JDWP 或 JVM TI1。
规范还写了一句很关键的设计理由:经验表明,跑在 debuggee 里、用 Java 写的调试支持代码,会和被调试程序抢资源,造成挂死之类的行为。所以 back-end 是 native 代码,这也反过来要求 JVM TI 是纯本地接口1。
连接怎么建,规范给了三类「Connector」:前端听(listening)、前端连已经在跑的 back-end(attaching)、前端直接拉起被调试进程(launching)1。传输则是另一件事:参考实现带了 dt_socket(TCP,可跨机器)和 Windows 上的 dt_shmem(同机共享内存)6。
这四块不在同一个进程里,也不在同一层。JDI 是调试器进程里的 front-end;jdwp agent 是目标进程里独立的 back-end,不在 JDI 里;「JVM TI」由 VM 自己实现,跑在被调试进程内部,是 C 函数接口,不收 JDWP 包。收包、拆 11 字节头、按 commandSet/command 分发的是 agent,再改口令成 GetLocalInt 这类 JVM TI 调用123。
flowchart LR
subgraph Dbg["调试器进程"]
IDE["IDE / jdb"] --> JDI2["JDI front-end"]
end
subgraph Wire["JDWP 线"]
PKT["Handshake / Command / Reply / Event"]
end
subgraph Gee["被调试进程"]
AG["jdwp agent back-end"] --> TI2["JVM TI"]
TI2 --> VM2["HotSpot VM"]
end
JDI2 --> PKT
PKT --> AG
style AG fill:#87CEEB,stroke:#333,stroke-width:3px
style TI2 fill:#90EE90,stroke:#333,stroke-width:2px
style JDI2 fill:#FFD700,stroke:#333,stroke-width:2px
调试器进程
IDE / jdb
└─ JDI(front-end,调用 com.sun.jdi.*)
└─ JDWP 包(socket / 共享内存)
被调试进程
jdwp agent(back-end,jdwp.so / jdwp.dll)
└─ JVM TI(VM 内部实现,C 函数)
└─ HotSpot
JDI 并不直接调 agent。中间是 JDWP 这条线。agent 也不在 JDI 里:它是夹在「JDWP 线」和「JVM TI」之间的独立一层。有的 VM 可以不实现 JVM TI、自己直接讲 JDWP,那是另一条路;HotSpot 参考实现不是这样2。
可以看到,IDE 从来不直接碰 VM 内部结构。它看见的是 JDI;线上过 JDWP;agent 翻译;真正挖 VM 的是 JVM TI。
二、为什么是一等公民
从这张图能看出,调试不是后来外挂进 IDE 的。VM 自己实现「JVM TI」,JDK 自带 native back-end 和「JDI」,中间用一条公开线协议把两台机器接上。这和「编译时另吐一份符号、再起一个 gdbserver」不是同一件事。
跨实现被写进了规格。JDWP 允许 debuggee 和 debugger 跑在不同的 VM、不同的平台上;前端甚至可以不是 Java,debuggee 也可以绕开 JVM TI、自己实现这条协议51。所以同一套 IntelliJ / Eclipse / jdb 能挂参考实现,也能挂别家实现了 JDWP 的 VM,而不必各写一条私有通道。
调试元数据长在部署物里。JVM 规范把 LineNumberTable、LocalVariableTable 放在 class 文件的 Code 属性上,从 1.0.2 就在7。javac -g 之后,jar 本身就能对上行号和局部变量槽位,不必再跟一份 DWARF 或 PDB。断点在 JDWP 里落在 location(类型标签 + classID + methodID + index),局部变量用 slot,这些 ID 的宽度由 VirtualMachine.IDSizes 协商,最大 8 字节5。编译到 JVM 字节码的语言(Kotlin、Scala、Groovy)只要还走这套 class 文件,就还是同一条 JDWP。
线上传的也不是地址。objectID 在对象生命周期内唯一,搬走也不换号;objectID 本身并不阻止 GC,访问已被回收的对象会得到 INVALID_OBJECT5。字段监视跟的是 fieldID,不是某块物理内存,对象被挪走仍然对得上。GDB 的 watchpoint 跟地址走,模型和这一套不一样。
工具接口跟语言发行版走在一起。「JDI」是 100% Java 的接口,随 JDK 提供;官方明确建议调试器写在这一层1。jdb 就是一个 JDI 客户端6。调试器可以当普通 Java 库来写,不用先啃 native。
一等公民指的是这些:调试是平台规格,跨 VM 有公开线协议,符号在 class 里,对象身份能过 GC,JDK 里自带同语言 API。它不是说 JDWP 的二进制头比 CDP 的 JSON 更时髦,也不是说 Java 在时间旅行或无限制热更上最强。
三、写调试器:当 JDI 的客户端
规范允许在任意一层挂钩,但写调试器默认只当「JDI」的客户端,不必自己实现 JDWP 或 JVM TI。官方说得很直1:
We recommend the JDI layer for all debugger development.
Some debuggers are built on top of lower layers, JDWP (for example if the front-end is not written in the Java language) or JVM TI (for specialized debuggers which need low-level functionality).
要注意用词:你是调用 com.sun.jdi.*,不是去实现 JDI 这一层本身。JDK 已经提供了 front-end(JDI → JDWP)、agent(JDWP → JVM TI)和 HotSpot 的 JVM TI。jdb、IntelliJ、Eclipse 都是这条路。
jdb 连目标 VM,走的就是 Connector。VMConnection 先按名字从 VirtualMachineManager 里找出 Connector,再按类型 launch / attach / listen:
// src/jdk.jdi/share/classes/com/sun/tools/example/debug/tty/VMConnection.java:72-79
private Connector findConnector(String name) {
for (Connector connector :
Bootstrap.virtualMachineManager().allConnectors()) {
if (connector.name().equals(name)) {
return connector;
}
}
return null;
}
// src/jdk.jdi/share/classes/com/sun/tools/example/debug/tty/VMConnection.java:365-378
synchronized VirtualMachine open() {
if (connector instanceof LaunchingConnector) {
vm = launchTarget();
} else if (connector instanceof AttachingConnector) {
vm = attachTarget();
} else if (connector instanceof ListeningConnector) {
vm = listenTarget();
} else {
throw new InternalError
(MessageOutput.format("Invalid connect type"));
}
vm.setDebugTraceMode(traceFlags);
// src/jdk.jdi/share/classes/com/sun/tools/example/debug/tty/VMConnection.java:562-565
private VirtualMachine attachTarget() {
AttachingConnector attacher = (AttachingConnector)connector;
try {
return attacher.attach(connectorArgs);
参见 VMConnection.java8。如上所示,jdb -attach 最后就是 AttachingConnector.attach(),拿到 VirtualMachine。之后设断点用 EventRequestManager,收事件用 EventQueue,读变量用 StackFrame.getValue——都还在 JDI 里。
目标 VM 那边仍要 -agentlib:jdwp=...。那是运行时已经有的 agent,不是调试器要写的。
往已经在跑的 VM 上挂,参考实现常用这几条「AttachingConnector」6:
| Connector | 你给什么 | 能跨机器吗 |
|---|---|---|
com.sun.jdi.SocketAttach |
hostname + port | 能。dt_socket 本来就是 TCP |
com.sun.jdi.ProcessAttach |
本机 pid | 不能。transport().name() 固定是 local |
com.sun.jdi.SharedMemoryAttach |
共享内存名 | 不能。只在 Windows |
jdb -attach localhost:8000 走 SocketAttach。jdb -connect com.sun.jdi.ProcessAttach:pid=12345 走按 pid。规范写明:ProcessAttach 从 Java SE 6 起有,目标必须带着 server=y 启动;它自己没有固定传输,真正用哪条线要到 attach 那一刻才定6。
pid 并不是拿去 ptrace。ProcessAttachingConnector 先用 Attach API 贴上那个进程,读 agent 属性里的 sun.jdwp.listenerAddress,再按这个地址做一次普通的 JDWP attach:
// src/jdk.jdi/share/classes/com/sun/tools/jdi/ProcessAttachingConnector.java:96-146
// Use Attach API to attach to target VM and read value of
// sun.jdwp.listenAddress property.
vm = com.sun.tools.attach.VirtualMachine.attach(pid);
Properties props = vm.getAgentProperties();
address = props.getProperty("sun.jdwp.listenerAddress");
...
if (address == null) {
throw new IOException("Not a debuggee, or not listening for debugger to attach");
}
...
Connection connection = ts.attach(address, timeout, 0);
return Bootstrap.virtualMachineManager().createVirtualMachine(connection);
参见 ProcessAttachingConnector.java9。如上所示,pid 只用来发现监听地址。后面握手、设断点、读变量,还是 JDWP。Linux / macOS 上 Attach API 走的是临时目录里的 .java_pid<pid>,和 jcmd 一家10。
sequenceDiagram
participant JDI as JDI ProcessAttach
participant API as Attach API
participant AG as jdwp agent
JDI->>API: VirtualMachine.attach(pid)
API->>AG: getAgentProperties
AG-->>JDI: sun.jdwp.listenerAddress
JDI->>AG: JDWP attach
不要和另外两条路混起来。IDE 里「随便点一个 Java 进程就挂上」,有时是 Attach API 再 loadAgentLibrary("jdwp", ...),目标进程里走 Agent_OnAttach,要 -XX:+EnableDynamicAgentLoading;这不是 ProcessAttach 规格里写的那条路11。sun.jvm.hotspot.jdi.SAPIDAttachingConnector 走 Serviceability Agent,只读,也不讲 JDWP12。
只有这几种情况才需要往下走:
| 要做的事 | 挂哪一层 |
|---|---|
| Java 写的调试器、IDE、小工具 | 「JDI」(默认) |
| 前端不是 Java(C++、Rust、Go) | 直接讲「JDWP」 |
| 和 native 混调,或要 JVM TI 独占能力 | 「JVM TI」 |
| 换传输(不是 socket / 共享内存) | JDI 的 Connector SPI,或 JDWP Transport Interface61 |
做一个能设断点、看栈、改变量的 Java 调试器,只对 JDI 编程就够。另外两层用 JDK 自带的即可。
四、先挂上 agent
写一个会停住的小程序:
public class Hang {
public static void main(String[] args) throws Exception {
int n = 42;
System.out.println("pid ready, n=" + n);
Thread.sleep(Long.MAX_VALUE);
}
}
用官方文档里的「-agentlib:jdwp」把它跑成可调试进程6:
$ java -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=8000 Hang
address=8000 且不写主机名时,从 JDK 9 起只绑 loopback;规范里写得很清楚:主机名为空就用本地回环,* 才监听所有网卡613。再开一个终端,用 JDK 自带的「jdb」挂上去:
$ jdb -attach localhost:8000
也可以写成 jdb -connect com.sun.jdi.SocketAttach:hostname=localhost,port=8000,或者按本机 pid:jdb -connect com.sun.jdi.ProcessAttach:pid=123456。挂上之后设断点、看变量,IDE 和 jdb 走的是同一套东西。远程调试能成,是因为目标 VM 里先加载了实现 JDWP 的 agent,再经「JVM TI」去动 VM。
-agentlib:jdwp 进去之后,agent 先向 VM 要一份「JVM TI」环境,再解析选项。OpenJDK 的入口在 debugInit.c:
// src/jdk.jdwp.agent/share/native/libjdwp/debugInit.c:150-245
DEF_Agent_OnLoad(JavaVM *vm, char *options, void *reserved)
{
...
error = JVM_FUNC_PTR(vm,GetEnv)
(vm, (void **)&(gdata->jvmti), JVMTI_VERSION);
...
if (!parseOptions(options)) {
forceExit(1);
}
...
needed_capabilities.can_access_local_variables = 1;
needed_capabilities.can_generate_single_step_events = 1;
needed_capabilities.can_generate_exception_events = 1;
needed_capabilities.can_generate_frame_pop_events = 1;
needed_capabilities.can_generate_breakpoint_events = 1;
needed_capabilities.can_suspend = 1;
参见 debugInit.c。可以看到,局部变量、单步、异常、断点、挂起这些能力,是 agent 启动时就向 VM 申请的,不是 IDE 后来临时发明的。
debug 模式不会因此再拉起一批独立的 debug 进程。规范写明:-agentlib:jdwp 加载的库住在目标 VM 里,用 JVM TI / JNI 和这台 VM 说话,再用传输 + JDWP 跟另一个调试器进程通信6。Agent_OnLoad 跑在本进程;后面 debugLoop_run 起的「JDWP Command Reader」也是同进程里的线程14。jdb、IntelliJ 是你另开的进程。server=y 时是目标 VM 在听,调试器来 attach,不是目标 VM 去 fork 调试器。
只有少数路径才会再出现一个进程,而且都不是上面这条默认命令:
- JDI 的 Launching Connector(
com.sun.jdi.CommandLineLaunch):调试器当父进程,把带调试选项的java拉起来6。 launch=(常配合onthrow/onuncaught):JDWP 初始化完成时 exec 给定程序,用来做 Just-In-Time debugging;文档写明被拉起的通常应是一个小程序,再去开调试器窗口6。
日常 java -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=8000 ... 就是「一个进程多了个 in-process agent」,不是「应用旁边再站几个 debug 守护进程」。
远程请求也不是 HotSpot 内核里另开一个 DebugServer。server=y 时,agent 调传输的 StartListening,把实际地址写进 sun.jdwp.listenerAddress,再起一条名叫「JDWP Transport Listener: dt_socket」的线程去 accept:
// src/jdk.jdwp.agent/share/native/libjdwp/transport.c:557-584
err = (*trans)->StartListening(trans, address, &retAddress);
...
setTransportProperty(getEnv(), prop_value);
(void)strcpy(threadName, "JDWP Transport Listener: ");
(void)strcat(threadName, name);
func = &acceptThread;
error = spawnNewThread(func, (void*)info, threadName);
参见 transport.c15。可以看到,调试器连上来之后,才轮到前面的 Command Reader 拆包。sun.jdwp.listenerAddress 就是 ProcessAttach 去读的那一项。
五、用 jdb 走一遍
jdb 是 JDK 自带的命令行调试器,本身就是一个 JDI 客户端616。官方最常用的起法是直接拿它当 java 用:它会再拉起一个带调试选项的 VM,并在主类第一条指令前停住16。目标进程不必自己写 -agentlib:jdwp。
$ javac -g Hang.java
$ jdb Hang
Initializing jdb ...
>
javac -g 会把 LocalVariableTable 写进 class。不带 -g 时,后面的 locals 会回 Local variable information not available. Compile with -g to generate variable information。
类还没加载,先下断点。stop in 对方法入口,stop at 对行号;重载方法要带参数类型,例如 stop in MyClass.myMethod(int,java.lang.String)16:
> stop in Hang.main
Deferring breakpoint Hang.main.
It will be set after the class is loaded.
> run
run 才真正启动主类。命中之后提示符变成 main[1]:
Breakpoint hit: "thread=main", Hang.main(), line=3 bci=0
3 int n = 42;
main[1] list
1 public class Hang {
2 public static void main(String[] args) throws Exception {
3 => int n = 42;
4 System.out.println("pid ready, n=" + n);
5 Thread.sleep(Long.MAX_VALUE);
6 }
7 }
如上所示,=> 是下一行。此时 n 还没赋值,locals 只能看见参数 args。next 走完当前行、不进调用;step 会跟进被调方法16:
main[1] next
Step completed: "thread=main", Hang.main(), line=4 bci=3
main[1] locals
Method arguments:
args = instance of java.lang.String[0] (id=450)
Local variables:
n = 42
main[1] print n
n = 42
main[1] where
[1] Hang.main (Hang.java:4)
locals 在 jdb 里就是 JDI 的 StackFrame.visibleVariables() 再 getValues,和 IDE 点变量走的是同一条路:
// src/jdk.jdi/share/classes/com/sun/tools/example/debug/tty/Commands.java:1668-1686
void commandLocals() {
...
List<LocalVariable> vars = frame.visibleVariables();
Map<LocalVariable, Value> values = frame.getValues(vars);
参见 Commands.java17。
jdb -attach localhost:8000 挂上已经在跑的进程之后,VM 已经起来了,不要再 run。主线程在 Thread.sleep 里,先选定线程、挂起,再 up 回到自己的帧:
> threads
Group main:
(java.lang.Thread)1 main sleeping
> thread 1
main[1] suspend
All threads suspended.
main[1] where
[1] java.lang.Thread.sleep0 (native method)
[2] java.lang.Thread.sleep (Thread.java:509)
[3] Hang.main (Hang.java:5)
当前帧是 native 的 sleep0。up 一次到 Thread.sleep,再 up 一次才回到 Hang.main:
main[1] up
main[2] up
main[3] locals
Method arguments:
args = instance of java.lang.String[0] (id=565)
Local variables:
n = 42
main[3] print n
n = 42
main[3] set n = 99
n = 99 = 99
set 在实现里只是 print 的语法糖,赋值表达式交给同一套求值器17。list 默认在当前目录找源文件。
日常这几条就够用。完整列表在 help(或 ?):
| 命令 | 做什么 |
|---|---|
stop in C.m / stop at C:行 |
方法入口 / 行号断点 |
clear |
去掉断点;不带参数则列出 |
run / cont |
拉起主类 / 从断点继续 |
step / next / step up |
步入 / 步过 / 跑到当前方法返回 |
list |
打出当前源码,=> 是下一行 |
where / up / down |
栈;在栈上移动当前帧 |
threads / thread id |
列线程 / 选定当前线程 |
suspend / resume |
挂起 / 恢复(默认全部线程) |
print / dump / locals |
表达式、对象字段、当前帧局部变量 |
set x = expr |
改字段或局部变量 |
watch C.f |
字段被改(或被读)时停 |
catch / ignore |
异常时停 / 取消 |
quit |
退出调试器 |
jdb Hang 走的是 Launching Connector;jdb -attach 走 Attaching Connector。命令本身不碰 JDWP 字节,只调 JDI。
六、线上那 11 个字节
传输连上之后、发任何 packet 之前,两边先交换 14 个 ASCII 字符 JDWP-Handshake:调试器先发,VM 原样回5。
之后是 packet。规范说 JDWP 基于 packet、无状态;两类基本包是 command 和 reply。调试器发 command 来查信息或控制执行;目标 VM 也可以发 command,用来通知断点、异常这类事件。reply 只回答某条 command 成功还是失败,事件目前不要求调试器回包。协议是异步的,可以连发多条 command,不必等第一条 reply5。
头部长 11 字节,大端,command 和 reply 一样长,方便抽象传输层5:
Command Packet Reply Packet
---------------- ----------------
length 4 bytes length 4 bytes
id 4 bytes id 4 bytes
flags 1 byte flags 1 byte (0x80 = reply)
commandSet 1 byte errorCode 2 bytes
command 1 byte
data variable data variable
id 用来把异步的 command/reply 配成对。command set 0–63 是发给目标 VM 的,64–127 是发给调试器的,128–256 留给厂商扩展5。设断点走「EventRequest」command set(15)的 Set(1),eventKind 为 BREAKPOINT(2);读栈上局部变量走「StackFrame」command set(16)的 GetValues(1)18。
握手和第一条命令的顺序如下:
sequenceDiagram
participant D as Debugger
participant T as Transport
participant A as jdwp agent
participant V as JVM TI / VM
D->>A: JDWP-Handshake
A-->>D: JDWP-Handshake
D->>A: Command packet (id=1)
A->>V: 对应的 JVM TI 调用
V-->>A: 结果
A-->>D: Reply packet (id=1, flags=0x80)
V->>A: 断点等事件
A->>D: Event command (command set 64)
agent 这边,读包和执行命令拆成两条路径。debugLoop.c 里先起一个「JDWP Command Reader」线程收包入队,主循环再出队、按 commandSet/command 找 handler:
// src/jdk.jdwp.agent/share/native/libjdwp/debugLoop.c:81-166
void
debugLoop_run(void)
{
...
func = &reader;
(void)spawnNewThread(func, NULL, "JDWP Command Reader");
...
func = debugDispatch_getHandler(cmd->cmdSet, cmd->cmd, &cmdSetName, &cmdName);
if (func == NULL) {
outStream_setError(&out, JDWP_ERROR(NOT_IMPLEMENTED));
} else if (gdata->vmDead &&
((cmd->cmdSet) != JDWP_COMMAND_SET(VirtualMachine))) {
outStream_setError(&out, JDWP_ERROR(VM_DEAD));
} else {
replyToSender = func(&in, &out);
}
参见 debugLoop.c。如上所示,不认识的 command 按规范回 NOT_IMPLEMENTED5;VM 已经死了,除 VirtualMachine 那一组外直接回 VM_DEAD。
读包线程还多防了一手:如果 flags 不是 0,直接拆连接。注释写的是,这可能是浏览器发来的 HTTP 请求碰巧过了握手;HTTP 正文都在 ASCII 可打印范围,拼不出 flags 为 0 的 command packet14。
七、点一下变量,实际走了三层
JPDA 文档用 IDE 点局部变量当例子3。IDE 调的是 JDI:
theStackFrame.getValue(theLocalVariable)
参考实现里,StackFrameImpl 并不是自己去读目标进程内存。它组一条 JDWP.StackFrame.GetValues 发走:
// src/jdk.jdi/share/classes/com/sun/tools/jdi/StackFrameImpl.java:211-240
public Value getValue(LocalVariable variable) {
List<LocalVariable> list = new ArrayList<>(1);
list.add(variable);
return getValues(list).get(variable);
}
public Map<LocalVariable, Value> getValues(List<? extends LocalVariable> variables) {
validateStackFrame();
...
slots[i] = new JDWP.StackFrame.GetValues.SlotInfo(variable.slot(),
(byte)variable.signature().charAt(0));
...
ps = JDWP.StackFrame.GetValues.enqueueCommand(vm, thread, id, slots);
参见 StackFrameImpl.java。front-end 把这条命令编成 JDWP 字节流;官方 walk-through 写的是 StackFrame command set 值为 16、GetValues 值为 1,后面跟 thread ID、frame ID 等3。back-end 解开后再调 JVM TI,整数局部变量对应 GetLocalInt3:
error = jvmti->GetLocalInt(frame, slot, &intValue);
reply 按 JDWP 编回去,JDI 的 getValue 才返回,IDE 才能画出那个数。
设断点同一条通信序列,只是三层上的名字不同。调试器发 EventRequest.Set,带 LocationOnly modifier(类型、方法、字节码 index)18。命中时 VM 经 JVM TI 回调 agent。OpenJDK 里断点回调是 eventHandler.c 的 cbBreakpoint:
// src/jdk.jdwp.agent/share/native/libjdwp/eventHandler.c:747-763
/* Event callback for JVMTI_EVENT_BREAKPOINT */
static void JNICALL
cbBreakpoint(jvmtiEnv *jvmti_env, JNIEnv *env,
jthread thread, jmethodID method, jlocation location)
{
EventInfo info;
...
info.ei = EI_BREAKPOINT;
info.thread = thread;
info.clazz = getMethodClass(jvmti_env, method);
info.method = method;
info.location = location;
event_callback(env, &info);
参见 eventHandler.c。文档里的原型几乎一样3。之后 agent 过滤、入队,再按 JDWP 的 Event command set 发给前端;JDI 暴露成 BreakpointEvent,IDE 从 EventQueue.remove() 拿走3。
sequenceDiagram
participant IDE as IDE
participant JDI as JDI front-end
participant A as jdwp agent
participant TI as JVM TI
IDE->>JDI: StackFrame.getValue
JDI->>A: StackFrame.GetValues
A->>TI: GetLocalInt
TI-->>A: intValue
A-->>JDI: Reply
JDI-->>IDE: Value
IDE->>JDI: BreakpointRequest.enable
JDI->>A: EventRequest.Set BREAKPOINT
A->>TI: 登记断点
TI->>A: cbBreakpoint
A->>JDI: Event command
JDI-->>IDE: EventQueue.remove
如上所示,点一下变量:JDI 组包,JDWP 过线,JVM TI 取值。IDE 看见的是「类、方法、局部变量、线程」,不是寄存器和地址。
八、和其他语言比,强在哪
常拿来比的几条通道,层级并不一样。
| 通道 | 官方定位 | 和 JDWP 的关系 |
|---|---|---|
| 「JDWP」 | 调试器进程 ↔ 目标 VM 的格式;不规定传输5 | Java 运行时自己的线协议 |
| 「GDB Remote Protocol」 | GDB 与远程 stub 之间的 packet,以 $ / # 加校验和封装;最少要支持停因、寄存器、内存19 |
面向原生进程,通常还要 DWARF 和 gdbserver |
| 「DAP」(Debug Adapter Protocol) | 开发工具 ↔ debug adapter 的抽象协议,JSON;刻意做语言无关、偏 UI20 | 适配层。VS Code 的 Java 调试器下面仍然要讲 JDWP |
| 「CDP」(Chrome DevTools Protocol) | 对 Chromium / Blink / V8 inspector 做插桩、调试、剖析;按 domain 划分,命令和事件都是固定结构的 JSON21 | JS / Node 运行时自己的协议,覆盖面比「只调试」宽 |
| 「ICorDebug」 | CLR 调试 API,进程外 COM 接口,用来 attach 并调试托管代码22 | 和 JPDA 最像:运行时一等公民,但暴露的是 API 而不是一条公开线协议 |
对照一下四套栈:
flowchart LR
subgraph JavaStack["Java"]
JIDE["IDE"] --> JJDI["JDI"]
JJDI --> JJDWP["JDWP"]
JJDWP --> JTI["JVM TI"]
end
subgraph NativeStack["C / Rust / Go"]
NIDE["IDE"] --> NDAP["DAP adapter"]
NDAP --> NGDB["GDB / LLDB / Delve"]
NGDB --> NPTR["ptrace + DWARF"]
end
subgraph JsStack["JS / Node"]
JSIDE["DevTools / IDE"] --> JSCDP["CDP"]
JSCDP --> JSV8["V8 inspector"]
end
subgraph DotnetStack[".NET"]
DIDE["VS / vsdbg"] --> DICD["ICorDebug"]
DICD --> DCLR["CLR"]
end
style JJDWP fill:#87CEEB,stroke:#333,stroke-width:2px
style JSCDP fill:#FFD700,stroke:#333,stroke-width:2px
style NDAP fill:#90EE90,stroke:#333,stroke-width:2px
DAP 自己把定位写死了:它标准化的是「开发工具怎么跟具体调试器说话」,而且假定中间会有一个 adapter 去适配已有调试器或运行时 API;它偏高层,不必摊开语言和底层调试器的所有细节20。所以 VS Code 能用同一套 UI 调 Java、Python、C++,并不等于这些语言共用一条运行时协议。Java 那一侧,adapter 下面还是 JDWP。
GDB 远程协议是另一条路。packet 长成 $packet-data#checksum,数字用十六进制,早期还要照顾七位干净的串口19。要远程调试 C/Rust,通常得自己起 stub、带上调试符号、对齐编译选项。能做,但不是语言运行时默认送你一条跨进程通道。
和「没有标准调试通道、只能靠打印或各家私有工具」的语言比,Java 确实省事:加一行 -agentlib:jdwp,IntelliJ、Eclipse、VS Code、jdb 都能挂。原因就是跨 VM 的公开协议、class 里的符号和过得了 GC 的对象 ID,不是 JDWP 的包头更新潮。
.NET 的「ICorDebug」是最近的对照:CLR 同样把调试做成运行时契约,调试器通过 COM 接口 attach22。差别在于 Java 把进程间那一跳也标准化成了 JDWP,所以不同 IDE 不用各写一套私有通道。
九、短板也在同一套设计里
线协议本身没有认证、没有加密。JDK 9 把默认监听收成 loopback:命令行不写 IP 或主机名时只绑 localhost;要恢复「听所有网卡」得写 address=*:端口,发布说明直接写 this is not secure and not recommended13。JDK 10 起 dt_socket 多了 allow=,可以按地址或网段过滤来源623。生产上如果必须开远程调试,至少写死来源,不要把 * 暴露到公网。
热替换也有边界。JDI 的 VirtualMachine.redefineClasses 可以换方法体;已经在栈上的帧继续跑旧字节码,不等价的旧方法会被标成 obsolete。它不跑初始化器,静态变量保持原值,该类上的断点会被删掉。能不能加方法、改 schema、改继承,要分别问 canAddMethod() 和 canUnrestrictedlyRedefineClasses()24。日常 IDE「改一行继续跑」,大多停在换方法体这一档。
虚拟线程会放大 agent 成本。includevirtualthreads=y 会让「所有线程」列表带上虚拟线程,并让 JDWP 库记住它们直到死亡;官方警告数量很大时会压垮调试器和 agent 自己,默认是 n6。OpenJDK 后来还专门修过「挂了 jdwp agent 但调试器没连上」时虚拟线程的额外开销25。
和 CDP、DAP 比,JDWP 也更老:固定 command set、厂商扩展挤在 128 以后、传输和安全不在协议里。它把「格式」和「怎么运」切开,在 1990 年代末是为了简单、好实现5;今天要加能力,往往得改规格、改 agent、改 JDI,而不是加一个 JSON domain。
Java 这块的优势在运行时:调试是平台规格的一部分,JDWP 只是把这一等公民送到另一台机器上。协议本身并不比别人更先进;和 .NET、现代 JS 比也谈不上碾压。后续如果要写,可以再顺着 VirtualMachine.redefineClasses 看 HotSpot 实际允许改到哪一步。
References
-
Oracle,《JPDA Structure Overview》。JVM TI / JDWP / JDI 分层、Connector 三类、传输与协议分离、back-end 必须是 native 的原因。参见:https://docs.oracle.com/en/java/javase/21/docs/specs/jpda/architecture.html ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9 ↩10 ↩11
-
Oracle,《Java Platform Debugger Architecture》。两套接口 + 一条协议 + front-end/back-end;HotSpot 实现 JVM TI,
jdwp.so/jdwp.dll做 back-end。参见:https://docs.oracle.com/en/java/javase/21/docs/specs/jpda/jpda.html ↩ ↩2 ↩3 ↩4 -
Oracle,《Java Platform Debugger Architecture》Walk-through。
StackFrame.getValue→ JDWP StackFrame/GetValues →GetLocalInt;断点事件经 JVM TI 回调再到 JDIEventQueue。参见:https://docs.oracle.com/en/java/javase/21/docs/specs/jpda/jpda.html ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 -
Oracle,《JVM Tool Interface 21.0.0》。JVM TI 是进程内 native 接口,定义在
jvmti.h;agent 可用任何支持 C 调用约定的语言。规格正文默认用 C 语言举例。参见:https://docs.oracle.com/en/java/javase/21/docs/specs/jvmti.html ↩ -
Oracle,《Java Debug Wire Protocol》。握手
JDWP-Handshake、11 字节头、command/reply/事件、大端、异步、command set 划分与 ID 尺寸。参见:https://docs.oracle.com/en/java/javase/24/docs/specs/jdwp/jdwp-spec.html ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9 ↩10 ↩11 -
Oracle,《Connection and Invocation Details》。
-agentlib:jdwp子选项、dt_socket/dt_shmem、三类 Connector、com.sun.jdi.SocketAttach/ProcessAttach(server=y,transport().name()为local)、address空主机名绑 loopback、allow=与jdb -attach/-connect。参见:https://docs.oracle.com/en/java/javase/21/docs/specs/jpda/conninv.html ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9 ↩10 ↩11 ↩12 ↩13 ↩14 -
Oracle,《The Java Virtual Machine Specification, Java SE 21》,§4.7.12 The LineNumberTable Attribute、§4.7.13 The LocalVariableTable Attribute。行号与局部变量调试信息放在 class 文件的
Code属性上,自 1.0.2(class file 45.3)起就有。参见:https://docs.oracle.com/javase/specs/jvms/se21/html/jvms-4.html#jvms-4.7.12 ↩ -
OpenJDK,
jdb示例调试器src/jdk.jdi/share/classes/com/sun/tools/example/debug/tty/VMConnection.java。findConnector走Bootstrap.virtualMachineManager().allConnectors(),open()按 Launching / Attaching / Listening 分支,attachTarget()调用AttachingConnector.attach()。 ↩ -
OpenJDK,
src/jdk.jdi/share/classes/com/sun/tools/jdi/ProcessAttachingConnector.java。VirtualMachine.attach(pid)后读sun.jdwp.listenerAddress,再按dt_socket/dt_shmem做 JDWP attach。官方 Connector 说明见 6 的 Process Attaching Connector。 ↩ -
Oracle,
com.sun.tools.attach.VirtualMachine.attach。按进程标识贴上目标 VM,再读 agent 属性。HotSpot 在 POSIX 上用临时目录里的.java_pid<pid>。参见:https://docs.oracle.com/en/java/javase/21/docs/api/jdk.attach/com/sun/tools/attach/VirtualMachine.html;OpenJDKattachListener_posix.cpp。 ↩ -
OpenJDK,JEP 451 Prepare to Disallow the Dynamic Loading of Agents。运行中用 Attach API 动态加载 agent 需要
-XX:+EnableDynamicAgentLoading;JDK 21 起未显式打开会警告。java.lang.instrument的 Implementation Note 也写了同一选项。参见:https://docs.oracle.com/en/java/javase/21/docs/api/java.instrument/java/lang/instrument/package-summary.html ↩ -
Oracle,《Connection and Invocation Details》(Java SE 11)。SA PID Attaching Connector 名为
sun.jvm.hotspot.jdi.SAPIDAttachingConnector,返回的VirtualMachine只读(canBeModified()为 false),目标不必以-agentlib:jdwp启动。较新的 JDK 用jhsdb做同类事后分析。参见:https://docs.oracle.com/en/java/javase/11/docs/specs/jpda/conninv.html;https://docs.oracle.com/en/java/javase/21/docs/specs/man/jhsdb.html ↩ -
Oracle,JDK 9 Release Notes — JDWP socket connector accept only local connections by default(JDK-8041435)。未指定 IP/主机名时只接受本机连接;
*监听所有接口,官方标明不安全、不推荐。参见:https://www.oracle.com/java/technologies/javase/9-notes.html ↩ ↩2 -
OpenJDK,
src/jdk.jdwp.agent/share/native/libjdwp/debugLoop.c。Command Reader 对非 0 flags 拆连接,注释说明用于拒绝碰巧通过握手的 HTTP。 ↩ ↩2 -
OpenJDK,
src/jdk.jdwp.agent/share/native/libjdwp/transport.c。server=y时StartListening,把name:address写入sun.jdwp.listenerAddress,再spawnNewThread起「JDWP Transport Listener: transport」。 ↩ -
Oracle,《The jdb Command》。
jdb classname拉起目标 VM 并在主类第一条指令前停下;stop at MyClass:22/stop in java.lang.String.length;step进入被调方法,next停在当前帧下一行;jdb -attach挂到已在跑的 VM。参见:https://docs.oracle.com/en/java/javase/21/docs/specs/man/jdb.html ↩ ↩2 ↩3 ↩4 -
OpenJDK,
jdb示例调试器src/jdk.jdi/share/classes/com/sun/tools/example/debug/tty/Commands.java。commandLocals()调StackFrame.visibleVariables()/getValues();commandSet()注释写明只是print的语法糖。help文本在TTYResources.java。 ↩ ↩2 -
Oracle,《Java Debug Wire Protocol》命令详表。EventRequest Command Set (15) Set (1)、
eventKindBREAKPOINT= 2、LocationOnly modifier;StackFrame Command Set (16)。参见:https://docs.oracle.com/en/java/javase/21/docs/specs/jdwp/jdwp-protocol.html ↩ ↩2 -
GDB 手册,《Remote Protocol — Overview》。
$packet-data#checksum、+/-确认、最少命令集。参见:https://sourceware.org/gdb/current/onlinedocs/gdb.html/Overview.html ↩ ↩2 -
Microsoft,《What is the Debug Adapter Protocol?》。DAP 标准化开发工具与具体调试器之间的抽象协议,由 adapter 适配已有调试器/运行时,面向语言无关的调试 UI。参见:https://microsoft.github.io/debug-adapter-protocol/ ↩ ↩2
-
Chrome DevTools 团队,《Chrome DevTools Protocol》。按 domain 划分的 JSON 命令与事件,用于插桩、检查、调试和剖析 Chromium / V8。参见:https://chromedevtools.github.io/devtools-protocol/ ↩
-
Microsoft Learn,《CreateDebuggingInterfaceFromVersion》。由 CLR 版本字符串返回
ICorDebug,用于 attach 目标进程中的 CLR 并调试托管代码。参见:https://learn.microsoft.com/en-us/dotnet/core/unmanaged-api/debugging/createdebugginginterfacefromversion-function ↩ ↩2 -
Alexey Ivanov,OpenJDK net-dev,《RFR: JDK-8184770: JDWP support for IPv6》(2019-04)。确认 JDK 9 的 localhost 默认(JDK-8041435)与 JDK 10 的
allow(JDK-8061228)。参见:https://mail.openjdk.org/pipermail/net-dev/2019-April/012327.html ↩ -
Oracle,JDI
VirtualMachine.redefineClasses。方法体替换、obsolete 帧、不跑初始化器、断点被删除,以及canAddMethod/canUnrestrictedlyRedefineClasses限制。参见:https://docs.oracle.com/en/java/javase/21/docs/api/jdk.jdi/com/sun/jdi/VirtualMachine.html ↩ -
OpenJDK PR 8368159,Significant performance overhead when started with jdwp agent and unattached debugger。挂上 jdwp agent 但调试器未连接时,大量虚拟线程会触发多余的 JVMTI 清理。 ↩