反射调用为什么慢?细推反射细节!

写在前面

​ Java的反射在日常开发中还是经常用到的技术点,这包括spring的Ioc,包括一些除cglib之外的bean copy(cglib采用asm动态生成字节码来实现),然而在spring的ioc中,我们或许无法感知到,这是因为大部分类实例都是单例,只在容器启动的时候加载一次,并在容器内缓存它的实例。但是在业务code中的beancopy则不然。你会发现请求量大的情况下,很多线程栈都会在这个位置慢下来,并且消耗较高的cpu。这也就是反射慢引起的,那么反射为什么慢呢?下面我们就来一一揭晓


反射方法的调用case [v1]

public class InflactTest {
    public static void targetMethod(){
        //打印堆栈信息
        new Exception().printStackTrace();
    }

    public static void main(String[] args) throws ClassNotFoundException, NoSuchMethodException, InvocationTargetException, IllegalAccessException {
        Class<?> inflactTest =  Class.forName("inflact.test.InflactTest");
        Method method = inflactTest.getMethod("targetMethod");
        method.invoke(null,null);
    }
}
  • 首先Class.forName属于native方法,native方法就要经过语言执行层面转换。也就是java到c再到java的切换。

  • 而getMethod这个操作则会遍历该类的公有方法,如果没有命中,则还要去父类中查找。并且返回该method对象的一份copy。在查找成功之后,这份copy对象,则会占用堆空间,而无法进行内联优化,相反还会引起gc频率的提高。对性能也是一份影响。

  • 值得注意的是,以getMethod为代表,其中getMethods和getDeclaredMethods等方法,都会进行getMethod相关的操作。所以要尽量避免在热点代码中使用该逻辑。

  • 因大部分场景中,我们都会在程序中缓存对象实例本身,也就是说运行过程中只会执行一次,不属于热点操作,我们暂且不论。那么接下来我们只需要关注invoke方法本身即可。调用输出如下所示

反射调用为什么慢?细推反射细节!

在图中博主贴出了反射调用与普通调用的栈信息区别,位于红色框中。那么可以肯定的是这些多出来的部分肯定是影响到反射慢的根本原因,那么jvm从底层都进行了哪些操作呢?


反射的委派实现

  • 在上一个case中,我们实现用一个方法的反射调用,相比于我们的正常执行栈理解,多出来了三行code,那么,这三行code是在做什么呢?分析源码不难发现,其实在Method对象内部维护了一个接口MethodAccessor,该接口有二个实现类,其中NativeMethodAccessorImpl用来实现本地native调用。而DelegatingMethodAccessorImpl顾名思义,是一个委派实现类,该方法将invoke操作委派给了native方法。

  • 那么我们不禁发问,为什么要多此一举呢?不能直接调用native方法吗?

    这里之所以抽象出来一个委派实现,而不直接调用native实现方法,难道还有另外一种实现?

反射的动态实现

  • 反射调用的第二个版本,在运行第十五次的时候将切换为动态实现
/**
 *
 * 反射调用的第二个版本,在运行第十五次的时候将切换为动态实现
 * @author Nero
 * @date 2019-08-06
 * *@param: null
 * @return 
 */
public class InflactTestV2 {
    public static void targetMethod(int i){
        //打印堆栈信息
        new Exception("index : "+i).printStackTrace();
    }

    public static void main(String[] args) throws ClassNotFoundException, NoSuchMethodException, InvocationTargetException, IllegalAccessException {
        Class<?> inflactTest =  Class.forName("inflact.test.InflactTestV2");
        Method method = inflactTest.getMethod("targetMethod",int.class);


        for (int i= 0 ; i < 20 ; i++){
            method.invoke(null,i);
        }
    }
}
  • 运行结果

    反射调用为什么慢?细推反射细节!


  • 动态实现的阈值

    为了方便理解,博主将v2版本的执行栈再次打印,在程序调用第16次的时候,调用栈更改成了GeneratedMethodAccessor1,而不再是native方法。这是因为jvm维护了一个阈值

    -Dsun.reflect.inflationThreshold,默认为15。当反射native调用超过15次就会触发jvm的动态生成字节码,之后的操作,全部都会调用该动态实现。

    动态实现与native实现相比,动态实现的效率要快的多,这是因为native的实现要在java语言层面切换到c语言,然后再次切换到java语言。

    但是,因为动态实现第一次生成的时候要生成字节码,而这个操作是比较耗时的。所以相比较起来单独一次调用的时候native反而要比动态实现快的多。


-Dsun.reflect.noInflation=true 关闭反射的多重实现

​ 上文中,我们讲到一个切换实现方式的阈值,如果在业务code中调用了第三方jar,在大量硬编码变更的情况下,可以自己设置阈值。当然也可以关闭反射的多重实现,使得在第一次调用的时候就生成字节码,在之后的调用中都是Java执行栈自己的调用。可以在jvm中设置-Dsun.reflect.noInflation=true


invoke变长参数与自动装箱

反射调用为什么慢?细推反射细节!

  • 观察invoke方法,public Object invoke(Object obj, Object… args),这个可变长参数在我们第二个版本它会发生什么呢? 看v2 code的bytecode 发现每一个调用便会调用ANEWARRAY,这相比直接调用的开销可想而知
  • 由于Object数组不支持基本类型,所以我们传入的对象虽然为基本类型,但是依然会触发每一次参数的自动装箱。这也属于我们直接调用之外的处理
  • 以上的两个处理除了额外处理占用的cpu耗时之外,还可能在堆上分配内存,以此来增加gc的频率。可以用虚拟机参数 -XX:PrintGC 查看。

总结


  • 本篇博文是博主基于极客时间郑雨迪博士的文章总结笔记。其中由于逃逸分析和方法内联没有完全吸收暂时不做讨论。

  • 文章链接:https://time.geekbang.org/column/intro/108 [需付费]

  • 由于时间和篇幅关系,暂且没有测试它们之间的性能差异。感兴趣的同学可以使用openJdk提供的jmh工具测试性能差异。虽然jmh不是绝对严谨。但总比out.print好的多。


延伸

上一篇:Lambda表达式演变历史


下一篇:如何编写Loader