JVM 垃圾收集器详解

一、垃圾收集器

如果说收集算法是内存回收的方法论,那垃圾收集器就是内存回收的实践者。《Java虚拟机规范》中对垃圾收集器应该如何实现并没有做出任何规定,因此不同的厂商、不同版本的虚拟机所包含的垃圾收集器都可能会有很大差别,不同的虚拟机一般也都会提供各种参数供用户根据自己的应用特点和要求组合出各个内存分代所使用的收集器。

JVM 垃圾收集器详解_第1张图片

图展示了七种作用于不同分代的收集器,如果两个收集器之间存在连线,就说明它们可以搭配使用,图中收集器所处的区域,则表示它是属于新生代收集器或是老年代收集器。

1、Serial收集器

Serial收集器是最基础、历史最悠久的收集器,曾经(在JDK1.3.1之前)是HotSpot虛拟机新生代收集器的唯一选择。

大家只看名字就能够猜到,Serial收集器是一个单线程工作的收集器,但它的”单线程”的意义并不仅仅是说明它只会使用一个处理器或一条收集线程去完成垃圾收集工作,更重要的是强调在它进行垃圾收集时,必须暂停其他所有工作线程,直到它收集结束。Serial收集器虽然老,但是在客户端仍然推荐使用的,因为它高效,简单,额外内存消耗最小的收集器。

“Stop The World”这个词语也许听起来很酷,但这项工作是由虚拟机在后台自动发起和自动完成的,在用户不可知、不可控的情况下把用户的正常工作的线程全部停掉。

JVM 垃圾收集器详解_第2张图片

对应的参数:

  • -XX:+UseSerialGC

使用串行回收器进行回收,这个参数会使新生代和老年代都使用串行回收器,新生代使用复制算法,老年代使用标记-整理算法。Serial收集器是最基本、历史最悠久的收集器,它是一个单线程收集器。一旦回收器开始运行时,整个系统都要停止。Client模式下默认开启,其他模式默认关闭。

  • -XX: SurvivorRatio

-XX:SurvivorRatio=6 ,设置的是Eden区与一个Survivor区的比值是6:1,Eden为6, 两个Survivor为2, Eden占新生代的6/8,也就是3/4, 每个Survivor占1/8,两个占1/4

  • -XX:PretenureSizeThreshold

-XX:PretenureSizeThreshold=1024102410超过这个值的时候,对象直接在老年代里分配内存默认值是0,意思是不管多大都是先在eden中分配内存

  • -XX: HandlePromotionFailure

-XX: HandlePromotionFailure=true/false,空间分配担保的开关:

在发生Minor GC之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象的总空间。

如果大于则进行Minor GC,如果小于则看-XX:+HandlePromotionFailure设置是否允许担保失败(不允许则直接Full GC)。
如果允许,那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,
如果大于则尝试Minor GC(如果尝试失败也会触发Full GC),如果小于则进行Full GC。

注意:在JDK 6 Update 24之后,-XX:HandlePromotionFailure参数不会再影响到虚拟机的空间分配担保策略。

2、 ParNew收集器

ParNew收集器实质上是Serial收集器的多线程并行版本,除了同时使用多条线程进行垃圾收集之外,其余的行为包括Serial收集器可用的所有控制参数、收集算法、Stop The World、对象分配规则、回收策略等都与Serial收集器完全一致。

JVM 垃圾收集器详解_第3张图片

说到ParNew,不得不提前说一下CMS:

在JDK 5发布时,HotSpot推出了一款具有划时代意义的垃圾收集器一CMS收集器。首次实现了让垃圾收集线程与用户线程(基本上)同时工作。激活CMS的参数:-XX:+UseConcMarkSweepGC。激活CMS后,默认使用CMS+ParNew的组合做垃圾收集工作的。JDK9后,ParNew收集器的开关配置:-XX:+UseParNewGC也被取消了!可以说ParNewGC是第一款从HotSpot中退出舞台的垃圾收集器。

对应的参数:

  • -XX:+UseParNewGC

Parallel是并行的意思,ParNew收集器是Serial收集器的多线程版本,使用这个参数后会在新生代进行并行回收,老年代仍旧使用串行回收。新生代S区任然使用复制算法。操作系统是多核CPU上效果明显,单核CPU建议使用串行回收器。
打印GC详情时ParNew标识着使用了ParNewGC回收器。默认关闭。

并行和并发都是并发编程中的专业名词,在谈论垃圾收集器的上下文语境中,它们可以理解为:

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

3、 Parallel Scavenge收集器

Parallel Scavenge收集器也是一款新生代收集器,它同样是基于标记复制算法实现的收集器,也是能够并行收集的多线程收集器。

