写点什么

JavaAgent 原理与实践

  • 2019-09-24
  • 本文字数:9109 字

    阅读完需:约 30 分钟

JavaAgent原理与实践

1JavaAgent

启动时加载的 JavaAgent 是 JDK1.5 之后引入的新特性,此特性为用户提供了在 JVM 将字节码文件读入内存之后,JVM 使用对应的字节流在 Java 堆中生成一个 Class 对象之前,用户可以对其字节码进行修改的能力,从而 JVM 也将会使用用户修改过之后的字节码进行 Class 对象的创建。

1.1 JVM Tool Interface

JVMTI 是 JVM 暴露出来的一些供用户进行自定义扩展的接口集合,每当 jvm 执行到一些特定的逻辑的时间,就会去进行触发这些回调接口,用户就恰好可以在此回调接口之中做一些自定义逻辑。


而对于此次所要描述的 JavaAgent 也恰恰是基于 JVMTI 的,JPLISAgent 就是用作实现 javaagent 功能的动态库。

1.2 JPLISAgent

JPLISAgent 实现了 Agent_OnLoad 方法,Agent_OnLoad 方法也就是整个启动时加载的 JavaAgent 的入口方法,后续也会说明整个运行流程。

2 如何使用

虽然大多数同学可能已经使用过 JavaAgent 了,但是为了下面原理的平滑过渡,我这里还是大概写一下使用:

2.1 编写 premain 启动类

编写一个含有以下 premain 函数的类


1[1]public static void premain(String agentArgs, Instrumentation instrumentation);2[2]public static void premain(String agentArgs); 
复制代码


上面的两个方法只需要实现一个即可,且[1]的优先级是高于[2]的,即如果上面的两个方法同时出现,则只会执行[1]方法


agentArgs 是跟随 javaagent:xx.jar=yyy 传入的 yyy 字符串


instrumentation 是一个 java.lang.instrument.Instrumentation 实例,由本地方法实例化并由 jvm 自动传入。此类是 JavaAgent 的核心类。


