写点什么

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:429358

评论 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
回复
没有更多了
发现更多内容

一女程序员因薪酬问题离职,rm -f * 删库,瘫痪6个小时,被判9个月

收到请回复

Java 程序员 面试 面经

Leetcode题目解析:274. H 指数

程序员架构进阶

面试 算法 LeetCode 10月月更

飞桨与海光人工智能加速卡DCU系列完成互证,助力国产AI加速 卡人工智能应用创新

百度大脑

人工智能 深度学习 飞桨

刚上岸字节年薪60W的Java架构师,耗时半年总结的24W字面试手册

Java 程序员 架构 面试 后端

谁说GitHub才能出经典?出自牛客网的Java程序员逆袭手册才是YYDS

Java 程序员 架构 面试 计算机

发布两小时,霸榜GitHub!Spring Boot实战文档

Java 编程 程序员 后端 计算机

Node.js 日志最佳实践指南

devpoint

Node console 10月月更

自定义View:如何绘制一个饼图

Changing Lin

10月月更

Java高级、架构师必备!Lucene+ElasticStack入门至项目实战!

Java 架构 面试 程序人生 编程语言

无敌!学透美团老哥的这套微服务进阶学习手册拿个P7还是so easy!

Java 架构 面试 程序人生 编程语言

通关宝典!Java 面试核心知识让你面试过,过,过!

Java 程序员 面试 后端 构架

字节总监毕生心血总结:收获,不止SQL优化抓住SQL的本质

Java 程序员 架构 面试 计算机

神马操作!Kafka 竟然宣布弃用 Java 8

收到请回复

Java kafka 后端 java8

ToB产品如何自传播(上)

石云升

产品经理 产品设计 产品思维 10月月更

凌晨加班回家路上捡到阿里技术人限产的MySQL高级笔记及面试宝典,从此我的人生像开挂一样!

Java 架构 面试 程序人生 编程语言

被疫情“带飞”的家庭健身市场,是时候卷起来了

脑极体

互动视频和5G的相互成就

脑极体

想不到吧!这本字节算法大佬562页《算法中文手册》,在Gihub上排名第一!

Java 架构 面试 程序人生 编程语言

SpringBoot 实战:在 RequestBody 中优雅的使用枚举参数

看山

Java Spring Boot Effective Spring 10月月更

升级了 Windows 11 正式版,有坑吗?

王磊

真香!兜兜转转还是得看你“阿里面试参考指南”

Java 程序员 架构 面试 后端

Prometheus 的 Metric 数据类型

耳东@Erdong

Prometheus 10月月更

横空出世!IDEA画图神器来了,比Visio快10倍

收到请回复

Java IDEA idea插件

双非本科毕业竟能四面阿里稳操胜券,轻松拿offer,定级P6+,怎么做到的?!

Java 程序员 架构 面试 后端

极客时间转眼间就4周年了

IT蜗壳-Tango

10月月更

TypeScript 中的 Index Signatures

Regan Yue

typescript ReganYue 10月月更

内卷破坏者!“阿里爸爸”全新出品SpringBoot高级笔记(全彩版)

Java 编程 架构 IT 计算机

linux之vi,vim命令

入门小站

Linux

这么卷吗?大三学生喜获阿里提前批

Java 程序员 架构 后端

Thread 的状态改变操作学习笔记

风翱

Thread 10月月更

碉堡了!Alibaba爆款Java高并发核心编程手册,在牛博网上被疯狂转载!

Java 架构 面试 程序人生 编程语言

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