Java 垃圾收集器和内存分配策略2

本文是笔者看了周志明大佬的深入理解Java虚拟机一书做的一些总结,强烈建议大家买一下这本书细细的读一读。

书接上回,上次我发了一些Java 垃圾收集的一些基本概念和原理,这次的文章主要聚焦于一些经典的垃圾收集器和它们的一些具体实现。

还有就是,因为笔者实在是太菜了,可能有些地方理解上和实际的情况有所出入。希望发现笔者错误的朋友能够指出斧正。

经典的垃圾收集器

在G1收集器出现之前,垃圾收集器一般基于经典的分代思想进行实现。一般一个垃圾收集器特定的作用于老年代或者是新生代。如果通过不同分代的GC之间相互配合组合完成整个堆的GC。

接下来我们先看看一些经典的GC。

Serial收集器:最基础,历史最悠久的收集器

Serial是一个单线程的收集器,这里的单线程指的是Serial收集器工作的时候会暂停其他所有工作线程,直到它收集结束。也就是“Stop The World”。

Serial采用标记-复制算法。

Serial现在任然是客户端模式下HotSpot虚拟机的默认新生代收集器,因为它的额外内存开销小,对于单核处理器或者处理器核心数较少的环境,Serial收集器没有线程交互的开销,不需要频繁的进行线程上下文切换,因此速度反而比较快。

Serial Old : Serial收集器的老年代版本,采用标记-整理算法(老年代存活下来的对象数量多,使用标记-复制算法开销大)。作为CMS收集器发生失败时的后背预案。

ParNew收集器:Serial收集器的多线程版本

ParNew收集器在单核处理器上的性能肯定比不上Serial收集器,但是随着核数的升高,ParNew在性能上还是强于Serial的。ParNew现在通常和CMS收集器绑定,作为使用CMS收集器的新生代解决方案。

Parallel Scavenge收集器

Parallel Scavenge收集器也是一款新生代的收集器,与ParNew相同,它也是基于标记-复制算法实现的。

不同点在于:Parallel Scavenge收集器的目标是达到一个可控制的吞吐量。

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

Parallel Scavenge收集器实现了自适应调节策略,采用Parallel Scavenge自适应调节策略后,就不需要自己手动指定新生代大小,Eden和Survivor区的比例等。Parallel Scavenge 会进行自动调优以提供合适的停顿时间和最大的吞吐量。

Parallel Old收集器:

Parallel Scavenge的老年代版本,基于标记-整理算法实现。

在注重吞吐量的应用场景中,可以采用Parallel Scavenge + Parallel Old实现。

CMS收集器:

一种以获取最短回收停顿时间为目标的收集器。CMS比较符合经常需要和用户交互的应用情景。

CMS收集器是基于标记-清除算法实现的,它的整个工作流程分为4个步骤:

  1. 初始标记,标记GC Roots的直接关联对象,速度快,任然需要“Stop The World”
  2. 并发标记,  从GC Roots的直接关联对象开始遍历整个对象图的过程,这个过程比较耗时但是不用停顿用户线程。
  3. 重新标记  修正2阶段,因为用户线程继续运行而导致标记产生变动的那一部分对象的标记记录,这个阶段也需要“Stop The World”。
  4. 并发清除  清除掉所有带标记的对象。

CMS收集器的缺点:

  1. 对处理器核数比较少的情况下,会大幅度降低处理器的执行用户线程的速度。
  2. CMS无法处理浮动垃圾, 也就是在并发标记过程中工作线程产生的垃圾并不能纳入当次的回收。
  3. CMS收集器可能会产生“Concurrent Mode Failure”, 因为CMS收集器需要预留一部分内存给工作线程在并发标记过程中使用。但是如果这部分内存无法满足CMS并发标记过程中的内存使用,那么就会发生“Concurrent Mode Failure”(并发模式失败),导致另一次Full GC的发生。此时,JVM会冻结CMS收集器,“Stop The World”,换用Serial Old收集器。
  4. CMS是基于标记-清除算法来实现的,因此会有存在空间碎片化的问题,如果有比较大的对象需要被创建,可能需要提前触发FULL GC。

G1收集器

G1收集器是垃圾收集器技术史上里程碑的突破。因为G1收集器开创了面向局部收集的设计思路和基于Region的内存布局模式。这就突破了传统的垃圾收集器分代设计的局限。

G1收集器是一款主要面向服务端应用的垃圾收集器。

G1收集器实现了停顿预测模型,能够支持指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间大概率不超过N毫秒这样的目标。

G1的基于Region的堆内存布局:

与经典分代内存布局不同,G1不再坚持固定大小以及固定数量的分代区域划分。G1把连续的Java堆划分为多个大小相等的独立区域(Region), 每一个Region都可以根据需要扮演新生代里面的Eden空间,Survivor空间,老年代空间(Region概念有点像操作系统里面的分页)。

Region中还有一类特殊的Humongous区域,专门用来储存大对象。G1的大多数行为都把Humongous Region作为老年代的一部分来进行看待。

G1收集器之所以能建立可预测的停顿时间模型,是因为它将Region作为单次回收的最小单元,即每次收集到的内存空间都是Region大小的整数倍。

