JVM-垃圾收集器详解

lisa2.jpg

今天这篇文章本来要写LinkedList的,为了准备面试最近在看垃圾收集器。临时变成介绍垃圾收集器啦,这篇文章会有点长,可以收藏下来慢慢看。

垃圾收集器搭配图:
lajishoujiqi.png

上图展示了7种作用于不同分代的收集器,如果两个收集器之间存在连线,就说明它们可以搭配使用。

虚拟机所处的区域,则表示它是属于新生代收集器还是老年代收集器。Hotspot实现了如此多的收集器,正是因为目前并无完美的收集器出现,只是选择对具体应用最适合的收集器。

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

接下来讨论的收集器基于JDK1.7 Update 14 之后的HotSpot虚拟机(在此版本中正式提供了商用的G1收集器,之前G1仍处于实验状态)。

下面说一些通用性概念:

并行和并发

  • 并行(Parallel):指多条垃圾收集线程并行工作,但此时用户线程仍然处于等待状态。
  • 并发(Concurrent):指用户线程与垃圾收集线程同时执行(但不一定是并行的,可能会交替执行),用户程序在继续运行。而垃圾收集程序运行在另一个CPU上。

吞吐量(Throughput)

吞吐量就是CPU用于运行用户代码的时间与CPU总消耗时间的比值,即

吞吐量 = 运行用户代码时间 /(运行用户代码时间 + 垃圾收集时间)。

假设虚拟机总共运行了100分钟,其中垃圾收集花掉1分钟,那吞吐量就是99%。

Minor GC 、Major GC 、 Full GC

这里引用一下知乎上的高赞回答(作者:RednaxelaFX),我觉得是对GC描述的比较清楚的一个:

针对HotSpot VM的实现,它里面的GC其实准确分类只有两大种:

Partial GC:并不收集整个GC堆的模式

  • Young GC:只收集young gen的GC
  • Old GC:只收集old gen的GC。只有CMS的concurrent collection是这个模式
  • Mixed GC:收集整个young gen以及部分old gen的GC。只有G1有这个模式

Full GC:收集整个堆,包括young gen、old gen、perm gen(如果存在的话)等所有部分的模式。

Major GC通常是跟full GC是等价的,收集整个GC堆。但因为HotSpot VM发展了这么多年,外界对各种名词的解读已经完全混乱了,当有人说“major GC”的时候一定要问清楚他想要指的是上面的full GC还是old GC。

最简单的分代式GC策略,按HotSpot VM的serial GC的实现来看,触发条件是:

  • young GC:当young gen中的eden区分配满的时候触发。注意young GC中有部分存活对象会晋升到old gen,所以young GC后old gen的占用量通常会有所升高。

  • full GC:当准备要触发一次young GC时,如果发现统计数据说之前young GC的平均晋升大小比目前old gen剩余的空间大,则不会触发young GC而是转为触发full GC(因为HotSpot VM的GC里,除了CMS的concurrent collection之外,其它能收集old gen的GC都会同时收集整个GC堆,包括young gen,所以不需要事先触发一次单独的young GC);或者,如果有perm gen的话,要在perm gen分配空间但已经没有足够空间时,也要触发一次full GC;或者System.gc()、heap dump带GC,默认也是触发full GC。

HotSpot VM里其它非并发GC的触发条件复杂一些,不过大致的原理与上面说的其实一样。
当然也总有例外。Parallel Scavenge(-XX:+UseParallelGC)框架下,默认是在要触发full GC前先执行一次young GC,并且两次GC之间能让应用程序稍微运行一小下,以期降低full GC的暂停时间(因为young GC会尽量清理了young gen的死对象,减少了full GC的工作量)。控制这个行为的VM参数是-XX:+ScavengeBeforeFullGC。这是HotSpot VM里的奇葩嗯。可跳传送门围观:JVM full GC的奇怪现象,求解惑? - RednaxelaFX 的回答

并发GC的触发条件就不太一样。以CMS GC为例,它主要是定时去检查old gen的使用量,当使用量超过了触发比例就会启动一次CMS GC,对old gen做并发收集。

新生代收集器

Serial收集器

Serial(串行)收集器是最基本、发展历史最悠久的收集器,它是采用复制算法的新生代收集器,曾经(JDK 1.3.1之前)是虚拟机新生代收集的唯一选择。

它是一个单线程收集器,只会使用一个CPU或一条收集线程去完成垃圾收集工作,更重要的是它在进行垃圾收集时,必须暂停其他所有的工作线程,直至Serial收集器收集结束为止(“Stop The World”)。

这项工作是由虚拟机在后台自动发起和自动完成的,在用户不可见的情况下把用户正常工作的线程全部停掉,这对很多应用来说是难以接收的。图解 Java 垃圾回收机制,这篇推荐看下。

下图展示了Serial 收集器(老年代采用Serial Old收集器)的运行过程:


image.png

为了消除或减少工作线程因内存回收而导致的停顿,HotSpot虚拟机开发团队在JDK 1.3之后的Java发展历程中研发出了各种其他的优秀收集器,这些将在稍后介绍。但是这些收集器的诞生并不意味着Serial收集器已经“老而无用”,实际上到现在为止,它依然是HotSpot虚拟机运行在Client模式下的默认的新生代收集器。

它也有着优于其他收集器的地方:简单而高效(与其他收集器的单线程相比),对于限定单个CPU的环境来说,Serial收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得更高的单线程收集效率。

