深入学习JVM:(4) 垃圾收集算法与垃圾收集器

268 阅读13分钟

一. 前言

今天总结与分享的是垃圾收集算法与垃圾收集器. 有了前几篇的文章的铺垫, 我们知道, 这些知识是Jvm调优的前提, 也是面试时高频提问的重点. 其实说白了, Jvm调优就是尽量减少Full gc, 因为它非常耗时. 而触发Full gc的前提大多是堆内存中的老年代满了, 所以我们需要通过选择垃圾收集器, 调整Jvm内存分代区域大小等方法让大多数对象不进入老年代, 使之在Minor gc时就被回收掉.

二. 垃圾收集算法

Jvm中的垃圾收集算法总共有四种: 复制算法、标记清除算法、标记整理算法、分代收集算法

1. 复制算法

个人习惯, 先上图:

垃圾收集 复制算法.png

可以看到, 复制算法就是把一整块内存, 平分成两半. 使用时, 只使用其中之一, 垃圾收集时, 直接将有用的对象复制到另一半内存中, 然后清空掉之前使用的这一半内存.

优点: 效率十分之高 缺点: 内存空间使用率低, 能使用的内存只有一半, 1个G就剩512M了.

2. 标记清除算法

图:

垃圾收集 标记清除算法.png

我们的标记清除算法很直观, 就是将无用对象标记一下, 然后清除.

优点: 简单 缺点: ① 空间使用率低(几次垃圾收集后, 会产生大量不连续的内存碎片) ② 效率问题(如果内存较大, 需要收集的对象较多, 那么标记与收集将会比较耗时)

3. 标记整理算法

图:

垃圾收集 标记整理算法.png

其实标记整理算法和标记清除算法差不多, 只不过它是标记出有用的对象, 然后将这些对象向内存的一端移动. 说不上优缺点, 但肯定, 效率是没有复制算法高的.

####4. 分代收集算法

这其实不是一种算法, 就是之前说的将内存分为不同的区域, 再针对这些区域采用不同的垃圾收集算法. 分区域是指年轻代和老年代, 年轻代又分为3块儿, eden区和2个survivor区. 一般情况下, 年轻代会采用复制算法, 因为有多个区域, 复制算法效率上很占优势. 又因为2个survivor区总共只占用了年轻代的2/10(默认情况下), 所以空间上也没有很大的问题. 老年代一般就是采用标记整理或标记清除算法.

三. 垃圾收集器

常见及常用的垃圾收集器总共有如下五种: Serial收集器、Parallel收集器、ParNew收集器、CMS收集器G1收集器 其中CMS收集器和G1收集器是面试提问的重点, 下面我就来一一描述一下这些垃圾收集器的特点吧.

1. Serial垃圾收集器

图:

垃圾收集 Serial垃圾收集.png

Serial垃圾收集器收集垃圾, 会STW(stop the world), 停止掉用户的所有工作线程, 然后开启一个线程收集垃圾. 收集完毕后, 再恢复用户线程的运行.

Serial垃圾收集器可以用在年轻代, 也可以用在老年代. 这是很多年前才会使用的垃圾收集器, 因为它停掉用户线程, 然后却只开了一个线程收集垃圾, 效率可想而知.

如果要使用该垃圾收集器, 可以配置如下参数: 年轻代算法: 复制算法; 年轻代参数: -XX:+UseSerialGC
老年代算法: 标记整理算法; 老年代参数: -XX:+UseSerialOldGC

2. Parallel垃圾收集器

图:

垃圾收集 Parallel垃圾收集.png

为了优化Serial垃圾收集器的性能, 出现了Parallel垃圾收集器. 但其实并没有提供很高深的优化, 可以看出, 和Serial垃圾收集器过程几乎是一样的, 只不过是在垃圾收集时多加了几个线程.

可以用在年轻代, 也能用在老年代

如果要使用该垃圾收集器, 可以配置如下参数: 年轻代算法: 复制算法; 年轻代参数: -XX:+UseParallelGC 老年代算法: 标记整理算法; 老年代参数: -XX:+UseParallelOldGC

3. ParNew垃圾收集器

图:

垃圾收集 ParNew垃圾收集.png

可能会有小伙伴儿懵圈儿了...哎? 你这图和Parallel收集器不是一样的吗? 哈哈哈, 确实. ParNew收集器和Parallel收集器的过程是一样的. 区别在于: ParNew只能用在年轻代, 且除了Serial收集器, 只有它能和CMS垃圾收集器配合使用.

如果要使用该垃圾收集器, 可以配置如下参数: 年轻代算法: 复制算法; 年轻代参数: -XX:+UseParNewGC

