垃圾回收算法和常见的垃圾回收器(CMS+G1)

3,765 阅读8分钟

垃圾回收算法

垃圾回收算法分类

分代收集理论

现在市面上常见的垃圾回收器都采用了分代收集理论。
所谓分代收集就是根据对象的存活周期将内存分为新生代和老年代。详细可查看JAVA内存模型
新生代对象“朝生夕死”,每次收集都有大量对象(99%)死去,所以可以选择标记-复制算法,只需要付出少量对象的复制成本就可以完成每次垃圾收集。
老年代对象生存几率比较高,存活对象比较多,如果选择复制算法需要付出较高的IO成本,而且没用额外的空间可以用于复制,此时选择标记-清除或者标记-整理就比较合理。

标记-复制算法

标记-复制算法将内存分为两块相同大小的区域(比如新生代的Survivor区),每次在其中一块区域分配元素,当这块区域内存占满时,就会将存活下来的元素复制到另一块内存区域并清空当前内存区域。

  • 缺点:浪费一半的内存空间。
  • 优点:简单高效。 JVM在Eden区保存新对象,在GC时,将Eden和Survivor中存活对象复制到Survivor的另一个分区。这是JVM对复制算法的一个优化。只浪费了1/10的内存空间【JVM的Eden区和Survivor区的比例为 8:2】

标记-清除算法

算法包括标记清除两个阶段,标记存活的对象,统一回收未标记的对象(一般情况是这种),也可以反过来,标记所有需要回收的对象并回收(标记成本相对较大)。

  • 优点:最基础的收集算法,比较简单
  • 缺点:效率不高(如果需要标记的对象比较多),空间问题(标记清除后会产生大量不连续的内存空间) 空间问题可能出现的问题:在发生GC之后,内存回收了大量死亡对象,出现大量空余内存,此时系统生成一个较大的对象,可能由于没有足够大的连续的内存空间,导致对象生成失败。【空间足够多,但是没有足够大的连续空间】

标记-整理算法

解决了标记清除中的空间问题。
标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象回收,而是让所有存活的对象向一端移动,然后直接清理掉端边界以外的内存。

垃圾收集器

目前市面上主流的垃圾收集器有:Serial收集器、Parallel Scavenge收集器【JDK8 默认收集器】、ParNew收集器、CMS收集器G1收集器

Serial 收集器 | -XX:+UseSerialGC -XX:+UseSerialOldGC

单线程垃圾收集器,最古老的的垃圾收集器,在Serial收集器执行期间会 "STOP THE WORLD"
STOP THE WORLD:只运行GC线程,暂停其他线程。

新生代采用复制算法,老年代采用标记-整理算法。

它简单而高效(与其他收集器的单线程相比)。Serial收集器由于没有线程交互的开销,可以获得很高的单线程收集效率。

Parallel Scavenge 收集器 | -XX:+UseParallelGC -XX:+UseParallelOldGC

