深入Java虚拟机

一、JVM内存分区

HotSpot虚拟机JDK7和JDK8的实现做了一些调整,JDK7主要有程序计数器、堆、虚拟机栈、本地方法栈、方法区,而永久代的概念就是在方法区中实现的,JDK7开始已经逐步意识到永久代的实现存在一些问题,包括时常OOM,故而开始将字符串常量池以及静态变量的实现移出到堆中。而JDK8中更是取消了永久代的实现,改为在本地内存中的元空间实现(类型信息),这样内存就直接跟系统内存关联,理论上只要系统内存足够不会发生OOM。

二、对象的内存布局

Hotspot虚拟机的对象在堆内存中的布局分为以下三部分:

1、对象头(MarkWord)

一类信息是用于存储对象自身的运行时数据,如哈希码(HashCode )、 GC 分代年龄、锁状态标
 
志、线程持有的锁、偏向线程 ID 、偏向时间戳等;第二类是类型指针,即对象指向它的类型元数据
 
的指针
 

2、实例数据

对象真正存储的有效数据

3、对齐填充

因为任何对象的大小都必须是8字节的整数倍,故而不满足的时候由该部分对齐

三、垃圾收集之对象存活性判断

1、引用计数法

对象有引用计数器加一,反之减一,直至为0时表示无任何引用。但是无法解决对象循环引用问题,并且Java中使用也需要许多额外处理,JVM并没使用该算法

2、GCroots可达性算法

基本思路为通过一系列的GCroots对象作为起始集,根据引用关系向下搜索,搜索过程走过的路径成为引用链,若某个对象到GCroots之间没有任何引用链相连,证明此对象不可能再被使用。

在Java技术体系里面,固定可作为GC Roots的对象包括以下几种:

(1)在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的 参数、局部变量、临时变量等。
(2)在方法区中类静态属性引用的对象,譬如 Java类的引用类型静态变量。
(3)在方法区中常量引用的对象,譬如字符串常量池( String Table )里的引用。
(4)在本地方法栈中 JNI (即通常所说的 Native方法)引用的对象。
(5)Java虚拟机内部的引用,如基本数据类型对应的 Class对象,一些常驻的异常对象(比如
NullPointExcepiton、 OutOfMemoryError)等,还有系统类加载器。
(6)所有被同步锁( synchronized关键字)持有的对象。
(7)反映 Java 虚拟机内部情况的 JMXBean JVMTI中注册的回调、本地代码缓存等。
除了这些固定的 GC Roots 集合以外,根据用户所选用的垃圾收集器以及当前回收的内存区域不
同,还可以有其他对象 临时性 地加入,共同构成完整 GC Roots 集合。
 
3、引用分为:强、软、弱、虚,用于细化对象的引用关系,而非只有引用和非引用两种,细化之后可以根据内存的具体使用情况,结合引用的类型来进行回收
 
四、垃圾收集之分代收集
 

分代即将堆内存根据对象生存的时间来划分不同的区域,有些对象朝生夕死,有些对象存活较久,对那些朝生夕死的对象可以多进行回收,其效率和收获也将较大,对那些很难消亡的对象可以较久回收。这也引发出在不同分代区使用不同的收集算法。

一般来说我们把堆分为年轻代(又分E区和S区)、老年代,年轻代对象更迭比较频繁,老年代对象比较长久,一般在年轻代使用复制或者标记整理算法,而老年代使用标记清除算法,对应的收集器年轻代有serial收集器、parnew收集器、parallel scavenage;老年代有serial old、parallel old、CMS;G1收集器可以同时作用于年轻代和老年代。

那么不同的垃圾回收算法,其都会涉及到对象是否移动的问题,清除法不移动对象,所以会产生内存碎片,复制和整理算法移动对象,会增加内存空间且需要暂停用户线程。是否移动对象都有利弊,但从整体上来看,移动对象更加划算,因为即使不移动对象会使得收集器的效率提升一些,但因内存分配(产生内存碎片,要分配大对象就需要额外的措施解决)和访问相比垃圾收集频率要 高得多,这部分的耗时增加,总吞吐量仍然是下降的。CMS收集器利用了标记清除,但是面对内存空间碎片化问题,它也采取了可以对空间压缩的配置项解决。

五、Hotspot算法细节点

根枚举:

现在可达性分析算法耗时 最长的查找引用链的过程已经可以做到与用户线程一起并发 ,但根节点枚举始终还 是必须在一个能保障一致性的快照中才得以进行—— 这里 一致性 的意思是整个枚举期间执行子系统看起来就像被冻结在某个时间点上,不会出现分析过程中,根节点集合的对象引用关系还在不断变化 的情况,若这点不能满足的话,分析结果准确性也就无法保证。这是导致垃圾收集过程必须停顿所有 用户线程的其中一个重要原因,即使是号称停顿时间可控,或者(几乎)不会发生停顿的CMS G1 、 ZGC等收集器,枚举根节点时也是必须要停顿的。
 
