Skip to content

Java Agent

codingPao edited this page Jan 18, 2023 · 22 revisions

一、Java Agent

Java1.5提供了java.lang.instrument包,用于给JVM植入java编程语言编写的Instrumentation,这种通过java.lang.instrument实现工具被称为java agentjava agent可以在JVM加载class时候修改目标字节码,或者修改已经加载的class的信息(已经加载的class不会再次初始化,可以调整方法体,但是不能添加/删除/重命名字段或方法,不能修改方法的签名或者更改继承)。

目前java agent支持两种模式加载,启动时加载,通过在JVM启动时配置-javaagent:jarpath[=options],还有一种通过Attach API动态加载java agent

目前基于java agent实现应用的工具有很多,例如SkyWalking(APM)Arthas(诊断),ChaosBlade(混沌注入),JaCoCo(代码覆盖率)等等。

JVM命令行启动配置模式:

首先命令行需要增加-javaagent:jarpath[=options]参数,jarpathjava agent Jar包的路径,agent Jar中的manifest文件必须包含Premain-Class属性,属性值为agent class,该类必须包含premain方法,premain方法可以有两种形式:

  public static void premain(String agentArgs, Instrumentation inst); (两个方法同时出现时,该方法优先级较高)

  public static void premain(String agentArgs);

java agentsystem class loader负责加载(ClassLoader.getSystemClassLoader),所以在premain阶段,很多时候是看不到当前目标程序引用的资源的。通过agentArgs可以获取到Agent配置的options,具体参数的解析需要由Agent自身解析。

JVM启动后Attach模式:

在目标JVM启动后attach上去,agent Jar中的manifest文件必须包含Agent-Class属性,属性值为agent class,该类必须包含agentmain方法,agentmain方法可以有两种形式:

  public static void agentmain(String agentArgs, Instrumentation inst);(两个方法同时出现时,该方法优先级较高)

  public static void agentmain(String agentArgs);

attach模式java agent也是由system class loader负责加载(ClassLoader.getSystemClassLoader)

二、Java Agent示例

目标代码:

package io.manbang.asm.demo;
 
/**
 * @author weilong.hu
 * @since 2021/11/23 14:17
 */
public class CargoTest {
    public static void main(String[] args){
        publishCargo();
    }
 
    /**
     * 发货
     */
    public static void publishCargo() {
        System.out.println("publish cargo success...");
    }
}

java agent代码

package io.manbang.asm.demo;
 
import org.objectweb.asm.ClassReader;
import org.objectweb.asm.ClassVisitor;
import org.objectweb.asm.ClassWriter;
import org.objectweb.asm.MethodVisitor;
import org.objectweb.asm.Opcodes;
 
import java.lang.instrument.ClassFileTransformer;
import java.lang.instrument.Instrumentation;
import java.security.ProtectionDomain;
 
/**
 * @author weilong.hu
 * @since 2021/09/24 10:14
 */
public class Premain {
 
    public static void premain(String agentArgs, Instrumentation inst) {
        inst.addTransformer(new ClassFileTransformer() {
            @Override
            public byte[] transform(ClassLoader loader,
                                    String className,
                                    Class<?> classBeingRedefined,
                                    ProtectionDomain protectionDomain,
                                    byte[] classfileBuffer) {
                if ("io/manbang/asm/demo/CargoTest".equals(className)) {
                    final ClassReader classReader = new ClassReader(classfileBuffer);
                    final ClassWriter classWriter = new ClassWriter(ClassWriter.COMPUTE_MAXS);
                    //处理
                    final ClassVisitor classVisitor = new MyClassVisitor(classWriter);
                    classReader.accept(classVisitor, ClassReader.SKIP_DEBUG);
                    final byte[] data = classWriter.toByteArray();
                    return data;
                }
                return null;
            }
        });
    }
 
    public static class MyClassVisitor extends ClassVisitor implements Opcodes {
        public MyClassVisitor(ClassVisitor cv) {
            super(ASM5, cv);
        }
 
        @Override
        public void visit(int version, int access, String name, String signature,
                          String superName, String[] interfaces) {
            cv.visit(version, access, name, signature, superName, interfaces);
        }
 
