JAVA 拾遗--Instrument 机制

最近在研究 skywalking,发现其作为一个 APM 框架,比起作为 trace 框架的 zipkin 多了一个监控维度:对 JVM 的监控。而 skywalking 集成进系统的方式也和传统的框架不太一样,由于其需要对 JVM 进行无侵入式的监控,所以借助了 JAVA5 提供的 Instrument 机制。关于“Instrument”这个单词,没找到准确的翻译,个人理解为“增强,装配”。

如果我们想要无侵入式的修改一个方法,大多数人想到的可能是 AOP 技术,Instrument 有异曲同工之处,它可以对方法进行增强,甚至替换整个类。

下面借助一个 demo,了解下 Instrument 是如何使用的。第一个 demo 很简单,在某一方法调用时,额外打印出其调用时的时间。

public class Dog {
    public String hello() {
        return "wow wow~";
    }
}
public class Main {

    public static void main(String[] args) {
        System.out.println(new Dog().hello());
    }

}

Dog 存在一个 hello 方法,希望在调用该方法时打印出是什么时刻发生的调用。

实现 Agent

GreetingTransformer

public class GreetingTransformer implements ClassFileTransformer {

    @Override
    public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {
        if ("moe/cnkirito/agent/Dog".equals(className)) {
            System.out.println("Dog's method invoke at\t" + new Date());
        }
        return null;
    }
}

对类进行装配的第一步是编写一个 GreetingTransformer 类,其继承自:java.lang.instrument.ClassFileTransformer,打印语句便编写在其中。对于入参和返参我们先不去纠结,因为仅仅完成这么一个简单的 AOP 功能,还不需要了解它们。

GreetingAgent

除了上述的 Transformer,我们还需要有一个容器去加载它。

public class GreetingAgent {
    public static void premain(String options, Instrumentation ins) {
        if (options != null) {
            System.out.printf("  I've been called with options: \"%s\"\n", options);
        }
        else
            System.out.println("  I've been called with no options.");
        ins.addTransformer(new GreetingTransformer());
    }
}

GreetingAgent 便是我们后面要用的代理,可以发现它只有一个 premain 方法,很简单很形象,它和 main 方法真的很像

public static void main(String[] args) {
}

不同的是 main 函数的参数是一个 string[],而 premain 的入参是一个 String 和一个 Instrumentation。

前者不用过多赘述,而后者 Instrumentation 便是 JAVA5 的 Instrument 机制的核心,它负责为类添加 ClassFileTransformer 的实现,从而对类进行装配。注意 premain 和它的两个参数不能随意修改,为啥?我们使用 main 函数的时候也没问为啥一定是 public static void main(String[] args) 啊,规定!规定!从premain 的命名也可以看出,它的运行显然是在 main 函数之前的。

MANIFEST.MF

我们最终会把上面的 GreetingTransformer 和 GreetingAgent 打成一个 jar 包,然后让 Main 函数在启动时加载,但想要使用这个 jar 包还得额外做的工作。

我们得告诉 JVM 在哪儿加载我们的 premain 方法,所以需要在 classpath 下增加一个 resources\META-INF\MANIFEST.MF 文件

Manifest-Version: 1.0
Premain-Class: moe.cnkirito.agent.GreetingAgent
Can-Redefine-Classes: true

MAVEN 插件

为了打包 agent 我们需要额外添加 maven 插件,将 mf 文件和两个类一起打包

<build>
    <finalName>agent</finalName>

    <plugins>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-jar-plugin</artifactId>
            <version>2.3.1</version>
            <configuration>
                <archive>
                    <manifestFile>src/main/resources/META-INF/MANIFEST.MF</manifestFile>
                </archive>
            </configuration>
        </plugin>

        <plugin>
            <artifactId>maven-assembly-plugin</artifactId>
            <configuration>
                <outputDirectory>${basedir}</outputDirectory>
                <archive>
                    <index>true</index>
                    <manifest>
                        <addClasspath>true</addClasspath>
                    </manifest>
                    <manifestEntries>
                        <Premain-Class>moe.cnkirito.agent.GreetingAgent</Premain-Class>
                    </manifestEntries>
                </archive>
                <descriptorRefs>
                    <descriptorRef>jar-with-dependencies</descriptorRef>
                </descriptorRefs>
            </configuration>
        </plugin>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-compiler-plugin</artifactId>
            <configuration>
                <source>1.8</source>
                <target>1.8</target>
            </configuration>
        </plugin>
    </plugins>
