第03讲:掌握 Java Agent 真的可以为所欲为

Posted Marion158

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了第03讲:掌握 Java Agent 真的可以为所欲为相关的知识,希望对你有一定的参考价值。

将 demo-provider 和 demo-webapp 接入 SkyWalking Agent 的时候,只需要在 VM options 中添加下面这一行配置即可:

-javaagent:/path/to/skywalking-agent.jar \\
-Dskywalking_config=/path/to/agent.config

并没有修改任何一行 Java 代码。这里便使用到了 Java Agent 技术,本课时我们将对 Java Agent 技术进行简单介绍并通过示例演示 Java Agent 技术的基本使用。

什么是 Java Agent

Java Agent 是从 JDK1.5 开始引入的,算是一个比较老的技术了。作为 Java 的开发工程师,我们常用的命令之一就是 java 命令,而 Java Agent 本身就是 java 命令的一个参数(即 -javaagent)。正如上一课时接入 SkyWalking Agent 那样,-javaagent 参数之后需要指定一个 jar 包,这个 jar 包需要同时满足下面两个条件:

  1. 在 META-INF 目录下的 MANIFEST.MF 文件中必须指定 premain-class 配置项。
  2. premain-class 配置项指定的类必须提供了 premain() 方法。

在 Java 虚拟机启动时,执行 main() 函数之前,虚拟机会先找到 -javaagent 命令指定 jar 包,然后执行 premain-class 中的 premain() 方法。用一句概括其功能的话就是:main() 函数之前的一个拦截器。

使用 Java Agent 的步骤大致如下:

1.  定义一个 MANIFEST.MF 文件,在其中添加 premain-class 配置项。

2.  创建  premain-class 配置项指定的类,并在其中实现 premain() 方法,方法签名如下:

public static void premain(String agentArgs, Instrumentation inst)
   ... 

3.  将 MANIFEST.MF 文件和  premain-class 指定的类一起打包成一个 jar 包。

4.  使用 -javaagent 指定该 jar 包的路径即可执行其中的 premain() 方法。

Java Agent 示例

首先,我们创建一个最基本的 Maven 项目,然后创建 TestAgent.java 这一个类,项目的整体结构如图所示。

TestAgent 中提供了 premain() 方法的实现,如下所示:

public class TestAgent 
    public static void premain(String agentArgs, 
            Instrumentation inst) 
        System.out.println("this is a java agent with two args");
        System.out.println("参数:" + agentArgs + "\\n");
    
    public static void premain(String agentArgs) 
        System.out.println("this is a java agent only one args");
        System.out.println("参数:" + agentArgs + "\\n");
    

premain() 方法有两个重载,如下所示,如果两个重载同时存在,【1】将会被忽略,只执行【2】:

public static void premain(String agentArgs) [1]
public static void premain(String agentArgs, 
      Instrumentation inst); [2]

代码中有两个参数需要我们注意。

  • agentArgs 参数:-javaagent 命令携带的参数。在前面介绍 SkyWalking Agent 接入时提到,agent.service_name 这个配置项的默认值有三种覆盖方式,其中,使用探针配置进行覆盖,探针配置的值就是通过该参数传入的。
  • inst 参数:java.lang.instrumen.Instrumentation 是 Instrumention 包中定义的一个接口,它提供了操作类定义的相关方法。

确定 premain() 方法的两个重载优先级的逻辑在 sun.instrument.InstrumentationImpl.java 中实现,相关代码如下:

private void loadClassAndCallPremain(String  String  optionsString)
            throws Throwable 
    loadClassAndStartAgent( classname, "premain", optionsString );