        @Override
        public MethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions) {
            MethodVisitor mv = cv.visitMethod(access, name, desc, signature,
                    exceptions);
            //Base类中有两个方法:无参构造以及process方法,这里不增强构造方法
            if (name.equals("publishCargo") && mv != null) {
                mv = new MyMethodVisitor(mv);
            }
            return mv;
        }
 
        class MyMethodVisitor extends MethodVisitor implements Opcodes {
            public MyMethodVisitor(MethodVisitor mv) {
                super(Opcodes.ASM5, mv);
            }
 
            @Override
            public void visitCode() {
                super.visitCode();
                mv.visitFieldInsn(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
                mv.visitLdcInsn("start.......");
                mv.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);
            }
 
            @Override
            public void visitInsn(int opcode) {
                if ((opcode >= Opcodes.IRETURN && opcode <= Opcodes.RETURN)
                        || opcode == Opcodes.ATHROW) {
                    //方法在返回之前,打印"end"
                    mv.visitFieldInsn(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
                    mv.visitLdcInsn("end.......");
                    mv.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);
                }
                mv.visitInsn(opcode);
            }
        }
    }
}

pom配置

<!--增加asm依赖-->
<dependencies>
        <dependency>
            <groupId>org.ow2.asm</groupId>
            <artifactId>asm</artifactId>
            <version>9.2</version>
        </dependency>
        <dependency>
            <groupId>org.ow2.asm</groupId>
            <artifactId>asm-util</artifactId>
            <version>9.2</version>
        </dependency>
    </dependencies>
<!--增加manifest配置,也可以自己手动配置文件-->
    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-jar-plugin</artifactId>
                <configuration>
                    <archive>
                        <manifestEntries>
                            <Premain-Class>
                                io.manbang.asm.demo.Premain
                            </Premain-Class>
                        </manifestEntries>
                    </archive>
                </configuration>
            </plugin>
                         
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-shade-plugin</artifactId>
                <version>3.2.4</version>
                <executions>
                    <execution>
                        <phase>package</phase>
                        <goals>
                            <goal>shade</goal>
                        </goals>
                    </execution>
                </executions>
            </plugin>
        </plugins>
    </build>

无论是命令行启动测试用例,还是在ide中启动测试用例,在VM Options中加上-javaagent:jarpath[=options]配置,示例如下:指向java agent jar

-javaagent:your\path\asm-demo-1.0-SNAPSHOT.jar

启动后命令行CargoTestmain方法后,控制台输出如下:

start.......
publish cargo success...
end.......

可以看到相关的织入逻辑已经成功织入目标class中,这个简单的示例只是演示agent如何使用,还有一些其他的问题没有考虑,比如agentsystem class loader负责加载,如果用户也引入了asm相关包,如何解决类的冲突,与用户资源如何隔离,如何发现用户的资源等等问题。

三、字节码

字节码结构,字节码指令解析相关文章较多,这里不在赘述,可以参考相关文章:字节码简介官方文档

基于字节码操作衍生出的框架有很多,主流的有ASMApache BCELJavassistcglibByte Buddy等等(JDK动态代理,如果从动态创建class方面来说,也可以当作JDK自带的一个字节码框架), 各自有各自的特性,资料也很多,这里不在赘述。字节码操作应用范围在Spring AOPORM框架,热部署,诊断检测框架中都很常见,甚至一些大家写的自研中间件中也很常见。

如果不讨论字节码操作方面,从字节码本身应用场景出发,字节码不仅作为JVM加载class的基石,还有其他很多应用场景,比如大家在开发中间件框架时,绝大多数情况,对于目标class的判断更多是通过反射实现,基于反射,可以获取一个class的绝大部分信息,在绝大部分场景下是没有问题的,但是对于一个面向管理生命周期的框架,通过反射可能不是最优解决方式。

比如Servlet3.0中的ServletContainerInitializer,通过在实现类上配置@HandlesTypes注解,即可在Servlet启动阶段获取@HandlesTypes注解上标注的类的实现类。比如spring中的SpringServletContainerInitializer就实现了ServletContainerInitializer

