QCon 演讲火热征集中,快来分享技术实践与洞见! 了解详情
写点什么

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

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

Jmix 1.4 功能概览

世开 Coding

企业级低代码 Jmix 企业级应用程序开发

智慧公路筑基者!天翼云打造全栈能力新底座

天翼云开发者社区

基金交易场景下,如何利用 Apache APISIX 来稳固 API 安全

API7.ai 技术团队

安全 金融行业 api 网关 APISIX

Linux内存泄露案例分析和内存管理分享

京东科技开发者

负载均衡 集群 内存泄漏 Linux Cron 运维、

探究并发和并行、同步和异步、进程和线程、阻塞和非阻塞、响应和吞吐等

C++后台开发

多线程 后端开发 并行 linux开发 C++开发

快速满足个性化业务需求的低代码平台

力软低代码开发平台

三面阿里java后台开发岗总结:进阿里必看这份究极面试文档

钟奕礼

Java 编程 java程序员 java面试 java架构

双11狂欢背后,火山引擎数智平台为品牌做了这件事

字节跳动数据平台

大数据 营销数字化 火山引擎

企业开发首选安全框架!阿里顶配版Spring Security OAuth2.0认证授权核心技术全解真香

Java永远的神

Java 源码 springboot springsecurity 安全框架

网络地址转换(NAT)(三)

我叫于豆豆吖.

11月月更

为什么变压器经常烧毁?怎么预防解决?

元器件秋姐

元器件采购 华秋商城 变压器 变压器安全

面向对象基础

断墨寻径

面向对象 java;

数字先锋| 教育资源乘云而来!46万城乡学子共享名师课堂

天翼云开发者社区

函数计算|如何使用层解决依赖包问题?

阿里巴巴云原生

阿里云 Serverless 云原生 函数计算

视频服务HDR Vivid 还原色彩,让所见成“真”

HarmonyOS SDK

视频 HMS Core

让迁移不再开盲盒,让云也能省钱丨Hackathon 项目背后的故事第一期回顾

PingCAP

hackathon

共享开源技术,共建开放生态丨平凯星辰余梦杰出席 2022 世界互联网大会开源论坛圆桌对话

PingCAP

开源

天翼云实时云渲染,助力打造世界VR产业大会云上之城

天翼云开发者社区

网络地址转换(NAT)(二)

我叫于豆豆吖.

网络 11月月更

架构实战训练营模块 5 作业

atcgnu

java文件流

hello java

文件 程序 Java core 11月月更

【从零开始学爬虫】采集同花顺基金评论数据

前嗅大数据

数据采集 爬虫软件 爬虫教程 数据采集教程 爬虫案例

钢网有多个种类,各自的用法都了解吗?

华秋PCB

PCB PCB设计 PCB生产

手写一个webpack插件

Geek_02d948

webpack

TiKV 源码阅读三部曲(三)写流程

PingCAP

源码阅读

软件测试面试真题 | TCP为什么要进行三次握手和四次挥手呢?

测试人

三次握手 软件测试 自动化测试 测试开发 TCP四次挥手

奖金+生态双丰收 首届昇腾AI创新大赛圆满收官

Geek_2d6073

融云 K 歌解决方案,应用丰富互动模式的「万能卡」

融云 RongCloud

互动平台

技术贴 | Rocksdb 中 Memtable 源码解析

KaiwuDB

数据库

【#HDC2022】HarmonyOS体验官活动正式开启,赶快投稿赢限量奖品吧!

HarmonyOS开发者

HarmonyOS

天翼云打造自研云操作系统TeleCloudOS4.0 推动算力蓬勃发展

天翼云开发者社区

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