private void loadClassAndStartAgent(String  classname, 
        String  methodname, String  optionsString) throws Throwable 
    ... ... // 省略变量定义,下面省略 try/catch代码块
    // 查找两个参数的 premain()方法
    m = javaAgentClass.getDeclaredMethod( methodname,
            new Class<?>[] String.class, 
                java.lang.instrument.Instrumentation.class);
    twoArgAgent = true;
    if (m == null)  // 查找一个参数的 premain()方法
        m = javaAgentClass.getDeclaredMethod(methodname,
            new Class<?>[]  String.class );
    
    ... ...// 省略其他查找 
    setAccessible(m, true);
    // 调用查找到的 premain()重载
    if (twoArgAgent) 
        m.invoke(nullnew Object[]  optionsString, this );
     else 
        m.invoke(nullnew Object[]  optionsString );
    
    setAccessible(m, false);

接下来还需要创建 MANIFEST.MF 文件并打包,这里我们直接使用 maven-assembly-plugin 打包插件来完成这两项功能。在 pom.xml 中引入 maven-assembly-plugin 插件并添加相应的配置,如下所示:

<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-assembly-plugin</artifactId>
    <version>2.4</version>
    <configuration>
        <appendAssemblyId>false</appendAssemblyId>
        <!-- 将TestAgent的所有依赖包都打到jar包中-->
        <descriptorRefs>
            <descriptorRef>jar-with-dependencies</descriptorRef>
        </descriptorRefs>
        <archive>
            <!-- 添加MANIFEST.MF中的各项配置-->
            <manifest>
                <!-- 添加 mplementation-*和Specification-*配置项-->
                <addDefaultImplementationEntries>true
                </addDefaultImplementationEntries>
                <addDefaultSpecificationEntries>true
                </addDefaultSpecificationEntries>
            </manifest>
            <!-- 将 premain-class 配置项设置为com.xxx.TestAgent-->
            <manifestEntries>
                <Premain-Class>com.xxx.TestAgent</Premain-Class>
            </manifestEntries>
        </archive>
    </configuration>
    <executions>
        <execution>
            <!-- 绑定到package生命周期阶段上 -->
            <phase>package</phase>
            <goals>
                <!-- 绑定到package生命周期阶段上 -->
                <goal>single</goal>
            </goals>
        </execution>
    </executions>
</plugin>

最后执行 maven 命令进行打包,如下:

mvn package -Dcheckstyle.skip -DskipTests

完成打包之后,我们可以解压 target 目录下的 test-agent.jar,在其 META-INF 目录下可以找到 MANIFEST.MF 文件,其内容如下:

Manifest-Version: 1.0
Implementation-Title: TestAgent
Premain-Class: com.xxx.TestAgent       # 关注这一项
Implementation-Versioclearn: 1.0.0-SNAPSHOT
Archiver-Version: Plexus Archiver
Built-By: xxx
Specification-Title: TestAgent
Implementation-Vendor-Id: com.xxx
Created-By: Apache Maven 3.6.0
Build-Jdk: 1.8.0_191
Specification-Version: 1.0.0-SNAPSHOT

到此为止,Java Agent 使用的 jar 包创建完成了。

下面再创建一个普通的 Maven 项目:TestMain,项目结构与 TestAgent 类似,如图所示:

在 Main 这个类中定义了该项目的入口 main() 方法,如下所示:

public class Main 
    public static void main(String[] args) throws Exception 
        Thread.sleep(1000);
        System.out.println("TestMain Main!");
    

在启动 TestMain 项目之前,需要在 VM options 中使用 -javaagent 命令指定前面创建的 test-agent.jar,如图所示:

启动 TestMain 之后得到了如下输出:

this is a java agent.
参数:option1=value2,option2=value2
TestMain Main!

修改类实现

Java Agent 可以实现的功能远不止添加一行日志这么简单,这里需要关注一下 premain() 方法中的第二个参数:Instrumentation。Instrumentation 位于 java.lang.instrument 包中,通过这个工具包,我们可以编写一个强大的 Java Agent 程序,用来动态替换或是修改某些类的定义。