</build>

完成上述的配置,使用 maven install 即可得到一个 agent.jar,到这儿一切的准备工作就完成了。

使用代理运行 Main 方法

如果不使用代理运行 Main 方法,毫无疑问我们只会得到一行 wow wow~

如果你使用的 IDEA,eclipse,只需要添加一行启动参数即可:

-javaagent:jarpath=[options] 其中的 jarpath 为 agent.jar 的路径,options 是一个可选参数,其值会被 premain 方法的第一个参数接收 public static void premain(String options, Instrumentation ins).

当需要装配多个 agent.jar 时,重复书写多次即可 -javaagent:C:\Users\xujingfeng\Desktop\agent.jar=hello -javaagent:C:\Users\xujingfeng\Desktop\agent.jar=hello2 ...

运行 Main.jar 的话就是这样的形式:java -javaagent:C:\Users\xujingfeng\Desktop\agent.jar=hello Main

运行结果

I've been called with options: "hello"
Dog's method invoke at    Sun Feb 04 23:54:45 CST 2018
wow wow~

I've been called with options: "hello" 代表我们的 premain 已经装载成功,并且正确接收到了启动参数。第二行语句也正常打印出了调用时间,至此便完成了 Dog 的装配。

Instrument 进阶

什么?为了打印一行调用时间,我们花了这么大精力,这是要跟自己过不去吗?你可能会有这样的疑惑,但请不要质疑 Instrument 的价值。

public interface ClassFileTransformer {
    byte[] transform(  ClassLoader         loader,
                String              className,
                Class<?>            classBeingRedefined,
                ProtectionDomain    protectionDomain,
                byte[]              classfileBuffer)
        throws IllegalClassFormatException;
}

ClassFileTransformer 可以对所有的方法进行拦截,看见返回值 byte[] 了没有

The implementation of this method may transform the supplied class file and return a new replacement class file. 这个方法的实现可能会改变提供的类文件并返回一个新的替换类文件。

这给了我们足够的操作自由度,我们甚至可以替换一个类的实现,只要你能够返回一个正确的替换类。ClassLoader 代表被转换类的类加载器,如果是 bootstrap loader 则可以省略,className 代表全类名,注意是以 /作为分隔符。其他参数我也不是太懂,想深究的同学自行翻看下文档。byte[] 代表被转换后的类的字节,为 null 则代表不转换。

替换 Dog 的实现

public class Dog {
    public String hello() {
        return "miao miao~";
    }
}

注意,这里我修改了 Dog 的实现,不是打印 wow wow~ 而是 miao miao ~,只是为了得到新 Dog 的字节码 Dog.class。我将新的 Dog.class 丢在了我的桌面方便加载:C:/Users/xujingfeng/Desktop

public class DogTransformer implements ClassFileTransformer {

    public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {
        System.out.println("className: " + className);
        if (!className.equalsIgnoreCase("moe/cnkirito/agent/Dog")) {
            return null;
        }
        return getBytesFromFile("C:/Users/xujingfeng/Desktop/Dog.class");//新的 Dog
//        return getBytesFromFile("app/target/classes/moe/cnkirito/agent/Dog.class");
    }

    public static byte[] getBytesFromFile(String fileName) {
        File file = new File(fileName);
        try (InputStream is = new FileInputStream(file)) {
            // precondition

            long length = file.length();
            byte[] bytes = new byte[(int) length];

            // Read in the bytes
            int offset = 0;
            int numRead = 0;
            while (offset <bytes.length
                    && (numRead = is.read(bytes, offset, bytes.length - offset)) >= 0) {
                offset += numRead;
            }

            if (offset < bytes.length) {
                throw new IOException("Could not completely read file "
                        + file.getName());
            }
            is.close();
            return bytes;
        } catch (Exception e) {
            System.out.println("error occurs in _ClassTransformer!"
                    + e.getClass().getName());
            return null;
        }
    }

}

return getBytesFromFile("C:/Users/xujingfeng/Desktop/Dog.class") 一行返回了新的 Dog 试图替换原先的 Dog。注意,这一切都放生在 Agent.jar 之中,我并没有对 Main 函数(也就是我们自己的源代码)做任何改动。

