《深入理解Java虚拟机》 — 学习笔记

参考资料:

深入理解JAVA虚拟机(第三版)_JVM高级特性与最佳实践—周志明

JVM安全点介绍


根节点枚举STW

迄今为止,所有收集器在GC Root根节点枚举这一步骤时都是必须暂停用户线程的,因此毫无疑问根节点枚举与整理内存碎片一样会面临相似的“Stop The World”的困扰

现在可达性分析算法耗时最长的查找引用链的过程已经可以与用户线程一起并发,但根节点枚举始终还是必须在一个能保障一致性的快照中才得以进行(不会出现分析过程中根节点集合的对象引用关系还在不断变化的情况)


安全点

安全点就是指代码中一些特定的位置,当线程运行到这些位置时它的状态是确定的,这样JVM就可以安全的进行一些操作,比如GC.
这些特定的位置主要有几下几种:

  1. 方法返回之前
  2. 调用某个方法之后
  3. 抛出异常的位置
  4. 循环的末尾

safepoint的使用场景

  1. 垃圾回收(这是最常见的场景)
  2. 取消偏向锁(JVM会使用偏向锁来优化锁的获取过程)
  3. Class重定义(比如常见的hotswap和instrumentation)
  4. Code Cache Flushing(JDK1.8在CodeCache满的情况下就可能出现)
  5. 线程堆栈转储(jstack命令)

对于安全点,另外一个需要考虑的问题是,如何在垃圾收集发生时让所有线程(这里其实不包括执行 JNI 调用的线程)都跑到最近的安全点,然后停顿下来。这里有两种方案可供选择:抢先式中断( Preemptive Suspension )和主动式中断( Voluntary Suspension ),抢先式中断不需要线程的执行代码主动去配合,在垃圾收集发生时,系统首先把所有用户线程全部中断,如果发现有用户线程中断的地方不在安全点上,就恢复这条线程执行,让它一会再重新中断,直到跑到安全点上。现在几乎没有虚拟机实现采用抢先式中断来暂停线程响应 GC 事件。

而主动式中断的思想是当垃圾收集需要中断线程的时候,不直接对线程操作,仅仅简单地设置一个标志位,各个线程执行过程时会不停地主动去轮询这个标志,一旦发现中断标志为真时就自己在最近的安全点上主动中断挂起。轮询标志的地方和安全点是重合的,另外还要加上所有创建对象和其他需要在 Java 堆上分配内存的地方,这是为了检查是否即将要发生垃圾收集,避免没有足够内存分配新对象。


记忆集与卡表、写屏障

讲解分代收集理论的时候,提到了为解决对象跨代引用所带来的问题,垃圾收集器在新生代中建立了名为记忆集( Remembered Set )的数据结构,用以避免把整个老年代加进 GC Roots 扫描范围。事实上并不只是新生代、老年代之间才有跨代引用的问题,所有涉及部分区域收集( Partial GC )行为的垃圾收集器,典型的如 G1 、 ZGC 和 Shenandoah 收集器,都会面临相同的问题,因此我们有必要进一步理清记忆集的原理和实现方式,以便在后续章节里介绍几款最新的收集器相关知识时能更好地理解。

记忆集是一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构。如果我们不考虑效率和成本的话,==最简单的实现可以用非收集区域中所有含跨代引用的对象数组来实现这个数据结构==。

下面列举了一些可供选择的记录精度:

  1. 字节精度:
  2. 对象精度:
  3. 卡精度:

其中,第三种“卡精度”所指的是用一种称为卡表的方式去实现记忆集,这也是目前最常用的一种记忆集实现形式。

写屏障:可以看作在虚拟机层面对“引用类型字段赋值”这个动作的AOP切面