在用户的桌面应用场景中,分配给虚拟机管理的内存一般不会很大,收集几十兆甚至一两百兆的新生代(仅仅是新生代使用的内存,桌面应用基本不会再大了),停顿时间完全可以控制在几十毫秒最多一百毫秒以内,只要不频繁发生,这点停顿时间可以接收。

所以,Serial收集器对于运行在Client模式下的虚拟机来说是一个很好的选择。

ParNew 收集器

ParNew收集器就是Serial收集器的多线程版本,它也是一个新生代收集器。除了使用多线程进行垃圾收集外,其余行为包括Serial收集器可用的所有控制参数、收集算法(复制算法)、Stop The World、对象分配规则、回收策略等与Serial收集器完全相同,两者共用了相当多的代码。

ParNew收集器的工作过程如下图(老年代采用Serial Old收集器):

image.png

ParNew收集器除了使用多线程收集外,其他与Serial收集器相比并无太多创新之处,但它却是许多运行在Server模式下的虚拟机中首选的新生代收集器,其中有一个与性能无关的重要原因是,除了Serial收集器外,目前只有它能和CMS收集器(Concurrent Mark Sweep)配合工作,CMS收集器是JDK 1.5推出的一个具有划时代意义的收集器,具体内容将在稍后进行介绍。

ParNew 收集器在单CPU的环境中绝对不会有比Serial收集器有更好的效果,甚至由于存在线程交互的开销,该收集器在通过超线程技术实现的两个CPU的环境中都不能百分之百地保证可以超越。

在多CPU环境下,随着CPU的数量增加,它对于GC时系统资源的有效利用是很有好处的。它默认开启的收集线程数与CPU的数量相同,在CPU非常多的情况下可使用-XX:ParallerGCThreads参数设置。

Parallel Scavenge 收集器

Parallel Scavenge收集器也是一个并行的多线程新生代收集器,它也使用复制算法。Parallel Scavenge收集器的特点是它的关注点与其他收集器不同,CMS等收集器的关注点是尽可能缩短垃圾收集时用户线程的停顿时间,而Parallel Scavenge收集器的目标是达到一个可控制的吞吐量(Throughput)。

停顿时间越短就越适合需要与用户交互的程序,良好的响应速度能提升用户体验。而高吞吐量则可以高效率地利用CPU时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。

Parallel Scavenge收集器除了会显而易见地提供可以精确控制吞吐量的参数,还提供了一个参数-XX:+UseAdaptiveSizePolicy,这是一个开关参数,打开参数后,就不需要手工指定新生代的大小(-Xmn)、Eden和Survivor区的比例(-XX:SurvivorRatio)、晋升老年代对象年龄(-XX:PretenureSizeThreshold)等细节参数了。

虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量,这种方式称为GC自适应的调节策略(GC Ergonomics)。自适应调节策略也是Parallel Scavenge收集器与ParNew收集器的一个重要区别。

另外值得注意的一点是,Parallel Scavenge收集器无法与CMS收集器配合使用,所以在JDK 1.6推出Parallel Old之前,如果新生代选择Parallel Scavenge收集器,老年代只有Serial Old收集器能与之配合使用。

老年代收集器

Serial Old收集器

Serial Old 是 Serial收集器的老年代版本,它同样是一个单线程收集器,使用“标记-整理”(Mark-Compact)算法。

此收集器的主要意义也是在于给Client模式下的虚拟机使用。如果在Server模式下,它还有两大用途:

  • 在JDK1.5 以及之前版本(Parallel Old诞生以前)中与Parallel Scavenge收集器搭配使用。
  • 作为CMS收集器的后备预案,在并发收集发生Concurrent Mode Failure时使用。

它的工作流程与Serial收集器相同,这里再次给出Serial/Serial Old配合使用的工作流程图:


image.png

Parallel Old收集器

Parallel Old收集器是Parallel Scavenge收集器的老年代版本,使用多线程和“标记-整理”算法。前面已经提到过,这个收集器是在JDK 1.6中才开始提供的,在此之前,如果新生代选择了Parallel Scavenge收集器。

老年代除了Serial Old以外别无选择,所以在Parallel Old诞生以后,“吞吐量优先”收集器终于有了比较名副其实的应用组合,在注重吞吐量以及CPU资源敏感的场合,都可以优先考虑Parallel Scavenge加Parallel Old收集器。

Parallel Old收集器的工作流程与Parallel Scavenge相同,这里给出Parallel Scavenge/Parallel Old收集器配合使用的流程图:


image.png

下面介绍一下当下最流行的两款垃圾收集器:CMS、G1;这两款收集器涉及到的概念比较多,我这里先做一下铺垫。

三色标记法

image.png

我们把遍历对象图过程中遇到的对象,按“是否访问过”这个条件标记成以下三种颜色:

白色:尚未访问过。

黑色:本对象已访问过,而且本对象 引用到 的其他对象 也全部访问过了。

灰色:本对象已访问过,但是本对象 引用到 的其他对象 尚未全部访问完。全部访问后,会转换为黑色。

img

最后被标记为白色的对象即为GC Roots 不可达,可以进行回收。

卡表(CMS)

卡表主要用来处理CMS跨代引用的,现代JVM,堆空间通常被划分为新生代和老年代。由于新生代的垃圾收集通常很频繁,如果老年代对象引用了新生代的对象,那么,需要跟踪从老年代到新生代的所有引用,从而避免每次YGC时扫描整个老年代,减少开销。

对于HotSpot JVM,使用了卡标记(Card Marking)技术来解决老年代到新生代的引用问题。具体是,使用卡表(Card Table)和写屏障(Write Barrier)来进行标记并加快对GC Roots的扫描。

