美文网首页
深入理解Java虚拟机三

深入理解Java虚拟机三

作者: 欧阳誉晨曦 | 来源:发表于2019-03-23 18:01 被阅读0次

    一、Java反射的实现原理

    1.反射调用的实现

           反射是Java语言中一个相当重要的特性,它允许正在运行的Java程序观测,甚至是修改程序的动态行为。反射在Java中的应用十分广泛,Java集成开发环境(IDE)的智能提示、Spring框架的依赖反转(IoC)、动态代理、远程RMI接口调用等。如下所示反射示例代码:

     public final class Method extends Executable {
          ...
          public Object invoke(Object obj, Object... args) throws ... {
            ... // 权限检查
            MethodAccessor ma = methodAccessor;
            if (ma == null) {
              ma = acquireMethodAccessor();
            }
            return ma.invoke(obj, args);
          }
        }
    

           Java反射实际上委派给MethodAccessor来处理。MethodAccessor是一个接口,它有两个已有的具体实现:一个通过本地方法来实现反射调用,另一个则使用了委派模式。为了方便记忆,我便用“本地实现”和“委派实现”来指代这两者。每个Method实例的第一次反射调用都会生成一个委派实现,它所委派的具体实现便是一个本地实现。本地实现非常容易理解。当进入了Java虚拟机内部之后,我们便拥有了Method实例所指向方法的具体地址。这时候,反射调用无非就是将传入的参数准备好,然后调用进入目标方法。可以看到,反射调用先是调用了Method.invoke,然后进入委派实现(DelegatingMethodAccessorImpl),再然后进入本地实现(NativeMethodAccessorImpl),最后到达目标方法。这里你可能会疑问,为什么反射调用还要采取委派实现作为中间层?直接交给本地实现不可以么?Java的反射调用机制还设立了另一种动态生成字节码的实现(下称动态实现),直接使用invoke指令来调用目标方法。之所以采用委派实现,便是为了能够在本地实现以及动态实现中切换。动态实现和本地实现相比,其运行效率要快上20倍。这是因为动态实现无需经过Java到C++再到Java的切换,但由于生成字节码十分耗时,仅调用一次的话,反而是本地实现要快上3到4倍。考虑到许多反射调用仅会执行一次,Java虚拟机设置了一个阈值15(可以通过-Dsun.reflect.inflationThreshold=来调整),当某个反射调用的调用次数在15之下时,采用本地实现;当达到15时,便开始动态生成字节码,并将委派实现的委派对象切换至动态实现,这个过程我们称之为Inflation。反射调用的Inflation机制是可以通过参数(-Dsun.reflect.noInflation=true)来关闭的。这样一来,在反射调用一开始便会直接生成动态实现,而不会使用委派实现或者本地实现。方法的反射调用会带来不少性能开销,原因主要有三个:变长参数方法导致的Object数组,基本类型的自动装箱、拆箱,还有最重要的方法内联。

    2.方法句柄

           方法句柄(invokedymaic)是一个强类型的、能够被直接执行的引用。它仅关心所指向方法的参数类型以及返回类型,而不关心方法所在的类以及方法名。方法句柄的权限检查发生在创建过程中,相较于反射调用节省了调用时反复权限检查的开销。方法句柄可以通过invokeExact以及invoke来调用。其中,invokeExact要求传入的参数和所指向方法的描述符严格匹配。方法句柄还支持增删改参数的操作,这些操作是通过生成另一个充当适配器的方法句柄来实现的。方法句柄的调用和反射调用一样,都是间接调用,同样会面临无法内联的问题。
           方法句柄VS反射VS代理:从访问控制层面来讲,反射需要调用setAccesible(),可能会受到安全管理器的禁止警告;代理有些情况下通过内部类实现,但是内部类只能访问受限的函数或字段;而方法句柄则在上下文中对所有方法都有完整的访问权限,并且不会受到安全管理器的限制,这是方法句柄的优势之一。从执行速度层面来讲,反射的性能会受到参数方法、类型的自动装箱和拆箱、方法内联的影响,相对来讲反射算是执行较慢的了(当然并没有和方法句柄通过执行具体操作示例作对比,可能在不同的JVM配置情况下执行情况不一样,比如解释器模式或编译模式下等);通过代理的方式因调用JAVA函数实现,速度与其它调用函数的速度是一样的,相对较快;而方法句柄可能不会有代理方式那样的执行速度快,但同样会受到JVM等不同的配置导致速度不同,但从JVM设计者的角度来说,应该是力求达到像调用函数一样快的速度,目前可能是达不到的。从类的开销层面来讲,代理通常声明多个类,需要占用方法区,而方法句柄并不需要像代理一样有多个类的开销,不需要方法区的开销。
           invokedymaic指令抽象出调用点的概念,并且将调用该调用点所链接的方法句柄。在第一次执行invokedynamic指令时,Java虚拟机将执行它所对应的启动方法,生成并且绑定一个调用点。之后如果再次执行该指令,Java虚拟机则直接调用已经绑定了的调用点所链接的方法。Lambda表达式到函数式接口的转换是通过invokedynamic指令来实现的。该invokedynamic指令对应的启动方法将通过ASM生成一个适配器类。对于没有捕获其他变量的Lambda表达式,该invokedynamic指令始终返回同一个适配器类的实例。对于捕获了其他变量的Lambda表达式,每次执行invokedynamic指令将新建一个适配器类实例。不管是捕获型的还是未捕获型的Lambda表达式,它们的性能上限皆可以达到直接调用的性能。其中,捕获型Lambda表达式借助了即时编译器中的逃逸分析,来避免实际的新建适配器类实例的操作。

    3.Java对象的内存布局

           Java中创建对象的五种方式:

    • new -通过调用构造器来初始化实例字段;
    • 反射-通过调用构造器来初始化实例字段;
    • Object.clone-通过直接复制已有的数据,来初始化新建对象的实例字段;
    • 反序列化-通过直接复制已有的数据,来初始化新建对象的实例字段;
    • Unsafe.allocateInstance-没有初始化对象的实例字段;

           Java对象的空间占用:

    • 通过new创建的对象,涵盖了它所有父类中的对象实例的字段;
    • 对象头,由标记字段和类型指针构成;
    • 标记字段,用于存储Java虚拟机有关该对象的运行数据,比如:哈希码、GC信息、锁信息等;
    • 类型指针,用于指向该对象的类;
    • 此对象的实例字段对应的内存空间;

           压缩指针:JVM的内存空间有限且昂贵,所以,能缩减的就缩减,通过一定的算法改进压缩类型指针的空间后仍可以寻址到对象的实例对应的类,所以,就采用了。字段重排:意思是JVM会重新分配字段的位置,和我们Java源码中属性声明的位置存在差异,猜想Java编译器编译后的字节码是没有改变源码中字段声明的位置的,这样做是为了更好的实现内存对齐,内存对齐本质上会浪费一定的内存空间,不过可以减少内存行的读取次数,通过一消一涨的比对发现这样对于JVM的性能有一定的提高,所以,也就使用了这种方式,浪费点空间能提高性能也是值得的。

    4.JVM垃圾回收

           垃圾回收,顾名思义,便是将已经分配出去的,但却不再使用的内存回收回来,以便能够再次分配。在Java虚拟机的语境下,垃圾指的是死亡的对象所占据的堆空间。JAVA主流的垃圾回收算法是:引用计数法它的做法是为每个对象添加一个引用计数器,用来统计指向该对象的引用个数。一旦某个对象的引用计数器为0,则说明该对象已经死亡便可以被回收了,除了需要额外的空间来存储计数器,以及繁琐的更新操作,引用计数法还有一个重大的漏洞,那便是无法处理循环引用对象;可达性分析法算法的实质在于将一系列GC Roots作为初始的存活对象合集(live set),然后从该合集出发,探索所有能够被该集合引用到的对象,并将其加入到该集合中,这个过程我们也称之为标记(mark)。最终,未被探索到的对象便是死亡的,是可以回收的,可达性分析可以解决引用计数法所不能解决的循环引用问题。GC Roots包括(但不限于)如下几种:
           1. Java方法栈桢中的局部变量;
           2. 已加载类的静态变量;
           3. JNI handles;
           4. 已启动且未停止的Java线程。

    5.Stop-The-World And Safepoint

           在Java虚拟机里,传统的垃圾回收算法采用的是一种简单粗暴的方式,那便是Stop-the-world,停止其他非垃圾回收线程的工作,直到完成垃圾回收。这也就造成了垃圾回收所谓的暂停时间(GC pause)。Java虚拟机中的Stop-the-world是通过安全点(safepoint)机制来实现的。当Java虚拟机收到Stop-the-world请求,它便会等待所有的线程都到达安全点,才允许请求Stop-the-world的线程进行独占的工作。安全点的初始目的并不是让其他线程停下,而是找到一个稳定的执行状态。在这个执行状态下,Java虚拟机的堆栈不会发生变化。这么一来,垃圾回收器便能够“安全”地执行可达性分析。对于解释执行来说,字节码与字节码之间皆可作为安全点。Java虚拟机采取的做法是,当有安全点请求时,执行一条字节码便进行一次安全点检测。执行即时编译器生成的机器码则比较复杂。由于这些代码直接运行在底层硬件之上,不受Java虚拟机掌控,因此在生成机器码时,即时编译器需要插入安全点检测,以避免机器码长时间没有安全点检测的情况。HotSpot虚拟机的做法便是在生成代码的方法出口以及非计数循环的循环回边(back-edge)处插入安全点检测。
           那么为什么不在每一条机器码或者每一个机器码基本块处插入安全点检测呢?原因主要有两个。第一,安全点检测本身也有一定的开销。不过HotSpot虚拟机已经将机器码中安全点检测简化为一个内存访问操作。在有安全点请求的情况下,Java虚拟机会将安全点检测访问的内存所在的页设置为不可读,并且定义一个segfault处理器,来截获因访问该不可读内存而触发segfault的线程,并将它们挂起。第二,即时编译器生成的机器码打乱了原本栈桢上的对象分布状况。在进入安全点时,机器码还需提供一些额外的信息,来表明哪些寄存器,或者当前栈帧上的哪些内存空间存放着指向对象的引用,以便垃圾回收器能够枚举GC
    Roots。由于这些信息需要不少空间来存储,因此即时编译器会尽量避免过多的安全点检测。不过,不同的即时编译器插入安全点检测的位置也可能不同。以Graal为例,除了上述位置外,它还会在计数循环的循环回边处插入安全点检测。其他的虚拟机也可能选取方法入口而非方法出口来插入安全点检测。不管如何,其目的都是在可接受的性能开销以及内存开销之内,避免机器码长时间不进入安全点的情况,间接地减少垃圾回收的暂停时间。除了垃圾回收之外,Java虚拟机其他一些对堆栈内容的一致性有要求的操作也会用到安全点这一机制。

    6.垃圾回收的三种方式

           第一种是清除(sweep),即把死亡对象所占据的内存标记为空闲内存,并记录在一个空闲列表(free list)之中。当需要新建对象时,内存管理模块便会从该空闲列表中寻找空闲内存,并划分给新建的对象。清除这种回收方式的原理及其简单,但是有两个缺点。一是会造成内存碎片。由于Java虚拟机的堆中对象必须是连续分布的,因此可能出现总空闲内存足够,但是无法分配的极端情况。另一个则是分配效率较低。如果是一块连续的内存空间,那么我们可以通过指针加法(pointer bumping)来做分配。而对于空闲列表,Java虚拟机则需要逐个访问列表中的项,来查找能够放入新建对象的空闲内存。
           第二种是压缩(compact),即把存活的对象聚集到内存区域的起始位置,从而留下一段连续的内存空间。这种做法能够解决内存碎片化的问题,但代价是压缩算法的性能开销。
           第三种则是复制(copy),即把内存区域分为两等分,分别用两个指针from和to来维护,并且只是用from指针指向的内存区域来分配内存。当发生垃圾回收时,便把存活的对象复制到to指针指向的内存区域中,并且交换from指针和to指针的内容。复制这种回收方式同样能够解决内存碎片化的问题,但是它的缺点也极其明显,即堆空间的使用效率极其低下。
           Java虚拟机将堆分为新生代和老年代,并且对不同代采用不同的垃圾回收算法。其中,新生代分为Eden区和两个大小一致的Survivor区,并且其中一个Survivor区是空的。在只针对新生代的Minor GC中,Eden区和非空Survivor区的存活对象会被复制到空的Survivor区中,当Survivor区中的存活对象复制次数超过一定数值时,它将被晋升至老年代。因为Minor GC只针对新生代进行垃圾回收,所以在枚举GC Roots的时候,它需要考虑从老年代到新生代的引用。为了避免扫描整个老年代,Java虚拟机引入了名为卡表的技术,大致地标出可能存在老年代到新生代引用的内存区域。二八法则-适用于许多的领域,对象在JVM对内存空间的生命周期也同样符合。为了更好的JVM性能以及充分利用对象生命周期的二八法则,JVM的作者将JVM的对内存空间进行了分代的处理:

    •        堆内存空间=年轻代+老年代;
    •        年轻代=Eden+from+to;
    •        年轻代用于分配新生的对象;
    •        Eden-通常用于存储新创建的对象,对内存空间是共享的,所以,直接在这里面划分空间需要进行同步;
    •        from-当Eden区的空间耗尽时,JVM便会出发一次Minor GC 来收集新生代的垃圾,会把存活下来的对象放入Survivor区,也就是from区。
             注意,from和to是变动的to-指向的Survivor区是空的,用于当发生Minor GC 时,存储Eden和from区中的存活对象,然后再交换from和to指针,以保证下一次Minor GC 时to指向的Survivor区还是空的。老年代-用于存储存活时间更久的对象,比如:15次Minor GC 还存活的对象就放入老年代中
             堆内存分代后,会根据他们的不同特点来区别对待,进行垃圾回收的时候会使用不同的垃圾回收方式,针对新生代的垃圾回收器有如下三个:Serial、Parallel Scavenge、Parallel New,他们采用的都是标记-复制的垃圾回收算法。针对老年代的垃圾回收器有如下三个:Serial Old 、Parallel Old 、CMS,他们使用的都是标记-压缩的垃圾回收算法。TLAB(Thread Local Allocation Buffer)-这个技术是用于解决多线程竞争堆内存分配问题的,核心原理是对分配一些连续的内存空间;卡表-这个技术是用于解决减少老年代的全堆空间扫描。

    相关文章

      网友评论

          本文标题:深入理解Java虚拟机三

          本文链接:https://www.haomeiwen.com/subject/vnbsvqtx.html