相当与Serial收集器的多线程版本,默认开启的GC线程数,和CPU核数一致(-XX:ParallelGCThreads 可以修改GC线程数,但不推荐修改

Parallel Scavenge收集器的注点是吞吐量(高效率的利用CPU)。CMS等垃圾收集器的关注点更多的是用户线程的停顿时间(提高用户体验)。所谓吞吐量就是CPU中用于运行用户代码的时间与CPU总消耗时间的比值。

ParNew 收集器 | -XX:+UseParNewGC

ParNew 和 Parallel Scavenge收集器极其相识,可以理解为ParNew是为配合CMS收集器开发的新一代 Parallel Scaenge收集器。

CMS 收集器 | -XX:+UseConcMarkSweepGC

CMS收集器是一种老年代区域的垃圾收集器,往往配合ParNew 收集器来使用。
它适合在注重用户体验的应用上使用,实现了GC线程和用户线程并发进行(部分步骤)。
采用了标记-清除算法。回收过程大致分为5个步骤:

  1. 初始标记:暂停其他线程(STW),标记GC roots 直接引用的对象。过程很快
  2. 并发标记:从GC roots 直接引用的对象出发向下查找所有引用的对象。这个过程最耗时,和用户线程并发执行。【这个过程可能出现的问题:用户线程执行中可能产生新的垃圾(浮动垃圾),无法被标记。】
  3. 重新标记:为修正 并发标记 中产生变动的对象标识,主要用三色标记中的增量更新算法来进行标记。这个过程会暂停其他进程(STW)。
  4. 并发清除:将标记为可回收的对象进行回收,和用户线程并发执行。【*这个过程也会产生新垃圾对象(浮动垃圾),这些对象将在下次GC时回收】
  5. 并发重置:将标记为不可回收的对象的标志清除。

G1 收集器 | -XX:+UseG1GC 官网

堆结构

G1 收集器采用了和此前完全不同的堆内存分配方式,他将堆内存分为2048个相同大小的region(单个大小为1MB~32MB)。

堆内存的分配

这些regions在逻辑上被动态的分为Eden、Survivor(新生代)、old generation(老年代)。这些区域不一定是连续的。

除了这三种之外,G1还新增一种新的类型:Humongous regions。如果对象超过所在regions的50%,就会被移入这个类型的region。这种大对象应尽量避免创建 G1 oracle官网图片

期望GC-STW时间

G1 新增一个配置(-XX:MaxGCPauseMillis=200),来设置我们期望每次GC-STW的时间。这是一个相对值,不会严格按照这个时间执行,jvm 会评估GC总的时间,如果不能满足这个期望时间,JVM 会通过一定的算法,只回收部分性价比高的内存空间,来达到这个目标。

Young GC

新生代内存初始化默认分配堆内存的5%,当新生代内存被占满时,JVM会评估回收新生代所需要的时间,如果比期望GC-STW时间短,不会马上触发Young GC,而是对新生代进行扩容。否则触发Young GC。

young GC 通过复制算法将存活的对象复制到Survivor region。并清空原来的region。

  • 这个过程会 "stop the world",并且重新计算并保存新生代的大小,以便下一次GC快速进行。
  • young GC 是多线程的.

Mixed GC

整个堆占用率达到(-XX:InitiatingHeapOccupancyPercent=45)时,触发Mixed GC

Mixed GC 过程

  1. 初始标记:暂停其他线程(STW),标记GC roots 直接引用的对象。过程很快
  2. 并发标记:从GC roots 直接引用的对象出发向下查找所有引用的对象。这个过程最耗时,和用户线程并发执行。【官网说这里会标记空白的region ---- 暂时无法理解,期待了解的大佬赐教~~~】
  3. 重新标记:为修正 并发标记 中产生变动的对象标识,这个过程会暂停其他进程(STW)。【然后在这一步空白的region被回收】
  4. 并发清除:这个阶段G1会对老年代各个region区域进行比较,其中"活性"低的region会被优先回收(活性:存活对象最少的,复制成本低,回收更快)。老年代和新生代一起回收.
  5. 复制幸存者对象:将之前回收区域幸存的对象复制到一个未使用的region区,并压缩.

G1的官方推荐

  1. 不要设置年轻代大小
    • 设置年轻一代的大小会禁用期望GC-STW时间的目标。
    • G1不再能够根据需要扩展和缩小年轻一代的空间。由于尺寸是固定的,因此无法更改尺寸。
  2. 期望GC-STW时间
    • 最好不要设置平均GC时间,而是使用 90% 的GC时间.
  3. Mixed GC 中 Evacuation Failure
    • 当没有更多的空闲region被提升到老一代或者复制到幸存空间时,并且由于堆已经达到最大值,堆不能扩展,从而发生Evacuation Failure。此时会触发full GC ,类似与 Serial 收集器的单线程垃圾回收,非常耗时
    • 增加(-XX:G1ReservePercent=10)的大小
    • 降低(-XX:InitiatingHeapOccupancyPercent=45)的大小
    • 增加(-XX:ConcGCThreads=n)并发标记线程数

G1 JVM参数请参照官网