由于目前主流 Java虚拟机使用的都是准确式垃圾收集,所以当用户线程停顿下来之后,其实并不需要一个不漏地检查完所有 执行上下文和全局的引用位置,虚拟机应当是有办法直接得到哪些地方存放着对象引用的。在HotSpot 的解决方案里,是使用一组称为OopMap 的数据结构来达到这个目的。一旦类加载动作完成的时候, HotSpot就会把对象内什么偏移量上是什么类型的数据计算出来,在即时编译 过程中,也 会在特定的位置记录下栈里和寄存器里哪些位置是引用。这样收集器在扫描时就可以直接得知这些信 息了,并不需要真正一个不漏地从方法区等GC Roots 开始查找。
 
安全点:
 
OopMap 的协助下, HotSpot 可以快速准确地完成 GC Roots 枚举,但一个很现实的问题随之而
来:可能导致引用关系变化,或者说导致 OopMap 内容变化的指令非常多,如果为每一条指令都生成 对应的OopMap ,那将会需要大量的额外存储空间,这样垃圾收集伴随而来的空间成本就会变得无法忍受的高昂。
实际上HotSpot 也的确没有为每条指令都生成 OopMap ,前面已经提到,只是在 特定的位置 记录
了这些信息,这些位置被称为安全点( Safepoint)。有了安全点的设定,也就决定了用户程序执行时并非在代码指令流的任意位置都能够停顿下来开始垃圾收集,而是强制要求必须执行到达安全点后才能够暂停。因此,安全点的选定既不能太少以至于让收集器等待时间过长,也不能太过频繁以至于过分增大运行时的内存负荷。例如方法调用、循环跳转、异常跳转
等才会产生安全点。
对于安全点,另外一个需要考虑的问题是,如何在垃圾收集发生时让所有线程(这里其实不包括
执行 JNI 调用的线程)都跑到最近的安全点,然后停顿下来。这里有两种方案可供选择:抢先式中断(Preemptive Suspension )和主动式中断( Voluntary Suspension ),抢先式中断不需要线程的执行代码 主动去配合,在垃圾收集发生时,系统首先把所有用户线程全部中断,如果发现有用户线程中断的地 方不在安全点上,就恢复这条线程执行,让它一会再重新中断,直到跑到安全点上。现在几乎没有虚 拟机实现采用抢先式中断来暂停线程响应GC 事件。
主动式中断的思想是当垃圾收集需要中断线程的时候,不直接对线程操作,仅仅简单地设置一
个标志位,各个线程执行过程时会不停地主动去轮询这个标志,一旦发现中断标志为真时就自己在最近的安全点上主动中断挂起。轮询标志的地方和安全点是重合的,另外还要加上所有创建对象和其他需要在Java 堆上分配内存的地方,这是为了检查是否即将要发生垃圾收集,避免没有足够内存分配新对象。
 
安全区域:
 
使用安全点的设计似乎已经完美解决如何停顿用户线程,让虚拟机进入垃圾回收状态的问题了,
但实际情况却并不一定。安全点机制保证了程序执行时,在不太长的时间内就会遇到可进入垃圾收集过程的安全点。但是,程序“ 不执行 的时候呢?所谓的程序不执行就是没有分配处理器时间,典型的场景便是用户线程处于Sleep 状态或者 Blocked 状态,这时候线程无法响应虚拟机的中断请求,不能再走到安全的地方去中断挂起自己,虚拟机也显然不可能持续等待线程重新被激活分配处理器时间。对于这种情况,就必须引入安全区域(Safe Region )来解决。
安全区域是指能够确保在某一段代码片段之中,引用关系不会发生变化,因此,在这个区域中任
意地方开始垃圾收集都是安全的。我们也可以把安全区域看作被扩展拉伸了的安全点。当用户线程执行到安全区域里面的代码时,首先会标识自己已经进入了安全区域,那样当这段时间里虚拟机要发起垃圾收集时就不必去管这些已声明自己在安全区域内的线程了。当线程要离开安全区域时,它要检查虚拟机是否已经完成了根节点枚举(或者垃圾收集过程中其他需要暂停用户线程的阶段),如果完成了,那线程就当作没事发生过,继续执行;否则它就必须一直等待,直到收到可以离开安全区域的信号为止。
 
 

记忆集与卡表