Parallel Scavenge的诸多特性从表面上看和ParNew非常相似,Parallel Scavenge收集器的特点是它的关注点与其他收集器不同:

  • CMS等收集器的关注点是尽可能地缩短垃圾收集时用户线程的停顿时间,停顿时间越短就越适合需要与用户交互或需要保证服务响应质量的程序,良好的响应速度能提升用户体验;
  • 而Parallel Scavenge收集器的目标则是达到一个可控制的吞吐量(Throughput)

这里的吞吐量: 是指处理器用于运行用户代码的时间与处理器总消耗时间的比值。

即:吞吐量 = 运行用户代码时间 / (运行用户代码时间 + 运行垃圾收集时间)
高吞吐量则可以最高效率地利用处理器资源,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的分析任务。

Parallel Scavenge收集器相关参数:

  • -XX:+UseParallelGC

代表新生代使用Parallel收集器,老年代使用串行收集器。Parallel Scavenge收集器在各个方面都很类似ParNew收集器,它的目的是达到一个可以控制的吞吐量。Server模式默认开启,其他模式默认关闭。

  • -XX: MaxGCPauseMillis参数

此参数允许的值是一个大于0的毫秒数,收集器将尽力保证内存回收花费的时间不超过用户设定值。
不过大家不要异想天开地认为如果把这个参数的值设置得更小一点就能使得系统的垃圾收集速度变得更快,
垃圾收集停顿时间缩短是以牺牲吞吐量和新生代空间为代价换取的:
系统把新生代调得小一些,收集300MB新生代肯定比收集500MB快,但这也直接导致垃圾收集发生得更频繁,
原来10秒收集一次、每次停顿100毫秒,现在变成5秒收集一次、每次停顿70毫秒。停顿时间的确在下降,但吞吐量也降下来了。

  • -XX: GCTimeRatio参数
    此参数的值则应当是一个大于0小于100的整数,也就是垃圾收集时间占总时间的比率,相当于吞吐量的倒数。
    如把此参数设置为19,那允许的最大垃圾收集时间就占总时间的5% (即1/(1+19)),
    默认值为99,即允许最大1% (即1/(1+99)) 的垃圾收集时间。

  • -XX: +UseAdaptiveSizePolicy参数
    这是一个开关参数,当这个参数被激活之后,就不需要人工指定新生代的大小(-Xmn) 、Eden与Surivor区的比例 (-XX: SurvivorRatio) 、升级到老年代对象大小(-XX: PretenureSizeThreshold) 等细节参数了,
    虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量。

4、Serial Old 收集器

Serial Old是Serial收集器的老年代版本,它同样是一个单线程收集器,使用标记整理算法

这个收集器的主要意义也是供客户端模式下的HotSpot虛拟机使用。有的时候你会看到这个名字PS MarkSweep默认的实现实际上是一层皮,它底下真正做mark-sweep-compact工作的代码和serial old是共用同一份代码的。

JVM 垃圾收集器详解_第4张图片

而在Server模式有两大用途:
(1)在JDK1.5及之前,与Parallel Scavenge收集器搭配使用(JDK1.6有Parallel Old收集器可搭配);
(2)作为CMS收集器的后备预案,在并发收集发生Concurrent Mode Failure时使用

5、Parallel Old 收集器

Parallel Old是Parallel Scavenge收集器的老年代版本,支持多线程并发收集,基于标记整理算法实现。

这个收集器是直到JDK 6时才开始提供的,在此之前,新生代的Parallel Scavenge收集器一直处于相当尴尬的状态,原因是如果新生代选择了Parallel Scavenge收集器,老年代除了Serial Old 收集器以外别无选择,其他表现良好的老年代收集器,如CMS无法与它配合工作。

Serial Old 收集器的单线程就是整个收集系统的瓶颈。直到Parallel Old收集器出现后,"吞吐量优先”收集器终于有了比较名副其实的搭配组合,在注重吞吐量或者处理器资源较为稀缺的场合,都可以优先考虑Parallel Scavenge加Parallel Old收集器这个组合。

JVM 垃圾收集器详解_第5张图片

-XX:+UseParallelOldGC:指定使用Parallel Old收集器;

6、CMS收集器

CMS (Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。

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

CMS收集器就非常符合这类应用的需求。从名字(包含"Mark Sweep")上就可以看出CMS收集器是基于标记清除算法实现的,它的运作过程相对于前面几种收集器来说要复杂一些,整个过程分为四个步骤,包括:

  • 1)初始标记(CMS initial mark)
  • 2)并发标记(CMS concurrent mark)
  • 3)重新标记(CMS remark)
  • 4)并发清除(CMS concurrent sweep)

其中初始标记、重新标记这两个步骤仍然需要"Stop The World"

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

由于在整个过程中耗时最长的并发标记和并发清除阶段中,垃圾收集器线程都可以与用户线程一起工作,所以从总体上来说,CMS收集器的内存回收过程是与用户线程可以看做是同时执行的。

JVM 垃圾收集器详解_第6张图片

CMS是一款优秀的收集器, 它最主要的优点在名字上已经体现出来:并发收集、低停顿

