Stay Hungry Stay Foolish

JVM垃圾收集器简介

Posted on By Jun Xi Gu

本文是对JVM垃圾收集器的基本知识的汇总和个人理解


垃圾收集器(garbage collector (GC)) 是什么?

GC其实是一种自动的内存管理工具,其行为主要包括2步

  • 在Java堆中,为新创建的对象分配空间
  • 在Java堆中,回收没用的对象占用的空间

为什么需要GC?

释放开发人员的生产力

为什么需要多种GC?

首先,Java平台被部署在各种各样的硬件资源上,其次,在Java平台上部署和运行着各种各样的应用,并且用户对不同的应用的 性能指标 (吞吐率和延迟) 预期也不同,为了满足不同应用的对内存管理的不同需求,JVM提供了多种GC以供选择

性能指标
最大停顿时长:垃圾回收导致的应用停顿时间的最大值
吞吐率:垃圾回收停顿时长和应用运行总时长的比例

不同的GC能满足不同应用不同的性能需求,现有的GC包括:

  • 序列化GC(serial garbage collector):适合占用内存少的应用
  • 并行GC 或 吞吐率GC(parallel or throughput garbage collector):适合占用内存较多,多CPU,追求高吞吐率的应用
  • 并发GC:适合占用内存较多,多CPU的应用,对延迟有要求的应用

GC进行内存回收的普遍方法

垃圾收集必须要完成两件事:垃圾对象的检测内存空间回收

垃圾对象的检测 有两种方法

  • 引用计数:每个对象都有个引用计数器,当引用数为0时成为垃圾,缺点是无法检测循环引用
  • 跟踪:从根对象开始遍历可达的对象图,不可达的都是垃圾

由于循环引用的问题,一般采用跟踪方法

内存空间回收 有两种方法

  • 压缩收集:在一个内存块内通过把存活对象移动到堆的一端,剩余空间即为回收的空间,这样可以消除碎片
  • 拷贝收集:把内存分成多块,通过把存活对象拷贝到一个新的区域,然后回收旧的区域,好处是垃圾对象的检测和收集同时进行,但需要更大的空间

不同的垃圾收集算法就是在基本的 跟踪方法 和 内存空间回收 方法之上加入额外的机制来提高回收的效率

提高回收效率的机制:分代

通过对大量应用的检测,可以发现应用中的对象生命周期符合一个规律:大量对象都会在创建以后不久就会死亡,只有少量对象存活的时间比较长
所以可以把内存分成两块进行管理,一块内存(新生代)用来存放创建不久的对象,一块内存(老年代)用来存放存活时间比较长的对象,当某一块足够满就收集那一块内存,这样老年代内存就可以很久才收集一次,减少每次内存收集所需要的 检测对象数目和内存回收大小,提高效率
新生代里的对象大多是创建不久就死的对象,回收时涉及少量存活对象所以回收的效率特别高,新生代的回收叫 次要收集(minor collection) 老年代里的对象大多是存活很久的对象,回收时涉及大量存活对象所以回收相对慢,老年代的回收叫 主要收集(major collection),为了减少回收次数,一般老年代空间比较大

所有的Java GC都会把Java堆进行分代回收,所以可以简单地说垃圾收集分为3个步骤:

  • 在新生代,给新创建的对象分配空间;把新生代中存活时间足够长的对象移动到老年代
  • 当整个Java堆的占用量达到阈值时,在老年代触发 寻找存活的对象的活动,对存活的对象进行标记
  • 把存活对象统一拷贝到某块内存区域,然后回收原来的内存区域

提高回收效率的机制:自适应

由于应用在运行过程中,在不同的阶段会对内存有不同的需求,GC通过自动的调整来增加或减少内存回收所使用的资源

GC进行 次要收集 的方法

Java的分代GC的 次要收集 方法逻辑上都相同,所以此处描述的次要收集方法对所有GC适用

新生代逻辑上划分为 一个Eden区和一对Survivor区
两个Survivor不会同时用来存放对象,每次都会有一个存放对象,另一个为空;所有新创建的对象都在Eden上分配内存,当Eden占用足够满就进行次要收集;收集时先用 跟踪 方法在Eden和一个Survior钟找出来所有存活对象,然后把存活对象(不够久)拷贝到空着的Survivor中,然后清空Eden和原来的Survivor;原Survivor中某些对象存活时间足够长时会拷贝到老年代

一些小细节

在Eden分配对象空间时使用 指针碰撞法 ,一个指针指向Eden的启示位置,分配空间是直接在指针出分配,然后滑动指针到新地址,新旧地址差为分配的空间大小

新创建的对象比Eden容量还大时,直接在老年代给对象分配空间

Survivor中没有足够空间存放存活对象时,存活时间不够长的对象会被拷贝到老年代,容易导致 主要收集