4. CMS垃圾收集器(重点)

CMS垃圾收集器, 全称Concurrent Mark Sweep. 从名字就可以看出它不简单, 并发标记清除. 它是第一款真正意义上并发的垃圾收集器, 并不是说它就不stop the world了.而是说它可以一边收集垃圾, 一边运行应用程序. 还是先看图再解释吧:

垃圾收集 CMS垃圾收集.png

这个可就比前面说的垃圾收集器复杂多了, 而且整个垃圾收集过程分为了五个阶段: 初始标记、并发标记、重新标记、并发清理、并发重置 下面详细说说这几个阶段的事.

1. 初始标记: 停止应用程序线程, 开启一个回收线程标记非垃圾对象. 使用可达性分析算法(前几篇文章讲过), 但只会标记GC Roots, 所以速度非常快.
2. 并发标记: 恢复工作线程, 与回收线程并发执行. 接着上一阶段的GC Roots继续向下标记, 这个过程的耗时非常长, 几乎可以占到整个垃圾收集过程的80%. 由于没有停止工作线程, 所以可能会发生标记的非垃圾对象在下一时刻又变成垃圾的问题.
3. 重新标记: 又停机工作线程. 因为并发标记阶段可能产生一些标记错误, 所以这一阶段主要是修复这些错误的标记. 如: 上一阶段标记的非垃圾对象, 这一时刻已经失去引用, 又沦为了垃圾对象.
4. 并发清理: 恢复工作线程, 启动回收线程, 清理掉未被标记的垃圾对象. 可能有同学会疑惑, 工作线程恢复了, 那不是又会有新的对象产生, 那这新产生的对象也没被标记, 会不会被回收掉呢? 答案是肯定不会, 不然CMS收集器没人敢用了. 在这个阶段新产生的对象会被直接标记. 具体下面的三色标记算法会讲到.
5. 并发重置: 这一阶段很简单, 就是将之前的标记清除掉.

从以上阶段可以看处, 这个CMS收集器, 把整个垃圾收集过程打散了. 这样就使得用户的感知没那么明显. 把这个时间放大个几十上百倍来理解就是...本来是一次性等10秒, 现在却变成了总体十分钟, 每分钟等1秒. 当然..垃圾收集的过程是很快的, 可能整个才耗时1秒左右, 这里不过是放大了收集时间, 便于理解.

关于CMS收集器必须要注意的是, 它可能会并发失败. 什么是并发失败? 因为垃圾收集和应用程序是并发执行的. 就有可能发生垃圾收集时, 应用程序又在不断的new对象, 导致老年代放满, 又触发了full gc. 这时, CMS不会再走前面说的阶段, 而是采用本文介绍的第一个垃圾收集器: Serial收集器来收集垃圾. 所以为避免这种情况的发生, 可以使用参数来调整老年代触发full gc的内存占用比例, 比如调成80%就触发full gc, 这样在并发清理时, 应用程序新new的对象把剩下的内存占满的概率就会大大降低了.

CMS收集器只能用在老年代, 采用的是标记清除算法(会产生内存碎片, 可通过参数设置整理内存碎片) 优点: 分散垃圾收集过程, 用户体验好. 缺点: 收集线程会抢占cpu资源; 无法处理浮动垃圾(并发收集时产生的垃圾); 采用标记清除算法, 会产生内存碎片(不连续的内存空间)

CMS核心参数:

  • -XX:+UseConcMarkSweepGC:启用cms
  • -XX:ConcGCThreads:并发的GC线程数
  • -XX:+UseCMSCompactAtFullCollection:FullGC之后进行压缩整理
  • -XX:CMSFullGCsBeforeCompaction:多少次FullGC之后压缩一次, 默认是0, 代表每次FullGC后都会压缩一次
  • -XX:CMSInitiatingOccupancyFraction: 当老年代使用达到该比例时会触发FullGC(默认是92, 这是百分比)
  • -XX:+UseCMSInitiatingOccupancyOnly:只使用设定的回收阈值(上一参数设定的值), 如果不指定, Jvm仅在第一次使用设定值,后续又会自动调整.
  • -XX:+CMSScavengeBeforeRemark:在CMS GC前先进行一次minor gc,目的在于减少老年代对年轻代的引用,降低CMS GC的标记阶段时的开销,一般CMS的GC耗时 80%都在标记阶段.
  • -XX:+CMSParallellnitialMarkEnabled:表示在初始标记的时候多线程执行
  • -XX:+CMSParallelRemarkEnabled:在重新标记的时候多线程执行
  • 更多参数请自行百度 ^^