并发可达性分析—三色标记

  • 白色:表示对象尚未被垃圾收集器访问过。显然在可达性分析刚刚开始的阶段,所有的对象都是白色的,若在分析结束的阶段,仍然是白色的对象,即代表不可达。

  • 黑色:表示对象已经被垃圾收集器访问过,且这个对象的所有引用都已经扫描过。黑色的对象代表已经扫描过,它是安全存活的,如果有其他对象引用指向了黑色对象,无须重新扫描一遍。黑色对象不可能直接(不经过灰色对象)指向某个白色对象。

  • 灰色:表示对象已经被垃圾收集器访问过,但这个对象上至少存在一个引用还没有被扫描过。

并发出现“对象消失”问题示意:

Wilson 于 1994 年在理论上证明了,当且仅当以下两个条件同时满足时,会产生“对象消失”的问题,即原本应该是黑色的对象被误标为白色:

  • 赋值器插入了一条或多条从黑色对象到白色对象的新引用;
  • 赋值器删除了全部从灰色对象到该白色对象的直接或间接引用。

因此,我们要解决并发扫描时的对象消失问题,只需破坏这两个条件的任意一个即可。由此分别产生了两种解决方案:增量更新( Incremental Update )和原始快照( Snapshot At The Beginning , SATB )

增量更新要破坏的是第一个条件,当黑色对象插入新的指向白色对象的引用关系时,就将这个新插入的引用记录下来,等并发扫描结束之后,再将这些记录过的引用关系中的黑色对象为根,重新扫描一次。这可以简化理解为,黑色对象一旦新插入了指向白色对象的引用之后,它就变回灰色对象了。

原始快照要破坏的是第二个条件,当灰色对象要删除指向白色对象的引用关系时,就将这个要删除的引用记录下来,在并发扫描结束之后,再将这些记录过的引用关系中的灰色对象为根,重新扫描一次。这也可以简化理解为,无论引用关系删除与否,都会按照刚刚开始扫描那一刻的对象图快照来进行搜索。

以上无论是对引用关系记录的插入还是删除,虚拟机的记录操作都是通过写屏障实现的。在 HotSpot 虚拟机中,增量更新和原始快照这两种解决方案都有实际应用,譬如, CMS 是基于增量更新来做并发标记的, G1 、 Shenandoah 则是用原始快照来实现。


垃圾收集器

并行( Parallel ):并行描述的是多条垃圾收集器线程之间的关系,说明同一时间有多条这样的线程在协同工作,通常默认此时用户线程是处于等待状态。
并发( Concurrent ):并发描述的是垃圾收集器线程与用户线程之间的关系,说明同一时间垃圾收集器线程与用户线程都在运行。由于用户线程并未被冻结,所以程序仍然能响应服务请求,但由于垃圾收集器线程占用了一部分系统资源,此时应用程序的处理的吞吐量将受到一定影响。

Serial(串行)收集器

单线程工作的收集器,但它的“单线程”的意义并不仅仅是说明它只会使用一个处理器或一条收集线程去完成垃圾收集工作,更重要的是强调在它进行垃圾收集时,必须暂停其他所有工作线程,直至它收集结束。

Serial + Serial Old:
在这里插入图片描述


ParNew收集器

ParNew收集器实质上是Serial收集器的多线程并行版本,除了支持多线程并行收集之外,其他与Serial收集器相比并没有太多创新之处,但它确实不少运行在服务端模式夏的HotSpot虚拟机,尤其是JDK7之前的遗留系统中首选的新生代收集器,其中有一个重要原因是除了Serial收集器外,目前只有它能与CMS收集器配合工作。


Parallel Scavenge收集器(吞吐量优先收集器)

Parallel Scavenge收集器也是一款新生代收集器,同样是基于标记-复制算法实现,也是能够并行收集的多线程收集器,这些特性跟ParNew非常相似,但它的特别之处在于它的关注点与其他收集器不同,==CMS等收集器的关注点是尽可能缩短STW的时间,而Parallel Scavenge收集器的目标则是达到一个可控制的吞吐量==。
在这里插入图片描述


Serial Old收集器

Serial Old 是 Serial 收集器的老年代版本,它同样是一个单线程收集器,使用标记 - 整理算法。这个收集器的主要意义也是供客户端模式下的 HotSpot 虚拟机使用。