为解决对象跨代引用所带来的问题,垃圾收集器在新生代中建立了名为记忆集(Remembered Set )的数据结构,用以避免把整个老年代加进 GC Roots 扫描范围。事实上并不只是新生代、老年代之间才有跨代引用的问题,所有涉及部分区域收集(Partial GC )行为的垃圾收集器,典型的如G1 ZGC Shenandoah收集器,都会面临相同的问题。卡表就是记忆集的一种具体实现,它定义了记忆集的记录精度、与堆内存的映射关系等,一个卡页的内存中通常包含不止一个对象,只要卡页内有一个(或更多)对象的字段存在着跨代指针,那就将对应卡表的数组元素的值标识为1,称为这个元素变脏(Dirty),没有则标识为0。在垃圾收集发生时,只要筛选出卡表中变脏的元素,就能轻易得出哪些卡页内存块中包含跨代指针,把它们加入GC Roots中一并扫描。

 

三色标记法

引入三色标记( Tri-color Marking 作为工具来辅 助推导,把遍历对象图过程中遇到的对象,按照“ 是否访问过 这个条件标记成以下三种颜色:
· 白色: 表示对象尚未被垃圾收集器访问过。显然在可达性分析刚刚开始的阶段,所有的对象都是
白色的,若在分析结束的阶段,仍然是白色的对象,即代表不可达。
· 黑色: 表示对象已经被垃圾收集器访问过,且这个对象的所有引用都已经扫描过。黑色的对象代
表已经扫描过,它是安全存活的,如果有其他对象引用指向了黑色对象,无须重新扫描一遍。黑色对象不可能直接(不经过灰色对象)指向某个白色对象。
· 灰色: 表示对象已经被垃圾收集器访问过,但这个对象上至少存在一个引用还没有被扫描过。
关于可达性分析的扫描过程,把它看作对象图上一股以灰色为波峰的波纹从黑向白推进的过程,如果用户线程此时是冻结的,只有收集器线程在工作,那不会有任何问题。但如果用户线程与收集器是并发工作呢?收集器在对象图上标记颜色,同时用户线程在修改引用关系—— 即修改对象图的结构,这样可能出现两种后果。一种是把原本消亡的对象错误标记为存活,这不是好事,但其实是可以容忍的,只不过产生了一点逃过本次收集的浮动垃圾而已,下次收集清理掉就好。另一种是把原本存活的对象错误标记为已消亡,这就是非常致命的后果了,程序肯定会因此发生错误。
因此,我们要解决并发扫描时的对象消失问题,只需破坏这两个条件的任意一个即可。由此分别
产生了两种解决方案:
增量更新( Incremental Update )和原始快照( Snapshot At The Beginning ,SATB)。
增量更新要破坏的是第一个条件,当黑色对象插入新的指向白色对象的引用关系时,就将这个新
插入的引用记录下来,等并发扫描结束之后,再将这些记录过的引用关系中的黑色对象为根,重新扫描一次。这可以简化理解为,黑色对象一旦新插入了指向白色对象的引用之后,它就变回灰色对象了。
原始快照要破坏的是第二个条件,当灰色对象要删除指向白色对象的引用关系时,就将这个要删
除的引用记录下来,在并发扫描结束之后,再将这些记录过的引用关系中的灰色对象为根,重新扫描一次。这也可以简化理解为,无论引用关系删除与否,都会按照刚刚开始扫描那一刻的对象图快照来进行搜索。
以上无论是对引用关系记录的插入还是删除,虚拟机的记录操作都是通过写屏障实现的。在
HotSpot 虚拟机中,增量更新和原始快照这两种解决方案都有实际应用,譬如, CMS 是基于增量更新来做并发标记的,G1 Shenandoah 则是用原始快照来实现。
 
六、CMS与G1

 

CMS是第一款真正意义上的并发收集器,用户线程和收集线程并发执行。

CMS大致经历以下几个阶段:

1、初始标记(STW)

标记出作为GCroots的对象

2、并发标记

用户线程和GC线程共同执行

3、重新标记(STW)

处理并发标记过程中,由于对象引用关系变动造成的漏标问题

4、并发清理

CMS在初始标记和重新标记阶段都会有Stop the world,且在并发标记及并发清理阶段产生的浮动垃圾也无法处理,需要在下一个GC处理,CMS使用标记清除算法所以将会产生内存碎片,其解决的方案为,可以通过JVM参数设置开启压缩策略,指明经过多少次FUll GC后执行标记整理算法进行内存压缩。

G1收集器基于CMS更加强大

1、初始标记(STW)

2、并发标记

3、最终标记(STW)

4、筛选清理(STW)

G1有三个阶段需要stop the world,但是筛选清理阶段可以由用户控制停顿时间,G1将内存划分成多个region,每个region都通过remember set记录跨代引用,也是用card表记录引用关系的变化,方便进行对象的标记,清理时,可以根据需要对部分region进行清理,而不是整个垃圾都清理完,整体来看在满足内存需要的前提下可以精确控制GC的时间。因为整体使用标记整理算法,所以不会产生内存碎片,清理过程对象是需要移动,用户线程必须停止,对于浮动垃圾,G1也需要等到下次GC进行清理。

CMS G1 之前的全部收集器,其工作的所有步骤都会产生 “Stop The World” 式的停顿;CMS和 G1 分别使用增量更新和原始快照 技术,实现了标记阶段的并发,不会因管理的堆内存变大,要标记的对象变多而导致停顿时间随之增长。但是对于标记阶段之后的处理,仍未得到妥善解决。CMS 使用标记 - 清除算法,虽然避免了整理阶段收集器带来的停顿,但是清除算法不论如何优化改进,在设计原理上避免不了空间碎片的产生,随着空间碎片不断淤积最终依然逃不过“Stop The
World” 的命运。 G1 虽然可以按更小的粒度进行回收,从而抑制整理阶段出现时间过长的停顿,但毕竟也还是要暂停的。

 

七、Shenandoah收集器与zGC收集器

以内存占用、吞吐量、低延时为目标,Shenandoah与ZGC都具有非常出色的性能,整体来说这两款收集器都处于并发处理,少部分阶段需要停顿,停顿时间Shenandoah是几百毫秒,ZGC达到了十毫秒之内,从JDK11开始可以使用ZGC。

Shenandoah对比G1:

1、基于region堆内存布局,但是取消分代收集

2、整理算法可以与用户线程并发执行

3、取消记忆集,用链接矩阵解决跨代引用

移动对象,涉及到引用的更新,Shenandoah使用了转发指针来解决这一问题,从结构上来看,Brooks提出的转发指针与某些早期Java虚拟机使用过的句柄定位有一些相似之处,两者都是一种间接性的对象访问方式,差别是句柄通常会统一存储在专门的句柄池中,而转发指针是分散存放在每一个对象头前面。修改转发指针到新的对象引用上实现对象移动后地址引用变化的问题,然而对于写需要保持对转发指针修改的并发同步,在多线程场景下,通过CAS保证并发时对象访问的正确性。

ZGC对比G1:

1、采用region堆内存布局,暂时不设分代

2、全程与用户线程并发,少量停顿

3、取消记忆集,使用读屏障、染色指针、内存多重映射技术实现并发

染色指针是在对象头部存放该对象引用关系是否变化的标记,一旦某个Region的存活对象被移走之后,这个Region立即就能够被释放和重用掉,而不必等待整个堆中所有指向该Region的引用都被修正后才能清理。这点相比起Shenandoah是一个颇大的优势,使得理论上只要还有一个空闲RegionZGC就能完成收集,而Shenandoah需要等到引用更新阶段结束以后才能释放回收集中的Region,这意味着堆中几乎所有对象都存活的极端情况,需要1∶1复制对象到新Region的话,就必须要有一半的空闲Region来完成收集。

并发重分配Concurrent Relocate):重分配是ZGC执行过程中的核心阶段,这个过程要把重分