5. G1垃圾收集器

想了想, 还是放在下一篇文章写吧. 下面先说说收集器的标记算法.

四. 垃圾收集器底层算法-三色标记算法

我们之前说过可达性分析算法标记非垃圾对象的过程, 这个可达性分析算法已经详细讲解过了, 就是根据gc roots向下查找并标记非垃圾对象. 那这个标记具体是怎么做的呢? 其实底层就是接下来要将的三色标记算法了, 老规矩, 先上图:

三色标记.png

三色标记算法根据对象的扫描完整度, 分为了黑灰白三种颜色:

  1. 黑色: 已经将一个对象的全部成员变量扫描完毕.
  2. 灰色: 已经扫描过该对象, 但是还未将对象的全部变量扫描完毕.
  3. 白色: 还未扫描过该对象.

以上图说明:

  1. 对象A包含了对象B和对象C两个成员变量, 并将B和C都扫描到了, 所以将A对象标记为黑色.
  2. 对象B没有成员变量, 直接标记为黑色
  3. 对象C包含对象D和对象E两个成员变量, 但只扫描了D对象, 并未来得及扫描E对象, 所以此时C对象的颜色被标记为灰色.
  4. 对象E还未来得及扫描, 所以是初始颜色: 白色(后续会扫描到)
  5. 所以垃圾回收主要收集的颜色就是白色, 也就是可达性分析算法扫描结束后, 并未被标记的对象.

看上去好像设计的相当好, 可也存在一些问题:

  • 前面我们说过CMS收集器是一款..收集线程和应用程序线程并发执行的垃圾收集器, 那么它就不可避免的会产生一些"浮动垃圾". 所谓浮动垃圾就是在可达性分析算法标记非垃圾对象后, 因为应用程序线程的同步运行, 这部分非垃圾对象失去引用又变成了垃圾对象的一种现象. 对于CMS, 它是无法完全清理这部分对象的. 这种现象也被称之为**"多标"**, 就是多标记了.

  • 其实还有一种少标记了的情况, 也称为**"漏标"**. 多标还好, 只是有一部分垃圾在这一次垃圾回收时无法清理掉, 在下一次垃圾回收时就没问题了. 可是对于漏标, 那问题就严重了, 因为我们的可达性分析算法是对标记的对象不进行被回收. 那么, 漏标的非垃圾对象如果被回收了...可想而知, 我们的程序处处都是空指针异常.

  • 那漏标是怎么产生的呢? 假设我们有A, B两种类型的对象...还是看图吧...

三色标记 漏标.png

由上图可以一句话总结: 漏标就是因为应用程序线程和收集线程的并行, 导致已标记为黑色对象的引用指向了未标记完成的对象上.

多标和漏标如何解决?

针对多标和漏标, Jvm提供了两种解决方式: 一叫做增量更新, 二叫做原始快照.

增量更新: 在并发收集过程中, 将每一次引用变更的对象重新标记为灰色, 这样一来, 在并发标记阶段结束后, 在重新标记阶段开始时, 会将这些对象重新扫描并标记. 由于重新标记是会停止用户线程的, 所以在重新标记结束时, 内存就不存在任何一个为白色的非垃圾对象. 注意: 新new的对象会被直接标记为黑色.

原始快照: 在并发收集过程中, 将每一次引用变更的白色对象都标记成黑色. 这样就彻底避免了漏标的情况, 不过可能会产生大量的浮动垃圾.

增量更新和原始快照底层是怎么做的呢?

其实Jvm底层运用了读写屏障, 此处的读写屏障并不是内存中的读写屏障. 而是类似于AOP一样的思想, 再引用的变更前后, 对该引用做了一些手脚. 如将其以另外的形式保存起来.

增量更新: 在引用变更前, 利用写屏障将引用保存起来. 在重新标记阶段重新扫描引用对象所在的根节点.

原始快照: 在引用变更前, 利用写屏障将引用保存起来. 在重新标记阶段直接将保存的引用对象标记为黑色.

不同的垃圾收集器, 底层采用了不同的实现:

  • CMS采用的是增量更新
  • 下篇文章将要讲的G1采用的是原始快照
  • 至于为什么? 可能是因为G1的内存分散, 对象处于不同的region, 如果采用增量更新, 扫描起来付出的性能较大. 而CMS的老年代处于一块独立的内存, 使用增量更新虽然也会重新扫描变更引用的对象, 但相较于G1性能开销会节省很多, 且能处理掉更多的浮动垃圾.

ok, 今天的知识总结就到这里. 如果有写的不对的地方, 仍然希望小伙伴儿们能不吝赐教~