CMS收集器是HotSpot虚拟机追求低停顿的第一次成功尝试, 但是它还远达不到完美的程度,至少有以下三个明显的缺点:

  • 1)首先,对计算机运算资源非常敏感

在并发阶段,它虽然不会导致用户线程停顿,但却会因为占用了一部分处理器的计算能力而导致应用程序变慢,降低总吞吐量。

CMS默认启动的回收线程数是(处理器核心数量+3)/4, 也就是说,如果处理器核心数在四个或以上,并发回收时垃圾收集线程只占用不超过25%的处理器运算资源,并且会随着处理器核心数量的增加而下降。但是当处理器核心数量不足四个时,CMS对用户程序的影响就可能变得很大。

  • 2)其次,CMS无法处理“浮动垃圾”

有可能出现“Concurrent Mode Failure”,导致“Stop The World”在CMS的并发标记和并发清理阶段,用户线程是还在继续运行的,程序在运行,自然就还会伴随有新的垃圾对象不断产生,但这一部分垃圾对象是出现在标记过程结束以后,CMS无法在当次收集中处理掉它们,只好留待下一次垃圾收集时再清理掉。这一部分垃圾就称为“浮动垃圾”。

同样也是由于在垃圾收集阶段用户线程还需要持续运行,那就还需要预留足够内存空间提供给用户线程使用,因此CMS收集器不能像其他收集器那样等待到老年代几乎完全被填满了再进行收集,必须预留一部分空间供并发收集时的程序运作使用。

在JDK 5的默认设置下,CMS收集器当老年代使用了68%的空间后就会被激活,这是一个偏保守的设置,到了JDK 6时,CMS收集器的启动阈值就已经默认提升至92%。但这又会更容易面临另一种风险:要是CMS运行期间预留的内存无法满足程序分配新对象的需要,就会出现一次“并发失败”(Concurrent Mode Failure) ,这时候虚拟机将不得不启动后备预案:冻结用户线程的执行此时就“Stop The World”,临时启用Serial Old收集器来重新进行老年代的垃圾收集,但这样停顿时间就很长了。

  • 3)最后,CMS使用“标记-清理”算法,不会移动存活对象,会产生过多的内存空间碎片

空间碎片过多时,将会给大对象分配带来很大麻烦,往往会出现老年代还有很多剩余空间,但就是无法找到足够大的连续空间来分配当前对象,而不得不提前触发一次Full GC的情况。在CMS收集器不得不进行Full GC时默认会进行内存碎片的合并整理。

对应配置参数:

  • -XX:+UseConcMarkSweepGC

启用CMS:-XX:+UseConcMarkSweepGC

  • -XX:ParallelCMSThreads

CMS默认启动的回收线程数目是 (ParallelGCThreads + 3)/4) ,如果你需要明确设定,可以通过-XX:ParallelCMSThreads=20来设定,其中ParallelGCThreads是年轻代的并行收集线程数

  • -XX:+UseCMSCompactAtFullCollection和-XX:CMSFullGCsBeforeCompaction=10(默认开启,JDK 9废弃)

CMS是不会整理堆碎片的,因此为了防止堆碎片引起full gc,通过会开启CMS阶段进行合并碎片选项:
-XX:+UseCMSCompactAtFullCollection(默认开启,JDK 9废弃),开启这个选项一定程度上会影响性能,也许可以通过配置适当的
-XX:CMSFullGCsBeforeCompaction=10(JDK 9废弃),来调整性能。

在上一次CMS并发GC执行过后,到底还要再执行多少次full GC才会做合并碎片。默认是0,也就是在默认配置下每次CMS GC顶不住了而要转入full GC的时候都会做合并碎片。

把CMSFullGCsBeforeCompaction配置为10,就会变成每隔10次真正的full GC才做一次合并碎片。

  • -XX:+CMSParallelRemarkEnabled

为了减少第二次暂停的时间,开启并行remark: -XX:+CMSParallelRemarkEnabled
如果remark还是过长的话,可以开启-XX:+CMSScavengeBeforeRemark选项,
强制remark之前开始一次minor gc,减少remark的暂停时间,但是在remark之后也将立即开始又一次minor gc。

  • 为了避免Perm区满引起的full gc,建议开启CMS回收Perm区选项:

-XX:+CMSPermGenSweepingEnabled
-XX:+CMSClassUnloadingEnabled

  • -XX:CMSInitiatingOccupancyFraction

JDK5默认CMS是在tenured generation沾满68%的时候开始进行CMS收集,
JDK 6时,CMS收集器的启动阈值就已经默认提升至92%。如果需要,可以适当调整此值:
-XX:CMSInitiatingOccupancyFraction=80 这里修改成80%沾满的时候才开始CMS回收。

  • -XX:ParallelGCThreads

年轻代的并行收集线程数默认是(cpu <= 8) ? cpu : 3 + ((cpu * 5) / 8),如果你希望降低这个线程数,
可以通过-XX:ParallelGCThreads=N 来调整。