@HandlesTypes(WebApplicationInitializer.class)
public class SpringServletContainerInitializer implements ServletContainerInitializer {
    @Override
    public void onStartup(@Nullable Set<Class<?>> webAppInitializerClasses, ServletContext servletContext)
            throws ServletException {
     ......
    }
}

这时候用户实现的WebApplicationInitializer的相关class,不需要配置SPI(Service Provider Interface),也没有APT(Annotation Processing Tool)做相关处理,也没有其他相关配置,但是在启动阶段,可以被发现处理,这时候需要研究下是如何实现的。

可以参考tomcat对于ServletContainerInitializer的处理方式,org.apache.catalina.startup.ContextConfig#checkHandlesTypes

protected void checkHandlesTypes(JavaClass javaClass,
            Map<String,JavaClassCacheEntry> javaClassCache) {
......
            populateJavaClassCache(className, javaClass, javaClassCache);
            JavaClassCacheEntry entry = javaClassCache.get(className);
            if (entry.getSciSet() == null) {
                try {
                    populateSCIsForCacheEntry(entry, javaClassCache);
                } catch (StackOverflowError soe) {
                    throw new IllegalStateException(sm.getString(
                            "contextConfig.annotationsStackOverflow",
                            context.getName(),
                            classHierarchyToString(className, entry, javaClassCache)));
                }
            }
......
}
 
private void populateJavaClassCache(String className, JavaClass javaClass,
            Map<String,JavaClassCacheEntry> javaClassCache) {
        if (javaClassCache.containsKey(className)) {
            return;
        }
 
        // Add this class to the cache
        javaClassCache.put(className, new JavaClassCacheEntry(javaClass));
 
        populateJavaClassCache(javaClass.getSuperclassName(), javaClassCache);
 
        for (String interfaceName : javaClass.getInterfaceNames()) {
            populateJavaClassCache(interfaceName, javaClassCache);
        }
}
 
private void populateJavaClassCache(String className,
            Map<String,JavaClassCacheEntry> javaClassCache) {
        if (!javaClassCache.containsKey(className)) {
            String name = className.replace('.', '/') + ".class";
            try (InputStream is = context.getLoader().getClassLoader().getResourceAsStream(name)) {
                if (is == null) {
                    return;
                }
                ClassParser parser = new ClassParser(is);
                JavaClass clazz = parser.parse();
                populateJavaClassCache(clazz.getClassName(), clazz, javaClassCache);
            } catch (ClassFormatException e) {
                log.debug(sm.getString("contextConfig.invalidSciHandlesTypes",
                        className), e);
            } catch (IOException e) {
                log.debug(sm.getString("contextConfig.invalidSciHandlesTypes",
                        className), e);
            }
        }
}

org.apache.tomcat.util.bcel.classfile.ClassParser#parse相关代码:

public JavaClass parse() throws IOException, ClassFormatException {
        /****************** Read headers ********************************/
        // Check magic tag of class file
        readID();
        // Get compiler version
        readVersion();
        /****************** Read constant pool and related **************/
        // Read constant pool entries
        readConstantPool();
        // Get class information
        readClassInfo();
        // Get interface information, i.e., implemented interfaces
        readInterfaces();
        /****************** Read class fields and methods ***************/
        // Read class fields, i.e., the variables of the class
        readFields();
        // Read class methods, i.e., the functions in the class
        readMethods();
        // Read class attributes
        readAttributes();
 
        // Return the information we have gathered in a new object
        return new JavaClass(class_name, superclass_name,
                access_flags, constant_pool, interface_names,
                runtimeVisibleAnnotations);
    }
 
 private void readID() throws IOException, ClassFormatException {
        if (dataInputStream.readInt() != MAGIC) {
            throw new ClassFormatException("It is not a Java .class file");
        }
    }

可以看到class的解析并不是直接通过反射获取关键信息,是通过BCELClassParser获取class字节流,解析字节码,获取关键信息。像这种解析字节码方式获取关键信息,而不是通过很常见常用的反射方式,可能是 1.为了避免加载过多无用的class,占用内存空间 2.避免class的提前初始化,防止提前触发一些不必要逻辑。