卡表是使用一个字节数组实现:CARD_TABLE[ ],每个元素对应着其标识的内存区域一块特定大小的内存块,称为“卡页”。hotSpot使用的卡页是2^9大小,即512字节

在这里插入图片描述

一个卡页中可包含多个对象,只要有一个对象的字段存在跨代指针,其对应的卡表的元素标识就变成1,表示该元素变脏,否则为0。

当对一个对象引用进行写操作时(对象引用改变),写屏障逻辑将会标记对象所在的卡页为dirty。

OpenJDK/Oracle 1.6/1.7/1.8 JVM默认的卡标记简化逻辑如下:

CARD_TABLE [this address >> 9] = 0;

首先,计算对象引用所在卡页的卡表索引号。将地址右移9位,相当于用地址除以512(2的9次方)。可以这么理解,假设卡表卡页的起始地址为0,那么卡表项0、1、2对应的卡页起始地址分别为0、512、1024(卡表项索引号乘以卡页512字节)。

其次,通过卡表索引号,设置对应卡标识为dirty。

带来的2个问题

1.无条件写屏障带来的性能开销

每次对引用的更新,无论是否更新了老年代对新生代对象的引用,都会进行一次写屏障操作。显然,这会增加一些额外的开销。但是,与YGC时扫描整个老年代相比较,这个开销就低得多了。

不过,在高并发环境下,写屏障又带来了虚共享(false sharing)问题。

2.高并发下虚共享带来的性能开销

在高并发情况下,频繁的写屏障很容易发生虚共享(false sharing),从而带来性能开销。

假设CPU缓存行大小为64字节,由于一个卡表项占1个字节,这意味着,64个卡表项将共享同一个缓存行。

HotSpot每个卡页为512字节,那么一个缓存行将对应64个卡页一共64*512=32KB。

如果不同线程对对象引用的更新操作,恰好位于同一个32KB区域内,这将导致同时更新卡表的同一个缓存行,从而造成缓存行的写回、无效化或者同步操作,间接影响程序性能。

一个简单的解决方案,就是不采用无条件的写屏障,而是先检查卡表标记,只有当该卡表项未被标记过才将其标记为dirty。

这就是JDK 7中引入的解决方法,引入了一个新的JVM参数-XX:+UseCondCardMark,在执行写屏障之前,先简单的做一下判断。如果卡页已被标识过,则不再进行标识。

简单理解如下:

if (CARD_TABLE [this address >> 9] != 0)
  CARD_TABLE [this address >> 9] = 0;

与原来的实现相比,只是简单的增加了一个判断操作。

虽然开启-XX:+UseCondCardMark之后多了一些判断开销,但是却可以避免在高并发情况下可能发生的并发写卡表问题。通过减少并发写操作,进而避免出现虚共享问题(false sharing)。

GC时,只要筛选本收集区的卡表中变脏的元素加入GCRoots里。卡表使用写屏障来进行维护,写屏障可以理解为Spring 中的 AOP。

Mod Union Table

在CMS的并发标记阶段,可能会出现两个并发问题:

第一,CMS在执行并发标记,同时,应用在修改老年代中对象的引用。这时候,老年代的引用状态会发生改变,所以CMS要想办法把这种改变记录下来。CMS使用card table来记录这些改变,把发生改变的对象所在的card标记为dirty,然后在最终标记阶段再次扫描这些标记为dirty的card。(不过这样也会产生浮动垃圾)

第二,CMS在执行并发标记,同时,新生代GC开始运行。注意,新生代GC也需要扫描上述的card table(据我理解这个card table只有一个),在扫描的时候,也是要对标记为dirty(这里的dirty是在引用赋值的时候标记的,具体是先插入一个write barrier,然后判断如果左值是老年代的引用,右值是新生代的引用,那么就把这个card标记为dirty)的card进行分析,如果这个card没有对新生代的引用了,那么新生代GC就会把它标记为clean,但是这样导致CMS收集器在最终标记阶段无法扫描这个card。
那这样到底有影响吗?想象一种可能的情况,就是在CMS在执行并发标记的时候,其他线程先改变了一个card里面的对象引用,然后新生代GC开始运行(这时CMS仍然在执行并发标记),新生代GC如果扫描到这个dirty card不再有新生代的引用,那么就把它标记为clean。这个时候就会出现了标记遗漏的情况了。

为了解决上述的问题,Mod Union Table被引入了,它是一个位向量,每个单元的大小只有1位,每个单元对应一个card(card的大小是512字节,cardtable每一个单元的大小是1个字节),在新生代GC处理dirty card之前,先把该card在Mod Union Table里面的对应项置位。这样,CMS在执行最终标记阶段的时候,就会扫描Mod Union Table和card table里面被标记的项。

另外,对于card table中每一个单元的取值,新生代GC和CMS所设置的dirty应该有所区别,意思就是说,它们两个用不同的值来代表drity这个状态。

多标(浮动垃圾)

假设已经遍历到E(变为灰色了),此时应用执行了 objD.fieldE = null

image.png

此刻之后,对象E/F/G是“应该”被回收的。然而因为E已经变为灰色了,其仍会被当作存活对象继续遍历下去。最终的结果是:这部分对象仍会被标记为存活,即本轮GC不会回收这部分内存。

这部分本应该回收 但是 没有回收到的内存,被称之为“浮动垃圾”。浮动垃圾并不会影响垃圾回收的正确性,只是需要等到下一轮垃圾回收中才被清除。