配集中的存活对象复制到新的 Region 上,并为重分配集中的每个 Region 维护一个转发表( Forward
Table ),记录从旧对象到新对象的转向关系。得益于染色指针的支持, ZGC 收集器能仅从引用上就明确得知一个对象是否处于重分配集之中,如果用户线程此时并发访问了位于重分配集中的对象,这次访问将会被预置的内存屏障所截获,然后立即根据Region 上的转发表记录将访问转发到新复制的对象上,并同时修正更新该引用的值,使其直接指向新对象,ZGC 将这种行为称为指针的 自愈 Self-Healing)能力。这样做的好处是只有第一次访问旧对象会陷入转发,也就是只慢一次,对比Shenandoah的 Brooks 转发指针,那是每次对象访问都必须付出的固定开销,简单地说就是每次都慢,因此ZGC 对用户程序的运行时负载要比 Shenandoah 来得更低一些。还有另外一个直接的好处是由于染色指针的存在,一旦重分配集中某个Region 的存活对象都复制完毕后,这个 Region 就可以立即释放用于新对象的分配(但是转发表还得留着不能释放掉),哪怕堆中还有很多指向这个对象的未更新指针也没有关系,这些旧指针一旦被使用,它们都是可以自愈的。
 

 

 

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 5
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 5
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

柯·金

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值