Java Agent开发及爬坑记录(Jvmm-Agent踩坑总结)

/ 教程经历分享Java / 0 条评论 / 1308浏览

PS: 本文是我在公司内部的一次分享

一、什么是Java Agent?

developer.com这样解释:Java agents work at the lowest level by providing services that enable us to intrude into a running Java program in JVM(Java agent通过提供使我们能够侵入 JVM 中正在运行的 Java 程序的服务在最低级别工作)。agent 从JDK1.5开始引入,提供了一种可侵入JVM且可以拦截代码执行、调试、修改字节码的java程序。

二、实际应用

2.1 栗子1:使用 Instrumentation 接口

我们项目的服务器热更功能就是agent实现的,在打包时配置agent class,然后启动时配置agent jar包。

jar {
    manifest {
        attributes("Premain-class": "com.xx.xx.agent.InstrumentationAgent",
                "Agent-class": "com.xx.xx.agent.InstrumentationAgent",
                "Can-Redefine-Classes": true, "Can-Retransform-Classes": true)
    }
}

applicationDefaultJvmArgs = ["-server", "-Xms1g", "-Xmx1g", "-javaagent:OS_APP_HOME/lib/LCCommon.jar"]

在我们的项目中只应用了agent的功能之一:类重定义,这个功能由JDK提供的 Instrumentation 接口,它还有其他的功能:

类重定义(热更)、类重转换(字节码检测)、计算对象内存、备用jar依赖库(bootstrap 或 system classLoader搜索失败后)等

/**
 * This class provides services needed to instrument Java
 * programming language code.
 * Instrumentation is the addition of byte-codes to methods for the
 * purpose of gathering data to be utilized by tools.
 * Since the changes are purely additive, these tools do not modify
 * application state or behavior.
 * Examples of such benign tools include monitoring agents, profilers,
 * coverage analyzers, and event loggers.
 *
 * <P>
 * There are two ways to obtain an instance of the
 * <code>Instrumentation</code> interface:
 *
 * <ol>
 *   <li><p> When a JVM is launched in a way that indicates an agent
 *     class. In that case an <code>Instrumentation</code> instance
 *     is passed to the <code>premain</code> method of the agent class.
 *     </p></li>
 *   <li><p> When a JVM provides a mechanism to start agents sometime
 *     after the JVM is launched. In that case an <code>Instrumentation</code>
 *     instance is passed to the <code>agentmain</code> method of the
 *     agent code. </p> </li>
 * </ol>
 * <p>
 * These mechanisms are described in the
 * {@linkplain java.lang.instrument package specification}.
 * <p>
 * Once an agent acquires an <code>Instrumentation</code> instance,
 * the agent may call methods on the instance at any time.
 *
 * @since   1.5
 */
public interface Instrumentation

我们使用agent的只用到了 Instrumentation 接口的原因是 Instrumentation 接口的实现实例只能通过 agent 方式获得,它对于应用程序来说并不安全。

2.2 栗子2:作为代理加载应用程序

ava agent不仅提供了启动时载入agent,也支持运行时动态载入agent。在运行时载入agent一般是作为一个 代理 角色,agent负责载入真正的附加应用程序(Attach Program),然后为宿主程序(Host Program)和附加程序建立连接。

比如:ArthasJvmm(厚脸皮一下,自荐个人开源项目)

载入流程如下:

java agent

三、agent开发

写agent其实很简单,和平时写 main 函数一样,JDK提供了两个入口函数:premainagentmain。这两个函数区别在于:premain是启动时以javaagent参数配置载入方式的入口函数,agentmain是在运行时以虚拟机attach载入方式的入口函数。

新建一个 TestAgent 类,然后各自实现上面的两个入口函数,然后打包时配置agent类路径就实现了一个简单的java agent。

public class TestAgent {
    public static void premain(String agentArgs, Instrumentation inst) {
    	System.out.println("以参数的方式载入了agent");
    }

    public static void agentmain(String agentArgs, Instrumentation inst) {
		System.out.println("VM Attach的方式载入了agent");
    }
}
jar {
    manifest {
        attributes("Premain-class": "com.xx.xx.agent.TestAgent",
                "Agent-class": "com.xx.xx.agent.TestAgent",
                "Can-Redefine-Classes": true, "Can-Retransform-Classes": true)
    }
}

四、Jvmm开发过程中爬过的坑

正如你所见,网上的教程也是如此简单,但是实际开发过程中远不止如此,下面罗列出我在写 Jvmm 过程中爬过的坑以及解决过程。

4.1 运行时怎么载入agent?

