Java Instrumentation

Start
从现有的前置知识来说,我们能够认识到两个事实:

Java Class 通过 ClassLoader 进行加载。
通过全限定名进行区分。当需要加载新的类时,ClassLoader 通过双亲委派机制判断是否已经加载过这个类。
换句话说: Class 一经加载,就不会尝试重复加载 (至少按绝大多数人的认知来说,确实是的)
有没有可能让被加载的 Class 与物理存储上的 .class 内容不同。
当然也是完全可以做到的。不管怎么说,CGlib 和 Java Proxy 也是一个耳熟能详的概念吧
(虽然可能不了解细节。在此,欢迎学习前置技能 CGlib Enhancer 主流程源码解析 和 Java Proxy 源码解析。不过不影响本文后续内容)
另一个方面,也许绝大多数人都听说过所谓的热部署。但是究竟怎么才能做到 热部署(话题开得有点大哈。Y_Y 本文不讲这个)

操作字节码一定是一个逃不开的话题,毕竟 Class 就是所谓的被加载到内存的字节码嘛。

如何操作字节码? ASM, CGlib, Java Proxy, Javassist ? 不过这些都要等到需要被操作的类被加载了才行啊,似乎有点晚…

Java 提供了一个可行的机制,用来在 ClassLoader 加载字节码之前完成对操作字节码的目的

Instrumentation
java.lang.instrument.Instrumentation 类为提供直接操作 Java 字节码的又一个途径(虽然 Java Doc 的说明是用来检测 Java 代码的)

相信我这个说明是没有问题的。毕竟完成对代码检测的途径是直接修改字节码。

下列有两种方法可以达到目的

当 JVM 以指示一个代理类的方式启动时,将传递给代理类的 premain 方法一个 Instrumentation 实例。
当 JVM 提供某种机制在 JVM 启动之后某一时刻启动代理时,将传递给代理代码的 agentmain 方法一个 Instrumentation 实例。
话不多说,下面将全部以实例来展现对这种 JVM 检测机制(虽然例子已经脱离了检测的目的)的使用

对各方法进行执行时间统计
随 JVM 一起启动
基本实例: 将对特定包 me.fangfeng.client 下的每个方法执行计时

首先了解一下 client 包的内容:

package me.fangfeng.client;

/**
 * Main.java
 * 执行两个方法,rand() & sleep() 
 *
 * @author fangfeng
 * @since 2018/8/7
 */
public class Main {