如果在服务端模式下,它也可能有两种用途:一种是在 JDK 5 以及之前的版本中与 Parallel Scavenge 收集器搭配使用 ,另外一种就是作为 CMS 收集器发生失败时的后备预案,在并发收集发生 Concurrent Mode Failure 时使用。


Parllel Old收集器

Parllel Old收集器是Parallel Scavenge收集器的老年代版本,基于标记-整理算法实现。

==在注重吞吐量或者处理器资源较为稀缺的场合,都可以优先考虑Parallel Scavenge + Parallel Old收集器这个组合==

Parallel Scavenge + Parallel Old:
在这里插入图片描述

CMS(Concurrent Mark Sweep)收集器

CMS 收集器是一种以获取最短回收停顿时间为目标的收集器。目前很大一部分的 Java 应用集中在互联网网站或者基于浏览器的 B/S 系统的服务端上,这类应用通常都会较为关注服务的响应速度,希望系统停顿时间尽可能短,以给用户带来良好的交互体验。 CMS 收集器就非常符合这类应用的需求。

从名字(包含“Mark Sweep” )上就可以看出 CMS 收集器是基于标记 - 清除算法实现的,它的运作过程相对于前面几种收集器来说要更复杂一些,整个过程分为四个步骤,包括:

  1. 初始标记
  2. 并发标记
  3. 重新标记
  4. 并发清除

其中初始和重新标记这两个步骤仍然需要STW,==初始标记仅仅是标记一下GC Roots能直接关联到的对象,速度很快==;并发标记阶段并发可达性分析)就是从 GC Roots 的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行;而重新标记阶段则是为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录(增量更新),这个阶段的停顿时间通常会比初始标记阶段稍长一些,但也远比并发标记阶段的时间短;最后是并发清除阶段,清理删除掉标记阶段判断的已经死亡的对象,由于不需要移动存活对象(产生内存碎片),所以这个阶段也是可以与用户线程同时并发的。

在这里插入图片描述
CMS不足之处:

  1. 会产生空间碎片: 既然CMS是基于标记清除算法实现的,那就无法避免产生不连续的内存空间(空间碎片)
  2. 无法处理浮动垃圾: 在 CMS 的并发标记和并发清理阶段,用户线程是还在继续运行的,程序在运行自然就还会伴随有新的垃圾对象不断产生,但这一部分垃圾对象是出现在标记过程结束以后, CMS 无法在当次收集中处理掉它们,只好留待下一次垃圾收集时再清理掉。这一部分垃圾就称为“浮动垃圾”。

Garbage First(G1)收集器

==Garbage First 收集器是垃圾收集器技术发展历史上的里程碑式的成果,它开创了收集器面向局部收集的设计思路和基于 Region 的内存布局形式。==

在 G1 收集器出现之前的所有其他收集器,包括 CMS 在内,垃圾收集的目标范围要么是整个新生代( Minor GC ),要么就是整个老年代( Major GC ),再要么就是整个 Java 堆( Full GC )。而 G1 跳出了这个樊笼,它可以面向堆内存任何部分来组成回收集( Collection Set ,一般简称 CSet ) 进行回收,衡量标准不再是它属于哪个分代,而是哪块内存中存放的垃圾数量最多,回收收益最大,这就是 G1 收集器的 Mixed GC 模式

G1 开创的基于 Region 的堆内存布局是它能够实现这个目标的关键。虽然 G1 也仍是遵循分代收集理论设计的,但其堆内存的布局与其他收集器有非常明显的差异: G1 不再坚持固定大小以及固定数量的分代区域划分,而是把连续的 Java 堆划分为多个大小相等的独立区域( Region ),每一个 Region 都可以根据需要,扮演新生代的 Eden 空间、 Survivor 空间,或者老年代空间。收集器能够对扮演不同角色的 Region 采用不同的策略去处理,这样无论是新创建的对象还是已经存活了一段时间、熬过多次收集的旧对象都能获取很好的收集效果。