下面先来简单介绍一下 Instrumentation 中的核心 API 方法:

  • addTransformer()/removeTransformer() 方法:注册/注销一个 ClassFileTransformer 类的实例,该 Transformer 会在类加载的时候被调用,可用于修改类定义。
  • redefineClasses() 方法:该方法针对的是已经加载的类,它会对传入的类进行重新定义。
  • **getAllLoadedClasses()方法:**返回当前 JVM 已加载的所有类。
  • getInitiatedClasses() 方法:返回当前 JVM 已经初始化的类。
  • getObjectSize()方法:获取参数指定的对象的大小。

下面我们通过一个示例演示 Instrumentation 如何与 Java Agent 配合修改类定义。首先我们提供一个普通的 Java 类:TestClass,其中提供一个 getNumber() 方法:

public class TestClass 
    public int getNumber()  return 1;  

编译生成 TestClass.class 文件之后,我们将 getNumber() 方法返回值修改为 2,然后再次编译,并将此次得到的 class 文件重命名为 TestClass.class.2 文件,如图所示,我们得到两个 TestClass.class 文件:

之后将 TestClass.getNumber() 方法返回值改回 1 ,重新编译。

然后编写一个 main() 方法,新建一个 TestClass 对象并输出其 getNumber() 方法的返回值:

public class Main 
    public static void main(String[] args) 
        System.out.println(new TestClass().getNumber());
    

接下来编写 premain() 方法,并注册一个 Transformer 对象:

public class TestAgent 
    public static void premain(String agentArgs, Instrumentation inst) 
              throws Exception 
        // 注册一个 Transformer,该 Transformer在类加载时被调用
        inst.addTransformer(new Transformer(), true);
        inst.retransformClasses(TestClass.class);
        System.out.println("premain done");
    

Transformer  实现了 ClassFileTransformer,其中的 transform() 方法实现可以修改加载到的类的定义,具体实现如下:

class Transformer implements ClassFileTransformer 
    public byte[] transform(ClassLoader l, String className, 
       Class<?> c, ProtectionDomain pd, byte[] b)  
        if (!c.getSimpleName().equals("TestClass")) 
            return null// 只修改TestClass的定义
        
        // 读取 TestClass.class.2这个 class文件,作为 TestClass类的新定义
        return getBytesFromFile("TestClass.class.2");
    

之后还需要在 maven-assembly-plugin 插件中添加 Can-Retransform-Classes 参数:

// 省略其他配置
<manifestEntries>
    <Premain-Class>com.xxx.TestAgent</Premain-Class>
    <Can-Retransform-Classes>true</Can-Retransform-Classes>
</manifestEntries>

最后,打包启动应用,得到的输出如下:

premain done
2 
# 此输出说明,类TestClass的定义在加载时已经被 Transformer替换成 
# 文件 TestClass.class.2 中的定义

统计方法耗时

介绍完 Java Agent 的基本使用流程之后,这里做一个简单的进阶示例,将 Java Agent 与 Byte Buddy 结合使用,统计 com.xxx 包下所有方法的耗时。

回到 TestAgent 项目,整个项目的结构不变,需要在 pom.xml 中添加 Byte Buddy 的依赖,如下所示:

<dependency>
    <groupId>net.bytebuddy</groupId>
    <artifactId>byte-buddy</artifactId>
    <version>1.9.2</version>
</dependency>
<dependency>
    <groupId>net.bytebuddy</groupId>
    <artifactId>byte-buddy-agent</artifactId>
    <version>1.9.2</version>
</dependency>

Byte Buddy 是一个开源 Java 库,其主要功能是帮助用户屏蔽字节码操作,以及复杂的 Instrumentation API 。Byte Buddy 提供了一套类型安全的 API 和注解,我们可以直接使用这些 API 和注解轻松实现复杂的字节码操作。另外,Byte Buddy 提供了针对 Java Agent 的额外 API,帮助开发人员在 Java Agent 场景轻松增强已有代码。在 下一课时中,我们将深入介绍 SkyWalking 涉及的 Byte Buddy 的 API,这里不做深入研究,只对此处涉及的 API 和注解做简单说明。

