java高性能反射及性能对比

发布时间:2019-09-25 08:13:59编辑:auto阅读(1576)

    java编程中,使用反射来增强灵活性(如各类框架)、某些抽象(如各类框架)及减少样板代码(如Java Bean)。
    因此,反射在实际的java项目中被大量使用。

    由于项目里存在反射的性能瓶颈,使用的是ReflectASM高性能反射库来优化。
    因此,在空闲时间研究了下的这个库,并做了简单的Beachmark。

    <!--more-->

    介绍

    ReflectASM是使用字节码生成来加强反射的性能。
    反射包含多种反射,这个库很简单,它提供的特性则是:

    1. 根据匹配的字符串操作成员变量。
    2. 根据匹配的字符串调用成员函数。
    3. 根据匹配的字符串调用构造函数。

    这三种也恰恰是实际使用中最多的,且在特殊场景下也容易产生性能问题。

    例子

    举个例子,使用MethodAccess来反射调用类的函数:

    Person person = new Person();
    MethodAccess m = MethodAccess.get(Person.class);
    Object value = m.invoke(person, "getName");

    更多的例子参考官方文档,这个库本身就不大,就几个类。

    实现原理

    MethodAccess.get方法

    static public MethodAccess get (Class type) {
        ArrayList<Method> methods = new ArrayList<Method>();
        boolean isInterface = type.isInterface();
        if (!isInterface) {
            Class nextClass = type;
            while (nextClass != Object.class) {
                addDeclaredMethodsToList(nextClass, methods);
                nextClass = nextClass.getSuperclass();
            }
        } else {
            recursiveAddInterfaceMethodsToList(type, methods);
        }
    
        int n = methods.size();
        String[] methodNames = new String[n];
        Class[][] parameterTypes = new Class[n][];
        Class[] returnTypes = new Class[n];
        for (int i = 0; i < n; i++) {
            Method method = methods.get(i);
            methodNames[i] = method.getName();
            parameterTypes[i] = method.getParameterTypes();
            returnTypes[i] = method.getReturnType();
        }
    
        String className = type.getName();
        String accessClassName = className + "MethodAccess";
        if (accessClassName.startsWith("java.")) accessClassName = "reflectasm." + accessClassName;
        Class accessClass;
    
        AccessClassLoader loader = AccessClassLoader.get(type);
        synchronized (loader) {
            try {
                accessClass = loader.loadClass(accessClassName);
            } catch (ClassNotFoundException ignored) {
                String accessClassNameInternal = accessClassName.replace('.', '/');
                String classNameInternal = className.replace('.', '/');
    
                ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_MAXS);
                MethodVisitor mv;
                /* ... 字节码生成 */
                byte[] data = cw.toByteArray();
                accessClass = loader.defineClass(accessClassName, data);
            }                
        }
    
        try {
            MethodAccess access = (MethodAccess)accessClass.newInstance();
            access.methodNames = methodNames;
            access.parameterTypes = parameterTypes;
            access.returnTypes = returnTypes;
            return access;
        } catch (Throwable t) {
            throw new RuntimeException("Error constructing method access class: " + accessClassName, t);
        }
    }

    大致逻辑为:

    1. 通过java反射获取必要的函数名、函数类型等信息。
    2. 动态生成一个用于调用被反射对象的类,其为MethodAccess的子类。
    3. 反射生成动态生成的类,返回。

    由于里面包含字节码生成操作,所以相对来说这个函数是比较耗时的。
    我们来分析一下,如果第二次调用对相同的类调用MethodAccess.get()方法,会不会好一些?
    注意到:

    synchronized (loader) {
        try {
            accessClass = loader.loadClass(accessClassName);
        } catch {
            /* ... */
        }
    }

    因此,如果这个动态生成的MethodAccess类已经生成过,第二次调用MethodAccess.get是不会操作字节码生成的。
    但是,前面的一大堆准备反射信息的操作依然会被执行。所以,如果在代码中封装这样的一个函数试图使用ReflectASM库:

    Object reflectionInvoke(Object bean, String methodName) {
        MethodAccess m = MethodAccess.get(bean.getClass());
        return m.invoke(bean, methodName);
    }

    那么每次反射调用前都得执行这么一大坨准备反射信息的代码,实际上还不如用原生反射呢。这个后面会有Beachmark。

    为什么不在找不到动态生成的MethodAccess类时(即第一次调用)时,再准备反射信息?这个得问作者。

    动态生成的类

    通过idea调试器获取动态生成类的字节码

    那么那个动态生成的类的内部到底是什么?
    由于这个类是动态生成的,所以获取它的定义比较麻烦。
    一开始我试图寻找java的ClassLoader的API获取它的字节码,但是似乎没有这种API。

    后来,我想了一个办法,直接在MethodAccess.get里面的这行代码打断点:

    byte[] data = cw.toByteArray();

    通过idea的调试器把data的内容复制出来。但是这又遇到一个问题,data是二进制内容,根本复制不出来。
    一个一年要400美刀的IDE,为啥不能做的贴心一点啊?

    既然是二进制内容,那么只能设法将其编码成文本再复制了。通过idea调试器自定义view的功能,将其编码成base64后复制了出来。
    然后,搞个python小脚本将其base64解码回.class文件:

    #!/usr/bin/env python3
    import base64
    
    with open("tmp.txt", "rb") as fi, open("tmp.class", "wb") as fo:
        base64.decode(fi, fo)

    反编译.class文件,得到:

    //
    // Source code recreated from a .class file by IntelliJ IDEA
    // (powered by Fernflower decompiler)
    //
    
    package io.github.frapples.javademoandcookbook.commonutils.entity;
    
    import com.esotericsoftware.reflectasm.MethodAccess;
    
    public class PointMethodAccess extends MethodAccess {
        public PointMethodAccess() {
        }
    
        public Object invoke(Object var1, int var2, Object... var3) {
            Point var4 = (Point)var1;
            switch(var2) {
            case 0:
                return var4.getX();
            case 1:
                var4.setX((Integer)var3[0]);
                return null;
            case 2:
                return var4.getY();
            case 3:
                var4.setY((Integer)var3[0]);
                return null;
            case 4:
                return var4.toString();
            case 5:
                return Point.of((Integer)var3[0], (Integer)var3[1], (String)var3[2]);
            default:
                throw new IllegalArgumentException("Method not found: " + var2);
            }
        }
    }

    可以看到,生成的invoke方法中,直接根据索引使用switch直接调用。
    所以,只要使用得当,性能媲美原生调用是没有什么问题的。

    MethodAccess.invoke方法

    来看invoke方法内具体做了哪些操作:

        abstract public Object invoke (Object object, int methodIndex, Object... args);
    
        /** Invokes the method with the specified name and the specified param types. */
        public Object invoke (Object object, String methodName, Class[] paramTypes, Object... args) {
            return invoke(object, getIndex(methodName, paramTypes), args);
        }
    
        /** Invokes the first method with the specified name and the specified number of arguments. */
        public Object invoke (Object object, String methodName, Object... args) {
            return invoke(object, getIndex(methodName, args == null ? 0 : args.length), args);
        }
    
        /** Returns the index of the first method with the specified name. */
        public int getIndex (String methodName) {
            for (int i = 0, n = methodNames.length; i < n; i++)
                if (methodNames[i].equals(methodName)) return i;
            throw new IllegalArgumentException("Unable to find non-private method: " + methodName);
        }

    如果通过函数名称调用函数(即调用invoke(Object, String, Class[], Object...)
    MethodAccess是先遍历所有函数名称拿到索引,然后根据索引调用对应方法(即调用虚函数invoke(Object, int, Object...)
    实际上是通过多态调用字节码动态生成的子类的对应函数。

    如果被反射调用的类的函数很多,则这个遍历操作带来的性能损失不能忽略。
    所以,性能要求高的场合,应该预先通过getIndex方法提前获得索引,然后后面即可以直接使用invoke(Object, int, Object...)来调用。

    Beachmark

    谈这种细粒度操作级别的性能问题,最有说服力的就是实际测试数据了。
    下面,Talk is cheap, show you my beachmark.

    首先是相关环境:
    操作系统版本: elementary OS 0.4.1 Loki 64-bit
    CPU: 双核 Intel® Core™ i5-7200U CPU @ 2.50GHz
    JMH基准测试框架版本: 1.21
    JVM版本: JDK 1.8.0_181, OpenJDK 64-Bit Server VM, 25.181-b13

    Benchmark                                                Mode  Cnt     Score    Error   Units
    // 通过MethodHandle调用。预先得到某函数的MethodHandle
    ReflectASMBenchmark.javaMethodHandleWithInitGet         thrpt    5   122.988 ±  4.240  ops/us
    // 通过java反射调用。缓存得到的Method对象
    ReflectASMBenchmark.javaReflectWithCacheGet             thrpt    5    11.877 ±  2.203  ops/us
    // 通过java反射调用。预先得到某函数的Method对象
    ReflectASMBenchmark.javaReflectWithInitGet              thrpt    5    66.702 ± 11.154  ops/us
    // 通过java反射调用。每次调用都先取得Method对象
    ReflectASMBenchmark.javaReflectWithOriginGet            thrpt    5     3.654 ±  0.795  ops/us
    // 直接调用
    ReflectASMBenchmark.normalCall                          thrpt    5  1059.926 ± 99.724  ops/us
    // ReflectASM通过索引调用。预先取得MethodAccess对象,预先取得某函数的索引
    ReflectASMBenchmark.reflectAsmIndexWithCacheGet         thrpt    5   639.051 ± 47.750  ops/us
    // ReflectASM通过函数名调用,缓存得到的MethodAccess对象
    ReflectASMBenchmark.reflectAsmWithCacheGet              thrpt    5    21.868 ±  1.879  ops/us
    // ReflectASM通过函数名调用,预先得到的MethodAccess
    ReflectASMBenchmark.reflectAsmWithInitGet               thrpt    5    53.370 ±  0.821  ops/us
    // ReflectASM通过函数名调用,每次调用都取得MethodAccess
    ReflectASMBenchmark.reflectAsmWithOriginGet             thrpt    5     0.593 ±  0.005  ops/us

    可以看到,每次调用都来一次MethodAccess.get,性能是最慢的,时间消耗是java原生调用的6倍,不如用java原生调用。
    最快的则是预先取得MethodAccess和函数的索引并用索引来调用。其时间消耗仅仅是直接调用的2倍不到。

    基准测试代码见:
    https://github.com/frapples/j...

    jmh框架十分专业,在基准测试前会做复杂的预热过程以减少环境、优化等影响,基准测试也尽可能通过合理的迭代次数等方式来减小误差。
    所以,在默认的迭代次数、预热次数下,跑一次基准测试的时间不短,CPU呼呼的转。。。

    最后总结

    在使用ReflectASM对某类进行反射调用时,需要预先生成或获取字节码动态生成的MethodAccess子类对象。

    这一操作是非常耗时的,所以正确的使用方法应该是:

    1. 在某个利用反射的耗时函数启动前,先预先生成这个MethodAccess对象。
    2. 如果是自己里面ReflectASM封装工具类,则应该设计缓存,缓存生成的MethodAccess对象。

    如果不这样做,这个ReflectASM用的没有任何意义,性能还不如java的原生反射。

    如果想进一步提升性能,那么还应该避免使用函数的字符串名称来调用,而是在耗时的函数启动前,预先获取函数名称对应的整数索引。
    在后面的耗时的函数,使用这个整数索引进行调用。

关键字