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

规范还写了一句很关键的设计理由:经验表明,跑在 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 规范把 LineNumberTableLocalVariableTable 放在 class 文件的 Code 属性上,从 1.0.2 就在7javac -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 提供;官方明确建议调试器写在这一层1jdb 就是一个 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 规格里写的那条路11sun.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 跟另一个调试器进程通信6Agent_OnLoad 跑在本进程;后面 debugLoop_run 起的「JDWP Command Reader」也是同进程里的线程14jdb、IntelliJ 是你另开的进程。server=y 时是目标 VM 在听,调试器来 attach,不是目标 VM 去 fork 调试器。

只有少数路径才会再出现一个进程,而且都不是上面这条默认命令:

日常 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 只能看见参数 argsnext 走完当前行、不进调用;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)

localsjdb 里就是 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 的 sleep0up 一次到 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 的语法糖,赋值表达式交给同一套求值器17list 默认在当前目录找源文件。

日常这几条就够用。完整列表在 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),eventKindBREAKPOINT(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.ccbBreakpoint

// 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

  1. 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

  2. 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

  3. Oracle,《Java Platform Debugger Architecture》Walk-through。StackFrame.getValue → JDWP StackFrame/GetValues → GetLocalInt;断点事件经 JVM TI 回调再到 JDI EventQueue。参见:https://docs.oracle.com/en/java/javase/21/docs/specs/jpda/jpda.html  2 3 4 5 6 7

  4. 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 

  5. 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

  6. Oracle,《Connection and Invocation Details》。-agentlib:jdwp 子选项、dt_socket / dt_shmem、三类 Connector、com.sun.jdi.SocketAttach / ProcessAttachserver=ytransport().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

  7. 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 

  8. OpenJDK,jdb 示例调试器 src/jdk.jdi/share/classes/com/sun/tools/example/debug/tty/VMConnection.javafindConnectorBootstrap.virtualMachineManager().allConnectors()open() 按 Launching / Attaching / Listening 分支,attachTarget() 调用 AttachingConnector.attach()。 

  9. OpenJDK,src/jdk.jdi/share/classes/com/sun/tools/jdi/ProcessAttachingConnector.javaVirtualMachine.attach(pid) 后读 sun.jdwp.listenerAddress,再按 dt_socket / dt_shmem 做 JDWP attach。官方 Connector 说明见 6 的 Process Attaching Connector。 

  10. 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;OpenJDK attachListener_posix.cpp。 

  11. 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 

  12. 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.htmlhttps://docs.oracle.com/en/java/javase/21/docs/specs/man/jhsdb.html 

  13. 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

  14. OpenJDK,src/jdk.jdwp.agent/share/native/libjdwp/debugLoop.c。Command Reader 对非 0 flags 拆连接,注释说明用于拒绝碰巧通过握手的 HTTP。  2

  15. OpenJDK,src/jdk.jdwp.agent/share/native/libjdwp/transport.cserver=yStartListening,把 name:address 写入 sun.jdwp.listenerAddress,再 spawnNewThread 起「JDWP Transport Listener: transport」。 

  16. Oracle,《The jdb Command》。jdb classname 拉起目标 VM 并在主类第一条指令前停下;stop at MyClass:22 / stop in java.lang.String.lengthstep 进入被调方法,next 停在当前帧下一行;jdb -attach 挂到已在跑的 VM。参见:https://docs.oracle.com/en/java/javase/21/docs/specs/man/jdb.html  2 3 4

  17. OpenJDK,jdb 示例调试器 src/jdk.jdi/share/classes/com/sun/tools/example/debug/tty/Commands.javacommandLocals()StackFrame.visibleVariables() / getValues()commandSet() 注释写明只是 print 的语法糖。help 文本在 TTYResources.java。  2

  18. Oracle,《Java Debug Wire Protocol》命令详表。EventRequest Command Set (15) Set (1)、eventKind BREAKPOINT = 2、LocationOnly modifier;StackFrame Command Set (16)。参见:https://docs.oracle.com/en/java/javase/21/docs/specs/jdwp/jdwp-protocol.html  2

  19. GDB 手册,《Remote Protocol — Overview》。$packet-data#checksum+/- 确认、最少命令集。参见:https://sourceware.org/gdb/current/onlinedocs/gdb.html/Overview.html  2

  20. Microsoft,《What is the Debug Adapter Protocol?》。DAP 标准化开发工具与具体调试器之间的抽象协议,由 adapter 适配已有调试器/运行时,面向语言无关的调试 UI。参见:https://microsoft.github.io/debug-adapter-protocol/  2

  21. Chrome DevTools 团队,《Chrome DevTools Protocol》。按 domain 划分的 JSON 命令与事件,用于插桩、检查、调试和剖析 Chromium / V8。参见:https://chromedevtools.github.io/devtools-protocol/ 

  22. Microsoft Learn,《CreateDebuggingInterfaceFromVersion》。由 CLR 版本字符串返回 ICorDebug,用于 attach 目标进程中的 CLR 并调试托管代码。参见:https://learn.microsoft.com/en-us/dotnet/core/unmanaged-api/debugging/createdebugginginterfacefromversion-function  2

  23. 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 

  24. Oracle,JDI VirtualMachine.redefineClasses。方法体替换、obsolete 帧、不跑初始化器、断点被删除,以及 canAddMethod / canUnrestrictedlyRedefineClasses 限制。参见:https://docs.oracle.com/en/java/javase/21/docs/api/jdk.jdi/com/sun/jdi/VirtualMachine.html 

  25. OpenJDK PR 8368159Significant performance overhead when started with jdwp agent and unattached debugger。挂上 jdwp agent 但调试器未连接时,大量虚拟线程会触发多余的 JVMTI 清理。