接下来对 TestAgent.java 进行修改,使用 Byte Buddy 提供的 API 拦截指定的类和方法,如下所示:

public class TestAgent 
    public static void premain(String agentArgs, 
        Instrumentation inst) 
        // Byte Buddy会根据 Transformer指定的规则进行拦截并增强代码
        AgentBuilder.Transformer transformer = 
            new AgentBuilder.Transformer() 
            public DynamicType.Builder<?> transform(
                        DynamicType.Builder<?> builder,
                        TypeDescription typeDescription, 
                        ClassLoader classLoader,
                        JavaModule module) 
                // method()指定哪些方法需要被拦截,ElementMatchers.any()表                  // 示拦截所有方法
                return builder.method(
                   ElementMatchers.<MethodDescription>any())
                      // intercept()指明拦截上述方法的拦截器
                     .intercept(MethodDelegation.to(
                      TimeInterceptor.class));
            
        ;
        // Byte Buddy专门有个AgentBuilder来处理Java Agent的场景
        new AgentBuilder 
                .Default()
                // 根据包名前缀拦截类
                .type(ElementMatchers.nameStartsWith("com.xxx"))
                // 拦截到的类由transformer处理
                .transform(transformer)
                .installOn(inst); // 安装到 Instrumentation
    

当拦截到符合条件的类时,会交给我们的 AgentBuilder.Transformer 实现处理,当 Transformer 拦截到符合条件的方法时,会交给上面指定的 TimeInterceptor 处理。TimeInterceptor 的具体实现如下:

public class TimeInterceptor 
    @RuntimeType
    public static Object intercept(@Origin Method method,
          @SuperCall Callable<?> callable) throws Exception 
        long start = System.currentTimeMillis();
        try 
            return callable.call(); // 执行原函数
         finally 
            System.out.println(method.getName() + ":"
                    + (System.currentTimeMillis() - start) + "ms");
        
    

TimeInterceptor 就类似于 AOP 的环绕切面。这里通过 @SuperCall 注解注入的 Callable 实例可以调到被拦截的目标方法,需要注意的是,在通过 Callable 调用目标方法时,即使目标方法带参数,这里也不用显式的传递。这里 @Origin 注解注入了被拦截方法对应的 Method  对象。

完成 TestAgent 的修改之后,执行如下命令,重新打包 test-agent.jar:

mvn clean  package -Dcheckstyle.skip -DskipTests

打包完成,回到 TestMain 项目,所有代码和配置都无需修改,直接启动,会得到输出如下:

TestMain Main!
main:1003ms

Attach API 基础

在 Java 5 中,Java 开发者只能通过 Java Agent 中的 premain() 方法在 main() 方法执行之前进行一些操作,这种方式在一定程度上限制了灵活性。Java 6 针对这种状况做出了改进,提供了一个 agentmain() 方法,Java 开发者可以在 main() 方法执行以后执行 agentmain() 方法实现一些特殊功能。

agentmain() 方法同样有两个重载,它们的参数与 premain() 方法相同,而且前者优先级也是高于后者的:

public static void agentmain (String agentArgs, 
      Instrumentation inst);[1]

public static void agentmain (String agentArgs); [2]

agentmain() 方法主要用在 JVM Attach 工具中,Attach API 是 Java 的扩展 API,可以向目标 JVM “附着”(Attach)一个代理工具程序,而这个代理工具程序的入口就是 agentmain() 方法。

Attach API 中有 2 个核心类需要特别说明:

  • VirtualMachine 是对一个 Java 虚拟机的抽象,在 Attach 工具程序监控目标虚拟机的时候会用到该类。VirtualMachine 提供了 JVM 枚举、Attach、Detach 等基本操作。
  • VirtualMachineDescriptor 是一个描述虚拟机的容器类,后面示例中会介绍它如何与 VirtualMachine 配合使用。