7、G1 收集器

Garbage First (简称G1)收集器是垃圾收集器技术发展历史上的里程碑式的成果,它开创了收集器面向局部收集的设计思路和基于Region的内存布局形式。G1是一款主要面向服务端应用的垃圾收集器。

JDK9发布之日,G1宣告取代Parallel Scavenge加Parallel Old组合,成为服务端模式下的默认垃圾收集器,而CMS则沦落至被声明为不推荐使用(Deprecate)的收集器。

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

G1开创的基于Region的堆内存布局是它能够实现这个目标的关键。

虽然G1也仍是遵循分代收集理论设计的,但其堆内存的布局与其他收集器有非常明显的差异:

  • 传统的GC收集器将连续的内存空间划分为新生代、老年代和永久代

JVM 垃圾收集器详解_第7张图片

  • 而G1的各代存储地址是不连续的,每一代都使用了n个不连续的大小相同的Region,每个Region占有一块连续的虚拟内存地址。

JVM 垃圾收集器详解_第8张图片

每一个Region都可以根据需要,扮演新生代的Eden空间、Survivor空间, 或者老年代空间。

收集器能够对扮演不同角色的Region采用不同的策略去处理,这样无论是新创建的对象还是已经存活了一段时间、熬过多次收集的旧对象都能获取很好的收集效果。

Region中还有一类特殊的Humongous区域,专门用来存储大对象。

G1认为只要大小超过了一个Region容量一半的对象即可判定为大对象。

每个Region的大小可以通过参数-XX: G1HeapRegionSize设定,取值范围为1MB ~ 32MB,且应为2的N次幂。

而对于那些超过了整个Region容量的超级大对象,将会被存放在N个连续的Humongous Region之中,G1的大多数行为都把Humongous Region作为老年代的一部分来进行看待。

虽然G1仍然保留新生代和老年代的概念,但新生代和老年代不再是固定的了,它们都是一系列区域 (不需要连续)的动态集合。

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

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

G1的GC模式,提供了两种GC模式:

  • Young GC
  • Mixed GC。

7.1 G1 Young GC

它在Eden空间耗尽时会被触发,开始对Eden区进行GC,在这种情况下,Eden空间的数据移动到Survivor空间中,如果Survivor空间不够,Eden空间的部分数据会直接晋升到年老代空间。

Survivor区的数据移动到新的Survivor区中,也有部分数据晋升到老年代空间中。

最终Eden空间的数据为空,GC停止工作,应用线程继续执行。

JVM 垃圾收集器详解_第9张图片

这时,我们需要考虑一个问题,如果仅仅GC新生代对象,Young区的对象可能还存在Old区的引用, 这就是跨代引用的问题。

为了避免对整个堆扫描下来会耗费大量的时间。于是,G1引进了RSet(Remembered Set)和卡表(card table)的概念。

基本思想就是用空间换时间。

7.1.1 记忆集(Remembered Set)

新生代 GC(发生得非常频繁)。一般来说, GC过程是这样的:

首先枚举根节点。根节点有可能在新生代中,也有可能在老年代中。

这里由于我们只想收集新生代(换句话说,不想收集老年代),所以没有必要对位于老年代的 GC Roots 做全面的可达性分析。但问题是,确实可能存在位于老年代的某个 GC Root,它引用了新生代的某个对象,这个对象你是不能清除的,G1模式是活对象。

关键是怎么快速判断哪些对象是这种对象呢?完全扫描老年代显然不经济。

JVM 垃圾收集器详解_第10张图片

事实上,对于位于不同年代对象之间的引用关系,虚拟机会在程序运行过程中给记录下来。

对应上面所举的例子,“老年代对象引用新生代对象”这种关系,会在引用关系发生时,在新生代边上专门开辟一块空间记录下来,这就是Remembered Set,Remembered Set记录的是新生代的对象被老年代引用的关系。

所以“新生代的 GC Roots ” + “ Remembered Set 存储的内容”,才是新生代收集时真正的 GC Roots 。然后就可以以此为据,在新生代上做可达性分析,进行垃圾回收。

G1 收集器使用的是化整为零的思想,把一块大的内存划分成很多个域( Region )。

但问题是,难免有一个 Region 中的对象引用另一个 Region 中对象的情况。为了达到可以以 Region 为单位进行垃圾回收的目的, G1 收集器也使用了 Remembered Set 这种技术。G1中每个Region都有一个与之对应的Remembered Set ,在各个 Region 上记录自家的对象被外面对象引用的情况。当进行内存回收时,在GC根节点的枚举范围中加入Remembered Set 即可保证不对全堆扫描也不会有遗漏。

G1 GC只在两个场景中依赖RSet:

  • 老年代到年轻代的引用:G1 GC维护了从老年代区间到年轻代区间的指针,这个指针保存在年轻代的RSet里面。
  • 老年代到老年代的引用:从老年代到老年代的指针保存在老年代的RSet里面。