另外,针对并发标记开始后的新对象,通常的做法是直接全部当成黑色,本轮不会进行清除。这部分对象期间可能会变为垃圾,这也算是浮动垃圾的一部分。

漏标(读写屏障)

假设GC线程已经遍历到E(变为灰色了),此时应用线程先执行了:

image.png

image.png

此时切回GC线程继续跑,因为E已经没有对G的引用了,所以不会将G放到灰色集合;尽管因为D重新引用了G,但因为D已经是黑色了,不会再重新做遍历处理。
最终导致的结果是:G会一直停留在白色集合中,最后被当作垃圾进行清除。这直接影响到了应用程序的正确性,是不可接受的。

不难分析,漏标只有同时满足以下两个条件时才会发生:
条件一:灰色对象 断开了 白色对象的引用;即灰色对象 原来成员变量的引用 发生了变化。
条件二:黑色对象 重新引用了 该白色对象;即黑色对象 成员变量增加了 新的引用。


image.png

读取 对象E的成员变量fieldG的引用值,即对象G;

对象E 往其成员变量fieldG,写入 null值。

对象D 往其成员变量fieldG,写入 对象G ;

我们只要在上面这三步中的任意一步中做一些“手脚”,将对象G记录起来,然后作为灰色对象再进行遍历即可。比如放到一个特定的集合,等初始的GC Roots遍历完(并发标记),该集合的对象 遍历即可(重新标记)。

写屏障用于拦截第二和第三步;而读屏障则是拦截第一步。
它们的拦截的目的很简单:就是在读写前后,将对象G给记录下来。

写屏障(tore Barrier)

所谓的写屏障就是在写操作前后加入一些处理,类似于AOP。

1、写屏障+SATB

当对象E的成员变量的引用发生变化时(objE.fieldG = null;),我们可以利用写屏障,将E原来成员变量的引用对象G记录下来,记录下来的就叫原始快照(Snapshot At The Beginning,SATB),后续的标记也照着SATB走。

SATB破坏了条件一:【灰色对象 断开了 白色对象的引用】,从而保证了不会漏标。

2、写屏障+增量更新

当对象D的成员变量的引用发生变化时(objD.fieldG = G;),我们可以利用写屏障,将D新的成员变量引用对象G记录下来。针对新增的引用,将其记录下来等待遍历,即增量更新(Incremental Update)。

增量更新破坏了条件二:【黑色对象 重新引用了 该白色对象】,从而保证了不会漏标。

读屏障(Load Barrier)

读屏障是直接针对第一步:var G = objE.fieldG;,当读取成员变量时,一律记录下来。

对于HotSpot:

CMS:写屏障 + 增量更新

G1:写屏障 + SATB

ZGC:读屏障

CMS收集器

CMS全称 Concurrent Mark Sweep,是老年代垃圾收集器,在收集过程中可以与用户线程并发操作。它可以与Serial收集器和Parallel New收集器搭配使用,使用标记-清除算法,采用并行的方法,STW(停顿)时间很短。

image.png
CMS运行过程

初始标记,会导致stw。

并发标记,与用户线程同时运行。

预清理,与用户线程同时运行。

可被终止的预清理,与用户线程同时运行。

重新标记,会导致stw。

并发清除,与用户线程同时运行。

并发重置,与用户线程同时运行。

垃圾回收算法基本就那么几种,无论使用哪种算法,标记都是必要的一步。首先需要标记出垃圾,再进行回收,而标记也可以有很多种方法,CMS使用的是三色标记法。 如果这个标记过程是STW的话,对象的引用关系是不会变的,意味着标记结果是正确的。

但是CMS是并发的进行标记的,对象间的引用可能发生变化,多标和漏标的情况就有可能发生。

CMS的具体做法
1、初始标记:
标记GC Roots可达的老年代对象;遍历GC Roots下的新生代对象能够可达的老年代对象,也就是跨代引用。
image.png
2、并发标记:
该阶段GC线程和应用线程并发执行,遍历InitialMarking阶段标记出来的存活对象,然后继续递归标记这些对象可达的对象。
image.png

这个过程应用线程在运行,可能Young GC也会发生,会发生以下几种情况:

1、新生代对象晋升到老年代

2、在老年代分配对象

3、新老年代对象的引用发生变化

对象的变化会导致上文说的漏标。CMS使用卡表(Cart Table)来解决标记过程中对象的变化。那么卡表是什么,就是将内存分为一块一块的页,卡表作为一个比特位的集合,每一个比特位可以用来表示老年代的某一区域中的对象持有的引用是否有变动。


image.png

回到上文的漏标:


1.png

当D和E的引用改变后,将对应的卡表中的位置1。

卡表还有一个作用就是发生YGC的时候用来查看有没有老年代的对象引用新生代,这样就不用每次都遍历老年代的对象的。

card table只有一份,既要用来支持young GC又要用来支持CMS。每次young GC过程中都涉及重置和重新扫描card table,这样是满足了young GC的需求,但却破坏了CMS的需求——CMS需要的信息可能被young GC给重置掉了。

为了避免丢失信息,就在card table之外另外加了一个bitmap叫做mod-union table。在CMS concurrent marking正在运行的过程中,每当发生一次young GC,当young
GC要重置card table里的某个记录时,就会更新mod-union table对应的bit。

这样,最后到CMS remark的时候,当时的card table外加mod-union table就足以记录在并发标记过程中old gen发生的所有引用变化了。

