分代收集理论:

首先,我们都知道,JVM在逻辑上,将内存分为了新生代、老年代。新生代中存放着一些“朝生夕死”的对象,而老年代中存放着一些“老顽固”的对象。而分代收集理论就是根据不同的区域,选择合适于该区域的一种垃圾收集算法,这就称之为分代收集算法(不同的代,用不同的算法)。
对于新生代这种“朝生夕死”的对象来说,每一次垃圾收集,都需要回收绝大部分的对象,对于这种情况,可以选用复制算法,只需要复制那些还存活着的对象(极少部分)就可完成本次的垃圾收集,回收成本较低
而对于老年代这种“老顽固”的对象来说,一次垃圾收集,可能并不能回收多少对象(存活率较高),在这种情况下采用复制算法的话,所需要复制的对象较多,成本较高。所以在老年代这种区域中,采用标记整理/标记压缩的方式来进行收集。


垃圾收集算法

复制算法

顾名思义,是将一块内存中的对象,复制到另一块内存中去。那么这就需要将一块内存分成大小相等的两块,每次仅使用其中的一块内存,当进行垃圾回收的时候,将正在使用中还存活的对象,复制到另一块区域中,然后将整片区域进行清理,使用另一块内存,每次垃圾回收,两块内存的角色进行替换。常见的使用场景为JVM新生代中的 survivors0,survivors1区域。 复制算法的示例图如下
image.png

复制算法的缺点

  • 内存利用率不高(仅有一半的内存处于使用状态)
  • 如果对象的存活率很高,我们可以极端一点,假设是100%存活,那么我们需要将所有对象都复制一遍,并将所有引用地址重置一遍。复制这一工作所花费的时间,在对象存活率达到一定程度时,将会变的不可忽视。 所以从以上描述不难看出,复制算法要想使用,最起码对象的存活率要非常低才行,而且最重要的是,我们必须要克服50%内存的浪费。

    标记清除算法

    就是当程序运行期间,若可以使用的内存被耗尽的时候,GC线程就会被触发并将程序暂停,随后将要回收的对象标记一遍,最终统一回收这些对象,完成标记清理工作接下来便让应用程序恢复运行。标记清除算法的示意图如下。
    image.png

    标记清除算法的两个阶段

  • 标记: 从引用根节点开始标记遍历所有的GC Roots, 先标记出要回收的对象。

  • 清除: 遍历整个堆,把标记的对象清除。

    标记清除算法的缺点

  • 需要遍历所有的GC Roots,效率不高,且标记阶段需要STW,用户体验不好

  • 容易产生内存碎片(清除之后没有进行内存的整理)

    标记整理算法

    也叫标记压缩算法,是在标记清理的基础上进行改进,在整理压缩阶段,不再对标记的对像做回收,而是通过所有存活对像都向一端移动,然后直接清除边界以外的内存。通过这个可以看到,标记的存活对象将会被整理,按照内存地址依次排列,而未被标记的内存会被清理掉。如此一来,当我们需要给新对象分配内存时,JVM只需要持有一个内存的起始地址即可,这比维护一个空闲列表显然少了许多开销。
    image.png

    标记整理的优点

      标记/整理算法不仅可以弥补标记/清除算法当中,内存区域分散的缺点,也消除了复制算法当中,内存减半的高额代价

    标记整理的缺点

    标记整理算法的唯一缺点就是效率不高,在标记清除算法的基础上还需要进行内存区域的整理。所以效率比标记清除更低。

    垃圾收集算法总结

    内存效率:
    复制算法>标记清除算法>标记整理算法(此处的效率只是简单的对比时间复杂度,实际情况不一定如此)。
    内存整齐度:
    复制算法=标记整理算法>标记清除算法。
    内存利用率:
    标记整理算法=标记清除算法>复制算法。
    可以看出,效率上来说,复制算法是当之无愧的老大,但是却浪费了太多内存,而为了尽量兼顾上面所提到的三个指标,标记/整理算法相对来说更平滑一些,但效率上依然不尽如人意,它比复制算法多了一个标记的阶段,又比标记/清除多了一个整理内存的过程