序列化GC

序列化GC在进行垃圾回收时会把所有的线程都停掉(Stop the world (STW)),并且用单线程的方式来进行回收,因此它适合单CPU,内存需求不高的应用;对于这类应用,它效率很高

序列化GC使用的次要收集方法如上所述

序列化GC使用的主要收集方法也很简单,当内存占用达到阈值时触发主要收集;首先通过 跟踪 找到所有存活的对象,然后通过 压缩收集 方法来回收老年代空间

并行GC(又名 吞吐率GC)

并行GC和序列化GC在 次要收集 和 主要收集 使用一样的方法,都会让应用程序停止,不同的是并行GC使用多线程同时进行内存回收来减少停顿时间,获取高吞吐率

一些小细节

每个线程都会在老年代预留一个缓冲来存放从Servivor拷贝的对象,这样容易产生碎片,增大主要收集的频率

当98%的时间用在垃圾回收而只有不到2%的空间得到回收,就会抛出OutOfMemoryException(OOME)

并发GC(The Mostly Concurrent Collectors)

并发GC通过使用特别的 主要收集 算法来使得在主要收集时,大部分的回收工作能和应用程序并发进行来减少应用的停顿时间,但这样会需要更长的主要收集阶段,并一直占用这线程资源,这样导致了吞吐率下降

并发GC适合在多核,中到大规模数据量,对延迟有一定要求的应用中使用

Hostpot JVM有两个并发GC:并发标记清除GC(Concurrent Mark Sweep (CMS) Collector) 和 垃圾优先GC(Garbage-First Garbage Collector (G1))

一些小细节

由于并发GC在主要收集过程中使用跟踪方法来找到所有存活对象,但跟踪的过程是和应用程序并发执行的,所以跟踪方法只能保证找到所有存活的对象,但不能保证剔除掉应用在跟踪过程中不再使用的垃圾对象,所以并发GC每次垃圾回收都不能把所有的垃圾对象收集完,这些存活的垃圾对象叫浮动垃圾,它们都会在下一次主要收集时被清除

并发GC的主要收集和应用并发执行,主要收集期间应用也一直在Eden上创建新对象,也会触发次要收集,所以主要收集过程中的一些并发执行阶段会被次要收集打断,所以整个主要收集过程会包含一些次要收集,并且次要收集也会向老年代拷贝对象

和并行GC一样,当98%的时间用在垃圾回收而只有不到2%的空间得到回收,就会抛出OOOME

并发标记清除GC(CMS)

CMS在进行主要收集时,某些步骤中和应用并发执行,减少应用的停顿时长,适合在多核,中到大规模数据量,对延迟有一定要求的应用中使用

CMS的次要收集过程和并行GC一样

CMS的主要收集过程包括一系列阶段:

  • 初始标记阶段:停掉所有应用线程(STW),从根源(Java栈,寄存器等)和Java堆的其他地方(例如新生代)来标记出所有存活的对象,然后恢复所有应用线程
  • 并发标记阶段:从上一阶段标记出来的存活对象开始跟踪找到那些可达的存活对象并标记,这个阶段是跟应用并发执行的
  • 并发重标记阶段:在上阶段应用可能会修改那些已经被跟踪过的对象,这些对象可能会引用一些新存活的对象,这时候需要重新跟踪一遍这些更新过的对象;这是一个可选的优化阶段,用来减少重新标记所停顿的时间
  • 重标记阶段:停掉所有应用线程(STW),重新跟踪一遍根源和被修改过的已跟踪对象,然后恢复所有应用线程
  • 并发清除阶段:并发的把内存回收,回收的内存放到一个空闲列表里
  • 准备阶段:并发的调整堆和为下一次主要收集准备各种数据结构

一些小细节

有两种机制能触发主要收集
一种机制是,CMS会维持着两个估计的时长:还有多久老年代会满,进行一次主要收集需要多久,当进行主要收集的时长小于老年代满掉的时长就会进行主要收集 另外一个机制是CMS有个配置是老年代的占用阈值,当老年代的占用率达到阈值则进行主要收集

主要收集期间会有多次次要收集,为了防止重标记的停顿和次要收集的停顿连起来造成比较长的停顿,CMS会故意把重标记安排在上一次次要收集和下一次次要收集的中间

在老年代中有个数据结构叫卡表,用来标记老年代的对象是否更新了对其他对象的引用,这样不用扫描整个老年代就能找到所有更新过引用的对象

主要收集完以后,没有对老年代进行压缩,只是把空闲内存放到一个空闲表里,下次分配空间时直接从空闲表里取,这样会导致内存有碎片