    static void sleep() {
        try {
            Thread.sleep(5000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

    public static void main(String[] args) {

        Rand rand = new Rand();

        for (int i=0;i<10;i++) {
            System.out.println(">>> start Rand.run() <<<");
            rand.run();
            System.out.println(">>> end Rand.run() <<<");

            System.out.println();

            System.out.println(">>> start Main.sleep() <<<");
            Main.sleep();
            System.out.println(">>> end MAin.sleep() <<<");

            System.out.println();
        }
    }
}
package me.fangfeng.client;

/**
 * Rand.java
 * @author fangfeng
 * @since 2018/8/7
 */
public class Rand {

    public void run() {
        while (true) {
            double rand = Math.random();
            if (rand > 0.995) {
                System.out.println(String.format("get random, values %f", rand));
                return;
            }
        }
    }
}

接着,来构造一个代理类,以及最重要的 premain 方法

package me.fangfeng.javaagent;

import java.lang.instrument.Instrumentation;

/**
 * Agent - 代理
 * 基于 JVM TI (JVM Tool Interface) 实现的 Java ClassFile 的增强
 * @author fangfeng
 * @since 2018/8/7
 */
public class Agent {

    // premain 将 JVM 初始化后,main(String... ) 执行前调用
    public static void premain(String args, Instrumentation instrumentation) {
        // new 一个转换器实例
        ClassTimer transformer = new ClassTimer();
        instrumentation.addTransformer(transformer);
    }

    // 之后的 agentmain(...) 将在这里提供,暂时隐去,避免对对读者产生干扰
}
package me.fangfeng.javaagent;

import org.objectweb.asm.ClassReader;
import org.objectweb.asm.ClassWriter;
import org.objectweb.asm.Opcodes;

import java.lang.instrument.ClassFileTransformer;
import java.security.ProtectionDomain;

/**
 * @author fangfeng
 * @since 2018/8/7
 */
public class ClassTimer implements ClassFileTransformer {

    @Override
    public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) {
        // 这里涉及到了 ASM 的内容,主要目的是向每个方法块的开始及方法块的结束部分插入与计时器有关的代码
        // 如果想了解 ASM 的内容,请参阅 https://dormouse-none.github.io/2018-06-25-ASM-Core/  提供了一些基础性的内容,更多的请自行学习
        // 不了解具体内容将不影响对主体内容的理解
        ClassReader cr = new ClassReader(classfileBuffer);
        ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES);
        MyClassWriter mcw = new MyClassWriter(Opcodes.ASM6, cw);
        cr.accept(mcw, ClassReader.EXPAND_FRAMES);
        return cw.toByteArray();
    }
}

其它代码略,详见附件。

Java 这种对操作字节码的支持有个坑爹的地方,就是不得不打包成 Jar 来使用。

具体来看一下

me.fangfeng.javaagent 包中包括

将被打包成 agent.jar 来使用

首先,来看一下需要打包在 agent.jar 的 MANIFEST.MF 的内容

Manifest-Version: 1.0
Class-Path: /Users/fangfeng/.m2/repository/org/ow2/asm/asm/6.1.1/asm-6.1.1.jar
Premain-Class: me.fangfeng.javaagent.Agent
Can-Retransform-Classes: true

再来个 SHELL 脚本,用来给打包这个 Jar

#!/bin/bash

# 编译 me.fangfeng.javaagent 包下的类
javac -cp .:/Users/fangfeng/.m2/repository/org/ow2/asm/asm/6.1.1/asm-6.1.1.jar me/fangfeng/javaagent/Agent.java me/fangfeng/javaagent/ClassTimer.java me/fangfeng/javaagent/MyClassWriter.java me/fangfeng/javaagent/MyMethodWriter.java me/fangfeng/javaagent/StaticTimer.java

# 打包 me.fangfeng.javaagent 的 .class -> agent.jar
jar cvfm agent.jar MANIFEST-agent.MF me/fangfeng/javaagent/Agent.class me/fangfeng/javaagent/ClassTimer.class me/fangfeng/javaagent/MyClassWriter.class me/fangfeng/javaagent/MyMethodWriter.class me/fangfeng/javaagent/StaticTimer.class

# 编译 me.fangfeng.client 包下的类
javac me/fangfeng/client/Main.java me/fangfeng/client/Rand.java

# 以 me.fangfeng.client.Main 作为主类启动
java -javaagent:agent.jar me.fangfeng.client.Main

执行后,可以看到类似如下内容:

而直接用 java me.fangfeng.client.Main 的执行结果是:

从理论上来讲,-javaagent:agent.jar 配合 agent.jar 中的 MANIFEST.MF 文件,
使得 JVM 在初始化之后触发了被声明为 Pre-Main 的 me.fangfeng.javaagent.Agent 类的 premain(…) 方法。

并为 ClassLoader 在加载类的流程上增加了一层拦截器 (这里是 ClassTimer.java 类,它实现了 ClassFileTransformer 接口

另外,Can-Retransform-Classes: true 的配置使得 ClassTimer 被允许对字节码进行重新转换。(而操作字节码是通过 ASM 来实现的)

在运行中进行增强
随着程序启动时直接使用了 -javaagent 选项。

那么是否存在在程序运行中进行额外代理操作的支持呢?当然是可以的。这里要借助 Java 提供的另一个类 com.sun.tools.attach.VirtualMachine 。

启动一个新的进程来连接到 正在运行中的进程,并令其加载 java agent。

基本的类与上一节的描述相同,主要是包 me.fangfeng.javaagent.* 和 me.fangfeng.client.*

新增一个类 me.fangfeng.javaagent.Main 用来启动另一个进程,并要求运行中的 java 进程加载 agent.jar 来进行增强。

package me.fangfeng.javaagent;

import com.sun.tools.attach.AgentInitializationException;
import com.sun.tools.attach.AgentLoadException;
import com.sun.tools.attach.AttachNotSupportedException;
import com.sun.tools.attach.VirtualMachine;

import java.io.IOException;

/**
 * @author fangfeng
 * @since 2018/8/7
 */
public class Main {

    public static void main(String[] args) throws IOException, AttachNotSupportedException, AgentLoadException, AgentInitializationException {
        VirtualMachine vm = null;
        try {
            // 通过 VirtualMachine 连接到 运行中的进程 (可以通过 jps 找到进程号)
            vm = VirtualMachine.attach(<PID>);
            vm.loadAgent(<agent.jar 的路径>);
        } finally {
            if (vm != null) {
                vm.detach();
            }
        }
    }
}
public class Agent {

    public static void premain(String args, Instrumentation instrumentation) {
        ClassTimer transformer = new ClassTimer();
        instrumentation.addTransformer(transformer);
    }

    // 现在在 Agent.java 上补上 agentmain(...) 的具体实现
    public static void agentmain(String args, Instrumentation instrumentation) throws UnmodifiableClassException {
        System.out.println("SUCCESS AGENTMAIN");
        ClassTimer transformer = new ClassTimer();
        // add Transformer
        instrumentation.addTransformer(transformer, true);
        // 对 Rand.class 进行重新转换
        instrumentation.retransformClasses(Rand.class);
    }
}

其它内容基本相同

首先需要先打包 agent.jar 。当然,如果是顺着本文的顺序进行本机实验,则 agent.jar 已经存在

先启动进程 java me.fangfeng.client.Main

通过 jps 获取 Main 进程的 PID

在 java me.fangfeng.javaagent.Main 中替换上进程号,并执行

从执行结果可以看到,原进程首先正常执行代码,等到被 load Agent 之后,字节码已经有了新的变化,从而导致输出结果动态的产生了变化。

当然,需要注意的是,执行中的进程被要求 load Agent 之后,运行中的 Class 将被改写,并始终如此,知道进程终止。再下一次重新启动

BTrace
以上描述的内容也可以理解为是 BTrace 实现的基础。毕竟,JVMTI (JVM Tool Interface) 原本的目的就是赋予使用者一个在运行中
查询系统各项数据的权利

当然,实现上,上述代码直接将各种增强(计时)硬编码到该进程中,同时统一使用了该进程的输入输出。

但是,BTrace 通过 Socket 将这些分离,检测代码通过 Socket 发回新的进程来维持输入输出。

在此,不再细说。

附录
[1]. 示例代码: instru.zip
[2]. java.lang.instrument.Instrumentation
[3]. Package java.lang.instrument

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,847评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,208评论 1 292
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,587评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,942评论 0 205
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,332评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,587评论 1 218
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,853评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,568评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,273评论 1 242
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,542评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,033评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,373评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,031评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,073评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,830评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,628评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,537评论 2 269

推荐阅读更多精彩内容