实际上HotSpot VM一般用的post-write barrier非常简单,就是无条件的记录下发生过引用关系变化的card,这里不关心对象所在的分代,所以其实只要有引用改变,其对应的card都会被记录。也就是说这个card table记录的不只是old -> young引用,而是所有发生了变化的引用的出发端,无论在old还是young。

3、预清理
前一个阶段已经说明,不能标记出老年代全部的存活对象,是因为标记的同时应用程序会改变一些对象引用,这个阶段就是用来处理前一个阶段因为引用关系改变导致没有标记到的存活对象的,它会扫描所有标记为Dirty的Card。
4、可被终止的预定清理
该阶段发生的前提是,新生代Eden区的内存使用量大于参数CMSScheduleRemarkEdenSizeThreshold 默认是2M,如果新生代的对象太少,就没有必要执行该阶段,直接执行重新标记阶段。

为什么需要这个阶段?

因为CMS GC的终极目标是降低垃圾回收时的暂停时间,所以在该阶段要尽最大的努力去处理那些在并发阶段被应用线程更新的老年代对象,这样在暂停的重新标记阶段就可以少处理一些,暂停时间也会相应的降低。

在该阶段,主要循环的做两件事:

1、处理 From 和 To 区的对象,标记可达的老年代对象

2、和上一个阶段一样,扫描处理Dirty Card和ModUnionTalble中的对象。

当然了,这个逻辑不会一直循环下去,打断这个循环的条件有三个:

1、可以设置最多循环的次数 CMSMaxAbortablePrecleanLoops,默认是0,意思没有循环次数的限制。

2、如果执行这个逻辑的时间达到了阈值CMSMaxAbortablePrecleanTime,默认是5s,会退出循环。

3、如果新生代Eden区的内存使用率达到了阈值CMSScheduleRemarkEdenPenetration,默认50%,会退出循环。

5、重新标记

遍历新生代对象,重新标记

根据GC Roots,重新标记

遍历老年代的Dirty Card和Mod Union Table,重新标记

6、并发清理
7、重置

使用CMS需要注意的几点

减少remark阶段停顿

一般CMS的GC耗时80%都在remark阶段,如果发现remark阶段停顿时间很长,可以尝试添加该参数:
-XX:+CMSScavengeBeforeRemark。
在执行remark操作之前先做一次Young GC,目的在于减少年轻代对老年代的无效引用,降低remark时的开销。

内存碎片问题

CMS是基于标记-清除算法的,CMS只会删除无用对象,不会对内存做压缩,会造成内存碎片,这时候我们需要用到这个参数:
-XX:CMSFullGCsBeforeCompaction=n
意思是说在上一次CMS并发GC执行过后,到底还要再执行多少次full GC才会做压缩。默认是0,也就是在默认配置下每次CMS GC顶不住了而要转入full GC的时候都会做压缩。 如果把CMSFullGCsBeforeCompaction配置为10,就会让上面说的第一个条件变成每隔10次真正的full GC才做一次压缩。

concurrent mode failure

这个异常发生在cms正在回收的时候。执行CMS GC的过程中,同时业务线程也在运行,当年轻带空间满了,执行ygc时,需要将存活的对象放入到老年代,而此时老年代空间不足,这时CMS还没有机会回收老年带产生的,或者在做Minor GC的时候,新生代救助空间放不下,需要放入老年代,而老年代也放不下而产生的。
设置cms触发时机有两个参数:

  • -XX:+UseCMSInitiatingOccupancyOnly
  • -XX:CMSInitiatingOccupancyFraction=70

-XX:CMSInitiatingOccupancyFraction=70 是指设定CMS在对内存占用率达到70%的时候开始GC。
-XX:+UseCMSInitiatingOccupancyOnly如果不指定, 只是用设定的回收阈值CMSInitiatingOccupancyFraction,则JVM仅在第一次使用设定值,后续则自动调整会导致上面的那个参数不起作用。

为什么要有这两个参数?
由于在垃圾收集阶段用户线程还需要运行,那也就还需要预留有足够的内存空间给用户线程使用,因此CMS收集器不能像其他收集器那样等到老年代几乎完全被填满了再进行收集,需要预留一部分空间提供并发收集时的程序运作使用。
CMS前五个阶段都是标记存活对象的,除了”初始标记”和”重新标记”阶段会stop the word ,其它三个阶段都是与用户线程一起跑的,就会出现这样的情况gc线程正在标记存活对象,用户线程同时向老年代提升新的对象,清理工作还没有开始,old gen已经没有空间容纳更多对象了,这时候就会导致concurrent mode failure, 然后就会使用串行收集器回收老年代的垃圾,导致停顿的时间非常长。
CMSInitiatingOccupancyFraction参数要设置一个合理的值,设置大了,会增加concurrent mode failure发生的频率,设置的小了,又会增加CMS频率,所以要根据应用的运行情况来选取一个合理的值。如果发现这两个参数设置大了会导致full gc,设置小了会导致频繁的CMS GC,说明你的老年代空间过小,应该增加老年代空间的大小了。

promotion failed

在进行Minor GC时,Survivor Space放不下,对象只能放入老年代,而此时老年代也放不下造成的,多数是由于老年带有足够的空闲空间,但是由于碎片较多,新生代要转移到老年带的对象比较大,找不到一段连续区域存放这个对象导致的。

过早提升与提升失败

