反向 Debug 了解一下?揭秘 Java DEBUG 的基本原理 | 京东云技术团队

Debug 的时候,都遇到过手速太快,直接跳过了自己想调试的方法、代码的时候吧......

一旦跳过,可能就得重新执行一遍,准备数据、重新启动可能几分钟就过去了。

好在IDE 们都很强大,还给你后悔的机会,可以直接删除某个 Stack Frame,直接返回到之前的状态,确切的说是返回到之前的某个 Stack Frame,从而实现让程序"逆向运行"。

这个 Reset Frame 的能力,可不只是返回上一步,上 N 步也是可以的;选中你期望的那个帧,直接Reset Frame/Drop Frame,可以直接回到调用栈上的某个栈帧,时间反转!

可惜这玩意也不是那么万能,毕竟是通过 stack pop 这种操作实现,实际上只是给调用栈栈顶的 N 个 frame pop 出来而已,还谈不上是真正的"反向 DEBUG"。

相比之下, GDB 的 Reverse Debugging 就比较强大,真正的 "反向" DEBUG,逆向运行,实现回放。

所以吧在运行过程中,已经修改的数据,比如引用传递的方法参数、变量,一旦修改肯定回退不了,不然真的成时光机了。

这些乱七八糟的调试功能,都是基于 Java 内置的 Debug 体系来实现的。

JAVA DEBUG 体系

Java 提供了一个完整的 Debug 体系 JPDA (Java Platform Debugger Architecture),这个 JPDA 架构体系由 3 部分组成:

  1. JVM TI - Java VM Tool Interface
  2. JDWP - Java Debug Wire Protocol
  3. JDI - Java Debug Interface

如果结合IDE 来看,那么一个完整的 Debug 功能看起来就是这个样子:

解释一下这个体系:

JVM TI 是一个 JVM 提供的一个调试接口,提供了一系列控制 JVM 行为的功能,比如分析、调试、监控、线程分析等等。也就是说,这个接口定义了一系列调试分析功能,而 JVM 实现了这个接口,从而提供调试能力。

不过吧,这个接口毕竟是 C++的,调用起来确实不方便,所以Java 还提供了 JDI 这么个 Java 接口。

JDI 接口使用 JDWP 这个私有的应用层协议,通过 TCP 和目标 VM 的 JVMTI 接口进行交互。

也可以把简单这个 JDWP 协议理解为 JSF/Dubbo 协议;相当于 IDE 里通过 JDI 这个 SDK,使用 JDWP 协议调用远程 JVMTI 的 RPC 接口,来传输调试时的各种断点、查看操作。

可能有人会问,搞什么套壳!要什么 JDWP,我直接 JVMTI 调试不是更香,链路越短性能越高!

当然可以,比如 Arthas 里的部分功能,就直接使用了 JVMTI 接口,要什么 JDI!直接 JVMTI 干就完了。

开个玩笑,Arthas 毕竟不是 Debug 工具,人家根本就不用 JDI 接口。而且 JVMTI 的能力也不只是断点,它的功能非常多:

左边的功能类,提供了各种乱七八糟的功能,比如我们常用的添加一个断点:

scss 复制代码
jvmtiError
SetBreakpoint(jvmtiEnv* env,
            jmethodID method,
            jlocation location)

右边的事件类,可以简单的理解为回调;还是拿断点举例,如果我用上面的 SetBreakpoint 添加了一个断点,那么当执行到该位置时,就会触发这个事件:

arduino 复制代码
void JNICALL
Breakpoint(jvmtiEnv *jvmti_env,
            JNIEnv* jni_env,
            jthread thread,
            jmethodID method,
            jlocation location)

JVMTI 的功能非常之多,而 JDI 只是实现了部分 JVMTI 的方法,所以某些专业的 Profiler 工具,可能会直接使用 JVMTI,从而实现更丰富的诊断分析功能。

远程调试与本地调试

不知道大家有没有留意过本地 Debug 启动时的日志:

第一行是隐藏了后半段的启动命令,展开后是这个样子:

bash 复制代码
/path/to/java -agentlib:jdwp=transport=dt_socket,address=127.0.0.1:53631,suspend=y,server=n -javaagent:/path/to/jetbrains/debugger-agent.jar ...

第二行是一个 Connected 日志,意思是使用 socket 连接到远程 VM 的53631端口

上一段说到,IDE 通过 JDI 接口,使用 JDWP 协议和目标 VM 的 JVMTI 交互。这里的 53631 端口,就是目标 JVM 暴露出的 JVM TI 的 server 端口。