例如spring中也有很多解析字节码的操作,比如常见的org.springframework.context.annotation.ClassPathBeanDefinitionScanner#doScan中的使用:

protected Set<BeanDefinitionHolder> doScan(String... basePackages) {
......
Set<BeanDefinition> candidates = findCandidateComponents(basePackage);
......
}
 
//org.springframework.context.annotation.ClassPathScanningCandidateComponentProvider#findCandidateComponents
public Set<BeanDefinition> findCandidateComponents(String basePackage) {
        ......
            return scanCandidateComponents(basePackage);
        ......
}
 
//org.springframework.context.annotation.ClassPathScanningCandidateComponentProvider#scanCandidateComponents
private Set<BeanDefinition> scanCandidateComponents(String basePackage) {
......
MetadataReader metadataReader = getMetadataReaderFactory().getMetadataReader(resource);
......
}
 
//org.springframework.core.type.classreading.SimpleMetadataReaderFactory#getMetadataReader(org.springframework.core.io.Resource)
@Override
    public MetadataReader getMetadataReader(Resource resource) throws IOException {
        return new SimpleMetadataReader(resource, this.resourceLoader.getClassLoader());
}
//org.springframework.core.type.classreading.SimpleMetadataReader#SimpleMetadataReader
SimpleMetadataReader(Resource resource, @Nullable ClassLoader classLoader) throws IOException {
        SimpleAnnotationMetadataReadingVisitor visitor = new SimpleAnnotationMetadataReadingVisitor(classLoader);
        getClassReader(resource).accept(visitor, PARSING_OPTIONS);
        this.resource = resource;
        this.annotationMetadata = visitor.getMetadata();
    }
 
    private static ClassReader getClassReader(Resource resource) throws IOException {
        try (InputStream is = resource.getInputStream()) {
            try {
                return new ClassReader(is);
            }
            catch (IllegalArgumentException ex) {
                throw new NestedIOException("ASM ClassReader failed to parse class file - " +
                        "probably due to a new Java class file version that isn't supported yet: " + resource, ex);
            }
        }
    }

上述代码可以看到spring使用asmClassReader,使用visitor模式,解析目标类。

正因为使用这种方式,对于常见的@Controller@Service@Repository@Component的注解判断其实也是通过字符串,而不是通过反射获取目标注解,然后进行equals判断。

//org.springframework.context.annotation.ClassPathScanningCandidateComponentProvider#registerDefaultFilters
protected void registerDefaultFilters() {
this.includeFilters.add(new AnnotationTypeFilter(Component.class));
......
}
 
//org.springframework.core.type.filter.AnnotationTypeFilter#matchSelf
    @Override
    protected boolean matchSelf(MetadataReader metadataReader) {
        AnnotationMetadata metadata = metadataReader.getAnnotationMetadata();
        return metadata.hasAnnotation(this.annotationType.getName()) ||
                (this.considerMetaAnnotations && metadata.hasMetaAnnotation(this.annotationType.getName()));
    }

通过上述步骤,spring就可以既完成了BeanDefinition的扫描,不用提前初始化class(因为反射可能会导致当前class引用的其他class初始化),又排除了无效class的提前初始化。

例如spring aop配置的切点,对于注解成配置的@Retention(RetentionPolicy.CLASS)级别的(CLASS级别只存在于class文件中,反射获取不到,RUNTIME级别存在于运行时,可以反射获取到),依旧可以命中该切点,正是因为使用的字节码解析,而不是通过反射。

所以对于一个管理生命周期的框架来说,解析字节码可能是个很常见的操作,对于常见的中间件组件,直面字节码解析可能较少,通过反射会更常见一些。

四、如何获取一个class的字节码

常规看class的字节码很简单,ide中就可以看到,如果有源码包,在ide中甚至可以直接看一个第三方class的源码。但是对于运行时生成的class,就没办法直接看到字节码了。

对于JDK动态代理/cglib可以通过配置-Dsun.misc.ProxyGenerator.saveGeneratedFiles=true / -Dcglib.debugLocation=PATH获取动态生成的class,但是限制大,只能获取各自场景的,不能很随意。