Region 中还有一类特殊的 Humongous 区域,专门用来存储大对象。 G1 认为只要大小超过了一个 Region 容量一半的对象即可判定为大对象。而对于那些超过了整个 Region 容量的超级大对象,将会被存放在 N 个连续的 Humongous Region 之中, G1 的大多数行为都把 Humongous Region 作为老年代的一部分来进行看待

==G1 收集器之所以能建立可预测的停顿时间模型,是因为它将 Region 作为单次回收的最小单元,即每次收集到的内存空间都是 Region 大小的整数倍,这样可以有计划地避免在整个 Java 堆中进行全区域的垃圾收集。==

更具体的处理思路是让 G1 收集器去跟踪各个 Region 里面的垃圾堆积的“价值”大小,价值即回收所获得的空间大小以及回收所需时间的经验值,然后在后台维护一个优先级列表,每次根据用户设定允许的收集停顿时间(使用参数 -XX : MaxGCPauseMillis 指定,默认值是 200 毫秒),优先处理回收价值收益最大的那些 Region ,这也就是“Garbage First” 名字的由来。这种使用 Region 划分内存空间,以及具有优先级的区域回收方式,保证了 G1 收集器在有限的时间内获取尽可能高的收集效率
在这里插入图片描述
需要解决的细节问题:

Q:Region 里面存在的跨 Region 引用对象如何解决?

解决的思路我们已经知道:使用记忆集避免全堆作为 GC Roots 扫描,但在 G1 收集器上记忆集的应用其实要复杂很多,==它的每个 Region 都维护有自己的记忆集==,这些记忆集会记录下别的 Region 指向自己的指针,并标记这些指针分别在哪些卡页的范围之内。G1 的记忆集在存储结构的本质上是一种哈希表, Key 是别的 Region 的起始地址, Value 是一个集合,里面存储的元素是卡表的索引号。 这种“双向”的卡表结构(卡表是“我指向谁”,这种结构还记录了“谁指向我”)比原来的卡表实现起来更复杂,同时由于 Region 数量比传统收集器的分代数量明显要多得多,因此 G1 收集器要比其他的传统垃圾收集器有着更高的内存占用负担。根据经验, G1 至少要耗费大约相当于 Java 堆容量 10% 至 20% 的额外内存来维持收集器工作

Q:如何保证并发可达性分析?

CMS通过增量更新,G1通过原始快照。此外,垃圾收集对用户线程的影响还体现在回收过程中新创建对象的内存分配上,程序要继续运行就肯定会持续有新对象被创建, G1 为每一个 Region 设计了两个名为 TAMS ( Top at Mark Start )的指针,把 Region 中的一部分空间划分出来用于并发回收过程中的新对象分配,并发回收时新分配的对象地址都必须要在这两个指针位置以上。 G1 收集器默认在这个地址以上的对象是被隐式标记过的,即默认它们是存活的,不纳入回收范围。

==G1收集器的运作过程大致可划分为以下四个步骤:==

  1. 初始标记( Initial Marking ):仅仅只是标记一下 GC Roots 能直接关联到的对象,并且修改 TAMS 指针的值,让下一阶段用户线程并发运行时,能正确地在可用的 Region 中分配新对象。这个阶段需要停顿线程,但耗时很短,而且是借用进行 Minor GC 的时候同步完成的,所以 G1 收集器在这个阶段实际并没有额外的停顿。
  2. 并发标记( Concurrent Marking ):从 GC Root 开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行。当对象图扫描完成以后,还要重新处理 SATB 记录下的在并发时有引用变动的对象。
  3. 最终标记( Final Marking ):对用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的最后那少量的 SATB 记录。
  4. 筛选回收( Live Data Counting and Evacuation ):负责更新 Region 的统计数据,对各个 Region 的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个 Region 构成回收集,然后把决定回收的那一部分 Region 的存活对象复制到空的 Region 中,再清理掉整个旧 Region 的全部空间。这里的操作涉及存活对象的移动,是必须暂停用户线程,由多条收集器线程并行完成的。
    在这里插入图片描述