官方也给出了责任说明,指出运行时载入agent是一个不安全的操作,我猜测这里的不安全应该是指两方面:一个是载入文件具有不确定性,这一点本身对JVM来说就具有安全隐患;二是 Instrumentation 接口的侵入性,它赋予了agent修改字节码的能力。

因此Java并未对外提供向虚拟机attach agent的依赖库,但是发现在下载的 jdk 里面一些工具又采用了agent的方法运行,比如最常用的jdk提供的虚拟机状态监控命令:jps、jstat、jstack、jmap、jhat等。最有通过google发现这些工具程序实际上都是 tools.jar 包实现的,这些工具程序只是对 tools.jar 里的命令封装,因此在这个jar包中也提供了attach agent的方法。

经过反编译 tools.jar 包去查看 jstat 命令实现发现,tools.jar包提供了直接访问JVM的方法,这些实现都基于 VirtualMachine 这个对象,这个对象里面的 loadAgent 方法就是我们要找的载入agent方法。

下面贴出我最终的实现函数:

public void attachAgent(long targetPid, String agentJarPath, String serverJarPath, Configuration config) throws Exception {
    checkArgument(targetPid > 0, "Can not attach to virtual machine with illegal pid " + targetPid);
    VirtualMachineDescriptor virtualMachineDescriptor = null;
    for (VirtualMachineDescriptor descriptor : VirtualMachine.list()) {
        String pid = descriptor.id();
        if (pid.equals(Long.toString(targetPid))) {
            virtualMachineDescriptor = descriptor;
            break;
        }
    }
    VirtualMachine virtualMachine = null;
    try {
        if (null == virtualMachineDescriptor) {
            virtualMachine = VirtualMachine.attach(Long.toString(targetPid));
        } else {
            virtualMachine = VirtualMachine.attach(virtualMachineDescriptor);
        }

        Properties targetSystemProperties = virtualMachine.getSystemProperties();
        String targetJavaVersion = JavaVersionUtils.javaVersionStr(targetSystemProperties);
        String currentJavaVersion = JavaVersionUtils.javaVersionStr();
        if (targetJavaVersion != null && currentJavaVersion != null) {
            if (!targetJavaVersion.equals(currentJavaVersion)) {
                log.warn("Current VM java version: {} do not match target VM java version: {}, attach may fail.",
                         currentJavaVersion, targetJavaVersion);
                log.warn("Target VM JAVA_HOME is {}, jvmm-server JAVA_HOME is {}, try to set the same JAVA_HOME.",
                         targetSystemProperties.getProperty("java.home"), System.getProperty("java.home"));
            }
        }

        virtualMachine.loadAgent(agentJarPath.replaceAll("\\\\","/"),
                                 serverJarPath.replaceAll("\\\\","/") + ";" + config.argFormat());
    } finally {
        if (null != virtualMachine) {
            virtualMachine.detach();
        }
    }
}

4.2 怎么在代码中使用 tools.jar 中的接口?

知道我们要用的接口实现在 tools.jar 中,但是怎么把它引入项目呢?直接把这个jar包拷贝到项目lib目录然后引入是不行的,首先这个jar包有 17M ,只为了调用其中一个接口就给项目增加如此大体积的依赖,很明显是不合适的,其次也是最重要的一点,Java运行是跨平台的,但跨平台也依赖于每个平台虚拟机的实现不一样,tools.jar包在不同OS中肯定也是不一样的,我在Windows中拷贝了tools.jar能跑但打包后到Linux中就跑不了了。

最终决定在运行时去搜索JVM的安装目录,然后搜索tools.jar的所在路径,最后用ClassLoader载入。主要考虑了以下三个方面:

  1. 这个jar包一般都会随java环境的安装而附带,如果运行环境安装了jdk必定有tools.jar;
  2. 如果只安装了jre肯定是搜不到的,但一般服务器部署都会安装一些监控类工具,这些依赖也都离不开tools.jar,因此大部分情况下都是能搜到的;
  3. 即使是没有tools.jar的情况也影响不大,因为运行时attach的过程并不影响任何程序功能,这只是将附带程序载入到宿主程序的方式之一,还可以选择启动时载入,明确告诉使用者如果想要在运行时attach agent就需要安装相关依赖。

下面贴出搜索并载入 tools.jar 包的实现:

protected synchronized void initToolsClassLoader() throws Throwable {
    if (toolsClassLoader == null) {
        File toolsJar = JavaEnvUtil.findToolsJar(JavaEnvUtil.findJavaHome());
        try {
            toolsClassLoader = ClassLoaderUtil.systemLoadJar(toolsJar.toURI().toURL());
            log.debug("Init tools classes successful.");
        } catch (MalformedURLException e) {
            //  ignored
        } catch (InvocationTargetException | NoSuchMethodException | IllegalAccessException e) {
            log.error("Init class loader failed. " + e.getMessage(), e);
            throw e;
        }
    }
}