7.1.2 卡表(Card Table)

如果老年代和新生代之间的引用关系很多,要对每个引用都记录在Rset里,Rset会占很大空间,这样得不偿失,为了解决这个问题,在G1 中又引入了另外一个概念,卡表(Card Table)。

卡表作为一个比特位的集合,每一个比特位可以用来标识老年代某一子区域(这个区域称之为卡。G1是512字节)中的所有的对象是否持有新生代对象的引用,这样新生代GC可以不用花大量的时间扫描老年代对象,来确定每一个对象的引用,而可以先扫描卡表,只有卡表标识为1,才需要扫描该区域的老年代对象,为0则一定不包含新生代的引用。

JVM 垃圾收集器详解_第11张图片

一般情况下,这个RSet其实是一个Hash Table,Key是别的Region的起始地址,Value是一个集合,里面的元素是Card Table的Index。

JVM 垃圾收集器详解_第12张图片

7.2 G1 Mix GC

Mix GC不仅进行正常的新生代垃圾收集,同时也回收部分后台扫描线程标记的老年代分区。它的GC步骤分4步:

  • 1、初始标记(initial mark,STW):在此阶段,G1 GC 对根进行标记。
  • 2、并发标记(Concurrent Marking):G1 GC 在整个堆中查找可访问的(存活的)对象。
    3、最终标记(Remark,STW):帮助完成标记周期。
    4、清除垃圾(Evacuation,STW):识别所有空闲分区;整理堆分区,为混合垃圾回收识别出有高回收价值的老年代分区;RSet梳理。

JVM 垃圾收集器详解_第13张图片

7.2.1 三色标记算法

原始快照(Snapshot At The Beginning,SATB),提到并发标记,就离不开SATB,说清楚STAB,就要说三色标记算法。

三色标记算法是描述追踪式回收器的一种有用的方法,利用它可以推演收集器并发标记的正确性。

首先,我们将对象分成三种类型:

  • 黑色:根对象,或者该对象与它的子对象都被扫描了,确定是活的。
  • 灰色:对象本身已被扫描,但还没扫描完该对象中的子对象,也就是对象里的字段属性引用的关系还没扫描完。
  • 白色:未被扫描对象,扫描完成所有对象之后,最终为白色的为不可达对象,即垃圾对象。

当GC开始扫描对象时,按照如下图步骤进行对象的扫描:

根对象被置为黑色,子对象被置为灰色。

JVM 垃圾收集器详解_第14张图片

在GC并发扫描后的结果如下:

JVM 垃圾收集器详解_第15张图片
在并发标记阶段,应用线程改变了这种引用关系

A.c=C

JVM 垃圾收集器详解_第16张图片

在重新标记阶段扫描结果如下:

JVM 垃圾收集器详解_第17张图片

这种情况下C会被当做垃圾进行回收。

Snapshot的存活对象原来是A、B,实际存活对象变成现在A、B、C了,Snapshot的完整遭到破坏了,显然这个做法是不合理。

G1采用的是pre-write barrier解决这个问题。简单说就是在并发标记阶段,当引用关系发生变化的时候,通过pre-write barrier函数会把这种这种变化记录并保存在一个队列里,在JVM源码中这个队列叫satb_mark_queue。

在remark阶段会扫描这个队列,通过这种方式,旧的引用所指向的对象就会被标记上,其子孙也会被递归标记上,这样就不会漏标记任何对象,snapshot的完整性也就得到了保证。

CMS的incremental update设计使得它在remark阶段必须重新扫描所有线程栈和整个young gen作为root;G1的SATB设计在remark阶段则只需要扫描剩下的satb_mark_queue ,解决了CMS垃圾收集器重新标记阶段长时间STW的潜在风险。

SATB的方式记录活对象,也就是那一时刻对象snapshot, 但是在之后这里面的对象可能会变成垃圾, 叫做浮动垃圾,这种对象只能等到下一次收集回收掉。在GC过程中新分配的对象都当做是活的,其他不可达的对象就是死的。

如何知道哪些对象是GC开始之后新分配的呢?

在Region中通过top-at-mark-start(TAMS)指针,分别为prevTAMS和nextTAMS来记录新配的对象。示意图如下:

JVM 垃圾收集器详解_第18张图片

其中top是该region的当前分配指针,

  • [bottom, top)是当前该region已用(used)的部分,[top, end)是尚未使用的可分配空间(unused)。
  • (1):[bottom, prevTAMS): 这部分里的对象第n-1轮concurrent marking已经标记过的对象
  • (2):[prevTAMS, nextTAMS): 这部分里的对象在第n-1轮concurrent marking是隐式存活的
  • (3):[nextTAMS, top): 这部分里的对象在第n轮concurrent marking是隐式存活的