如果CMS在主要收集完成,老年代空间已经用完,则会抛Concurrent Mode Failure(CMF),引发一次STW的全收集,代价非常大,这意味着需要增大老年代的空间或增多主要收集使用的线程数

CMS有一个渐进式模式,这个模式在JDK8时开始弃用,将来有可能会被去掉;这模式适合在1或2核的机器上

垃圾优先GC(G1)

和CMS差不多,G1在主要收集的一些步骤和应用是并发的,所以减少应用的停顿时长,适合在多核,大规模数据量,对延迟和吞吐率都有一定要求的应用中使用;当应用有以下任何一种特性时非常适合用G1:

  • 存活的数据多于50%Java堆的占用率
  • 对象的创建速率和存活率变动很大
  • 应用不希望停顿时间长(长于0.5s甚至1s)

G1通过几个特别的技术来达到高吞吐率和低停顿

G1按固定大小把内存划分为很多小区块(region),这个堆大概有2000多块;在逻辑上,某些小区块构成Eden,某些构成Survivor,某些构成老年代,这些小区块物理上是不相连的,并且构成新生代和老年代的区块是可以动态改变的,所以Minor GC的时间是可控的
当堆的占用率达到一定比例后被触发进行Major GC回收时,G1会在整个堆并发的标记存活对象,所以应用在标记时大部分时间没有停顿
当标记完后,G1会根据标记结果找出那些存活对象少的那些区块,然后回收这些找到区块,这样就能回收更多的空间(G1名字的来源),回收这些区块时会把部分区块放到一次新生代区块回收中进行,通过在多次新生代回收中来回收掉老年代的区块 回收空间时,通过把存活对象并行地拷贝到空闲这的区块来回收旧的区块,这样能减少停顿时间,提高吞吐率,并起到压缩内存的效果
回收空间后,根据最大停顿时长利用预测模型来确定新生代的区块数,使得下次的Minor GC或混合GC不会超过最大停顿时长

通过以上这几个特别的设计,G1要比CMS能更好的控制停顿时间,并且不产生内存碎片,同时达到比较好的延迟和吞吐率

Minor GC

当Eden包含的区块被新创建的对象占满时触发次要收集,停顿应用线程,并行地跟踪新生代里存活的对象,并行地把存活对象拷贝到新的Survivor区块或老年代区块,当完成拷贝后,

Major GC

当整个Java堆的占用率达到某个阈值时触发主要收集,其包括以下步骤:

  • 初始标记阶段:G1标记出从根源可达的对象,这个阶段是附属于一次次要收集的最后阶段,所以是STW
  • 根区域扫描阶段:G1从上一阶段在Survivor里的标记的存活对象开始跟踪所有可达的老年代对象,这是一个并发的过程,而且必须在进行下一次次要收集之前完成
  • 并发扫描阶段:G1在整个堆上并发地跟踪所有存活对象,期间可以被次要收集打断
  • 重标记阶段:G1并行地跟踪在上面阶段经过更新的存活对象,找到未被标记的存活的对象,这是一个STW的阶段
  • 清除阶段:这个阶段有两个步骤,首先是STW,根据预测模型确定需要回收的区块,这些区块会跟新生代一块回收;然后是并发的清除一些要回收区块的数据机构

一些小细节

如果G1在拷贝对象到空闲区块时已经没有空闲区块时则会抛Concurrent Mode Failure(CMF),引发一次STW的全收集,代价非常大

G1会在标记的开始时使用一个数据结构snapshot-at-the-beginning (SATB),用它来记录所有存活的对象,包括那些开始标记阶段以后新创建的对象;重标记阶段就是用SATB来找出需要重新标记的对象;SATB是造成浮动垃圾的根源

每个区块都有自己的卡表,这样在查找更新过引用的对象时直接扫描卡表就行了,不用扫描整个堆

G1在主要收集对老年代区块进行回收时,会把老年代区块和次要收集需要回收的Eden和Survivor区块放到一起并行回收,这种收集叫混合收集(Mixed Garbage Collections),由于一次可能收集不完需要收集的老年代区块,所以多次次要收集都会是混合收集;当所有需要收集的老年代区域收集完成后,就会切换为只收集Eden和Survivor区块

任何比区块的一半要大的对象是大对象(Humongous Object),这些对象直接在老年代里分配空间,老年代里专门有一块连续的区块组成的大对象区块,每个大对象区块只保存一个大对象;
在分配空间给大对象前,都会先检查是否达到主要收集的阈值,若是则先进行一次主要收集
大对象是在主要收集和全收集的最后被回收的
大对象是不会在主要收集中被拷贝的,只有在全收集时G1才压缩大对象区块
每个大对象区块之间很可能会有空隙,所以会产生碎片

参考文献

Getting Started with the G1 Garbage Collector
Java’s Garbage First Garbage Collector