public static File findToolsJar(String javaHome) {
    if (JavaVersionUtils.isGreaterThanJava8()) {
        throw new RuntimeException("Do not support greater than java 1.8");
    }

    File toolsJar = new File(javaHome, "lib/tools.jar");
    if (!toolsJar.exists()) {
        toolsJar = new File(javaHome, "../lib/tools.jar");
    }
    if (!toolsJar.exists()) {
        // maybe jre
        toolsJar = new File(javaHome, "../../lib/tools.jar");
    }

    if (!toolsJar.exists()) {
        throw new IllegalArgumentException("Can not find tools.jar under java home: " + javaHome);
    }

    log.debug("Found tools.jar: {}", toolsJar.getAbsolutePath());
    return toolsJar;
}

public static String findJavaHome() {
    if (JAVA_HOME != null) {
        return JAVA_HOME;
    }

    String javaHome = System.getProperty("java.home");

    if (JavaVersionUtils.isLessThanJava9()) {
        File toolsJar = new File(javaHome, "lib/tools.jar");
        if (!toolsJar.exists()) {
            toolsJar = new File(javaHome, "../lib/tools.jar");
        }
        if (!toolsJar.exists()) {
            // maybe jre
            toolsJar = new File(javaHome, "../../lib/tools.jar");
        }

        if (toolsJar.exists()) {
            JAVA_HOME = javaHome;
            return JAVA_HOME;
        }

        if (!toolsJar.exists()) {
            log.debug("Can not find tools.jar under java.home: {}", javaHome);
            String javaHomeEnv = System.getenv("JAVA_HOME");
            if (javaHomeEnv != null && !javaHomeEnv.isEmpty()) {
                log.debug("Try to find tools.jar in System Env JAVA_HOME: {}", javaHomeEnv);
                // $JAVA_HOME/lib/tools.jar
                toolsJar = new File(javaHomeEnv, "lib/tools.jar");
                if (!toolsJar.exists()) {
                    // maybe jre
                    toolsJar = new File(javaHomeEnv, "../lib/tools.jar");
                }
            }

            if (toolsJar.exists()) {
                log.info("Found java home from System Env JAVA_HOME: {}", javaHomeEnv);
                JAVA_HOME = javaHomeEnv;
                return JAVA_HOME;
            }

            throw new IllegalArgumentException("Can not find tools.jar under java home: " + javaHome
                                               + ", please try to start arthas-boot with full path java. Such as /opt/jdk/bin/java -jar arthas-boot.jar");
        }
    } else {
        JAVA_HOME = javaHome;
    }
    return JAVA_HOME;
}

4.3 tools.jar 运行时可使用但是开发时项目怎么引入?

众所周知运行时引入的任何依赖,在实际编码时都无法使用,否则编译都不能通过。

解决方法:在gradle配置中以 compileOnly 的方式引入开发环境中的所有依赖库,这样你在打包时就不会打入jar包并且可以在开发时调用其中的接口

import javax.tools.ToolProvider

dependencies {
    compileOnly(files(((URLClassLoader) ToolProvider.getSystemToolClassLoader()).getURLs()))
}

4.4 agent程序载入附加程序遇到依赖冲突的坑

之前分享的的这篇文章 Java解决相同依赖包不同版本的兼容问题 中有讲到依赖冲突的解决方案,其实当时分享的解决思路就是来源于这里,分享的是proto 2 和 3 的冲突,在写Jvmm时是 netty 冲突。

双亲委派模型

首先要知道虚拟机载入agent是由一个名为 Agent Listener 的线程载入的,这个线程载入的所有agent都是用的AppClassLoader,也就是和启动 main 方法所在类载入的ClassLoader是同一个。

我最开始的做法就是直接用AppClassLoader载入附加程序JvmmServer,因为我的JvmmServer中有引入Netty4.1.65,而当时测试的宿主程序用的是Netty4.0.*,这两个版本中的一些类是不兼容的,直接载入会报 LinkageErrorVerifyError

解决方法和上次分享一样,自定义一个ClassLoader去载入JvmmServer,这个ClassLoader需要破坏双亲委派模型,但是又不能绝对破坏,需要保证Java核心库仍然使用父加载器,这样就保证了宿主程序和附加程序在作用空间上的完全隔离

4.5 作用空间隔离了日志怎么继承?