7.2.2 清除垃圾(Evacuation)

Evacuation阶段是全暂停的。它负责把一部分region里的活对象拷贝到空region里去(并行拷贝),然后回收原本的region的空间。Evacuation阶段可以自由选择任意多个region来独立收集,这些被选中的region构成收集集合(collection set,简称CSet),CSet集合中Region的选定依赖于用户设定允许的收集停顿时间(使用参数-XX: MaxGCPauseMilis指定, 默认值是200毫秒),该阶段并不是选择evacuate所有有活对象的region,只选择回收价值高的少量region来evacuate,这种暂停的开销就可以(在一定范围内)可控。

JVM 垃圾收集器详解_第19张图片

7.2.3 Full GC

G1的垃圾回收过程是和应用程序并发执行的,当Mixed GC的速度赶不上应用程序申请内存的速度的时候,Mixed G1就会降级到Full GC,使用的是Serial GC。Full GC会导致长时间的STW,应该要尽量避免。

导致G1 Full GC的原因可能有两个:

  • Evacuation的时候没有足够的to-space来存放晋升的对象;
  • 并发处理过程完成之前空间耗尽

相关核心配置参数用JDK8做环境:

G1 GC给我们提供了很多的命令行选项,也就是参数,这些参数一类以布尔类型打头,“+”表示启用该选项,“-”表示关闭该选项。另一类采用数字赋值,不需要布尔类型打头。

  • -XX:+UseG1GC 启用G1垃圾收集器

  • -XX:G1HeapRegionSize=nM(要带单位)

这是G1GC独有的选项,Region的大小默认为堆大小的1/200,也可以设置为1MB、2MB、4MB、8MB、16MB,以及32MB,这六个划分档次。增大Region块的大小有利于处理大对象。

前面介绍过,大对象没有按照普通对象方式进行管理和分配空间,如果增大Region块的大小,则一些原本走特殊处理通道的大对象就可以被纳入普通处理通道了。

这就好比我们在机场安检,飞行员、空姐可以走特殊通道,乘客如果也搞特殊化,一部分人去特殊通道处理,那么特殊通道就得増加几个,相应的普通通道就得减少了,对效率就起了降低作用。

反之,如果Region大小设置过小,则会降低G1的灵活性,对于各个年龄代的大小都会造成分配问题。

  • -XX:MaxGCPauseMillis=200 最长暂停时间设置目标值。默认值是200 毫秒。

  • -XX:G1NewSizePercent=5

将要使用的堆百分比设置为年轻代大小的最小值。默认值是Java堆的5%,这是一个实验性的标志。

有关示例,请参见如何解锁实验性VM标志。要求先配置:-XX:+UnlockExperimentalVMOptions

  • -XX:G1MaxNewSizePercent=60

将要使用的堆大小百分比设置为年轻代大小的最大值。默认值是Java堆的60%,这是一个实验性的标志。

要求前面先配置:-XX:+UnlockExperimentalVMOptions

  • -XX:ParallelGCThreads=n

设置STW工作线程的值。将n的值设置为逻辑处理器的数量。n的值与逻辑处理器的数量相同,最多为8。

如果有超过8个逻辑处理器,则将n的值设置为大约5/8个逻辑处理器。

这在大多数情况下都是可行的,除了较大的SPARC系统,其中n的值大约是逻辑处理器的5/16。

  • -XX:ConcGCThreads=n

-XX:ConcGCThreads=n 设置并行标记线程的数目。将n设置为并行垃圾收集线程(ParallelGCThreads)数量的大约1/4。

  • -XX:InitiatingHeapOccupancyPercent=45

这个选项决定了是否开始一次老年代回收动作,即年轻代GC结束之后,G1会评估剩余的对象是否达到了整个Java堆的45%这个阈值。

  • -XX:G1MixedGCLiveThresholdPercent=85

在并发标记阶段识别需要被回收的old region,标记成candidate old region,以便在Mixed GC阶段进入CSet而被回收。

是通过G1MixedGCLiveThresholdPercent来控制的,

当region中的存活数据占比率不超过该阈值时,则表示要被回收,默认占用率为85%。

在并发标记阶段每个region中的存活数据占比率会被重新计算,那些存活数据占比较多的region,回收时的代价相对较昂贵,它们还会被标记为expensive region。

如果在MixedGC阶段这种region大量进入CSet中可能会导致MixedGC的停顿时间过长。

G1为了区分开这些region而做了分开标记,在MixedGC阶段优先回收candidate old region,如果代价许可,会尝试回收expensive region。

  • -XX:G1HeapWastePercent=5

表示可容忍的浪费堆空间百分比。如果可回收百分比小于该设置的百分比,JVM不会启动混合垃圾回收周期。

  • -XX:G1MixedGCCountTarget=8

老年代Region的回收时间通常来说比年轻代Region稍长一些,这个选项可以设置一个并发标记之后启动多少个混合GC,默认值是8个。设置一个比较大的值可以让G1 GC在老年代Region回收时多花一些时间,