在 Minor GC 过程中,Survivor Unused 可能不足以容纳 Eden 和另一个 Survivor 中的存活对象, 那么多余的将被移到老年代, 称为过早提升(Premature Promotion),这会导致老年代中短期存活对象的增长, 可能会引发严重的性能问题。 再进一步,如果老年代满了, Minor GC 后会进行 Full GC, 这将导致遍历整个堆, 称为提升失败(Promotion Failure)。

早提升的原因

  1. Survivor空间太小,容纳不下全部的运行时短生命周期的对象,如果是这个原因,可以尝试将Survivor调大,否则端生命周期的对象提升过快,导致老年代很快就被占满,从而引起频繁的full gc;
  2. 对象太大,Survivor和Eden没有足够大的空间来存放这些大对象。

提升失败原因

当提升的时候,发现老年代也没有足够的连续空间来容纳该对象。为什么是没有足够的连续空间而不是空闲空间呢?老年代容纳不下提升的对象有两种情况:

  1. 老年代空闲空间不够用了;
  2. 老年代虽然空闲空间很多,但是碎片太多,没有连续的空闲空间存放该对象。

解决方法

  1. 如果是因为内存碎片导致的大对象提升失败,cms需要进行空间整理压缩;
  2. 如果是因为提升过快导致的,说明Survivor 空闲空间不足,那么可以尝试调大 Survivor;
  3. 如果是因为老年代空间不够导致的,尝试将CMS触发的阈值调低。

CMS常用参数

参数 类型 默认值 说明
-XX:+UseConcMarkSweepGC boolean false 老年代采用CMS收集器收集
-XX:+CMSScavengeBeforeRemark boolean false The CMSScavengeBeforeRemark forces scavenge invocation from the CMS-remark phase (from within the VM thread as the CMS-remark operation is executed in the foreground collector)
-XX:+UseCMSCompactAtFullCollection boolean false 对老年代进行压缩,可以消除碎片,但是可能会带来性能消耗
-XX:CMSFullGCsBeforeCompaction=n uintx 0 CMS进行n次full gc后进行一次压缩。如果n=0,每次full gc后都会进行碎片压缩。如果n=0,每次full gc后都会进行碎片压缩
–XX:+CMSIncrementalMode boolean false 并发收集递增进行,周期性把cpu资源让给正在运行的应用
–XX:+CMSIncrementalPacing boolean false 根据应用程序的行为自动调整每次执行的垃圾回收任务的数量
–XX:ParallelGCThreads=n uintx (ncpus <= 8) ? ncpus : 3 + ((ncpus * 5) / 8) 并发回收线程数量
-XX:CMSIncrementalDutyCycleMin=n uintx 0 每次增量回收垃圾的占总垃圾回收任务的最小比例
-XX:CMSIncrementalDutyCycle=n uintx 10 每次增量回收垃圾的占总垃圾回收任务的比例
-XX:CMSInitiatingOccupancyFraction=n uintx 当老年代内存使用达到n%,开始回收。CMSInitiatingOccupancyFraction = (100 - MinHeapFreeRatio) + (CMSTriggerRatio * MinHeapFreeRatio / 100)
-XX:CMSMaxAbortablePrecleanTime=n intx 5000 在CMS的preclean阶段开始前,等待minor gc的最大时间。

G1收集器

G1其实是Garbage First的意思,垃圾优先? 不是,是优先处理那些垃圾多的内存块的意思。

首先说一下G1的特性:

  • 可以建立可预测的停顿模型(软实时性)。
  • 局部复制算法,整体标记整理。解决内存碎片问题。
  • 分代收集。
  • 并行与并发
  • 将整个Java堆划分为多个大小相等的Region。
  • 面向服务端,适合管理较大的堆。

G1的内存结构

在大的理念上,它还是遵循JVM的内存分代假设(其实叫假设不准确,这是从实际Java应用的内存使用观察得到的结论):

90%的对象熬不过第一次垃圾回收,而老的对象(经历了好几次垃圾回收的对象)则有98%的概率会一直活下来。

基于这个分代假设,一般的垃圾回收器把内存分成三类: Eden(E), Suvivor(S)和Old(O), 其中Eden和Survivor都属于年轻代,Old属于老年代,新对象始终分配在Eden里面,熬过一次垃圾回收的对象就被移动到Survisor区了,经过数次垃圾回收之后还活着的对象会被移到Old区。
这样分代的好处是,把一个复杂的大问题,分成两类不同的小问题,针对不同的小问题,采用更有针对性的措施(分而治之):

  • 对于年轻代的对象,由于对象来的快去得快,垃圾收集会比较频繁,因此执行时间一定要短,效率要高,因此要采用执行时间短,执行时间的长短只取决于对象个数的垃圾回收算法。但是这类回收器往往会比较浪费内存,比如Copying GC,会浪费一半的内存,以空间换取了时间。
  • 对于老年代的对象,由于本身对象的个数不多,垃圾收集的次数不多,因此可以采用对内存使用比较高效的算法。
    跟其它垃圾回收器不一样的是:G1虽然也把内存分成了这三大类,但是在G1里面这三大类不是泾渭分明的三大块内存,G1把内存划分成很多小块, 每个小块会被标记为E/S/O中的一个,可以前面一个是Eden后面一个就变成Survivor了。
    image.png

    heap被划分为一系列大小相等的“小堆区”,也称为region。每个小堆区(region)的大小为1~32MB,整个堆默认划分出2048个小堆区。与上一代的垃圾收集器一样在逻辑上被划分Eden、Survivor和老年代,但是各种角色的region个数都不是固定的。下图中的绿色代表Eden小堆区、黄色为Survivor小堆区、蓝色则为老年代小堆区、而灰色则会未被使用的区域。
    在G1中,还有一种特殊的区域,叫Humongous区域。 如果一个对象占用的空间超过了分区容量50%以上,G1收集器就认为这是一个巨型对象。这些巨型对象,默认直接会被分配在年老代,但是如果它是一个短期存在的巨型对象,就会对垃圾收集器造成负面影响。为了解决这个问题,G1划分了一个Humongous区,它用来专门存放巨型对象。如果一个H区装不下一个巨型对象,那么G1会寻找连续的H分区来存储。为了能找到连续的H区,有时候不得不启动Full GC。
    新生代和老年代所占堆的空间是没那么固定的(会动态根据「最大停顿时间:-XX:MaxGCPauseMillis」进行调整)。
    初始的新生代大小通过参数:下限:-XX:G1NewSizePercent,默认值5%,上限:-XX:G1MaxNewSizePercent,默认值60%。来进行指定,后续运行的过程中会通过最大暂停时间进行动态调整