1public class Agent {  2 public static void premain(String args, Instrumentation inst){  3        System.out.println("Hi, This is a agent!");  4        inst.addTransformer(new TestTransformer()); //将类转换器添加到此`agent`的`instrumentation`实例之中5    }  6}
复制代码

2.2 类转换器

类转换器的作用主要是在某个类的字节码被 JVM 读入之后,在 Java 堆上创建 Class 对象之前,JVM 会遍历所有的 instrumentation 实例并执行其中的所有的 ClassFileTransformer 的 transform 方法,其中关于启动时加载的 javaAgent 重点需要关注的入参:


className:当前类的限定类名


classfileBuffer:当前类的以 byte 数组呈现的字节码数据(可能跟 class 文件的数据不一致,因为此处的 byte 数据是此类最新的字节码数据,即此数据可能是原始字节码数据被其他增强方法增强之后的自己买数据)


1public class TestTransformer implements ClassFileTransformer {  2  @Override  3  public byte[] transform(ClassLoader classLoader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) {  4        //进行对应类字节码的操作,并返回新字节码数据的byte数组,如果返回null,则代码不对此字节码作任何操作5        return null;6   } 7} 
复制代码

2.3 MAINIFEST.MF

1Manifest-Version: 1.0  2Premain-Class: test.Agent  
复制代码


然后将上述的代码打成 jar 包并在 jvm 启动时增加-javaagent:xx.jar 即可以完成 javaAgent 的生效。

3 实现原理

上面关于 JavaAgent 的使用有涉及到这么几个关键字:


permain,Instrumentation,ClassFileTransformer,MAINIFEST.MF 中的 Premain-Class


其实对于上面这些关键字就恰好组成了 JavaAgent 的实现原理


对于在启动命令添加的-javaagent=xx.jar 如果有多个,加载顺序就是从前往后,且每一个-javaagent 都是独立的。


以下的加载流程仅仅只是针对其中的一个 javaagent 描述的。


于 javaAgent 的入口方法就是 InvocationAdapter.c 中的 Agent_OnLoad 方法。经过查看 openjdk 源码,发现如下注释


1/*2 *  This will be called once for every -javaagent on the command line.3 *  Each call to Agent_OnLoad will create its own agent and agent data.4 *
复制代码


由注释我们可以指定,每一个-javaagent 都会有其自己的 agent 和 agent 数据,且每一个 javaagent 都会调用一次 Agent_OnLoad 方法就会被调用一次,且每一次的调用都是独立的。


在 Agent_OnLoad 方法中主要做的事情有下面三个:


1)初始化一个 JPLISAgent 对象,并给此对象设置 VMInit 事件的回调函数 eventHandlerVMInit.


2)找到 jvm 启动参数中-javaagent:xx.jar=yyy 中的 xx.jar 文件添加到 classpath 之中,并获取 yyy


3)找到 xx.jar 包中的 MAINIFEST.MF 中定义的 premainClass 并作为此 Agent 的入口类


4)并将 premainClass 和 yyy 设置到步骤 1 初始化的 JPLISAgent 对象之中。


当 VMInit 事件完成以后,会回调 InvocationAdapter.c 中的 eventHandlerVMInit 方法,eventHandlerVMInit 方法主要做的事情有下面:


1)实例化一个 InstrumentationImpl 对象,jvm 并依借此对象与 java 代码进行交互。


2)通过 JNI 执行 MAINIFEST.MF 中定义的类中的 premain 方法(我们上面的例子之中在 premain 方法中给 Instrumentation 对象添加了一个 ClassFileTransformer)


3)去除 JPLISAgent 对象中的 VMInit 回调函数,转而设置一个 ClassFileLoadHook 事件的回调函数。


当 ClassFileLoadHook 事件(在字节码文件被 jvm 读入之后,在 Class 对象创建之前)完成后,进行触发 eventHandlerClassFileLoadHook,此方法主要做的事情有下面几件:


1)进行调用 InstrumentationImpl 对象中的 mTransform 方法,而对于 mTransform 方法,最终会调用到我们在 Agent 的 premain 方法中给 Instrumentation 增加的 ClassFileTransformer。


此时,JVM 会通过 JNI 调用 java 代码,对应的类就是 sun.instrument.InstrumentationImpl 类之中,而对应于 mTransform 的方法就是 byte[] transform(ClassLoader var1, String var2, Class<?> var3, ProtectionDomain var4, byte[] var5, boolean var6)


方法是上述中 c 代码调用的地方,其中的 var5 是对应当前文件的字节码数据,如果此接口返回的数据为 null,则认为 transform 方法并未对此 class 文件有过修改,如果返回的数据不为 null,则会使用返回的新字节码作为 jvm 中此类最新的字节码并进行下一个 Javaagent 的处理或者创建 Class 对象进行链接以及初始化。


其中对于 c 代码通过 JNI 反射调用 java 代码的方法声明都在 JPLISAgent.h 类中可以看见


 1struct  _JPLISAgent; 2 3typedef struct _JPLISAgent        JPLISAgent; 4typedef struct _JPLISEnvironment  JPLISEnvironment; 5 6 7/* constants for class names and methods names and such 8    these all must stay in sync with Java code & interfaces 9*/10#define JPLIS_INSTRUMENTIMPL_CLASSNAME                      "sun/instrument/InstrumentationImpl"11#define JPLIS_INSTRUMENTIMPL_CONSTRUCTOR_METHODNAME         "<init>"12#define JPLIS_INSTRUMENTIMPL_CONSTRUCTOR_METHODSIGNATURE    "(JZZ)V"13#define JPLIS_INSTRUMENTIMPL_PREMAININVOKER_METHODNAME      "loadClassAndCallPremain"14#define JPLIS_INSTRUMENTIMPL_PREMAININVOKER_METHODSIGNATURE "(Ljava/lang/String;Ljava/lang/String;)V"15#define JPLIS_INSTRUMENTIMPL_AGENTMAININVOKER_METHODNAME      "loadClassAndCallAgentmain"16#define JPLIS_INSTRUMENTIMPL_AGENTMAININVOKER_METHODSIGNATURE "(Ljava/lang/String;Ljava/lang/String;)V"17#define JPLIS_INSTRUMENTIMPL_TRANSFORM_METHODNAME           "transform"18#define JPLIS_INSTRUMENTIMPL_TRANSFORM_METHODSIGNATURE      \19    "(Ljava/lang/ClassLoader;Ljava/lang/String;Ljava/lang/Class;Ljava/security/ProtectionDomain;[BZ)[B"
复制代码


对于启动时加载的 JavaAgent 涉及到的 c 代码主要为:


其中涉及到的 c 代码在/src/share/instrument 目录下的 JPLISAgent.c,PLISAgent.h,InvocationAdapter.c。


涉及到的 java 代码为 sun.instrument.InstrumentationImpl。

4 字节码工具

上面我们说过,javaAgent 提供了一些接口可以让我们在某些特定的时机进行对于 java 字节码的操作,在不改变代码的前提下,修改最终载入内存的字节码。但是由于直接操作字节码需要对 java 的字节码底层有较深入的研究。所以一些能帮助我们不需要了解字节码底层也能修改字节码的工具就诞生了。其中较为流行的字节码操作工具有 byteBuddy 和 javassist 等等。

4.1 Javassist 冲突

使用了两种 JavaAgent(一种 JavaAgent 是基于 ByteBuddy,另外一种 JavaAgent 是基于 Javassist)同时去增强同一个类的同一个方法:


  • 当两个 JavaAgent 的载入顺序为:Javassist-ByteBuddy,这两个 JavaAgent 对于同一个类的同一个方法的增强都生效了

  • 当两个 JavaAgent 的载入顺序为:ByteBuddy-Javassist,Javassist 对应的增强生效了,而 ByteBuddy 对应的增强却没生效

  • 当使用三个 JavaAgent(两个 Javassist,一个 ByteBuddy)的载入顺序为:Javassist-ByteBuddy-Javassist,两个 Javassist 的增强都生效了,而 ByteBuddy 对应的增强却没生效

4.2 冲突根因

针对上面结果的不同,我们来开始分析不同的 JavaAgent 的实现在不同加载顺序下为什么会造成如此大的差异呢?


使用 Javassist 创建 JavaAgent


 1ClassPool classPool = ClassPool.getDefault(); 2CtClass ctClass = classPool.getCtClass(className); 3CtMethod[] ctMethods = ctClass.getDeclaredMethods(); 4for (int i = 0; i < ctMethods.length; i++) { 5     CtMethod ctMethod = ctMethods[i]; 6     if (!ctMethod.getName().equalsIgnoreCase("main")) { 7          continue 8     } 9    ctMethod.insertBefore("System.out.println(\"这是第2个 javassist Agent Before~~~~~\");");10    ctMethod.insertAfter("System.out.println(\"这是第2个 javassist Agent After~~~~~\");");11}12return ctClass.toBytecode();
复制代码


其中对于 Javassist 是使用 ClassPool 对象来存储对应的 class 对象,对于默认的 defaultPool 是 static 属性,故如果有多个基于 Javassist 实现的 JavaAgent,那么它们使用的 ClassPool 是同一个对象。


当使用 classPool.getCtClass(className)方法获取一个 CtClass 对象,其中 Javassist 都做了哪些事情呢?


让我们进去看看


 1 protected synchronized CtClass get0(String classname, boolean useCache) throws NotFoundException { 2        CtClass clazz = null; 3        if (useCache) { 4            clazz = this.getCached(classname); 5            if (clazz != null) { 6                return clazz; 7            } 8        } 910        if (!this.childFirstLookup && this.parent != null) {11            clazz = this.parent.get0(classname, useCache);12            if (clazz != null) {13                return clazz;14            }15        }1617        clazz = this.createCtClass(classname, useCache);18        if (clazz != null) {19            if (useCache) {20                this.cacheCtClass(clazz.getName(), clazz, false);21            }2223            return clazz;24        } else {25            if (this.childFirstLookup && this.parent != null) {26                clazz = this.parent.get0(classname, useCache);27            }2829            return clazz;30        }31    }
复制代码


由上面源码可以当 ClassPool 的 Cache 中不存在对应的 class 的时候,javassist 会调用 this.createCtClass(classname, useCache)来实例化一个 CtClass 对象,那它是如何创建的呢?


 1 protected CtClass createCtClass(String classname, boolean useCache) { 2        if (classname.charAt(0) == '[') { 3            classname = Descriptor.toClassName(classname); 4        } 5 6        if (!classname.endsWith("[]")) { 7            return this.find(classname) == null ? null : new CtClassType(classname, this); 8        } else { 9            String base = classname.substring(0, classname.indexOf(91));10            return (!useCache || this.getCached(base) == null) && this.find(base) == null ? null : new CtArray(classname, this);11        }12    }
复制代码


可以看出,对于此次,javassist 仅仅只是使用 className 用实例化了个 CtClassType 对象。


当获取了 CtClass 对象之后,我们想知道其中都有哪些方法,接下来使用 ctClass.getDeclaredMethods()来获取其中的方法,我们看看他是如何获取的呢?


getDeclaredMethods()–>getMembers()–>makeBehaviorCache(cache)–>getClassFile2()–>openClassfile(classname)


 1 public InputStream openClassfile(String classname) { 2        try { 3            char sep = File.separatorChar; 4            String filename = this.directory + sep + classname.replace('.', sep) + ".class"; 5            return new FileInputStream(filename.toString()); 6        } catch (FileNotFoundException var4) { 7            ; 8        } catch (SecurityException var5) { 9            ;10        }1112        return null;13    }
复制代码


可以看出 javassist 获取到对应类的 class 文件,并以文件流的形式读入以获取到其 class 中所有的方法和属性并缓存在 CtClassType 对象之中(由此,我们可以明确一个问题,第一个 javassist 对于字节码的修改都是基于对应类的源字节码文件)


当 javasist 使用了 ctMethod.insertBefore 方法对某方法进行增强的之后,会刷新其对应 ClassPool 之中缓存的 CtClassType 的属性。


当第二个基于 Javassist 实现的 JavaAgent 对相同的类进行增强的时候,其也会去 ClassPool 中获取 CtClass 对象,而这个 CtClass 恰恰就是上一个 JavaAgent 对原始字节码增强之后刷新的结果,这样可以说明第二个 JavaAgent 在增强的时候使用的字节码的源文件跟第一个 JavaAgent 使用的字节码的源文件的获取方式不同。


这时候,问题已经有一点眉目了。


我们可以明确这么几个事情:


1)非 Javassist 的字节码工具中一定中不存在与 Javassist 相同的 ClassPool 对象


2)两个基于 Javassist 产生的 JavaAgent 增强的字节码的获取来源不同


这时候,就需要上 Instrument 的源码了!


下面代码对应于 jdk8 的 openJdk 源码的/src/share/instrumentJPLISAgent.c


 1void 2transformClassFile(             JPLISAgent *            agent, 3                                JNIEnv *                jnienv, 4                                jobject                 loaderObject, 5                                const char*             name, 6                                jclass                  classBeingRedefined, 7                                jobject                 protectionDomain, 8                                jint                    class_data_len, 9                                const unsigned char*    class_data,10                                jint*                   new_class_data_len,11                                unsigned char**         new_class_data,12                                jboolean                is_retransformer) {13    jboolean        errorOutstanding        = JNI_FALSE;14    jstring         classNameStringObject   = NULL;15    jarray          classFileBufferObject   = NULL;16    jarray          transformedBufferObject = NULL;17    jsize           transformedBufferSize   = 0;18    unsigned char * resultBuffer            = NULL;19    jboolean        shouldRun               = JNI_FALSE;2021    /* only do this if we aren't already in the middle of processing a class on this thread */22    shouldRun = tryToAcquireReentrancyToken(23                                jvmti(agent),24                                NULL);  /* this thread */2526    if ( shouldRun ) {27       .................2829        /*  now call the JPL agents to do the transforming */30        /*  potential future optimization: may want to skip this if there are none */31        if ( !errorOutstanding ) {32            jplis_assert(agent->mInstrumentationImpl != NULL);33            jplis_assert(agent->mTransform != NULL);34            transformedBufferObject = (*jnienv)->CallObjectMethod(35                                                jnienv,36                                                agent->mInstrumentationImpl,37                                                agent->mTransform,38                                                loaderObject,39                                                classNameStringObject,40                                                classBeingRedefined,41                                                protectionDomain,42                                                classFileBufferObject,43                                                is_retransformer);44            errorOutstanding = checkForAndClearThrowable(jnienv);45            jplis_assert_msg(!errorOutstanding, "transform method call failed");46        }47        ..................48        ..........49    }50    return;51}
复制代码


我们可以看到 CallObjectMethod()方法的后六个入参,这六个入参直接对应于 sun.instrument.InstrumentationImpl.byte[] transform(ClassLoader var1, String var2, Class<?> var3, ProtectionDomain var4, byte[] var5, boolean var6),其中 byte[] var5 对应于上面的 classFileBufferObject,classFileBufferObject 对象是对应类的字节码数据,方法 transform 的返回值是对应类改变之后的字节码数据,而改变之后的值也会将 classFileBufferObject 覆盖,然后进行下一个 JavaAgent 的增强或则进行载入,链接和初始化。


看完 Instrument 的源码,我们回忆一下 Javassist 的增强过程:Javassist 使用的是对应类的 class 文件进行读入并进行增强。


如果某一时刻,Javassist 的 JavaAgent 接收到的某个类的字节码数据(原始字节码已经被其他非 Javassist 的方式增强之后的字节码)和此类对应的 class 文件的数据不一致,这时候 Javassist 就会废弃掉接收到的已经被改变的字节码数据,转而使用此类最原始的 class 文件进行增强。这种情况就是先使用 ByteBuddy,后使用 Javassist 的情况。


当两个 JavaAgent 的载入顺序为:Javassist-ByteBuddy,这时候 Javassist 将此类进行增强之后,新的字节码数据传递给 ByteBuddy,ByteBuddy 会基于最新的字节码数据进行新的增强,这时候两个 JavaAgent 都可以正常增强。


当使用三个 JavaAgent(两个 Javassist,一个 ByteBuddy)的载入顺序为:Javassist-ByteBuddy-Javassist


第一个 Javassist 读取对应类的 class 文件并存储在 ClassPool 之中,并进行增强并刷新,ByteBuddy 获取到最新的字节码数据,也进行增强,第二个 Javassist 获取到第一个 Javassist 和 ByteBuddy 增强的数据之后,舍弃掉了,转而使用 ClassPool 只会中的数据,因为 ClassPool 之中的数据是第一个 Javassist 增强之后的字节码;所以结果之中保留着第一个 Javassist 的改动,但是对于 ByteBuddy 的改动就废弃掉了。


由上面的分析我们可以明确出两种 JavaAgent 顺序不同所导致的结果不同是由于 javassist 的内部实现机制所导致的,但是还是有个疑问?那么对于 byteBuddy 的 agent,它操作的字节码数据是从哪里来的呢?


下面展示的方法是 ByteBuddy 的 Agent 之中对于 transform 方法的实现,binaryRepresentation 此参数就是针对于当前类最新的字节码数据,ByteBuddy 使用其生成了一个 ClassFileLocator 对象,然后经查看 ClassFileLocator 类的注释,可以得知,ByteBuddy 则是使用最新的字节码数据进行自己其他的增强操作,这刚好也能印证了上面的结果。


以下代码对应的是


net.bytebuddy.agent.builder.AgentBuilder.java


 1 private byte[] transform(JavaModule module, 2                          ClassLoader classLoader, 3                          String internalTypeName, 4                          Class<?> classBeingRedefined, 5                          ProtectionDomain protectionDomain, 6                          byte[] binaryRepresentation) { 7     if (internalTypeName == null || !lambdaInstrumentationStrategy.isInstrumented(classBeingRedefined)) { 8         return NO_TRANSFORMATION; 9     }10     String typeName = internalTypeName.replace('/', '.');11     try {12         ClassFileLocator classFileLocator = ClassFileLocator.Simple.of(typeName,13                 binaryRepresentation,14                 locationStrategy.classFileLocator(classLoader, module));15         ......16         ......17     } catch (Throwable throwable) {18         listener.onError(typeName, classLoader, module, throwable);19         return NO_TRANSFORMATION;20     } finally {21         listener.onComplete(typeName, classLoader, module);22     }1/**2 * Locates a class file or its byte array representation when it is given its type description.3 */4public interface ClassFileLocator extends Closeable {
复制代码

4.3 冲突结论

根据上述现象与原理的分析,我们可以得出:


如果同时使用基于 Javassist 和基于其他字节码工具的 JavaAgent 去增强同一个类,Javassist 的加载顺序一定要在其他字节码的 JavaAgent 之前,这样才能保证两个字节码工具都可以进行完整的增强。如果基于 Javassist 的 JavaAgent 最后增强,那么之前的非 Javassist 的 JavaAgent 对于字节码的增强都会被丢弃掉,这也能会带来不小的麻烦。


作者介绍:


作者跳跳虎(企业代号名),目前负责贝壳找房 JAVA 服务端研发工作。


本文转载自公众号贝壳产品技术(ID:gh_9afeb423f390)。


原文链接:


https://mp.weixin.qq.com/s/3hXyFCgclsuoznNQ2ulC4g


2019-09-24 15:429346

评论 2 条评论

发布
用户头像
比如:Java代码覆盖率工具Jacoco的on-the-fly模式基于javaagent实现,和基于Javassist的mock框架powermock的冲突,在运行JUnit单元测试时导致Jacoco无法统计到代码覆盖率。
2021-02-06 23:39
回复
用户头像
MAINIFEST.MF 拼错啦,害我弄了半天。正确的是MANIFEST.MF
2020-08-13 12:01
回复
没有更多了
发现更多内容

软件测试/测试开发 | 数据持久化技术(Java)

测试人

软件测试 测试发开

一个可以早点下班的开发技巧

引迈信息

前端 敏捷开发 低代码 JNPF

那些年,我们写过的无效单元测试

阿里巴巴中间件

阿里云 编程 云原生

九科祝福丨人生不设限,愿你自由绽放

九科Ninetech

C++ sort和for_each算法的普通、文艺和2B用法

老王同学

c++ 排序

SpringApplication启动类的Args详解

石臻臻的杂货铺

spring springboot

直播报名 | 金融机构如何通过标签画像实现精细化客户运营?

索信达控股

预售登上计算机新书热卖榜TOP1,开年重磅,助力Java程序员飙升核心技能

图灵教育

Spring boot starter test java 后端、 程序员 java

ERP和MES如何做到优势互补,它们的区别在哪?

工赋开发者社区

我在京东做研发丨【混合多云第一课】为何多云多活被称为“技术皇冠上的明珠”?

京东科技开发者

京东云 研发 混合多云

如何让SpringBoot项目启动时执行特定代码

Java Spring Boot

软件测试/测试开发 | Spring Boot 异常处理

测试人

软件测试 springboot 自动化测试 测试发开

Kubernetes 部署主从结构的 MySQL 服务

CTO技术共享

构建一个可复用的自定义BaseAdapter

芯动大师

Adapter viewholder item

Redis缓存知识大集合

阿呆

redis 缓存 缓存击穿 缓存雪崩

如何降低 Flink 开发和运维成本?阿里云实时计算平台建设实践

Apache Flink

大数据 flink 实时计算

再见ChatGPT!又一值得国内程序员注册体验的AI生产力工具问世!

程序员小毕

AI 工具 后端 架构师 java程序员

直播预约|Search for Future,阿里云 × Elastic 中国用户峰会 2023

阿里云大数据AI技术

大数据 阿里云 搜索

2023年适用于Windows和Mac的FTP传输工具

镭速

【经验分享】电路板上电就挂?新手工程师该怎么检查PCB?

华秋PCB

工程师 电路 PCB PCB设计

Dubbo 在 Proxyless Mesh 模式下的探索与改进

阿里巴巴中间件

阿里云 云原生 dubbo

EasyRecovery16数据恢复软件有哪些新功能?

茶色酒

EasyRecovery Photo16

CorelDRAW矢量图形设计软件2023最新版本功能介绍

茶色酒

CorelDraw2023

分享5个我不能没有的Vue.js库

引迈信息

前端 低代码 开发工具 Vue 3

字典数据结构 FST(Finite State Transducer)

alexgaoyh

Java Trie FST dat 字典数据结构

三天吃透Kafka面试八股文

程序员大彬

Java Kafka Producer

Centos7下安装Dogtail GUI自动化测试工具并打开sniff工具过程中遇到的问题解决方法

Python centos 自动化测试 sniff dogtail

Gartner首次针对中国市场发布产业数字化白皮书,联合卡奥斯共探区域经济发展最优解

Openlab_cosmoplat

数字化 产业数字化 白皮书 开源社区 Gartner

MySQL 底层之 MVCC、回滚段、一致性读、锁定读

程序知音

预售登上计算机新书热卖榜TOP1,开年重磅,助力Java程序员飙升核心技能

图灵社区

Spring boot starter test java 后端、 程序员 java

工赋开发者社区 | 关于ChatGPT八个技术问题的猜想

工赋开发者社区

JavaAgent原理与实践_文化 & 方法_跳跳虎_InfoQ精选文章