而第一行里,IDEA 自动给我们加上了 -agentlib:jdwp=transport=dt_socket,address=127.0.0.1:53631 这么一段,这个参数的意思就是,让 jvm 以 53631 暴露 jdwp 协议

小知识,这个 agentlib 可不只是为 jvmti 提供的。它还可以让 JVM 加载其他的 native lib包,直接"外挂"到你的 jvm 上,下面是"外挂"的参数格式:

所以吧,上面的描述其实不太严谨,更专业的说法是:

让 JVM 加载 JDWP 这个 agent 库,参数为transport=dt_socket,address=127.0.0.1:53631 ,这个 jdwp agent 库以 53631 端口提供了 jdwp 协议的 server。只不过这个 jdwp 是jvm 内部的库,不需要额外的 so/dylib/dll 文件。

如有需要,你完全可以弄个 "datupiao" 的 agentlib,"外挂"到这个 jvm 上,然后在这个 lib 里调用 JVMTI 接口,然后暴露个端口提供服务和远程交互,实现自己的 jdwp!

可能某些老板们注意到了,本地调试还要127.0.0.1走tcp 交互一遍,那远程调试呢?

基于上面的解释,本地调试和远程调试真的没啥区别!或者说,在目前 IDEA/Eclipse 的实现下,不存在本地调试,都是远程!只不过一个是 127.0.0.1,一个是远程的 IP 而已。

在本地调试时,IDEA 会自动给我们的 JVM 增加 agent 参数,随机指定一个端口,然后通过 JDI 接口连接,代码大概长这样(JDI 的 SDK 在 JDK_HOME/lib/tools.jar ):

ini 复制代码
Map<String, Connector.Argument> env = connector.defaultArguments();
env.get("hostname").setValue(hostname);
env.get("port").setValue(port);

VirtualMachine vm = connector.attach(env);

瞅瞅, VirtualMachine 里的就这点方法,能力上比 JVMTI 还是差远了

csharp 复制代码
List<ReferenceType> classesByName(String className);

List<ReferenceType> allClasses();

void redefineClasses(Map<? extends ReferenceType, byte[]> classToBytes);

List<ThreadReference> allThreads();

void suspend();

void resume();

List<ThreadGroupReference> topLevelThreadGroups();

EventQueue eventQueue();

EventRequestManager eventRequestManager();

VoidValue mirrorOfVoid();

Process process();

再回来看看 IDEA 中独立的远程调试,配置好之后,红框里的信息会提示你 ,远程的 JVM 需增加这一段启动参数,而且支持多个版本 JDK 的格式,CV 大法就能直接用。

-agentlib 和 -javaagent

有些细心的同学可能发现了,IDEA 默认的启动脚本里,同时配置了 -agentlib 和 -javaagent。

javascript 复制代码
-javaagent:/path/to/jetbrains/debugger-agent.jar 

这个 debugger-agent吧,其实也没干啥事,只是对 JDK 内置的一些线程做了些增强,辅助 IDEA 的 debug 功能,支持一些异步的调试。

agentlib、javaagent 这俩兄弟,定位其实很像,都是加载自定义的代码。

不过区别在于,agentlib 是加载 native lib,需要c/cpp 去写,相当于外挂自己的代码在 jvm 上,可以为所欲为,比如在 agentlib 里调用上面说的 JVMTI 。

而 javaagent 是用 java 写的,可以直接用上层的 Instrumentation API,做一些类的增强转换之类,这也是大多数 APM Agent、Profiler Agent实现的基本原理。

Arthas 的玩法

Arthas 的核心入口,其实还是 javaagent,支持静态加载和动态加载两种玩法。

静态没啥好说的,启动脚本里增加一个-javaagent:/tmp/test/arthas-agent.jar,然后为所欲为。

动态的叫 attach,使用 Java 提供的 VirtualMachine 就可以实现运行时添加 -javaagent,效果一样:

ini 复制代码
VirtualMachine virtualMachine = VirtualMachine.attach(virtualMachineDescriptor);
virtualMachine.loadAgent(agentPath, agentArgs);

这个 Agent 在 JVM 里启动了一个TCP server,用于收发 Arthas Client 的各种 trace、watch 、Dashboard 等指令,然后通过 Instrumentation 增强Class 插入代码、或者直接调用某些 Java API,实现各种功能。

注意到了吗?Arthas 可以直接下载一个 jar 包,java -jar 就能连上。