如果一个混合GC的停顿时间很长,说明它要做的事情很多,所以可以增大这个值的设置,缩短停顿时间,

但是如果这个值过大,也会造成并行循环等待混合GC完成的时间相应的增加。

  • -XX:G1OldCSetRegionThresholdPercent=10

混合垃圾收集期间,每次能进入CSet的old region的最大阈值(进入CSet表示要垃圾收集)。实验室性标记

默认值是Java堆的 10%。如果该值设置过大,则每次Mixed GC需要收集的old region数量会变多,导致停顿时间拉长。

该值可以限制每次Mixed GC最多能回收的old region数量。

  • -XX:G1ReservePercent=10

选项的值表示相应增加总的堆大小,为“目标空间”增加预留内存量; 相对保证晋升对象不会分配失败!

  • -XX:+UnlockExperimentalVMOptions

要更改实验性标志的值,必须先对其解锁。显式地设置该参数。

二、低延迟垃圾收集器(了解)

1、Shenandoah 收集器

最初Shenandoah是由RedHat公司独立发展的新型收集器项目,在2014年RedHat把Shenandoah贡献给了OpenJDK,并推动它成为OpenJDK 12的正式特性之一,但是OracleJDK不支持。

Shenandoah也是使用基于Region的堆内存布局,同样有保存大对象的Humongous Region,默认的回收策略也同样是优先处理回收价值最大的Region。但在管理堆内存方面,它与G1至少有三个明显的不同之处。

  • 1、最重要的当然是支持并发的整理算法,G1的回收阶段是可以多线程并行的,但却不能与用户线程并发,但是Shenandoah最核心的功能就是回收清理的线程不仅是多线程还可以和用户线程并发。

  • 2、Shenandoah (目前)是默不使用分代收集,不会有专门的新生代Region和老年代Region。

  • 3、Shenandoah和G1有不同的数据结构记录夸Region的引用关系,使用的是“链接矩阵”,

JVM 垃圾收集器详解_第20张图片

Shenandoah 具体的工作过程,可以参见2016年RedHat发表的Shenandoah垃圾收集器的论文。

论文地址:https://www.researchgate.net/publication/306112816_Shenandoah_An_open-source_concurrent_compacting_garbage_collector_for_OpenJDK

这里说一下2016年RedHat公司发表的实际应用性能对比:

JVM 垃圾收集器详解_第21张图片

2、ZGC 收集器

ZGC (“Z”” 并非什么专业名词的缩写,这款收集器的名字就叫作Z Garbage Collector) 是一款在JDK 11中新加入的具有实验性质的低延迟垃圾收集器,是由Oracle公司研发的。2018年将ZGC提交给OpenJDK,推动其进入OpenJDK 11的发布清单之中。

ZGC和Shenandoah的目标是高度相似,但实现技术上区别很大,简单了解一下ZGC的技术特点:

  • 1)首先从ZGC的内存布局说起。与Shenandoah和G1一样,ZGC也采用基于Region的堆内存布局,但与它们不同的是,ZGC 的Region (在一些官方资料中将它称为Page或者ZPage)具有动态性,能动态创建和销毁, 以及动态的区域容量大小。在x64硬件平台下,ZGC的Region可以具有大、中、小三类容量:

    • 小型Region (Small Region):容量固定为2MB,用于放置小于256KB的小对象。
    • 中型Region (Medium Region): 容量固定为32MB,用于放置大于等于256KB但小于4MB的对象。
    • 大型Region (Large Region):容量不固定,可以动态变化,但必须为2MB的整数倍,用于放置4MB或以上的大对象。

每个大型Region中只会存放一个大对象,这也预示着虽然名字叫作"大型Region",但它的实际容量完全有可能小于中型Region,最小容量可低至4MB。

  • 2)ZGC的核心功能,和Shenandoah一样实现了并发整理功能,但实现方式和Shenandoah不太一样,它用了一种关键技术,染色指针技术。

最后说一下性能:

在ZGC的强项停顿时间测试上,它就毫不留情地与Parallel Scavenge、G1拉开了两个数量级的差距。

不论是平均停顿,还是95%停顿、99%停顿、99.9%停顿, 抑或是最大停顿时间,ZGC均能不费劲地控制在十毫秒之内,以至于把它和另外两款停顿数百近千毫秒的收集器放到一起对比,就几乎显示不了ZGC的柱状条:

JVM 垃圾收集器详解_第22张图片
相关参数:

激活ZGC:-XX:+UnlockExperimentalVMOptions -XX:UseZGC

2、Epsilon(ε)收集器

在G1、Shenandoah或者ZGC这些越来越复杂、越来越先进的垃圾收集器相继出现的同时,也有一个“反其道而行”的新垃圾收集器出现在JDK 11的特征清单中一Epsilon,这是一款以不能够进行垃圾收集为"卖点”的垃圾收集器,"不干活”的收集器