一种方式是通过java agent,利用Instrumentation和ClassFileTransformer可以获取到类的字节码了,但是对于已经加载过的类,如果想获取的话,必须触发java.lang.instrument.Instrumentation#retransformClasses,才能重新获取到字节码,,而且如果有多个java agent,因为顺序原因,可能获取到的字节码不是最终字节码。

还有一种方式使用sa-jdi.jar无侵入,直接获取到目标想要的字节码。

java  -classpath ".;./bin;%JAVA_HOME%/lib/sa-jdi.jar" -Dsun.jvm.hotspot.tools.jcore.outputDir=D:/dump -Dsun.jvm.hotspot.tools.jcore.PackageNameFilter.pkgList=com.ymm sun.jvm.hotspot.tools.jcore.ClassDump 28984
通过这种方式,就可以直接在相应目录下,直接获取到正在运行JVM中的class的字节码了

如果还有一些特殊的过滤需求,比如目前这种方式如果在两个ClassLoader中有两个相同的class类名,会出现文件覆盖现象,可以自己定义导出规则。

<!--首先项目中引入sa-jdi.jar-->
        <dependency>
            <groupId>jdk.tools</groupId>
            <artifactId>jdk.tools</artifactId>
            <version>1.8</version>
            <scope>system</scope>
            <systemPath>${JAVA_HOME}/lib/sa-jdi.jar</systemPath>
        </dependency>
import sun.jvm.hotspot.oops.InstanceKlass;
import sun.jvm.hotspot.tools.jcore.ClassFilter;
 
public class MyFilter implements ClassFilter {
    @Override
    public boolean canInclude(InstanceKlass kls) {
        if(kls.getClassLoader()!=null){
            final String s = kls.getClassLoader().getKlass().getName().asString();
            if(!s.contains("AppClassLoader")){
                return false;
            }
        }
        String klassName = kls.getName().asString();
        return klassName.startsWith("com/dianping/lion/client");
    }
 
}

上述命令行再新增-Dsun.jvm.hotspot.tools.jcore.filter=MyFilter配置,就可以使用自定义过滤逻辑,导出AppClassLoader下的com/dianping/lion/client包下的类。甚至可以复制sun.jvm.hotspot.tools.jcore.ClassDump的代码,自定义一些导出逻辑配置,翻看sun.jvm.hotspot.tools.jcore.ClassDump源码,可以看到是如何找到过滤器的。

//MyClassDump#run
public void run() {
......
dirName = System.getProperty("sun.jvm.hotspot.tools.jcore.filter", "sun.jvm.hotspot.tools.jcore.PackageNameFilter");
 
                try {
                    Class filterClass = Class.forName(dirName);
                    if (this.pkgList == null) {
                        this.classFilter = (ClassFilter) filterClass.newInstance();
                    } else {
                        Constructor con = filterClass.getConstructor(String.class);
                        this.classFilter = (ClassFilter) con.newInstance(this.pkgList);
                    }
                } catch (Exception var5) {
                    System.err.println("Warning: Can not create class filter!");
                }
......
}
//sun.jvm.hotspot.tools.jcore.PackageNameFilter#PackageNameFilter()
 public PackageNameFilter() {
        this(System.getProperty("sun.jvm.hotspot.tools.jcore.PackageNameFilter.pkgList"));
}
//sun.jvm.hotspot.tools.jcore.PackageNameFilter#canInclude
public boolean canInclude(InstanceKlass kls) {
    String klassName = kls.getName().asString().replace('/', '.');
    int len = this.pkgList.length;
    if (len == 0) {
        return true;
    } else {
        for(int i = 0; i < len; ++i) {
            if (klassName.startsWith((String)this.pkgList[i])) {
                return true;
            }
        }
        return false;
    }
}

相关参考:

manifest:
https://docs.oracle.com/javase/10/docs/specs/jar/jar.html#jar-manifest
https://docs.oracle.com/javase/tutorial/deployment/jar/manifestindex.html

Java Agent:
https://nullwy.me/2018/10/java-agent/

bytecode:
https://docs.oracle.com/javase/specs/jvms/se8/html/index.html
https://dzone.com/articles/introduction-to-java-bytecode

dump:
https://www.iteye.com/blog/rednaxelafx-727938