常见的垃圾收集器

垃圾收集器与垃圾收集算法 的关系

垃圾收集算法 == 天上飞的理念
垃圾收集器 == 按照垃圾收集算法的思想进行落地的实现

常见的垃圾收集器种类

image.png

1. Serial 串行垃圾收集器 (-XX:+UseSerialGC -XX:+UseSerialOldGC)

串行垃圾收集器是最基础、历史最悠久的收集器,是一个单线程工作的收集器,但它的“单线 程”的意义并不仅仅是说明它只会使用一个处理器或一条收集线程去完成垃圾收集工作,更重要的是强调在它进行垃圾收集时,必须暂停其他所有工作线程(stop the world),直到它收集结束。
新生代采用复制算法,老年代采用标记-整理算法
image.png

JVM的设计者当然知道STW的用户体验问题,在后续的垃圾收集器中(CMS,G1)等垃圾收集器中不断缩短STW的时间。 Serial相比于其他垃圾收集器的优点在于:简单高效,没有线程上下文切换的开销。

Serial Old是串行垃圾收集器的老年代版本,除了与Parallel收集器进行组合使用之外,还可作为 ParNew+ CMS 垃圾收集组合的兜底方案(详见后续的CMS垃圾收集器)

2. Parallel 并行垃圾收集器(-XX:+UseParallelGC(年轻代),-XX:+UseParallelOldGC(老年代))

Parallel 收集器其实就是Serial串行垃圾收集器的多线程版本,除了将垃圾收集的线程数从单线程改成了多线程之外,其余的行为(新生代老年代的收集算法等)与Serial完全相同,Parallel的默认收集线程数为CPU的核数(可以让多个核同时运行),也可通过(-XX:ParallelGCThreads) 参数进行收集线程的修改,但是一般不修改。
Parallel 收集器与其他垃圾收集器所关注的内容不同,Parallel注重于如何高效的利用CPU(让CPU更多的时间在于运行用户代码,而非执行垃圾收集),而其他垃圾收集器(例如CMS,G1等)更关注于STW的时间,减少用户等待的时间,进而提升用户体验
新生代采用复制算法,老年代采用标记-整理算法(Serial相同)

3. ParNew 收集器(-XX:+UseParNewGC)

ParNew收集器与Parallel收集器类似,主要区别在于 ParNew可以与CMS垃圾收集器进行组合。
新生代采用复制算法,老年代采用标记-整理算法
它是许多运行在Server模式下的虚拟机的首要选择,除了Serial收集器外,只有它能与CMS收集器配合工作

4. CMS垃圾收集器(-XX:+UseConcMarkSweepGC) 老年代使用

CMS (Concurrent Mark Sweep)并发标记清理,是真正意义上的第一款并发的垃圾收集器,实现了垃圾收集与用户同时工作(基本上可以同时运行)
从Concurrent Mark Sweep 这个名字中即可看出,CMS垃圾收集器采用的算法为标记清除算法
CMS垃圾收集器主要分为如下的几个步骤:

  • 初始标记:仅扫描GC Roots中的对象,速度很快,会STW
  • 并发标记:和用户线程一起执行,此时不会STW,开始遍历初始标记阶段中的GC Roots所关联的对象,这个过程耗时较长(但是由于用户线程也在执行,将有可能导致标记的对象状态发生改变)。
  • 重新标记:修改在并发标记阶段中由于用户线程运行过程中而导致的标记对象状态发生改变的那一部分对象的标记记录,这个阶段也会STW,会比初始标记时间长,但比并发标记时间短。(修改标记主要采用三色标记中的增量更新)
  • 并发清理:不会STW,GC线程与用户线程一起运行,对三色标记中的垃圾对象进行清理。此时如果产生了新的对象,将会直接标记成黑色,不做处理,等待下一次GC进行回收。(详见三色标记算法)
  • 并发重置:将本次GC所标记的数据进行重置。