Epsilon收集器由RedHat公司在JEP 318中提出,在此提案里Epsilon被形容成一个无操作的收集器,而事实上只要Java虚拟机能够工作,垃圾收集器便不可能是真正“无操作”的。原因是“垃圾收集器”这个名字并不能形容它全部的职责,更贴切的名字应该是“自动内存管理子系统”。

一个垃圾收集器除了垃圾收集这个本职工作之外,它还要负责堆的管理与布局、对象的分配、与解释器的协作、与编译器的协作、与监控子系统协作等职责,其中至少堆的管理和对象的分配这部分功能是Java虚拟机能够正常运作的必要支持,是一个最小化功能的垃圾收集器也必须实现的内容。

从JDK 10开始,为了隔离垃圾收集器与Java虚拟机解释、编译、监控等子系统的关系,RedHat提出了垃圾收集器的统一接口,即JEP 304提案,Epsilon是这个接口的有效性验证和参考实现,同时也用于需要剥离垃圾收集器影响的性能测试和压力测试。

在实际生产环境中,不能进行垃圾收集的Epsilon也仍有用武之地。很长一段时间以来,Java技 术体系的发展重心都在面向长时间、大规模的企业级应用和服务端应用,可是近年来大型系统从传统单体应用向微服务化、无服务化向发展的趋势已越发明显,Java在这方面比起Golang等后起之秀来确实有一些先天不足, 使用率正渐渐下降。传统Java有着内存占用较大,在容器中启动时间长,即时编译需要缓慢优化等特点,这对大型应用来说并不是什么太大的问题,但对短时间、小规模的服务形式就有诸多不适。为了应对新的技术潮流,最近几个版本的JDK逐渐加入了提前编译、面向应用的类数据共享等支持。

Epsilon也是有着类似的目标,如果应用只要运行数分钟甚至数秒,只要Java虚拟机能正确分配内存,在堆耗尽之前就会退出,那显然运行负载极小、没有任何回收行为的Epsilon便是很恰当的选择。

相关参数:

-XX:+UnlockExperimentalVMOptions
-XX:+UseEpsilonGC

三、选择合适的垃圾收集器

HotSpot虚拟机提供了种类繁多的垃圾收集器,选择太多反而令人踌躇难决,若只挑最先进的显然不可能满足全部应用场景,下面来探讨一下如何选择合适的垃圾收集器。

1、这个问题的答案主要受以下三个因素影响:

1)应用程序的主要关注点是什么?

如果是数据分析、科学计算类的任务,目标是能尽快算出结果,那吞吐量就是主要关注点;

如果是客户端/服务器模式的应用,那停顿时间直接影响服务质量,严重的甚至会导致事务超时,这样延迟就是主要关注点;而如果是客户端应用或者嵌入式应用,那垃圾收集的内存占用则是不可忽视的。

2)运行应用的基础设施如何?

譬如硬件规格,要涉及的系统架构是X86-32/64、SPARC还是ARM/Aarch64;

处理器的数量多少,分配内存的大小;

选择的操作系统是Linux、Solaris还是Windows等。

3)使用JDK的发行商是什么?版本号是多少?

是ZingJDK/Azul、OracleJDK、Open-JDK、 OpenJ9抑或是其他公司的发行版?

该JDK对应了《Java虚拟机规范》的哪个版本?

一般来说,收集器的选择就从以上这几点出发来考虑。

2、举个例子

假设某个直接面向用户提供服务的B/S系统准备选择垃圾收集器,一般来说延迟时间是这类应用的主要关注点,那么,

如果你有充足的预算但没有太多调优经验,那么一套带商业技术支持的专有硬件或者软件解决方案是不错的选择,Azul公司以前主推的Vega系统和现在主推的Zing VM是这方面的代表,这样你就可以使用传说中的C4收集器了。

如果你虽然没有足够预算去使用商业解决方案,但能够掌控软硬件型号,使用较新的版本,同时又特别注重延迟,那ZGC很值得尝试。

如果你对还处于实验状态的收集器的稳定性有所顾虑,或者应用必须运行在Windows操作系统下,那ZGC就无缘了,试试Shenandoah吧。

如果你接手的是遗留系统,软硬件基础设施和JDK版本都比较落后,那就根据内存规模衡量一下,对于大概4GB到6GB以下的堆内存,CMS一般能处理得比较好,而对于更大的堆内存,可重点考察一下G1。

当然,以上都是仅从理论出发的分析,实战中切不可纸上谈兵,根据系统实际情况去测试才是选择收集器的最终依据。

上面信息来自《深入理解JAVA虚拟机(JVM高级特性与最佳实践 第3版)》作者周志明著的这本书,这里学习做相应的记录。

– 求知若饥,虚心若愚。

你可能感兴趣的:(#,JVM,JVM,垃圾收集器详解)