其实吧,它这个直接启动的 jar 包,是一个 boot 包,启动之后把乱七八糟的 jar 都下载下来。接着动态 attach 的方式,连接到本机指定进程号的 JVM,然后再为所欲为。

在 3.5 版本之后,Arthas 还新增了一个 vmtool 命令,这个命令可以直接获取内存中的指定对象实例。

ruby 复制代码
$ vmtool --action getInstances --className java.lang.String --limit 10
@String[][
    @String[com/taobao/arthas/core/shell/session/Session],
    @String[com.taobao.arthas.core.shell.session.Session],
    @String[com/taobao/arthas/core/shell/session/Session],
    @String[com/taobao/arthas/core/shell/session/Session],
    @String[com/taobao/arthas/core/shell/session/Session.class],
    @String[com/taobao/arthas/core/shell/session/Session.class],
    @String[com/taobao/arthas/core/shell/session/Session.class],
    @String[com/],
    @String[java/util/concurrent/ConcurrentHashMap$ValueIterator],
    @String[java/util/concurrent/locks/LockSupport],
]

直接获取内存对象,这玩意只靠 Instrumentation API 可做不到。Arthas 搞了个骚操作,直接 JNI 调用自定义 lib,用过 cpp 直接调用了 JVMTI 的 API,融合了 Instrumentation 和 JVMTI 的能力,这下是真的为所欲为了!

c 复制代码
#include <stdio.h>
#include <jni.h>
#include <jni_md.h>
#include <jvmti.h>
#include "arthas_VmTool.h" // under target/native/javah/

static jvmtiEnv *jvmti;

...

extern "C"
JNIEXPORT jobjectArray JNICALL
Java_arthas_VmTool_getInstances0(JNIEnv *env, jclass thisClass, jclass klass, jint limit) {
    jlong tag = getTag();
    limitCounter.init(limit);
    jvmtiError error = jvmti->IterateOverInstancesOfClass(klass, JVMTI_HEAP_OBJECT_EITHER,
                                               HeapObjectCallback, &tag);
    if (error) {
        printf("ERROR: JVMTI IterateOverInstancesOfClass failed!%u\n", error);
        return NULL;
    }

    jint count = 0;
    jobject *instances;
    error = jvmti->GetObjectsWithTags(1, &tag, &count, &instances, NULL);
    if (error) {
        printf("ERROR: JVMTI GetObjectsWithTags failed!%u\n", error);
        return NULL;
    }

    jobjectArray array = env->NewObjectArray(count, klass, NULL);
    //添加元素到数组
    for (int i = 0; i < count; i++) {
        env->SetObjectArrayElement(array, i, instances[i]);
    }
    jvmti->Deallocate(reinterpret_cast<unsigned char *>(instances));
    return array;
}

总结

  1. Debug 基于 JDPA 体系

    1. IDE 直接接入 JDPA 体系中的 JDI 接口完成
    2. JDI 通过 JDWP 协议,调用远程 VM 的 JVMTI 接口
    3. JDWP 是通过 agentlib 加载的,agentlib 算是一个 native 的静态"外挂"接口
  2. javaagent 是 JAVA 层面的"外挂"接口,用过 Instrumentation API(Java)实现各种功能,主要用于APM、Profiler 工具

  3. 如果你想,在 javaagent 里调用功能更丰富的 JVMTI 也不是不行。

作者:京东保险 蒋信

来源:京东云开发者社区 转载请注明来源

相关推荐
IT_陈寒1 分钟前
JavaScript性能飞跃:5个V8引擎优化技巧让你的代码提速300%
前端·人工智能·后端
骑士雄师12 分钟前
Java 泛型中级面试题及答案
java·开发语言·面试
Victor35614 分钟前
Redis(61)Redis的连接数上限是多少?
后端
Victor35618 分钟前
Redis(60) Redis的复制延迟如何优化?
后端
.格子衫.6 小时前
Spring Boot 原理篇
java·spring boot·后端
多云几多6 小时前
Yudao单体项目 springboot Admin安全验证开启
java·spring boot·spring·springbootadmin
Jabes.yang8 小时前
Java求职面试实战:从Spring Boot到微服务架构的技术探讨
java·数据库·spring boot·微服务·面试·消息队列·互联网大厂
聪明的笨猪猪8 小时前
Java Redis “高可用 — 主从复制”面试清单(含超通俗生活案例与深度理解)
java·经验分享·笔记·面试
兮动人9 小时前
Spring Bean耗时分析工具
java·后端·spring·bean耗时分析工具
MESSIR229 小时前
Spring IOC(控制反转)中常用注解
java·spring