Java中的日志系统五花八门,还好有了一个 SLF4J 的日志框架制定了一份统一接口,它并不是日志的实现者只是提供了接口日志调用的接口,目前大部分主流日志框架都实现了SLF4j的接口,Log4j、Logback、JdkLog等,几乎所有知名框架都在用SLF4J,比如Netty、Springboot、Druid、ElasticSearch等,为了更让开发者们接受,Jvmm也用了SLF4J。

任何一个有强迫症的程序员都不允许自己写的程序,在漂亮整齐的输出日志中看到一条另类输出。为了输出漂亮同时希望输出到文件或日志服务器,需要继承宿主程序的日志配置。

SLF4J有个约定,要想应用他的API必须至少有一个实现,也就是我在附加程序中不能只引用它的API还得引用他的一个实现,但是对附加程序来说宿主程序用的是什么日志实现是不透明的,所以我需要在agent里先搜索应用的是什么日志框架,然后共享给附加程序。

比如我用的是Log4j我需要载入Log4j的impl包,还好SLF4J官方明确说明了日志框架必须实现一个Binder,入口就在 org.slf4j.impl.StaticLoggerBinder 这个绑定类,顺着这个Binder就可以找出jar包依赖就可以了。但在实际Coding中发现还引出一个问题:不确定宿主程序jar包的层级,比如SpringBoot的jar包可能会内嵌多个jar包,也就是说我需要找的jar包可能在jar包中的jar包中的jar包。。。ClassLoader只提供了一级jar包搜索,所以还得自己写一个多级jar包解析搜索的方法。

日志实现继承了但是配置却成了问题,两个作用空间的日志配置怎么共享目前没有找到合适的解决方案!

无奈之下我用了一个生产者-消费者模式暂时解决了这个问题。

生产者消费者

在做命令行工具时也自己实现了一套日志框架,不过只有输出,但用户体验上更好一点。

Jvmm日志示例

4.6 同一个类全路径在作用空间隔离的ClassLoader中不是同一个类

在实现日志事件传递时,打算用LoggerEvent来传递日志事件,LoggerEvent定义在一个公共库里面,agent和附加程序共同引用,在agent中定义了下面这样一个方法:

public class AgentBootstrap {
    public static boolean logger(LoggerEvent event) {
        return logQueue.offer(event);
    }
}

然后在附加程序中去获取这个方法然后调用:

public class DefaultLoggerAdaptor extends DefaultImplLogger implements InternalLogger {
    
    private static void publish(LoggerEvent event) {
        try {
            Class<?> bootClazz = Thread.currentThread().getContextClassLoader().loadClass(AGENT_BOOT_CLASS);
            bootClazz.getMethod("logger", LoggerEvent.class).invoke(null, event);
        } catch (Throwable e) {
            System.err.println("Invoke agent boot method(#logger) failed!");
            e.printStackTrace();
        }
    }
    
}

实际运行时发现始终会报 NoSuchMethodException,检查全路径定义和调用的地方都是 org.beifengtz.jvmm.agent.AgentBootStrap,同一个类却搜不到里面的方法。

加载agent的 AppClassLoader 和 加载附加程序的 JvmmClassLoader两个是兄弟关系,相互作用空间隔离,那么即使载入同一个全路径的Class也是“不同”的,LoggerEvent.class 在整个进程中实际上会被载入两次,在静态代码块加入测试输出代码,执行时会执行两次。

public class LoggerEvent {
    
    //	静态代码块会执行两次
 	static {
        System.out.println("------- Load LoggerEvent -------");
    }
    
    ...
}

jvmm-agent

所以最后的解决方法是用它们共有父ClassLoader加载的类作为媒介传递数据,比如Map、String等。

4.7 agent要尽可能的小,尽可能不要与附加程序载入重复且无用的类

一开始我的做法是agent和附加程序都引入 common 模块,这个模块提供了很多方便的公共方法,但是这个包打包后有4.7M,agent引入之后整个agent.jar差不多有4.8M,附加程序也引入了common,打包后有 10M,也就是说attach一次上去就要载入 15M 左右的jar。但是仔细看agent的代码会发现,整个agent用到common中的方法也就不超过10个,相当于common中99.99%的类或方法在运行时是用不到的,但是JVM却会加载进去,而附加程序也会加载一次,附加程序里逻辑相对比较复杂,调用地方非常多,引用common是有必要的,而agent没必要引入。

agent需要的哪些类或方法在agent模块中单独实现一次即可,去掉common模块依赖后整个agent.jar缩小到了59K

微信公众号浏览体验更佳,在这里还有更多优秀文章为你奉上,快来关注吧!

北风IT之路