控制台输出

miao miao~

替换成功!我们并没有对 Main 程序的 Dog 做任何修改,只是加载了一个新的 Dog.class 替换了 Main 程序中的 Dog。

统计方法运行耗时

这个需求有点接近我们研究 Instrument 的初衷了,统计方法的运行耗时。由于代码的篇幅问题,在本文中只给出思路,详细的实现,可以参考文末的 github 链接,本文的三个例子:

  1. 打印 hello
  2. 替换 Dog
  3. 统计方法运行耗时

代码都在其中。

思路:对每个需要统计耗时的方法替换字节码,在方法开始前插入开始时间,在方法结束时插入结束时间,计算差值,more 你可以连同 methodName 和耗时一起发送出去,给 collector 统一采集...wait,这不就是一个简易的监控吗?!~

运行结果:

Call to method hello_timing took 1 ms.
wow wow~

JAVA6 的 agentmain

值得一提的是,java6 提供了 public static void agentmain (String agentArgs, Instrumentation inst); 这个新的方法,可以在 main 函数之后装配(premain 是在 main 之前),这使得操控现有程序的自由度变得更高了,有兴趣的朋友可以去了解下 premain 和 agentmain 的特性。

本文示例代码

https://github.com/lexburner/java5-Instrumentation-demo

参考资料

Java 5 特性 Instrumentation 实践

Java SE 6 的新特性:虚拟机启动后的动态 instrument

芋道源码

原文发布于微信公众号 - Kirito的技术分享(cnkirito)

原文发表时间:2018-02-05

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

发表于

我来说两句

0 条评论
登录 后参与评论

相关文章

来自专栏Ryan Miao

java中byte, iso-8859-1, UTF-8,乱码的根源

Post@https://ryan-miao.github.io 测试代码https://github.com/Ryan-Miao/someTest/comm...

68070
来自专栏IT技术精选文摘

跟着实例学习ZooKeeper的用法: 队列

使用Curator也可以简化Ephemeral Node (临时节点)的操作。Curator也提供ZK Recipe的分布式队列实现。 利用ZK的 PERSIS...

27570
来自专栏野路子程序员

Thinkphp修改一句代码,使得foreach标签支持对象,增加变量[数组对象]混合解析法!

33580
来自专栏zhisheng

Lombok 看这篇就够了

前提 自从进公司实习后,项目代码中能用 Lombok 的都用了,毕竟这么好的轮子要充分利用好。也可以减少一些 get/set/toString 方法的编写,虽说...

37690
来自专栏西枫里博客

PHP7新特性之两个小小语法糖。

想起写下这篇原本是因为群里龙大佬说PHP7下count有问题,顺道就讽了他一句。其实我自己也没有详细了解下PHP7到底在哪些方面做了修改。所以空了就翻了翻手册,...

17610
来自专栏用户2442861的专栏

JSON 入门指南(IBM)

尽管有许多宣传关于 XML 如何拥有跨平台,跨语言的优势,然而,除非应用于 Web Services,否则,在普通的 Web 应用中,开发者经常为 XML 的...

11510
来自专栏趣谈编程

高并发下的HashMap

HashMap不是一个线程安全的类,在并发下可能会出现死循环(JDK1.7),今天我们来聊聊这个死循环是如何形成的

10900
来自专栏coolblog.xyz技术专栏

MyBatis 源码分析 - 配置文件解析过程

由于本篇文章篇幅比较大,所以这里拿出一节对本文进行快速概括。本篇文章对 MyBatis 配置文件中常用配置的解析过程进行了较为详细的介绍和分析,包括但不限于se...

13120
来自专栏龙首琴剑庐

Java动态代理一览笔录

1、什么是代理? 比较经典的含义如销售代理,签订合同的基础上,为委托人(厂商)销售某些特定产品或全部产品的代理商,对价格、条款及其他交易条件可全权处理。我们从销...

32360
来自专栏十月梦想

ES6数据传递的传值和传址

看一下上面一段代码,通过正常的理解确实这个样子,但是下面的代码我们只改变了test.y值而obj的也随之改变!这个样子是用于前一部分是传值,后面是传地址!   ...

27940

扫码关注云+社区

领取腾讯云代金券