JVM笔记(三)垃圾收集算法以及HotSpot的算法实现(安全点、记忆集与卡表、写屏障、三色标记等)
一、垃圾收集算法
1.1 堆的布局
分为新生代(Young Generation)和老年代(Old Generation)
其中新生代又分为:
- Eden区
- survivor0(from)区
- survivor1(to)区
其中 新生代三个区的比例默认为: 8:1:1
可通过**-XX:SurvivorRatio**参数调整,如-XX:SurvivorRatio=9
1.2 GC类型
-
Minor GC(Young GC)
对新生代进行GC
-
Major GC(Old GC)
对老年代进行GC。目前只有CMS收集器能够单独收集老年代对象。
-
Full GC
对整个堆和方法区进行GC
1.2.1 Minor GC流程
-
eden和from区复制到to区
eden区满时触发第一次GC,将eden和from区活着的对象复制到to区,清理eden和from区的空间,同时将到to区的对象年龄+1,但如果已经到达到老年代的阈值(默认15,可通过-XX:MaxTenuringThreshold参数调整),则直接转移到老年代
-
交换from和to区
from和to区不是固定的,每次minorGC后,两个survivor区空闲的一块作为to区,非空闲的一块作为from区
1.2.2 分配担保
Minor GC 的步骤1中,出现了to区不足以储存活下来的对象,则这些对象直接被转移到老年代,这个过程就是空间分配担保。
JDK8以后,在进行Minor GC前,如果老年代的连续空间大于新生代对象大小总和或历次晋升的平均大小,则进行Minor GC,否则进行Full GC。
如果Full GC后仍然内存不足,则抛出OOM.
1.3 内存分配策略
-
对象优先在Eden区分配
-
大对象进入老年代
使用
-XX:PretenureSizeThreshold
参数指定大于该值的对象直接进入老年代,值单位必须是字节,不能使用M或K,如使用2,097,152表示2M -
长期存活的对象进入老年代
使用
-XX:MaxTenuringThreshold
参数设置大于该年龄的对象进入老年代 -
在survivor空间中相同年龄所有对象总和大于survivor空间一半,则年龄大于等于该年龄的对象直接进入老年代
只要满足该条件,则无需达到
-XX:MaxTenuringThreshold
参数指定的年龄 -
MinorGC时,如果to区无法满足存活下的对象的内存需求,则将其分配到老年代
二、垃圾收集算法
2.1 标记-清除算法
标记出所有要回收的对象, 统一回收被标记的对象。
缺点:
- 执行效率不稳定
如果对象过多,则会进行大量的标记清除,效率与对象数量成反比。
- 造成内存碎片化
很明显,标记清除后一定会出现很多不连续的内存空间,此为内存碎片。如果有一个大对象,虽然总内存足够,但因过于碎片化,找不到连续的空间分配给大对象,就会造成一次GC,影响性能。
如下图:
2.2 标记-复制算法
将内存分为大小相等的两块,只使用一块。
当一块用完了,就将存活的对象复制到另一块,并回收这一块使用的内存。
因此。当存活对象过多时,复制的开销非常大。
而大量对象需要回收时,只需要对半个堆的少量对象进行复制,简单而高效,且不会产生碎片。
缺点:
可用内存缩小一半,空间浪费。
如下图:
但是Serial、ParNew等新生代收集器并没有采用这种1:1的划分策略,而是按照上面讲的8:1的比例进行标记-复制回收。
2.3 标记-整理算法
而使用标记-整理算法,该算法也是先对可回收对象进行标记,然后将所有存活的对象向内存的一端移动,再清理掉边界以外的所有内存。
如下图回收前和回收后的对比:
但是老年代中每次回收都有大量对象,移动后还需要更新对象的引用,且需要Stop The World(暂停用户程序)
与标记-复制相比:
1.标记整理需要移动,回收时较复杂,标记-复制不需要移动,但分配时较复杂(大量碎片)
2.复制的停顿时间短,整理停顿长
3.标记-整理算法吞吐量高
虽然标记-复制不需要移动效率高,但内存分配和访问比垃圾收集频率高得多,耗时增加,总体吞吐量仍然是下降的。
三、HotSpot的算法细节实现
3.1 根节点枚举
- 迄今为止,所有收集器在根节点枚举这一步骤都必须暂停用户线程,会面临Stop The World的困扰
- 当前主流的虚拟机都使用准确是垃圾收集,因此虚拟机有办法直接得到哪些地方存在对象引用。HotSpot使用一组OopMap的数据结构来达到这个目的
- 一旦类加载动作完成,HotSpot 就会把对象内什么偏移量上是什么类型的数据计算出来(在即时编译过程中,也会在特定的位置记录下栈和寄存器中哪些位置是引用),这样收集器在扫描时就能直接得知这些信息,不必从方法区等 GC Roots 开始查找,提高查找效率。
3.2 安全点
HotSpot没有为每个指令都生成OopMap,前面提到的在“特定的位置”记录下这些信息,这些位置称为安全点。
强制用户程序必须执行到安全点才能够暂停。就像高速开车只能在服务区停车休息一样。
- 安全点太少,会让收集器等候过久
- 安全点太多,会过分增大运行时的内存负荷
- 安全点的选取以是否具有让程序长时间执行的特征
- “长时间执行”最明显的特征是:指令列的复用,如方法调用、循环跳转、异常跳转等,具有这种功能的指令才会产生安全点。
如何在垃圾收集时让所有线程跑到最近的安全点停顿下来
-
抢先式中断
垃圾收集时,首先把所有用户线程全部中断,如果用户线程没有在安全点,就恢复该线程,直到到达安全点。
几乎不再使用该方式。
-
主动式中断
a. 垃圾收集需要中断线程时,仅简单地设置一个标志位,各线程不停地主动轮询该标志,一旦发现中断标志位为真,就在最近的安全点停下
b. 轮询标志是和安全点重合的
c. hotSpot使用内存保护陷阱的方式把轮询操作精简到只有一条汇编指令。
3.3 安全区域
当程序“不执行”时,如用户线程在Sleep或Blocked,线程无法响应中断到安全点挂起自己,因此需要借助安全区域。
安全区域就是能够确保在某段代码中,引用关系不会发生变化,因此在这个区域任意位置进行GC都是安全的。(可理解为被扩展拉伸的安全点)
原理
3.4 记忆集与卡表
为了解决跨代引用问题,在新生代引入的记录集(Remember Set)的数据结构(记录从非收集区到收集区的指针集合),避免把整个老年代加入GCRoots扫描范围。
垃圾收集场景中,收集器只需通过记忆集判断出某一块非收集区域是否存在指向收集区域的指针即可,无需了解跨代引用指针的全部细节。
3.4.1 记忆集的实现
可以采用不同的记录粒度,以节省记忆集的存储和维护成本,如:
- 字长精度:每个记录精确到一个机器字长(处理器的寻址位数,如常见的 32 位或 64 位),该字包含跨代指针
- 对象精度:每个记录精确到一个对象,该对象中有字段包含跨代指针
- 卡精度:每个记录精确到一块内存区域,该区域中有对象包含跨代指针
卡表
第三种卡精度是使用一种叫做“卡表”的方式实现记忆集,也是目前最常用的一种方式
记忆集是一种抽象概念,卡表是它的实现方式。它记录了记忆集的记录精度、与堆内存的映射关系等。
卡表是使用一个字节数组实现:CARD_TABLE[this addredd >>9]=0
,每个元素对应着其标识的内存区域一块特定大小的内存块,称为“卡页”。
hotSpot使用的卡页是2^9大小,即512字节
一个卡页中可包含多个对象,只要有一个对象的字段存在跨代指针,其对应的卡表的元素标识就变成1,表示该元素变脏,否则为0.
GC时,只要筛选卡表中变脏的元素加入GCRoots。
3.5 写屏障
3.5.1 卡表的维护
卡表变脏上面已经说了,但是需要知道如何让卡表变脏,即发生引用字段赋值时,如何更新卡表对应的标识为1.
hotSpot使用写屏障维护卡表状态。
可看做在虚拟机层面对“引用类型字段赋值”动作的AOP切面,在赋值时产生一个环形通知。赋值前后都属于写屏障,赋值前称为“写前屏障(Pre-Write Barrier)”,赋值后称为“写后屏障(Post-Write Barrier)”。
3.5.2 写屏障的问题
-
虚拟机会为所有赋值操作生成相应的指令,一旦收集器在写屏障中增加了更新卡表操作,无论更新的是不是老年代对新生代的引用,每次只要对引用进行更新,就会产生额外的开销。
-
伪共享问题
并发场景下,当多个互相独立的变量被读取到一个缓存行时,会影响性能。
伪共享参考:https://blog.****.net/weixin_43696529/article/details/104884373解决:
-
加条件
只有卡表元素未被标记时才将其标记为变脏。
- JDK7以后,使用
-XX:+UseCondCardMark
参数设定是否开启卡表更新时的条件判断
- JDK7以后,使用
开启条件自然会增加一个判断开销,但能够避免伪共享问题。根据实际来。
-
3.6 并发的可达性分析
可达性分析工作必须要在能保障一致性的快照中进行,因此必须停止用户进程。
如果用户进程被停止,那不会产生任何问题。
但如果用户进程和GC进程并发进行,就会出现两种后果:
- 错误地标记已经消亡的对象
- 将存活的对象标记为消亡
3.6.1 三色标记
使用三色标记来解释上述问题:
白色: 对象未被收集器访问(未扫描)
黑色:对象已被收集器访问,且该对象所有引用已扫描过。(安全存活)(扫描完毕)
灰色:对象被访问过,但对应至少还有一个引用没有扫描过。(正在扫描)
但如果用户线程在并发标记进行时修改了引用关系,如下情况会出现存活对象消亡的现象:
如上图:
- 原本引用关系为:A->B,B->C
- 扫描到B时,用户线程取消了B到C的引用,反而添加了一条从已扫描过的对象A到对象C的引用
- 2的情况就会造成对象C不被扫描到,而C本应该是存活的,却在这个情况下意外地被标记为”死亡“
同理,当标记到该图的B时。取消 了B到C的引用,添加了A到D的引用,但因为A已经标记过,因此D不会再被扫描,C也不会被扫描,这样D和C也因用户线程的修改“意外死亡”,这就是“对象消失“的问题。
对象消失的原因
- 添加了一条或多条从黑色到白色的新引用
- 删除了全部从灰色到白色的旧引用(直接or间接)
对象消失的解决
原因1和2分别对应两个解决方案:
-
增量更新(CMS用到)
记录下新插入的引用,并发扫描完毕后,重新以记录下的引用关系的黑色对象为根扫描。
即黑色一旦插入了新的到白色的引用,就变成了灰色。
-
原始快照(G1和Shenandoah)
灰色对象要删除指向白色对象的引用时,将该引用记录下来,扫描完毕后,再从被记录下的引用的灰色对象开始重新扫描。
HotSpot主要采用直接指针进行对象访问。
======================================================================
其他相关笔记:
JVM笔记(一)java内存区域与内存溢出以及对象的创建、布局和定位
JVM笔记(二)对象的生死与java的四大引用
JVM笔记《四》七个常见的垃圾收集器
================================================================
参考:
《深入理解java虚拟机第三版》