下面的示例依然通过用类文件替换的方式修改 TestClass 这个类的返回值。首先,前文使用到的 TestClass,以及 TestClass.class.2 文件不变。main() 方法略作修改,其中每隔 1s 创建一个新的 TestClass 对象并输出 getNumber() 方法返回值,具体实现如下:

public static void main(String[] args) throws InterruptedException 
    System.out.println(new TestClass().getNumber());
    while (true) 
        Thread.sleep(1000); // 注意,这里是新建TestClass对象
        System.out.println(new TestClass().getNumber());
    

另外,将 TestAgent 中的 premain() 方法修改成 agentmain() 方法(除了名称变化,没有任何其他变化)。

需要再添加一个 AttachMain 类,其中会通过 Attach API 监听上面 main() 方法的启动,这里每隔 1s 检查一次所有的 Java 虚拟机,当发现有新的虚拟机出现的时候,就调用 attach() 方法,将 TestAgent 所在的 jar 包“附加”上去,“附加”成功之后,TestAgent 就会通过 Transformer 修改TestClass 类定义。AttachMain 的具体实现如下:

public class AttachMain 
    public static void main(String[] args) throws Exception 
        List<VirtualMachineDescriptor> listBefore = 
             VirtualMachine.list();
        String jar = ".../test-agent.jar"// agentmain()方法所在jar包
        VirtualMachine vm = null;
        List<VirtualMachineDescriptor> listAfter = null;
        while (true) 
            listAfter = VirtualMachine.list();
            for (VirtualMachineDescriptor vmd : listAfter) 
                if (!listBefore.contains(vmd))  // 发现新的JVM
                    vm = VirtualMachine.attach(vmd); // attach到新JVM
                    vm.loadAgent(jar); // 加载agentmain所在的jar包
                    vm.detach(); // detach
                    return;
                
            
            Thread.sleep(1000);
        
    

在 pom.xml 文件中添加如下依赖,否则在编译的过程中会抛异常:

<dependency>
    <groupId>com.sun</groupId>
    <artifactId>tools</artifactId>
    <version>1.8</version>
    <scope>system</scope>
    <systemPath>$java.home/../lib/tools.jar</systemPath>
</dependency>

另外,还要在 MANIFEST.MF 文件中添加 Agent-Class 这一项, pom.xml 文件中具体配置如下:

<manifestEntries>
    <Can-Retransform-Classes>true</Can-Retransform-Classes>
    <!-- 指向agentmain()方法所在的类 -->
    <Agent-Class>com.xxx.TestAgent</Agent-Class>
</manifestEntries>

最后,我们先启动 AttachMain 类,然后通过命令行启动 Main :

java  -cp ./test-agent.jar com.xxx.attach.Main
-------
输出:
1
premain done # attach成功,Transformer会修改TestClass的定义
2  # 修改后的TestClass.getNumber()方法返回值为 2
2

在 SkyWalking Agent 中并没有使用到 Attach API,但是作为 Java Agent 的扩展,还是希望你对其有所了解。

总结

本课时重点介绍了 Java Agent 技术的基础知识,然后通过 TestAgent 和 TestMain 两个示例项目简单展示了 Java Agent 技术的使用流程,之后通过 Java Agent 和 Byte Buddy 实现了统计方法耗时的功能,最后还简单介绍 Attach API 的相关功能并进行了示例演示。

希望你在课后可以自己多加练习。

精通Framework是真的可以为所欲为

近十几年来,随着以Android系统为代表的智能手机普及与发展,互联网行业早已进入“移动”的时代。但是现如今的“风口”已经从移动转向,整个移动互联网行业正处于增量下降、存量厮杀的阶段。面对技术更新迭代加速,前景不太明朗,很多开发者都感到了有些焦虑和迷茫。并且,在如此的大环境下,整个行业头部企业,越来越重视产品的体验与成本,对中高级的开发者的能力要求也越来越高。