更具体的思路是让G1垃圾收集器去跟踪各个Region里面的垃圾堆积“价值”,价值即回收所获得的空间大小以及回收所需时间的经验值,然后在后台维护一个优先级列表,每次根据用户设定允许的收集停顿时间,优先回收价值收益最大的Region。

G1收集器看起来很美好,但是实现起来有非常多的细节需要注意:

  1. 跨Region引用怎么处理?

之前我们谈过,对于经典分代理论中的跨代引用,我们可以通过维护记忆集的方式来添加对应老年代中的“GC Root”。(记忆集是一种用于记录非收集区域指向收集区域的指针集合的抽象数据结构)

在G1的每一个Region都维护了自己的记忆集,这些记忆集会记录下别的Region指向自己的指针,并标记这些指针分别在哪些卡页的范围内。G1的记忆集在储存结构的本质上是一种哈希表,Key是别的Region的起始地址,Value是一个集合,里面储存的元素是卡表的索引号。

因此,G1的记忆集是一个双向的卡表结构,相比起传统的记忆集,G1为每个Region都维护自己的记忆集会产生更大的开销。

什么是卡表?

设计者在实现记忆集的时候,可以选择更为粗犷的记录粒度来节省记忆集的储存和维护成本。

卡精度代表每个记录精确到了一个对象,表明这个区域里面有对象含有跨代指针。

卡表就是具体到卡精度的记忆集表,卡表是记忆集的具体实现方案。

卡页代表了一个区域的内存,卡表里面的每一个元素都对应着其标识的内存区域中一块特定大小的内存块,这个内存块被称为“卡页”。只要卡页中有一个对象的字段存在跨段引用,那么就把对应的卡表标识为1。

  1. 并发标记期间如何保证收集线程和工作线程互不干扰的运行?

在CMS收集器中,采用的是增量更新算法。而G1收集器中采用的是原始快照算法(SATB)。

什么是增量更新算法?什么是原始快照算法?

讲这两个问题之前,我们先来看看工作线程和并发标记同时进行如果不采取措施会有什么后果?

  1. 已经被GC标记为可以回收的对象,因为工作线程修改图的结构,被标记为存活。这虽然不是好事,但是只是产生了一些浮动垃圾,下一次GC收集了就好。
  2. 被GC标记为不可回收的对象,因为工作线程修改图的结构,被标记为可回收,这就非常严重了,可能导致程序的错误!!

因此,我们需要特别避免第二种情况发生。

那么第二种情况什么情况下会发生呢?

可以看一下下面这个图:

其中,黑色的节点表示已经被标记为存活的对象,白色的结点为未标记为存活的对象,灰色的节点表示黑色节点与白色节点的交界。

出现如下面这个图这种情况,那么原本应该为黑色的节点,最后却没有被标记上。

也就是说,当扫描越过了初始的黑色节点(GC Root)的时候,此时下一个节点也就是灰色节点到最下面这个白色节点的引用被用户线程修改了,并且初始的黑色节点生成了一个引用到下面这个白色节点。那么这个时候,下面这个白色节点其实本来是不应该被回收的,但是实际上却被回收了,这就造成了错误。

前面已经有了大佬证明,当且仅当下面两个条件都满足,才会产生“对象消失问题”(也就是上面这个问题)

  1. 赋值器插入了一条或者多条从黑色对象到白色对象的新引用
  2. 赋值器删除了全部从灰色对象到该白色对象的新引用。

也就是说,想要避免出现“对象消失”的问题,可以通过破坏上面两个条件来达到这个目的。

这就对应了两个解决方法:1. 增量更新 2. 原始快照

  1. 增量更新:

增量更新破坏的是第一个条件,当黑色对象插入新的指向白色对象的引用时,将新插入的引用记录下来,等并发扫描结束后,再将这些记录过的引用关系中的黑色对象为根,重新扫描一次。

  1. 原始快照:

原始快照破坏的是第二个条件,当灰色对象删除指向白色对象的引用关系时,就把这个引用记录下来,当并发标记结束后,以这些灰色对象为根,再搜索一次。相当于无论引用删除与否,都按照删除前的快照再进行搜索。

让我们回到G1收集器,G1收集器采用的是原始快照的方法来保证收集线程和用户线程互不干扰。

G1还为每个region都分配了两个TAMS指针,这两个指针之间的内存空间用于给并发标记期间用户线程创建新的对象提供空间,如果这个空间不够,则也会像CMS收集器那样出现“Concurrent Mode Failure” 失败导致Full GC。

      G1收集器的大致工作步骤分为下面4个

  1. 初始标记,仅仅只是标记一下GC Roots能直接关联到的对象,并且修改TAMS指针的值。此阶段需要停顿用户线程。
  2. 并发标记,从GC Roots开始对堆中对象进行可达性分析,在对象图扫描完成后,重新处理SATB记录下的在并发时有引用变动的对象。
  3. 最终标记:停顿用户线程,处理最后遗留下来的SATB。
  4. 筛选回收,停顿用户线程,更新Region的统计数据,对各个Region的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划。将活着的Region对象复制到新的Region里面,清空旧的Region。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值