以下概念引用R大

CSet

收集集合(CSet)代表每次GC暂停时回收的一系列目标分区。在任意一次收集暂停中,CSet所有分区都会被释放,内部存活的对象都会被转移到分配的空闲分区中。因此无论是年轻代收集,还是混合收集,工作的机制都是一致的。年轻代收集CSet只容纳年轻代分区,而混合收集会通过启发式算法(重点就是如何平衡局部搜索与全局搜索;有效逃离局部最优解;),在老年代候选回收分区中,筛选出回收收益最高的分区添加到CSet中。

CSet根据两种不同的回收类型分为两种不同CSet。

  • CSet of Young Collection
  • CSet of Mix Collection
    CSet of Young Collection 只专注回收 Young Region 跟 Survivor Region ,而CSet of Mix Collection 模式下的CSet 则会通过RSet计算Region中对象的活跃度,活跃度阈值-XX:G1MixedGCLiveThresholdPercent(默认85%),只有活跃度高于这个阈值的才会准入CSet,混合模式下CSet还可以通过XX:G1OldCSetRegionThresholdPercent(默认10%)设置CSet跟整个堆的比例的数量上限。

RSet(Remember Set :记忆集合)

image.png

G1 GC的heap与HotSpot VM的其它GC一样有一个覆盖整个heap的card table。
逻辑上说,G1 GC的remembered set(下面简称RSet)是每个region有一份。这个RSet记录的是从别的region指向该region的card。所以这是一种“points-into”的remembered set。

用card table实现的remembered set通常是points-out的,也就是说card table要记录的是从它覆盖的范围出发指向别的范围的指针。以分代式GC的card table为例,要记录old -> young的跨代指针,被标记的card是old gen范围内的。

G1 GC则是在points-out的card table之上再加了一层结构来构成points-into RSet:每个region会记录下到底哪些别的region有指向自己的指针,而这些指针分别在哪些card的范围内。
这个RSet其实是一个hash table,key是别的region的起始地址,value是一个集合,里面的元素是card table的index。

举例来说,如果region A的RSet里有一项的key是region B,value里有index为1234的card,它的意思就是region B的一个card里有引用指向region A。所以对region A来说,该RSet记录的是points-into的关系;而card table仍然记录了points-out的关系。

TAMS指针

在并发标记过程中,如何进行新对象的内存分配呢?
G1有两个TAMS指针,把Region中的一部分空间划分出来用于并发回收过程中的新对象的分配。并发回收时新分配的对象地址都必须在这两个指针之上,G1收集器默认在这个地址上的对象是存活的,不纳入回收范围。

SATB

SATB抽象的说就是在一次GC开始的时候是活的对象就被认为是活的,此时的对象图形成一个逻辑“快照”(snapshot);然后在GC过程中新分配的对象都当作是活的。其它不可到达的对象就是死的了。

很容易知道哪些对象是一次GC开始之后新分配的:每个region记录着两个top-at-mark-start(TAMS)指针,分别为prevTAMS和nextTAMS。在TAMS以上的对象就是新分配的,因而被视为隐式marked。

但是在并发GC里,collector一边动mutator也一边动,如果collector并发mark的过程中mutator覆盖了某些引用字段的值而collector还没mark到那里,那collector不就得不到完整的snapshot了么?为了解决这个问题就有了SATB write barrier。G1 GC具体使用的是“湯浅”(Yuasa)式的SATB write barrier的变种。
Write barrier是对“对引用类型字段赋值”这个动作的环切,也就是说赋值的前后都在barrier覆盖的范畴内。在赋值前的部分的write barrier叫做pre-write barrier,在赋值后的则叫做post-write barrier。

G1收集过程

从最高层看,G1的collector一侧其实就是两个大部分:

  • 全局并发标记(global concurrent marking)
  • 拷贝存活对象(evacuation)
    而这两部分可以相对独立的执行。
G1在运行过程中的主要模式:YGC、并发标记、混合回收、FullGC(一般是G1出现问题时发生)
YGC