现在大厂面试时,我们经常会被问到这些问题:

  • 为什么Zygote通信fork进程,使用的是socket,而不是Android的Binder?
  • 为什么是从zygote进程fork App,而不是其他进程?
  • Binder在做数据传输过程中,最大的数据量限制是多少?
  • 打开一个Activity的过程中经历过几次跨进程调用?
  • ANR弹框的原理是什么?
  • ……

每当这时候,内心真是一万只槽泥马奔腾而过……

大部分Android开发者一遇到这种面试题就直接懵逼了,不少人不是没有看过相关的解答,但也都只是浅尝辄止,没有深入掌握其中原理,面试的时候自然会被问个措手不及。

下面这张图想必大家都看过,Google官方提供过一张经典的平台架构图,从下往上依次分为:Linux内核、硬件抽象层、Native层、Java Framework层、App层,每一层都包含大量的子模块或子系统。

可以看到具体app的下面就是Framework层的支撑。所以掌握Framework层非常有助于我们开发出一个性能良好的App,另外在大厂的面试过程中,Framework也是高阶面试时必问的问题:

在所有的Framework知识中,要数最重要的还是AMS,主打和Activity,Service,ContentProvider,Broadcast等交互:

看一下上图,Activity启动,涉及到ActivityThread,AMS,H类,上述过程还涉及到多次跨进程调用,涉及到各种binder的知识。

搞清楚这些:我们就可以去研究各种黑科技,例如在做插件化的时候,你需要占坑Activity等,hook代码等都是在和AMS斗智斗勇;在做性能优化的时候,你也要了解AMS是如何调度Activity的,消息队列是如何运转的。

**但AMS本身比较复杂、难以理解,许多工作多年的Android开发者也很难弄清AMS的作用。**于是,系统的整体运行过程就成为了大厂面试的重灾区。

比如下面这张Android启动流程图,不少人都看过,但少有人沉下心去仔仔细细的研究过。

作为过来人,我发现很多学习者和实践者都在 Android Framework上面临着很多的困扰,比如:

  • 工作场景中遇到难题,往往只能靠盲猜和感觉,用临时性的补救措施去掩盖,看似解决了问题,但下次同样的问题又会发作,原因则是缺乏方法论、思路的指引以及工具支持
  • 能力修炼中,缺乏互联网项目这一实践环境,对Framework只能通过理论知识进行想象,无法认识其在工作实战中的真实面目和实操过程
  • 职场晋升中,只管功能开发,不了解底层原理,缺少深入地思考与总结,无法完成复杂系统设计这类高阶工作,难以在工作中大展拳脚,而有挑战的工作往往留给有准备的人。

总之,一旦遇到问题,很少人能够由点及面逆向分析,最终找到瓶颈点和最优解决方案,而Framework是Android开发的深水区,也是衡量一个Android程序员能力高低的标准

如果你还没有掌握Framework,现在想要在最短的时间里吃透它,那么必须要跟着正确的学习路线学习!

这里给大家推荐一套学习路线,并附有相关《Framework核心知识点笔记》,相信可以给大家提供一些帮助,有需要的朋友们也可以下领取一下随时查漏补缺。

Framework核心知识点笔记》已经进行了整理上传至公号中:Android开发之家,可以自行访问查阅。

Framework核心知识点笔记》已经进行了整理上传至公号中:Android开发之家,可以自行访问查阅。

以上是关于第03讲:掌握 Java Agent 真的可以为所欲为的主要内容,如果未能解决你的问题,请参考以下文章

有了这份SpringBoot神级文档,面试真的可以为所欲为

掌握这个方法,LeetCode 上的「股票买卖问题」就能为所欲为

精通音视频真的可以为所欲为?懂这些真的太香了

精通Framework是真的可以为所欲为

精通性能优化是真的可以为所欲为

懂编译真的可以为所欲为|不同前端框架下的代码转换