image.png
CMS是一款优秀的收集器,它最主要的优点在名字上已经体现出来:并发收集、低停顿,但是其包含如下的几个缺点:

  • CMS垃圾收集器对CPU十分敏感,会占用一部分的线程,降低应用程序的吞吐量(即会和应用程序抢资源)
  • 无法处理浮动垃圾(在并发标记以及并发清理的过程中,由于用户线程还在执行,会导致新的垃圾对象产生,这种垃圾对象只能等到下一次垃圾回收再进行处理。)
  • 会有大量的内存碎片产生,因为CMS采用标记清除算法(标记清除算法的缺点),当然也可以通过 (-XX:+UseCMSCompactAtFullCollection)参数执行Full GC之后进行内存空间的整理
  • 可能会导致 “concurrent mode failure”,并发收集失败的情况(有可能上一次CMS收集处于并发清理/并发标记的阶段,由于用户线程的执行产生了新的垃圾对象,再次产生了GC。就产生了还没回收完成,又需要再一次回收的情况),针对于这种情况,将会 “Stop The World”,停止掉用户线程,转而使用 Serial Old垃圾收集器,进行老年代的回收(这也就是最开始图中所示的红线所代表的意思)。

    CMS的核心JVM参数

  1. -XX:+UseConcMarkSweepGC:启用cms
  2. -XX:ConcGCThreads:并发的GC线程数
  3. -XX:+UseCMSCompactAtFullCollection:FullGC之后做压缩整理(减少碎片)
  4. -XX:CMSFullGCsBeforeCompaction:多少次FullGC之后压缩一次,默认是0,代表每次FullGC后都会压缩一次
  5. -XX:CMSInitiatingOccupancyFraction: 当老年代使用达到该比例时会触发FullGC(默认是92,这是百分比)
  6. -XX:+UseCMSInitiatingOccupancyOnly:只使用设定的回收阈值(-XX:CMSInitiatingOccupancyFraction设定的值),如果不指定,JVM仅在第一次使用设定值,后续则会自动调整(设置得太高将会很容易导致 大量的并发失败产生,性能反而降低,用户应在生产环境中根据实际应用情况来权衡设置
  7. -XX:+CMSScavengeBeforeRemark:在CMS GC前启动一次minor gc,目的在于减少老年代对年轻代的引用,降低CMS GC的标记阶段时的开销,一般CMS的GC耗时 80%都在标记阶段
  8. -XX:+CMSParallellnitialMarkEnabled:表示在初始标记的时候多线程执行,缩短STW
  9. -XX:+CMSParallelRemarkEnabled:在重新标记的时候多线程执行,缩短STW;

三色标记算法

为了解决在并发标记过程中,由于用户程序还在执行过程中,而导致对象的引用状态发生改变,可能会导致多标,漏标的情况发生。从而引入三色标记,将从GC Roots遍历对象过程中,按照“是否访问过”来分为三种颜色

  • 白色:表示这个对象还没有被垃圾收集器扫描过,在可达性分析算法刚开始时,所有的对象都是白色的,在分析结束之后,如果这个对象还是白色的,说明这个对象不可达,可以进行垃圾回收
  • 黑色:表示这个对象已经被垃圾收集器扫描过,且这个对象所关联的对象也都已经扫描过,即黑色的对象是安全存活的,如果有其他对象指向了黑色对象,则无需再去扫描黑色对象。
  • 灰色:表示这个对象已经被垃圾收集器扫描过,但是这个对象所关联的对象还没有被完全扫描过(即至少还有一个对象没有被扫描过)。

    多标(浮动垃圾)

    原先被标记为非垃圾对象的对象由于程序运行,导致此时已经成为了垃圾对象,但是由于被标记为非垃圾对象,本次GC不会进行回收,变成了“浮动垃圾”,等待下一次垃圾回收进行处理。

    漏标(对象消失)

    由于程序运行,导致的对象引用状态改变,原本不应该回收的对象被当做了垃圾对象回收。

    Wilson于1994年在理论上证明了,当且仅当以下两个条件同时满足时,会产生“对象消失”的问 题,即原本应该是黑色的对象被误标为白色: 1.·赋值器插入了一条或多条从黑色对象到白色对象的新引用; 2.·赋值器删除了全部从灰色对象到该白色对象的直接或间接引用。 针对于上面这种情况可以大致理解为这 1.A为黑色对象,一开始A.C = null,后面新增 A.C = new C(); 2.B为灰色对象,垃圾收集扫描时B.C还没有进行扫描,此时C为白色对象,此时用户线程执行 B.C = null,断开了灰色对象B到白色对象C的引用

所以我们要解决上述的情况的话,有两种解决方案: 增量更新(Incremental Update) 以及 原始快照 (SnapShot At The Beginning,SATB)
增量更新是用来解决第一种情况的,当赋值器插入一个新的引用,将会把这个黑色对象给记录下来,等待并发标记结束之后,再讲这些黑色对象为Root,重新扫描一遍。可以理解为,黑色对象插入一个白色引用,这个黑色对象就变成了灰色对象
原始快照是用来解决第二种情况的,当灰色对象删除一个白色对象的引用时,会将这个灰色对象给记录下来,等待并发标记结束之后,再将这些灰色对象拿出来,重新扫描一遍,将扫描到的白色对象直接修改为黑色,让其通过本次的垃圾收集,等待下次的垃圾收集再进行重新的扫描 (这个对象有可能是浮动垃圾)。

无论上上述哪种情况,在虚拟机中都是通过 写屏障 来实现的。

写屏障

给属性赋值时写屏障的大概代码如下(类似于Spring的AOP,在执行真正的逻辑前后进行一些预操作)

  1. void oop_field_store(oop* field, oop new_value) {
  2. // 写前屏障
  3. pre_write_barrier(field, new_value);
  4. *field = new_value;
  5. // 写后屏障
  6. post_write_barrier(field, new_value);
  7. }

增量更新写屏障(在赋值之后的操作)

  1. void post_write_barrier(oop* field, oop new_value) {
  2. // 把这个字段/值添加到一个集合中,待结束之后再去遍历
  3. list.add(field);
  4. }

SATB写屏障(赋值之前的操作)

  1. void pre_write_barrier(oop* field) {
  2. // 把这个字段/值添加到集合中。
  3. list.add(field)
  4. }

针对于垃圾收集器中有并发,对应的处理方法为:

CMS是基于增量更新 来做并发标记的,
G1、Shenandoah则是用原始快照来实现。

问题:

为什么G1使用SATB,CMS使用增量更新?

因为G1分为了许多的region,如果G1使用增量更新的话,在并发结束之后,还需要深度遍历所有的Region,成本相比于CMS较高,所以G1使用SATB。而CMS仅有一个老年代,深度遍历的成本较低。(SATB的浮动垃圾更多,时间效率更高,增量更新相反)


记忆集和卡表

记忆集和卡表主要解决的问题为跨代引用的问题(在GC Roots扫描时,可能引用到老年代的对象,此时再去遍历老年代效率不高)
针对于上面这种情况,虚拟机在新生代中引入了记忆集(Remerber Set)这个数据结构,记录非收集区到收集区的指针集合,避免再去遍历整个老年代。
实际上不只新生代与老年代之间存在跨代引用的问题,所有涉及到部分区域回收的垃圾收集器(G1等)都会存在这样的问题。
引入记忆集之后,在垃圾收集时,只需要通过记忆集判断这块非收集区域中是否存在收集区域的指针即可,而不需要了解跨代引用。
在HotSpot中,使用卡表(Card Table)的方式来实现记忆集。(记忆集==理念,卡表==落地实现)
卡表是通过字节数组来实现的(CARD_TABLE[]),字节数组CARD_TABLE的每一个元素都对应着其标识的内存区域中一块特定大小的内存块,这个 内存块被称作“卡页”(Card Page),默认是2的9次方,即512字节
image.png
一个卡页的内存中通常包含不止一个对象,只要卡页内有一个(或更多)对象的字段存在着跨代 指针,那就将对应卡表的数组元素的值标识为1,称为这个元素变脏(Dirty),没有则标识为0。在垃 圾收集发生时,只要筛选出卡表中变脏的元素,就能轻易得出哪些卡页内存块中包含跨代指针,把它 们加入GC Roots中一并扫描
在HotSpot虚拟机里是通过写屏障(Write Barrier)技术维护卡表状态的