在新生代没有可用空间时进行触发,再回收之后所有之前数与新生代的Region全部变成空白空间,即不属于任何一个分区。

  • 第一阶段,根扫描: 一定要考虑remembered Set,看是否有老年代中的对象引用了新生代对象
    根是指static变量指向的对象,正在执行的方法调用链条上的局部变量等。根引用连同RSet记录的外部引用作为扫描存活对象的入口)
  • 第二阶段,更新RSet: 处理dirty card queue(见备注)中的card,更新RSet。 此阶段完成后,RSet可以准确的反映老年代对所在的内存分段中对象的引用
    dirty card queue: 对于应用程序的引用赋值语句object.field=object,JVM会在之前和之 后执行特殊的操作以在dirty card queue中入队一个保存了对象引用信息的card。在年轻代回 收的时候,G1会对Dirty CardQueue中所有的card进行处理,以更新RSet,保证RSet实时准确的 反映引用关系。那为什么不在引用赋值语句处直接更新RSet呢?这是为了性能的需要,RSet的 处理需要线程同步,开销会很大,使用队列性能会好很多
  • 第三阶段,处理RSet: 识别被老年代对象指向的Eden中的对象,这些被指向的Eden中的对象被认为是存活的对象
  • 第四阶段,复制对象: 此阶段,对象树被遍历,Eden区 内存段中存活的对象会被复制到Survivor区中空的内存分段,Survivor区内存段中存活的对象如果年龄未达阈值,年龄会加1,达到阀值会被会被复制到old区中空的内存分段。如果Survivor空间不够,Eden空间的部分数据会直接晋升到老年代空间
  • 第五阶段,处理引用: 处理Soft,Weak, Phantom, Final, JNI Weak等引用。最终Eden空间的数据为空,GC停止工作,而目标内存中的对象都是连续存储的,没有碎片,所以复制过程可以达到内存整理的效果,减少碎片
并发标记(三色标记法)

并发标记主要是为混合回收提供标记服务的,并不是一次GC中的必须环节。

  • 一、初始标记阶段:标记从根节点直接可达的对象。这个阶段是STW的,并且会触发一次年轻代GC
  • 二、根区域扫描(Root Region Scanning):G1 GC扫描Survivor区直接可达的老年代区域对象,并标记被引用的对象。这一过程必须在young GC之前完成(YoungGC时,会动Survivor区,所以这一过程必须在young GC之前完成)
  • 三、并发标记(Concurrent Marking):在整个堆中进行并发标记(和应用程序并发执行),此过程可能被young GC中断。在并发标记阶段,若发现区域对象中的所有对象都是垃圾,那这个区域会被立即回收。同时,并发标记过程中,会计算每个区域的对象活性(区域中存活对象的比例)。
  • 四、再次标记(Remark):由于应用程序持续进行,需要修正上一次的标记结果。是STW的。G1中采用了比CMS更快的初始快照算法:snapshot一at一the一beginning (SATB).
  • 五、独占清理(cleanup,STW):计算各个区域的存活对象和GC回收比例,并进行排序,识别可以混合回收的区域添加到CSet中。为下阶段做铺垫。是STW的。(这个阶段并不会实际上去做垃圾的收集) 注意完全空的region不会被加到CSet,都在这个阶段直接回收了。
混合回收

Mixed GC并不是FullGC,老年代的堆占有率达到参数(-XX:InitiatingHeapOccupancyPercent)设定的值则触发,回收所有的Young和部分Old(根据期望的GC停顿时间选择合适的CSet)以及大对象区,正常情况G1的垃圾收集是先做MixedGC,主要使用复制算法,需要把各个region中存活的对象拷贝到别的region里去,拷贝过程中如果发现没有足够的空region能够承载拷贝对象就会触发一次Full GC

  • 并发标记结束以后,老年代中百分百为垃圾的内存分段被回收了,部分为垃圾的内存分段被计算了出来。默认情况下,这些老年代的内存分段会分8次(可以通过-XX:G1MixedGCCountTarget设置)被回收。
  • 混合回收的回收集(Collection Set)包括八分之一的老年代内存分段,Eden区 内存分段,Survivor区内存分段。 混合回收的算法和年轻代回收的算法完全一样,只是回收集多了老年代的内存分段。具体过程请参考上面的年轻代回收过程。
  • 由于老年代中的内存分段默认分8次回收,G1会优先回收垃圾多的内存分段。垃圾占内存分段比例越高的,越会被先回收。并且有一个阈值会决定内存分段是否被回收,-XX:G1MixedGCLiveThresholdPercent,默认为65%,意思是垃圾占内存分段比例要达到65%才会被回收。如果垃圾占比太低,意味着存活的对象占比高,在复制的时候会花费更多的时间。
  • 混合回收并不一定 要进行8次。有一个阈值-XX :G1HeapWastePercent,默认值为10%,意思是允许整个堆内存中有10%的空间被浪费,意味着如果发现可以回收的垃圾占堆内存的比例低于10%,则不再进行混合回收。因为GC会花费很多的时间但是回收到的内存却很少。
Full GC

如果mixed GC实在无法跟上程序分配内存的速度,导致old gen填满无法继续进行mixed GC,就会切换到G1之外的serial old GC来收集整个GC heap(注意,包括young、old、perm)。这才是真正的full GC。Full GC之所以叫full就是要收集整个堆,只选择old gen的部分region算不上full GC。进入这种状态的G1就跟-XX:+UseSerialGC的full GC一样(背后的核心代码是两者共用的)。

至此垃圾收集器告一段落,篇幅有点长,大家可以收藏之后观看!如果有错误的概念或者文中没有提及到的欢迎在评论区进行补充。
我是巴哥,我一定能进大厂!

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,470评论 6 501
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,393评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 162,577评论 0 353
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,176评论 1 292
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,189评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,155评论 1 299
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,041评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,903评论 0 274
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,319评论 1 310
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,539评论 2 332
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,703评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,417评论 5 343
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,013评论 3 325
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,664评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,818评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,711评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,601评论 2 353

推荐阅读更多精彩内容