深入理解Java虚拟机
2021-02-26 16:51:33 0 举报
AI智能生成
深入了解Java虚拟机
作者其他创作
大纲/内容
对象的实例化、内存布局与访问定位
对象的实例化
创建对象的方式
new
最常见的new对象
变形:Xxx的静态方法
变形:XxxBuilder/XxxFactory的静态方法
Class的newInstance()
反射的方式,只能调用空参的构造方法,权限必须时public
Constructor的newInstance(Xxx)
反射的方式,可以调用空参、带参的构造方法,权限没有要求
使用clone
不调用任何构造器,当前类需要实现Cloneable接口,实现clone()
使用反序列化
从文件总、从网络中获取一个对象的二进制流
第三方类库Objenesis
创建对象的步骤
判断对象对应的类是否加载、链接、初始化
为对象分配内存
如果内存规整
指针碰撞
如果内存不规整
虚拟机需要维护一个列表
空间列表分配
处理并发安全问题
采用CAS配上失败重试保证更新的原子性
每个线程预先分配一块TLAB
初始化分配到的空间
所有属性设置默认值,保证对象实例字段在不赋值时可以直接使用
设置对象的对象头
执行init方法进行初始化
内存布局
对象头(Header)
运行时元数据(Mark word)
哈希值(hashCode)
GC分代年龄(年龄计数器)
锁标志状态
线程持有的锁
偏向线程ID
偏向时间戳
类型指针
指向元数据InstanceKlass,确定该对象所属类型
注意
如果是数组,还需记录数组的长度
实例数据(Instance Data)
说明
它是对象真正存储的有效信息,包括程序代码中定义的各种类型的字段(包括从父类继承下来的和本身拥有的字段)
规则
相同宽度的字段总是被分配在一起
父类中定义的变量会出现在子类之前
如果CompactFields参数为true(默认为true):子类的窄变量可能插入到父类变量的空隙
对齐填充(Padding)
不是必须的,也没特殊含义,仅仅起到占位符的作用
对象的访问定位
创建对象的目的是为了使用它
JVM是如何通过栈帧中的对象引用访问到其内部的对象实例的呢?
栈帧指向堆区,堆区使用元数据指针指向方法区
对象访问方式
句柄访问
栈帧指向句柄池,句柄池中的到对象实例数据的指针指向实例池内的对象实例数据,拿到对象实例数据后返回句柄池再重新指向方法区
优缺点
优点
reference中存储稳定句柄地址,对象移动(垃圾收集时移动对象很普遍)时指挥改变句柄中实例数据指针即可,reference本身不需要被修改
缺点
开辟了一块存储实例数据的实例池,在访问时需要去实例池中查找再返回句柄池至方法区,效率较直接指针低
直接指针(hotspot采用)
指针直接指向堆中的对象实例,由对象实例数据指针再指向方法区
优缺点
优点
直接指向实例数据地址,效率高
缺点
当对象移动时,reference中存储的地址需要改变
reference解释
强引用类型
Vector v=new Vector();v就是新建向量对象的reference;
直接内存
了解
直接内存是再Java堆外的、直接向操作系统申请的内存区间
来源于NIO,通过存在堆中的DirectByteBuffer操作Native内存
通常,访问直接内存的速度会由于Java堆,读写性能更高
因此出于性能考虑,读写频繁的场合可能会考虑使用直接内存
Java的NIO库允许Java程序使用直接内存,用于数据缓冲区
直接内存概述
也可能导致OutOfMemoryError:Direct buffer memory异常
由于直接内存存在Java堆外,因此它的大小不会直接受限于-Xmx指定的最大堆大小,但是系统内存时有限的,Java堆和直接内存的综合依赖受限与操作系统能给出的最大内存
直接内存大小可以通过MaxDirectMemorySize设置
如果不指定,默认与堆的最大值-Xmx参数值一致
缺点
分配回收成本较高
不受JVM内存回收管理
读写文件场景
非直接内存
流程
应用程序 - > 读写用户地址空间内容 - > 将内容复制到内核地址空间 - > 物理磁盘读写内容
读写文件,需要与磁盘交互,需要由用户态切换至内核态。再内核态时,需要内存发生读写
这里需要两份内存存储重复数据,效率低
直接内存
流程
应用程序 - > 读写物理内存映射文件内容 - > 物理磁盘读写内容
使用NIO时,操作系统划出了直接缓存区可以被Java代码直接访问,只有一份
NIO适合对大文件的读写操作
垃圾回收
垃圾回收概述篇
什么是垃圾?
运行程序中没有任何指针指向的对象,这个对象就是需要回收的垃圾
如果不及时对内存中的垃圾进行清理,那么,这些垃圾对象所占用的内存空间会一直保留到应用程序结束,被保留的空间无法其他对象使用。甚至可能导致内存溢出
垃圾标记算法篇
垃圾标记算法
引用计数算法
概念
对每个对象保存一个整型的引用计数器属性,用于记录对象被引用的情况
基本思路
对于一个对象A,只要有任何一个对象引用了A,则A的引用计数器就加1;当引用失效时,引用计数器就减1。只要对象A的引用计数器的值为0,即表示对象A不可能再被使用,可进行回收
优缺点
优点
实现简单,垃圾对象便于辨识;判定效率高,回收没有延迟性
缺点
他需要单独的字段存储计数器,这样的做法增加了存储空间的开销
每次赋值都需要更新计数器,这样的作法增加了存储空间的开销
引用计数器有一个严重的问题,即无法处理循环引用的情况。这是一个致命的缺陷,导致在Java的垃圾回收器中没有使用这类算法
如何解决循环引用问题
手动解除:很好理解,就是在合适的时机,解除引用关系
使用弱引用weakref,weakref是Python提供的标准库,旨在解除循环引用
可达性分析算法
概念
相对于引用计数算法而言,可达性分析算法不仅同样具备实现简单和执行高效等特点,更重要的是该算法可以有效地解决在引用计数算法中循环引用的问题,防止内存泄漏的发生
相较于引用计数算法,这里的可达性分析就是Java、C#选择的。这种类型的垃圾回收通常也叫做追踪性垃圾回收
GC Roots
所谓“GC Roots”根集合就是一组必须活跃的引用
可以作为GC Roots的元素
虚拟机栈中引用的对象
比如:各个线程被调用的方法中使用到的参数、局部变量等
本地方法栈内JNI(通常说的本地方法)引用的对象
方法区中类静态属性引用的对象
比如:Java类的引用类型静态变量
方法区中常量引用的对象
比如:字符串常量池(String Table)里的引用
所有同步锁synchronized持有的对象
Java虚拟机内部的引用
基本数据类型对应的Class对象,一些常驻的异常对象(如:NullPointerException、OutOfMemoryError),系统类加载器
反应Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存
小技巧
由于Root采用栈方式存放变量和指针,所以如果一个指针,它保存了堆内存里面的对象,但是自己又不存放在堆内存里面,那它就是一个Root
基本思路
可达性分析算法是以根对象集合(GC Roots)为起始点,按照从上到下的方式搜索被跟对象集合所连接的目标的对象是否可达
使用可达性分析算法后,内存中存活对象都会被根对象集合直接或间接连接着,搜索所走过的路径称为引用链
如果目标对象没有任何引用链相连,则是不可达,就意味着该对象已经死亡,可以标记为垃圾对象
在可达性分析算法中,只有能够被根对象集合直接或间接连接的对象才是存活对象
注意
如果要使用可达性分析算法来判断内存是否可回收,那么分析工作必须在一个能保证一致性的快照中进行。这点不满足的话分析结果的准确性就无法保证
这点也是导致GC进行时必须“Stop The World”的一个重要原因。
即使是号称(几乎)不会发生停顿的CMS收集器中,枚举根节点时也时必须要停顿的
finalization机制
概述
Java语言提供了对象终止(finalization)机制来允许开发人员提供对象被销毁之前的自定义处理逻辑
当垃圾回收器发现没有引用指向一个对象,即:垃圾回收此对象之前,总会先调用这个对象的finalize()方法
finalize()方法允许在子类中被重写,用于在对象被回收时进行资源释放。通常在这个方法中进行一些资源释放和清理的工作,比如:关闭文件、套接字和数据库连接等
finalize()方法只能被调用一次,GC或自己调用
不要主动调用某个对象的finalize()方法的理由
在finalize()时可能会导致对象复活
finalize()方法的执行时间是没有保障的,它完全由GC线程决定,极端情况下,若不发生GC,则finalize()方法将没有执行机会
一个糟糕的finalize()方法会严重影响GC性能
虚拟机中的对象一般处于三种可能的状态
如果从所有的根节点都无法访问到某个对象,说明对象已经不再使用了。一般来说,此对象需要被回收。但事实上,也并非是“非死不可”的,这时候它们暂时处于“缓刑”阶段。一个无法触及的对象有可能在某一个条件下“复活”自己,如果这样,那么对它的回收就是不合理的,为此,定义虚拟机中的对象可能的三种状态,如下:
可达的
从根节点开始,可以到达这个对象
可复活的
对象的所有引用都被释放,但是对象有可能在finalize()中复活
不可达的
对象的finalize()被调用,并且没有复活,那么就会进入不可达状态。不可达状态的对象不可能被复活,因为finalize()只会被调用一次
判断一个都西昂objA是否可回收,至少要经历两次标记过程
如果对象objA到GC Roots没有引用链,则进行第一次标记
进行筛选,判断此对象是否有必要执行finalize()方法
如果对象objA没有重写finalize()方法,或者finalize()方法已经被虚拟机调用过,则虚拟机视为“没有必要执行”,objA被判定为不可触及的
如果对象objA重写了finalize()方法,且还未执行过,那么objA会被插入到F-Queue对象中,有一个虚拟机自动创建的、低优先级的Finalizer线程触发其finalize()方法执行
finalize()方法是对象逃脱死亡的最后机会,稍后GC会对F-Queue队列中的对象进行第二次标记。如果objA在finalize()方法中与引用链上的任何一个对象建立了联系,那么在第二次标记时,objA会溢出“即将回收”集合。之后,对象会再次出现没有引用存在的情况。在这个情况下,finalize()方法不会被再次调用,对象会直接变成不可触及的状态,也就是说,一个对象的finalize()方法指挥被调用一次。
垃圾回收算法篇
标记清除算法
执行过程
当堆中的有效内存空间被耗尽的时候,就会停止整个程序(也被称为Stop the world),然后进行两项工作,第一项则是标记,第二项则是清除
标记
Collector从引用根节点开始便利,标记所有被引用的对象。一般是在对象的Header中记录为可达对象
清除
Collector对堆内存从头到尾进行线性的遍历,如果发现某个对象在其Header中没有标记为可达对象,则将其回收
缺点
效率不算高
在进行GC的时候,需要停止整个应用程序,导致用户体验差
这种方式清理出来的空闲内存是不连续的,产生内存碎片。需要维护一个空闲列表
何为清除?
这里所谓的清除并不是真的置空,而是把需要清除的对象地址在空闲的地址列表里。下次有新对象需要加载时,判断垃圾的位置空间是否够,如果够,就存放。
复制算法
核心思想
将活着的内存空间分为两块,每次只使用其中一块,在垃圾回收时将正在使用的内存中的存活对象复制到未被使用的内存块中,之后清除正在使用的内存块中的所有对象,交换两个内存的角色,最后完成垃圾回收
优缺点
优点
没有标记和清除的过程,实现简单,运行高效
复制过去以后保证空间的连续性,不会出现“内存碎片”问题
缺点
此算法的缺点也是很明显的,就是需要两倍的内存空间
对于G1这种分拆成大量region的GC,复制而不是移动,意味着GC需要维护region之间对象引用关系,不管是内存占用或者时间开销也不小
特别点
如果系统中的垃圾对象很多,复制算法需要复制的存储对象数量并不会太大,或者说非常低才行
特别适合垃圾对象很多,存活对象很少的场景,比如:年轻代的S0和S1区
应用场景
在新生代,对常规应用的垃圾回收,一次通常可以回收70%~99%的内存空间。回收性价比很高。所以现在的商业虚拟机都是用这种收集算法回收新生代
标记整理(压缩)算法
核心思想
标记压缩算法的最终结果等同于标记清除算法执行完成后,再进行一次整理内存碎片的过程
执行过程
第一阶段和标记清除算法一样,从根节点开始标记所有被引用对象
第二阶段将所有的存活对象压缩到内存的一端,按顺序排放
之后,清理边界所有的空间
可以看到,标记的存活对象将会被整理,按照内存地址一次排序,而未被标记的内存会被整理掉。如此依赖,当我们需要给新对象分配内存时,JVM只需要持有一个内存的起始地址即可,这比维护一个空闲列表显然少了很多开销
与标记清除算法的差异
二者本质的差异在于标记清除算法是一种非移动式的回收算法,标记压缩是移动式的。是否移动回收后的存活对象是一项优缺点并存的风险决策
指针碰撞
如果内存空间以规整和有序的方式分布,即已用和未用的内存都各自一边,彼此之间维系着一个记录下一次分配起始点的标记指针,当为新对象分配内存时,只需要通过修改指针的偏移量将新对象分配在第一个空闲内存位置上,这种分配方式就叫做指针碰撞(Bump the Pointer)
优缺点
优点
消除了标记清除算法中给,内存区域分散的缺点,我们需要给新对象分配内存时,JVM只需要持有一个内存的起始地址即可
消除了复制算法中,内存减半的高额代价
缺点
从效率上来说,标记整理算法要低于复制算法
移动对象的同时,如果对象被其他对象应用,则还需要调整引用的地址
移动过程中,需要全程暂停用户应用程序,即STM
小结
标记清除算法
速度
中等
空间开销
少(但会堆积碎片)
移动对象
否
标记整理(压缩)算法
速度
最慢
空间开销
少(不堆积碎片)
移动对象
是
复制算法
速度
最快
空间开销
通常需要活对象的2倍大小(不堆积碎片)
移动对象
是
分代收集算法
核心思想
在HotSpot中,基于分代的概念,GC所使用的内存回收算法必须结合年轻代和老年代各自的特点
年轻代
特点
区域相对老年代较小,对象生命周期短、存活率低,回收频繁
算法选型
这种情况复制算法的回收整理,速度是最快的。复制算法的效率只和当前存活对象大小有关,因此很适用于年轻代的回收。而复制算法内存利用率不高的问题,通过HotSpot中的两个Survivor的设计得到了缓解
老年代
特点
区域大,对象生命周期长、存活率高,回收不及年轻代频繁
注意
以HotStop中的CMS回收器为例,CMS是基于Mark-Sweep实现的,对于对象的回收频率很高。而对于碎片问题,CMS采用基于Mark-Compact算法的Serial Old回收期作为补偿措施:当内存回收不佳(碎片导致的Concurrent Mode Failure时),采用Serial Old执行Full GC以达到对老年代内存的整理
增量算法
核心思想
如果一次性将所有的垃圾进行处理,需要造成系统长时间的停顿,那么就可以让垃圾收集线程和应用程序线程交替执行。每次,垃圾收集只收集一小片区域的内存空间,接着切换到应用程序线程。依次反复,直到垃圾收集完成
总的来说,增量收集算法的基础仍是传统的标记清除和复制算法。增量收集算法通过对线程间冲突的妥善管理,允许垃圾收集线程以分阶段的方法完成标记清除或复制工作
缺点
使用这种方式,由于在垃圾回收过程中,间断性地还执行了引用程序代码,所以能减少系统的停顿时间。但是,因为线程切换和上下文转换的消耗,会使得垃圾回收的总体成本上升,造成系统吞吐量的下降
分区算法
核心思想
一般来说,在相同条件下,堆空间越大,一次GC时所需要的时间就越长,有关GC产生的停顿也越长。为了更好的控制GC产生的停顿时间,将一块大的内存区域分割成多个小块,根据目标的停顿时间,每次合理地回收若干小区间,而不是整个堆空间,从而减少一次GC所产生地停顿
分区算法将整个堆空间划分成连续的不同小区间,每一个小区间都独立使用,独立回收。这种算法的好处是控制一次回收多少个小区间
垃圾回收概念篇
System.gc()
概述
在默认情况下,通过System.gc()或者Runtime.getRuntime().gc()的调用,会显示触发Full GC,同时对老年代和新生代进行回收,尝试释放被丢弃对象占用的内存
然而System.gc()调用附带一个免责声明,无法保证对垃圾收集器的调用
应用场景
性能基准
JVM实现者可以通过System.gc()调用来决定JVM的GC行为。而一般情况下,垃圾回收应该是自动进行的,无须手动触发,否则就太过于麻烦了。在一些特殊情况下,如我们正在编写一个性能基准,我们可以在运行之间调用System.gc()
内存溢出与内存泄漏
存储溢出
概述
内存溢出相对于内存泄漏来说,尽管更容易理解,但是同样的,内存溢出也是引发程序崩溃的罪魁祸首之一
由于GC一直在发展,所有一般情况下,除非应用程序占用的内存增长速度非常快,造成垃圾回收已经跟不上内存消耗的速度,否则不太容易出现OOM的情况
大多数情况下,GC会进行各种年龄段的垃圾回收,实在不行了就放大招,来一次独占式的Full GC操作,这时候会回收大量的内存,供应用程序继续使用
javadoc中对OutOfMemoryError的解释是,没有空闲内存,并且垃圾收集器也无法提供更多内存
在这里隐含着一层意思是,在抛出OOM之前,通常垃圾收集器会被触发,尽其所能去清理出空间
例如:在引用机制分析中,涉及到JVM会去尝试回收软引用指向的对象等
在java.nio.BIts.reserveMemory()方法中,我们能清楚的看到,System.gc()会被调用,以清理空间
当然,也不是在任何情况下垃圾收集器都会被触发的
比如:我们去分配一个超大对象,类似一个超大数组超过堆的最大值,JVM可以判断出垃圾收集并不能解决这个问题,所以直接抛出OOM
内存泄漏
概述
内存泄漏,也称作“存储渗漏”。严格来说,只有对象不会再被程序用到了,但是GC又不能回收它们的情况,才能叫内存泄漏
但实际情况很多时候一些不太好的实践(被疏忽)会导致对象的生命周期变得很长甚至导致OOM,也可以叫做宽泛意义上的内存泄漏
尽管内存泄漏并不会立即引起程序崩溃,但是一旦发生内存泄漏,程序中的可用内存就会被逐步蚕食,直至耗尽所有内存,最终出现OOM异常,导致程序崩溃
注意
这里的存储空间并不是指物理内存,而是虚拟内存大小,这个虚拟内存大小取决于磁盘交换区设定的大小
内存泄漏的例子
单例模式
单例的对象生命周期和应用程序一样长,所以单例程序中,如果持有对外部对象的引用的话,那么这个外部对象不能被回收的,则会导致内存泄漏的产生
一些提供close()的资源未关闭导致内存泄漏
数据库连接dataSourse.getConnection()
网络连接socket
IO连接
以上连接必须手动关闭,否则是不能被回收的,尽量使用finally块来保证最终关闭
Stop The World
概述
简称STM,指的是GC事件发生过程中,会产生应用程序的停顿。停顿产生时整个应用程序线程都会被暂停,没有任何响应,有点像卡死的感觉,整个停顿成为Stop the world
可达性分析算法之STW
可达性分析算法中枚举根节点(GC Roots)会导致所有Java执行线程停顿
分析工作必须再一个能保证一致性的快照中进行
一致性指整个执行系统看起来像被冻结在某个时间点上
如果出现分析过程中对象应用关系还在持续不断变化,则分析结果的准确性无法保证
用户之STW
被STW中断的应用程序线程会在完成GC之后恢复,频繁中断会让用户感觉像是网速不快造成电影卡带一样,所以我们需要减少STW的发生
STW是JVM在后台发起和自动完成的。在用户不可见的情况下,把用户正常的工作线程全部停掉
GC之STW
STW事件和采用那款GC无关,所有GC都有这个事件
哪怕时G1也不能完全避免STW的发生,只能说垃圾回收器越来越优秀,回收效率越高,尽可能缩短了暂停时间
开发中不要使用System.gc();会导致STW的发生
垃圾回收并发与并行
并发
CPU把一个时间段划分成几个时间片,然后在这几个时间片快速的切换并交替执行
并发,指的是多个事情,在同一时间段内同时发生了
并发的多个任何之间是相互抢占资源的
并行
一个CPU对应执行一个线程,不存在抢占CPU时间片的事件
并行,指的是多个事件,在同一个时间点上同时发生了
并行的多个任务之间是不互相抢占资源的
只有在多个CPU或者一个CPU多核的情况下,才会发生并行。否则,看似同时发生的事情,其实都是并发执行
垃圾回收的并行与并发
垃圾回收之并行
指的是多条垃圾收集线程并行工作,但此时用户线程仍处于等待状态。如:ParNew、Parallel Scavenge、Parallel Old
垃圾回收之串行
相较于并行的概念,单线程执行
如果内存不够,则程序暂停,启动JVM垃圾回收器进行垃圾回收。回收完,再启动程序的线程
垃圾回收之并发
指的是用户线程与垃圾收集线程同时执行(但不一定是并行的,可能会交替执行),垃圾回收线程在执行时不会停顿用户程序的运行
用户程序在继续执行,而垃圾收集程序运行与另一个CPU上,如:CMS、G1
安全点与安全区域
安全点
程序执行时并非在所有地方都能停顿下来开始GC,只有在特定的位置才能停顿下来开始GC,这些位置成为“安全点(Safepoint)”
安全点的选择很重要,如果太少则可能导致GC等待的时间太长,如果太频繁可能导致运行时的性能问题。大部分指令的执行时间都是非常短暂,通常会根据“是否具有让程序长时间执行的特征”为标准。比如:选择一些执行时间较长的指令作为Safe Point,如方法调用、循环跳转和异常跳转等
中断
如何在GC发生时,检查所有线程都跑到最近的安全点停顿下来呢?
抢先式中断(目前没有虚拟机在使用了)
首先中断所有线程。如果还有线程不在安全点,就恢复线程,让线程跑到安全点
主动式中断
设置一个中断标志,各个线程运行Safe Point的时候主动轮询这个标志,如果中断标志为真,则将自己进行中断挂起
安全区域
SafePoint机制保证了程序执行时,在不太长的时间内就会遇到可进入GC的Safepoint。但是,程序“不执行”的时候呢?例如线程处于Sleep状态或Blocked状态,这时候线程无法响应JVM的中断请求,“走”到安全点去中断挂起,JVM也不太可能等待线程被唤醒。对于这种情况,就需要安全区域(Safe Region)来解决
安全区域是指在一段代码片段中,对象的引用关系不会发生变化,在这个区域中的任何位置开始GC都是安全的。我们也可以把Safe Region看做是被扩展了的SafePoint
执行过程
当线程运行到SafeRegion的代码时,首先标识已经进入SafeRegion,如果这段时间内发生了GC,JVM会忽略标识为SafeRegion状态的线程
当线程即将离开SafeRegion时,会检查JVM是否已经完成GC,如果完成了,则继续运行,否则线程必须等待直到收到可以安全离开SafeRegion的信号为止
强软弱虚引用
强引用(Strong Reference)——不可回收
概述
强引用的对象是可达的,垃圾收集器就永远不会回收掉被引用的对象。而强引用也是最常见的普通对象引用,也是默认的引用类型
强引用是造成Java内存泄漏的主要原因之一
特点
强引用可以直接访问目标对象
强引用所指向的对象在任何时候都不会被系统回收,虚拟机宁愿抛出OOM异常,也不会回收强引用所指向对象
强引用可能导致内存泄漏
软引用(Soft Reference)——内存不足即回收
概述
只被软引用关联着的对象,在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存,才会抛出OOM异常
垃圾回收器在某个时刻决定回收软可达的对象的时候,会清理软引用,并可选地把引用存放到一个引用队列(Reference Queue)
类似弱引用,只不过Java虚拟机会尽量让软引用地存活时间长一些,迫不得已才清理
场景
软引用通常用来实现内存敏感的缓存。
比如:高速缓存就有用到软引用。如果还有空闲内存,就可以暂时保存缓存,当内存不足时清理掉,这样就保证了使用缓存的同时,不会耗尽内存
使用
在JDK1.2版之后提供了java.lang.ref.SoftReference类来实现软引用
弱引用(Weak Reference)——发现即回收
概述
弱引用也是用来描述那些非必需对象,只被弱引用关联地对象只能生存到下一次垃圾收集发生为止。在系统GC时,只要发现弱引用,不管系统堆空间使用是否充足,都会回收掉只被弱引用关联地对象
但是,由于垃圾回收器的线程通常优先级很低,因此,并不一定能很快地发现持有弱引用的对象。在这种情况下,弱引用对象可以存在较长的时间
弱引用和软引用一样,在构造弱引用时,也可以指定一个引用队列,当弱引用对象被回收时,就会加入指定的引用队列,通过这个队列可以跟踪对象的回收情况
场景
软引用、弱引用都非常适合来保存那些可有可无的缓存数据。如果那么做,当系统内存不足时,这些缓存数据会被回收,不会导致内存溢出。而当内存资源充足时,这些缓存数据又可以存在相当长的时间,从而起到加速系统的作用
弱引用缓存数据,可以利用WeekHashMap来实现
使用
在JDK1.2版之后提供了java.lang.ref.WeekReference类来实现弱引用
虚引用与弱引用的不同点
弱引用对象与软引用对象的最大不同就在于,当GC在进行回收时,需要通过算法检查是否回收引用对象,而对于弱引用对象,GC总是进行回收。弱引用对象更容易、更快被GC回收
虚引用(Phantom Reference)——对象回收跟踪
概述
虚引用也成为“幽灵引用”或者“幻影引用”,时所有引用类型中最弱的一个
一个对象是否有虚引用的存在,完全不会决定对象的生命周期。如果一个对象仅持有虚引用,那么它和没有引用几乎一样的,随时都可能被垃圾回收器回收
他不能单独使用,也无法通过虚引用来获取被引用的对象。当试图通过虚引用的get()方法取得对象时,总是null
场景
为一个对象设置虚引用关联的唯一目的在于跟踪垃圾回收过程。比如:能在这个对象被收集器回收时收到一个系统通知
使用
虚引用必须和引用队列一起使用。虚引用在创建时必须提供一个引用队列作为参数。当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象后,将这个虚引用加入引用队列,以通知应用程序对象的回收情况
由于虚引用可以跟踪对象的回收hi时间,因此,也可以将一些资源释放操作放置在虚引用中执行和记录
在JDK1.2版之后提供了PhantomReference类来实现虚引用
终结器引用(Final Reference)
它用以实现对象finalize()方法,也可以成为终结器引用
无需手动编码,其内部配合引用队列使用
在GC时,终结器引用入队。有Finalizer线程通过终结器引用找到被引用对象并调用它的finalize()方法,第二次GC时才能回收被引用对象
垃圾回收器篇
垃圾回收器概述
垃圾收集器没有在规范中进行过多的规定,可以由不同的厂商、不同版本的JVM来实现
由于JDK的版本处于高速迭代过程中,因此Java发展至今已经衍生出了众多的GC版本
GC性能指标
吞吐量
运行用户代码的时间占总运行时间的比例(总运行时间:程序的运行时间+内存回收的时间)
吞吐量就是CPU用于运行用户代码的时间与CPU总消耗时间的比值,即吞吐量 = 运行用户代码时间 / (运行用户代码时间 + 垃圾收集时间)
比如:虚拟机总共运行了100分钟,其中垃圾收集花掉1分钟,那么吞吐量就是99%
这种情况下,应用程序能容忍较高的暂停时间,因此,高吞吐量的引用程序有更长的时间基准,快速响应是不必考虑的
吞吐量优先,意味着在单位时间内,STW的时间最短
垃圾收集开销
吞吐量的补救,垃圾收集所用时间与总运行时间的比例
暂停时间
执行垃圾收集时,程序的工作线程被暂停的时间
收集频率
相对于应用程序的执行,收集操作发生的频率
内存占用
Java堆区所占的内存大小
快速
一个对象从诞生到被回收所经历的时间
目标
在最大吞吐量优先的情况下,降低停顿时间
垃圾回收器分类
串行、并行、并发分类
串行
Serial、Serial Old
并行
ParNew、Parallel Scavenge、Parallel Old
并发
CMS、G1
新生代、老年代、整堆分类
新生代收集器
Serial、ParNew、Parallel Scavenge
老年代收集器
Serial Old、Parallel Old、CMS
整堆收集器
G1
垃圾回收器
Serial(串行回收)
分类
串行运行
作用位置
作用于新生代
使用算法
复制算法
特点
响应速度优先
使用场景
适用于单CPU环境下的client模式
ParNew(并行回收)
分类
并行运行
作用位置
作用于新生代
使用算法
复制算法
特点
响应速度优先
使用场景
适用于多CPU环境Server模式下与CMS配合使用
Parallel Scavenge(吞吐量优先)
分类
并行运行
作用位置
作用于新生代
使用算法
复制算法
特点
吞吐量优先
使用场景
适用于后台运算而不需要太多交互的场景
Serial Old
分类
串行运行
作用位置
作用于老年代
使用算法
标记整理算法
特点
响应速度优先
使用场景
适用于单CPU环境下的Client模式
Parallel Old
分类
并行运行
作用位置
作用于老年代
使用算法
标记整理算法
特点
吞吐量优先
使用场景
适用于后台运算而不需要太多交互的场景
CMS(低延迟)
分类
并发运行
作用位置
作用于老年代
使用算法
标记清除算法
特点
响应速度优先
使用场景
适用于互联网或B/S业务
G1(区域化分代式)
分类
并发、并行运行
作用位置
作用于新生代、老年代
使用算法
标记整理算法、复制算法
特点
响应速度优先
使用场景
面向服务端应用
GC发展阶段
Serial - > Parallel(并行) - > CMS(并发) - > G1 - > ZGC
如何选择垃圾收集器?
优先调整堆的大小让JVM自适应完成
如果内存小于100M,使用串行收集器
如果单核、单机程序,并且没有停顿时间的要求,串行收集器
如果是多核CPU、需要高吞吐量、允许停顿时间超过1秒,选择并行或者JVM自己选择
如果是多CPU、追求低停顿时间,需快速响应(比如延迟不能超过1秒,如互联网应用),使用并发收集器
官方推荐G1,性能高。现在互联网的项目,基本都是使用G1
JVM概述
跨语言的平台
使用Java虚拟机,各语言必须遵守Java虚拟机的字节码文件规范
特点
一次编译,到处执行
自动内存管理
自动垃圾回收机制
JVM的生命周期
虚拟机的启动
虚拟机的执行
虚拟机的退出
Java代码的执行流程
Java源码(xxx.java)
Java编译器
词法分析
语法分析
语法/抽象语法树
语义分析
注解抽象语法树
字节码生成器
字节码文件(xxx.class)
类加载器
字节码校验器
解释执行
编译执行(JIT编译器)
操作系统
Java虚拟机结构
Class Files(字节码文件)
类加载子系统
Loading(加载阶段)
类加载器
双亲委派机制
工作原理:向上委托
作用
避免类的重复加载
防止核心API随意篡改
沙箱安全机制
自定义String类,但会报错
作用
保护了Java核心源代码
分类
Bootstrap ClassLoader(引导类/启动类加载器)
加载rt.jar内的核心类库
Extension ClassLoader(扩展类加载器)
加载ext子目录下的扩展类型类库
Application ClassLoader(系统/应用类加载器)
系统默认类加载器
User Defined ClassLoader(用户自定义类加载器)
用户自定义的类加载器
自定义类加载器的作用
隔离加载类
修改类加载器的方式
扩展加载源
防止源码泄露
Liking(链接阶段)
Verification(验证)
确保Class文件的字节流中包含信息符合虚拟机要求
文件格式验证
元数据验证
字节码验证
符号引用验证
Perparation(准备)
为类变量分配内存并且设置该类的默认初始值
注意
这里不包含final修饰的static,因为final在编译的时候就会分配了,准备阶段会显示初始化
这里不会为实例变量分配初始化,因为实例变量是在类中的,隶属于堆中
Resolution(解析)
将常量池内的符号引用转换为直接引用
Initialization(初始化阶段)
将静态代码块、静态变量和构造器合并在一起,执行<clinit>()构造方法的过程
运行时数据区
线程私有
虚拟机栈
随着方法的调用,对应方法的栈帧压栈,随着方法的结束,栈帧出栈
注意
栈不存在垃圾回收
异常
超过虚拟机栈的最大容量,会出现StackOverFlowError
没有足够的内存创建对应的虚拟机栈,会出现OutOfMemoryError
局部变量表
存储基本数据类型、对象引用、returnAddress类型等
Slot(变量槽)
操作系统槽占用情况
32位(包括returnAddress):1Slot
64为(double、long):2Slot
注意
byte、short、char在存储前都被转换为了int
boolean也被转化为int(0代表false,1代表true)
long、double则占据两个Slot
Slot变量槽会对过期的Slot变量槽进行重复利用,以达到节省资源的目的
操作数栈(表达式栈)
在方法执行过程中,根据字节码指令,向栈中写入数据或提取数据,即入栈(push)/出栈(pop)
代码追踪
根据反编译查看字节码指令的执行流程
栈顶缓存技术
将栈顶元素全部缓存到物理CPU的寄存器中,以此降低对内存的读写次数,提升执行引擎的执行效率
桢数据区
动态链接
指向运行时常量池中的该栈帧所属方法的引用
目的就是将这些符号引用转换为直接引用
方法的调用
静态链接
静态链接
当一个字节码文件被装载进JVM中,被调用的目标方法在编译器可知,且运行期保持不变时
这种情况将调用方法的符号引用转换为直接引用的过程称之为静态链接
早期绑定
调用的目标方法如果在编译器可知,且运行期保持不变时,这个方法与所属的类型进行绑定
非虚方法
在编译期间就确定了具体的调用版本,且运行时不可变的是非虚方法
动态链接
动态链接
如果被调用的方法在编译器无法被确定下来,也就是说,只能够在程序运行期间将调用方法的符号引用转换为直接引用
这种引用转换过程具备动态性,因为也就是被称为了动态链接
晚期绑定
被调用的方法在编译器无法被确定下来,只能在程序运行期间根据时机的类型绑定相关方法
虚方法
静态方法、私有方法、final方法、实例构造器、父类方法都是非虚方法
虚方法表
使用虚方法表来解决很频繁的使用动态分派,提供性能
需方发表会在类加载的链接阶段被创建并开始初始化,类的变量初始值准备完成后,JVM会把类的方法表也初始化
调用指令
普通调用指令
invokestatic
调用静态方法,解析阶段确定下来唯一方法版本
invokespecial
调用<init>方法、私有及父类方法,解析阶段确定唯一方法版本
invokevirtual
调用所有虚方法
invokeinterface
调用接口方法
动态调用指令
invokedynamic
动态解析出需要调用的方法,比如lambda表达式
方法返回地址
正常返回
方法正常退出,调用者PC寄存器的值作为返回地址,即调用该方法的下一条指令地址
异常返回
异常返回是通过异常表来确定的,栈帧一般不会保存此信息
一些附加信息
本地方法栈
与虚拟机栈结构相同,不同点为执行native方法
PC寄存器/程序计数器
虚拟机栈要想知道下一步执行哪个执行,要靠PC寄存器来存储下一条指令的地址
程序控制中的分支、循环、跳转、异常处理、线程恢复等功能都依赖于PC寄存器
靠执行引擎来读取下一条指令地址
是唯一一个Java虚拟机中没有任何OOM的组件
线程共享
堆空间
新生代
Eden(伊甸园区)
new出来的对象优先放入伊甸园区
新生代中80%的对象都是“朝生夕死”,所以一般Minor GC回收速度比较快
Survivor(幸存者区)
Survivor0【幸存者0区】(from)
Survivor1【幸存者1区】(to)
年龄计数器
经过垃圾回收1一次没有被清理掉,则年龄+1,默认15次则放入老年代
注意
幸存者0区和1区之间的to和from的切换是一直在变的
复制之后有交换,谁空谁是to
new出来的大对象,新生代无法容纳时,直接晋升老年代
新生代收集(Minor GC/Young GC)
伊甸园区空间满了会触发Minor GC开始垃圾回收,堆中不再被其他对象引用的对象将被销毁
剩余的对象移动至幸存者0区
只收集新生代的垃圾
其他
三者默认8:1:1的关系
新生代占整个堆空间的1/3
STW
Stop-The-World机制简称STW,是在执行垃圾收集算法时,所用线程挂起,只有GC线程工作销毁,native代码可以执行,但 不能与JVM交互,这种现象基本上是由于GC引起的
垃圾回收结束后,则恢复其他线程运行
执行新生代GC比执行老年代GC的STW次数少
Minor GC在回收伊甸园区垃圾时,也会顺带着回收幸存者0、1区的垃圾
老年代
存储生存时间很长的对象
老年代收集(Major GC/Old GC)
当老年代满时,则执行Major GC回收垃圾
Major GC的出现,一般都会伴随着至少一次的Minor GC(但非绝对,在ParallelScavenge收集器的收集策略中,就有直接进行Major GC的策略选择过程)
也就是在老年代空间不足时,会先尝试触发Minor GC。如果之后空间还不足,则触发Major GC
其他
老年代占整个堆空间的2/3
Major只收集老年代的垃圾
Major GC会比Minor GC的速度慢10倍以上,STW的时间更长
Major GC后,内存还是不足,则会爆出OOM
混合回收(Mixed GC)
收集整个新生代以及部分老年代的垃圾
目前只有G1 GC会有这种行为
整堆回收(Full GC)
Full GC会收集整个Java堆区和方法去的垃圾
其他
触发机制
调用System.gc()方法时,系统建议执行Full GC,但是不必去执行
老年代空间不足
方法去空间不足
通过Minor GC后进入老年代的平均大小大于老年代的可用内存
由伊甸园区、S0(from)区向S1(to)区复制时,对象大小大于to可用内存,则把该对象转存到老年代,且老年代的可用内存小于该对象大小
Full GC时开发中或调优中尽量要避免的,这样暂停的时间会短一些
内存分配策略
优先分配到Eden区
大对象直接分配到老年代
长期存活的对象分配到老年代
动态对象年龄判断
如果Survivor区中相同年龄的所有对象的大小的总和大于Survivor空间的一般,年龄大于或等于改年龄的对象可以直接进入老年代,无须等到年龄阈值到达默认的15
空间分配担保
在发生Minor GC之前,虚拟机会检查老年代最大可用的连续空间是否大于新生代所有对象的总空间
如果大于,则此Minor GC是安全的
如果小于,则虚拟机会查看-XX:HandlePromotionFailure设置值是否允许
担保失败
如果HandlePromotionFailure=true,那么会继续检查老年代最大可用连续空间是否大于历次晋升到老年代的对象的平均大小
如果大于,则尝试进行一次Minor GC,但这次Minor GC依然是风险的
如果小于,则改为进行一次Full GC
如果HandlerPromotionFailure=false,则改为进行一次full GC
在JDK 6 Update24之后,HandlePromotionFailure参数不会再影响到虚拟机的空间分配担保策略,观察Open JDK的源码变化,虽然源码中还定义了HadnlePromotionFailure参数,但是在代码中已经不会再使用它。
JDK6 Update 24之后的规则变为只要老年代的连续空间大于新生代对象的总大小或者历次晋升的平均大小就会进行Minor GC,否则将进行Full GC
堆的线程安全问题
Thread Local Allocation Buffer(TLAB)
从内存模型而不是垃圾收集的角度,对Eden区域继续进行划分,JVM为每一个线程分配了一个私有缓存区域,它包含在Eden空间内
多线程同时分配内存时,使用TLAB可以避免一系列的非线程安全问题,同时还能够提升内存分配的吞吐量,因此我们可以将这种内存分配方式叫做快速分配策略
据我所知所有OpenJDK衍生出来的JVM都提供了TLAB的设计
尽管不是所有的对象实例都能够在TLAB中成功分配内存,但JVM确实将TLAB作为了内存分配的首选
默认情况下,TLAB空间的内存非常小,仅占有整个Eden空间的1%
一旦对象在TLAB空间分配内存失败时,JVM就会尝试着通过使用加锁机制确保数据操作的原子性,从而直接在Eden空间分配内存
堆是分配对象存储的唯一选择吗?
逃逸分析技术
逃逸
被方法外部引用或者由被方法外部引用的可能
非逃逸
只在方法内部引用
优点
将非逃逸对象,放到堆外,并且GC不能管理GCIH内部的Java对象,以此达到降低GC回收频率和提升GC的回收效率的目的
栈上分配
将堆分配转换为栈分配
同步省略
非逃逸对象没有必要使用同步监视器(锁),可以省略
分离对象或标量替换
new的实例对象内赋值该对象的私有属性,可以替换为直接为私有属性赋值
字符串常量池(版本差异)
全局常量(static final)
被声明为final的类变量的处理方法则不同,每个全局变量在编译的时候就会被分配了
常量池VS运行时常量池
如何理解常量池和运行时常量池
方法区,内部包含了运行时常量池
字节码文件,内部包含了常量池
要弄清楚方法区,需要理解清楚ClassFile,因为加载类的信息都在方法区
要弄清楚方法区的运行时常量池,需要理解清楚ClassFile中的常量池
常量池
一个有效的字节码文件中除了包含类的版本信息、字段、方法以及接口等描述信息外,还包含一项信息那就是常量池表,包括各种字面量和对类型、域和方法的符号引用
JVM为每个已加载的类型(类或接口)都维护一个常量池。池中的数据项像数据项一样,是通过索引访问的
为什么要常量池
一个源文件中的类、接口,编译后产生一个字节码文件。而Java中的字节码需要数据支持,通常这种数据会很大以至于不能直接存到字节码里
换另外一种方式,可以存到常量池,这个字节码包含了指向常量池的引用。在动态链接的时候会用到运行时常量池
注意
如果代码多,字节码文件中引用到的结构会更多,这时候就会需要常量池
常量池内存储的数据类型
数量值
字符串值
类引用
字段引用
方法引用
常量池表
常量池表时Class文件的一部分,用于存放编译器生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中
运行时常量池
运行时常量池,在加载类和接口到虚拟机后,就会创建对应的运行时常量池
运行时常量池中包含了多种不同的常量,包括编译期就已经明确的数值字面量,也包括到运行期解析后才能够获得的方法或者字段引用。此时不再是常量池中的符号引用了,这里换为真实地址
运行时常量池,相对与Class文件常量池的另一重要特征是:具备动态性
当创建类或接口的运行时常量池时,如果构造运行时常量池所需的内存空间超过了方法区所能提供的最大值,则JVM会抛出OutOfMemoryError异常
静态变量(版本差异)
non-final的类变量
静态变量和类关联在一起,随着类的加载而加载,它们成为类数据在逻辑上的一部分
类变量被类的所有实例共享,即使没有类实例时你也可以访问它
注意
静态引用对应的对象实例始终都在堆中
方法区(非堆)
永久代
演进过程
JDK6时,永久代包括类信息、静态变量和字符串常量池
JDK7时,被称为“去永久代”,包括只包括类信息等(把静态变量和字符串常量池移到了堆中)
JDK8+,将JRockit和Hotspot结合,去掉了永久代,并使用元空间(静态变量和字符串常量池还是存在堆中)
注意:JRockit和J9都只使用元空间
缺点
因为永久代直接占用的JVM内存,大量的静态变量和字符串创建,导致程序更容易出现OOM,而且会频繁触发Full GC,增加STW的次数
永久代为什么要被元空间而取代?
为永久代设置空间大小时很难确定的
对永久代进行调优很困难
字符串常量池为什么要调整?
JDK7中将字符串常量池放在堆中。因为永久代回收效率很低,在Full GC的时候才会触发
而Full GC是老年代的空间不足、永久代不足时才会被触发
这导致字符串常量池回收效率不高。而我们开发中会有大量的字符串被创建,回收效率低,导致永久代内存不足。放在堆里,能及时回收
元空间
优点
元空间使用的是操作系统的直接内存,则在GC的管辖之外,减少了Full GC的次数,随着减少了STW的次数
方法区存储内容
类型信息
类型
类
接口
注解
枚举
信息
类型的完整有效名称(全类名=包名+类名)
直接父类的有效名(Java.lang.Object)
类型的修饰符(abstract、final等)
类型直接接口的有序列表
JIT代码缓存
即时编译器编译后的缓存代码等
域信息
JVM必须在方法区中保存类型的所有域的相关信息以及域的声明顺序
域的相关信息及声明顺序
域名称
域类型
域修饰符
注意
域,即为属性
方法信息
方法的相关信息及声明顺序
方法名称
方法的返回类型(或void)
方法参数的数量和类型(按顺序)
方法的修饰符
方法的字节码、操作数栈、局部变量表及大小(abstract和native方法,除外)
异常表(abstract和native方法,除外)
每个异常处理的开始位置、结束位置、代码处理在程序计数器中的偏移地址、被捕获的异常类的常量索引
方法区内常量池
字面量
字面量比较接近Java语言层次的常量概念,如:文本字符串、被声明为final的常量值等
符号引用
符号引用属于编译原理方面的概念包括下面三类常量
类和接口的全限定名
字段的名称和描述符
方法的名称和描述符
方法区的垃圾回收
方法区垃圾回收条件
尴尬局面
一般来说方法区的回收效果比较难令人满意,尤其时类型的卸载,条件相当苛刻。但是部分区域的回收有时又确实是必要的(如JDK 11时期,ZGC收集器就不支持类卸载)
方法区垃圾回收的内容
常量池中废弃的常量和不再使用的类型
方法区垃圾回收的条件
该类的所有实例都已经被回收,也就是Java堆中不存在该类及其任何派生子类的实例
加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如OSGi、JSP的重加载等,否则通常是很难达成的
该类对应的java.lang.Class对象,没有在任何地方被引用,无法在任何地方通过反射访问该类的方法
注意
在大量使用反射、动态代理、CGLib等字节码框架,动态生成JSP以及OSGi这类频繁自定义类加载器的场景中,通常都需要Java虚拟机具备类型卸载的能力,以保证不会对方法区造成过大的内存压力
本地方法
本地方法库
native方法库(其他语言实现)
本地方法接口
提供native方法接口,用于融合Java代码的接口
使用本地方法的原因
Java解释器基于C编写
Java与C的时代关系,Java朝向C抱团取暖
基于硬件交互问题
执行引擎
了解
执行引擎的任务
将字节码指令解释/编译为对应平台上的本地机器指令
简单来说,JVM中执行引擎充当了将高级语言翻译为机器语言的译者
执行引擎的工作过程
执行引擎再执行的过程中究竟需要执行什么样的字节码指令完全依赖于PC寄存器
每当执行完一项指令操作后,PC寄存器就会更新下一条需要被执行的指令地址
当然方法在执行的过程中,执行引擎有可能会通过存储在局部变量表中的对象引用准确定位到存储在Java堆区中的对象实例信息,以及对对象头中的元数据指针定位到目标对象的类型信息
从外观上来看,所有的Java虚拟机的执行引擎输入、输出都是一致的:输入的时字节码二级制流,处理过程是字节码解释执行的等效过程,输出的是执行结果
Java编译和执行过程
程序源码 - > 词法分析 - > 单词流 - > 语法分析 - > 抽象语法树 - > 指令流(可选) - > 解释器 - > 解释执行
程序源码 - > 词法分析 - > 单词流 - > 语法分析 - > 抽象语法树 - > 中间代码(可选) - > 生成器 - > 目标代码
Java代码编译流程
源代码 - > 词法分析器 - > Token流 - > 语法分析器 - > 语法树/抽象语法树 - > 语法分析器 - > 注解抽象语法树 - > 字节码生成器 - > JVM字节码
Java字节码执行流程
JVM字节码 - > 借助Java虚拟机执行引擎 - > 机器无关优化 - > 中间代码 - > 寄存器分配器 - > 中间代码 - > 目标代码生成器 - > 目标代码
JVM 字节码 - > 借助符号表 - > 字节码解释器
机器码&指令&指令集&汇编语言&字节码
机器码
各种用二进制编码方式表示的指令,叫做机器指令码
指令
将机器码中的特定0和1序列,简化成对应的英文简写,叫做指令
指令集
不同硬件平台,各自支持的指令,是有差别的。因此每一个平台所支持的指令,叫做对应平台的指令集
汇编语言
在汇编语言中,用助记符代替机器指令的操作码,用地址符号或标号代替指令或操作数的地址
在不同的硬件平台,汇编语言对应着不同的机器语言指令集,通过汇编过程转换为机器指令
由于计算机知人知指令码,所以汇编语言编写的程序还必须翻译成机器指令码,计算机才能识别和执行
字节码
字节码时一种中间状态(中间码)的二进制代码(文件),它比机器码更抽象,需要直译器转译后才能成为机器码
字节码主要是为了实现特定软件运行和软件环境、于硬件环境无关
字节码的实现方式是通过编译器和虚拟机器。编译器将源码编译成字节码,特定平台上的虚拟机器将字节码转译为可以直接执行的指令
字节码的典型应用为Java bytecode
解释器&JIT编译器
解释器
当Java虚拟机启动时会根据预定义的规范堆字节码采用逐行解释的方式执行,将每条字节码文件中的内容“翻译”为对应平台的本地机器指令执行
JIT编译器
就是虚拟机将源代码直接编译成和本地机器平台相关的机器语言
重点
当虚拟机启动时,解释器可以首先发挥作用,而不必等待即时编译器全部编译完成再执行,这样可以省去许多不必要的编译时间。
并且随着程序运行时间的推移,即时编译器逐渐发挥作用,跟据热点探测功能,将有价值的字节码编译为本地机器指令,以换取更高的程序执行效率
热点代码及探测方式
了解
是否需要启动JIT编译器将字节码直接编译为对应平台的本地机器指令,则需要根据代码被调用的执行的频率而定
关于那些需要被编译为本地代码的字节码,也被称之为“热点代码”,JIT编译器在运行时会针对那些频率被调用的“热点代码”做出深度优化,将其直接编译为对应平台的本地机器指令,以此提升Java程序的执行性能
栈上替换(OSR编译)
一个被多次调用的方法,或者是一个方法体内部循环次数较多的循环体都可以被称之为“热点代码”,因此都可以通过JIT编译器编译为本地机器指令。由于这种编译方式发生在方法的执行过程中,因此也被称为栈上替换,或简称OSR(On Stack Replacement)编译
热点探测功能
一个方法究竟要被调用多少次,或者一个循环体究竟需要执行多少次循环才可以达到这个标准?必然需要一个明确的阈值,JIT编译器才会将这些“热点代码”编译为本地机器指令执行。这里主要依靠热点探测功能
采用基于计数器的热点探测,Hot stop VM将会为每一个方法都建立2个不同类型的计数器,分别为方法调用计数器和回边计数器
调用计数器(Invocation Counter)
方法调用计数器用于统计方法的调用次数
解释
这个计数器就用于统计方法被调用的次数,它的默认阈值在Client模式下是1500次,在Server模式下是10000次。超过这个阈值,就会触发JIT编译器。这个阈值也可以来人为设定
热度衰减
如果不做任何设置,方法调用计数器统计的并不是方法被调用的绝对次数,而是一个相对的执行效率,即一段时间之内方法被调用的次数。当超过一定的时间限度,如果方法的调用次数仍然不足以让它提交给即时编译器,那这个方法的调用计数器就会被减少一半,这个过程成为方法调用计数器热度的衰减,而这段时间就称为此方法统计的半衰周期
进行热度衰减的动作是在虚拟机进行垃圾收集时顺便进行的,可以使用虚拟机参数-XX:-UserCounterDecay来关闭热度衰减,让方法计数器统计方法调用的绝对次数。这样,只要系统运行时间足够长,绝大部分方法都会被编译成本地代码
另外,可以使用-XX:CounterHalfLifeTime参数设置半衰周期的时间,单位是秒
回边计数器(Back Edge Counter)
回边计数器则用于统计循环体执行的循环次数
它的作用是统计一个方法中循环体代码执行的次数,在字节码中遇到控制流向后跳转的指令成为“回边”(Back Edge)。显然,建立回边计数器统计的目的就是为了触发OSR编译
调用过程
当一个方法被调用时,会先检查该方法是否存在被JIT编译过的版本,则优先使用编译后的本地代码来执行。如果不存在已被编译过的版本,则将此方法的调用计数器值加1,然后判断方法调用计数器于回边计数器之和是否超过方法调用计数器的阈值。如果已超过阈值,那么将会向即时编译器提交一个该方法的代码编译请求
Hotspot VM编译解释相关
解释编译模式设置
了解
缺省情况下Hot stop VM是采用解释器与即时编译器并存的架构,当然开发人员可以根据具体的应用场景,通过命令显式地为Java虚拟机指定在运行时到底是完全采用解释器执行,还是完全采用即时编译器执行
完全采用解释器模式执行程序
-Xint
完全采用即时编译器模式执行程序。如果即时编译器出现问题,解释器会介入执行
-Xcomp
采用解释器+即时编译器的混合模式共同执行程序
-Xmixed
Hotspot中JIT分类
在Hotspot VM中内嵌由两个JIT编译器,分别为Client Compiler和Server Compiler,但大多数情况下我们简称为C1编译器和C2编译器。
开发人员可以通过命令显示指定Java虚拟机在运行时到底使用哪一种即时编译器
-client
指定Java虚拟机运行在Client模式下,并使用C1编译器
C1编译器会对字节码进行简单和可靠的优化,耗时段。以达到更快的编译速度
-server
指定Java虚拟机运行在Server模式下,并使用C2编译器
C2进行耗时较长的优化,以及激进优化。但优化的代码执行效率更高
分层编译策略
程序解释执行(不开启性能监控)可以触发C1编译,将字节码编译成机器码,可以进行简单优化,也可以加上性能监控,C2编译会根据性能监控信息进行激进优化
不过在Java7版本之后,一旦开发人员在程序中显示指定命令“-server”时,默认将会开启分层编译策略,由C1编译器和C2编译器相互协作共同来执行编译任务
C1和C2编译器优化策略
C1编译器优化策略
在不同的编译器上有不同的优化策略,C1编译器上主要有方法内联,去寻虚拟化、冗余消除
方法内联
将引用的函数代码编译到引用点处,这样可以减少栈帧的生成,减少参数传递以及跳转的过程
去虚拟化
对唯一的实现类进行内联
冗余消除
在运行期间把一些不会执行的代码折叠掉
C2编译器优化策略
C2的优化主要是在全局层面,逃逸分析是优化的基础。基于逃逸分析在C2上有如下几种优化
标量替换
用标量值代替聚合对象的属性值
栈上分配
对于未逃逸的对象分配对象在栈而不是堆
同步消除
消除同步操作,通常指synchronized
其他
Graal编译器
自JDK10起,Hotspot又加入一个全新的即时编译器:Graal编译器
编译效果短短几年时间就追平了C2编译器。未来可期
目前带着“实验状态”的标签,需要使用开关参数-XX:+UnlockExperimentalVMOptions -XX:+UseJVMCICompiler去激活,才可以使用
AOT编译器
JDK9引入AOT编译器(静态提前编译器)
Java9引入实验性AOT编译工具jaotc。它借助了Graal编译器,将所输入的Java类文件转换为机器码,并存放至生成的动态共享库之中
所谓AOT编译,是与即时编译器相对立的一个概念。我们知道,即时编译值得是在程序的运行过程中,将字节码转换为可在硬件上直接运行的机器码,并部署至托管环境中的过程。而AOT编译指的是,在程序运行之前,便将字节码转换为机器码的过程
优缺点
优点
Java虚拟机加载已经预编译成二进制库,可以直接执行。不必等待即时编译器的预热,减少Java应用给人带来的“第一次运行慢”的不良体验
缺点
破坏了Java“一次编译,到处运行”,必须为每个不用硬件、OS编译对应的发行包
降低了Java链接过程中的动态性,加载的代码在编译期就必须全部已知
还需要继续优化中,最初只支持Linux x64 java base
总结
一般来讲,JIT编译出来的机器码性能比解释器高
C2编译器启动时长比C1编译器慢,系统稳定执行以后,C2编译器执行速度远远快于C1编译器
执行引擎开始工作
编译器
PC寄存器
逐行编译字节码
本地机器指令
JIT
寻找字节码文件内热点代码
编译成本地机器指令,并放入方法区的元空间内,即JIT代码缓存
多次执行后直接从方法区的元空间的JIT代码缓存中取
0 条评论
下一页