Zhu.Yang

朱阳的个人博客(公众号:think123)

0%

Java中的垃圾回收算法以及垃圾回收器

最新出来的ZGC对JVM的垃圾回收有了一个很大的颠覆,那就是内存不在分区了,直接一个大堆搞定,而且还能保证回收速率,虽然现在还是实验版本但是可以遇见,以后升级JDK,ZGC就是一个巨大的诱惑。

我们都知道,JVM将内存在逻辑上划分为了以下几个区域,而新创建的对象都是在堆上分配内存,所以我们的垃圾收集也是在堆上面。

JVM内存区域

现在大量的应用使用JDK6-JDK8仍然是主流,了解垃圾回收可以帮助我们调优JVM,遇到JVM方面的问题不至于束手无策.所以研究JVM的垃圾回收很有必要,我们先介绍下垃圾搜集算法.

收集算法

标记-清除算法

这是最基本的收集算法,分为“标记”和”清除“两个阶段,首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象,标记是通过”可达性分析算法“,这里不做过多展开。
这个算法存在两个问题,一个是效率问题,标记和清除两个过程的效率都不高;另一个是标记清除之后会产生大量的内存碎片,经过几次的GC之后这些碎片可能已经小到不足以分配任何对象了。

标记清除算法

复制算法

为了解决效率问题,复制算法出现了,它是将可用内存按照容量划分为大小相等的两块,每次只使用其中的一块,当这一块的内存使用完了,就将还存活着的对象复制到另一块上面,然后在将使用过的内存空间清理掉。

这种算法不要考虑内存碎片等复杂情况,只需要按照顺序分配即可,实现简单,运行高效。但是代价就是将内存缩小为了原来的一半,代价太高了。

现在的商业虚拟机都采用这种收集算法来回收新生代,我们使用的HotSpot也是。IBM公司的专门研究发现,98%的对象基本上都是朝生夕死的,所以不需要按照1:1的比例来划分内存空间,而是将内存划分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden和一块Survivor,当回收的时候将Eden和Survivor中还存活着的对象一次性复制到另一块空间上,最后清理Eden和刚才Survivor用过的空间。HotSpot虚拟机默认Eden和两块Survivor的分配比例是8:1:1.当然我们没有办法保证每次回收都只有不多于10%的对象存活,当Survivor空间不够时,这个时候会进行担保分配,直接将对象放到老年代。

复制算法

标记整理算法

标记整理算法于“标记-清除”算法一样,但是后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉边界以外的内存。

标记整理算法

分代收集算法

现在商业虚拟机的垃圾收集都采用“分代收集”算法,这种算法只是根据对象存活周期的不同将内存划分为几块,一般是把对内存分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。在新生代中每次垃圾收集时都有大量对象死去,只有少量存活。那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。
而老年代因为对象存活率高,没有额外空间对他进行担保分配,则必须使用“标记-清除”或者“标记-整理”算法来进行回收。
而老年代因为对象存活率高,没有额外空间对他进行担保分配,则必须使用“标记-清除”或者“标记-整理”算法来进行回收。

堆的分代

堆的分代

Perm区被称为永久代,其实它只是方法区的一种实现,JVM规范中这一块被称为方法区,只是对于HotSpot虚拟机上开发者来说是这样的,实际上其他虚拟机(BEA JRockit,IBM J9)来说是不存在永久代概念的。

JDK8之后方法区被MetaSpace取代了,对应的JVM配置参数也修改了(-XX:MetaspaceSize,-XX:MaxMetaspaceSize).

Minor GC又被称为Young GC,只收集young gen的GC
Major GC(old gc)主要指对老年代的回收,只有CMS的concurrent collection是这个模式
Full GC 收集整个堆,包含young gen,old gen,perm gen(metaspace)等所有部分的模式。
Mixed GC: 收集真个young gen以及部分old gen 的GC,只有G1又这种模式

垃圾收集器

垃圾收集器

以上是 HotSpot 虚拟机中的 7 个垃圾收集器,连线表示垃圾收集器可以配合使用。

  • 单线程与多线程:单线程指的是垃圾收集器只使用一个线程进行收集,而多线程使用多个线程;
  • 串行与并行:串行指的是垃圾收集器与用户程序交替执行,这意味着在执行垃圾收集的时候需要停顿用户程序;并行指的是垃圾收集器和用户程序同时执行。除了 CMS 和 G1 之外,其它垃圾收集器都是以串行的方式执行。

    年轻代垃圾收集器

Serial收集器

单线程收集器,只会使用一个CPU或一条收集线程去完成垃圾收集工作,在进行垃圾收集时必须暂停其他工作线程。采用了复制算法来进行垃圾收集,它是虚拟机运行在Client模式下的默认新生代收集器

ParNew收集器

ParNew收集器是Serial收集器的多线程版本,除了使用 多条线程进行垃圾收集之外,其余行为(JVM参数、收集算法、对象分配规则、回收策略、Stop The World等)和Serial收集器完全一样。但是它是许多运行在Server模式下的虚拟机中首选的新生代收集器,因为只有它能与CMS收集器配合工作。默认开启的线程数量与 CPU 数量相同,可以使用 -XX:ParallelGCThreads 参数来设置线程数。

Parallel Scavenge收集器

使用了复制算法,该收集器的关注点是在吞吐量上面,主要适合在后台运算而不需要太多交互的任务。因此也被常称为“吞吐量优先“收集器。它有一个参数-XX:UseAdaptiveSizePolicy参数值得关注,当这个参数打开后,就不需要手工指定新生代的大小(-Xmn)、Eden与Survivor区的比例(-XX:SurvivorRatio)、晋升老年代对象年龄(-XX:PretenureSizeThreshold)等细节参数了。系统会动态调整这些参数以提供最合适的停顿事件或者最大的吞吐量,这种调节方式被成为GC的自适应调节策略。

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

老年代垃圾收集器

Serial Old收集器

Serial Old是Serial收集器的老年代版本,它同样是一个单线程收集器使用”标记-整理“算法。主要也是在client模式下使用,除此之外还有两大用途。一种是在JDK1.5以及之前的版本中于Parallel Scavenge收集器搭配使用(现在很少JDK1.5之前的版本,所以这个作用就基本废弃了),另外一种是作为CMS收集器的后备预案,在并发收集发成Concurrent Mode Failure时使用。

Parallel Old收集器

Parallel Old是Parallel Scavenge收集器的老年代版本,使用多线程的”标记-整理“算法。该收集器是在JDK6及之后才出现的,也就是说如果之前年轻代选怎了Parallel Scavenge,那么老年代只能选择Serial Old.但是现在终于有了对应的应用组合了,在注重吞吐量以及CPU资源敏感的场合,都可以优先考虑Parallel Scavenge + Parallel Old收集器。

CMS收集器

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。是基于”标记-清除“算法实现的。
CMS收集器分为四个步骤:

  1. 初始标记(CMS initial mark):仅仅只是标记一下 GC Roots 能直接关联到的对象,速度很快,会stop the world
  2. 并发标记(CMS concurrent mark):进行 GC Roots Tracing 的过程,它在整个回收过程中耗时最长,不需要停顿
  3. 重新标记(CMS remark):为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,会stop the world
  4. 并发清除(CMS concurrent sweep):不需要停顿

CMS中中的初始标记和重新标记都会产生stop the world的行外,但是并发标记和并发清除收集器线程都可以和用户线程一起运行,所以从总体上来说,CMS收集器的内存回收过程是与用户线程一起并发执行的。

CMS默认启动的回收线程数是(CPU数量+3)/4,也就是当CPU在四个以上时,并发回收时垃圾收集线程不少于25%的CPU资源,并且随着CPU数量的增加而下降。但是当CPU不足4个(譬如2个)时,对用户程序的影响就比较大了,此时回收线程数为1,本来CPU负载就比较大,还分出一半的运算能力去执行收集器线程,就可能导致用户的执行速度降低了50%,令人难以接受,所以如果CPU数量过少,就不太适合使用CMS收集器。

CMS收集器无法处理浮动垃圾,可能会出现“Concurrent Mode Failure(并发模式故障)”失败而导致Full GC产生。
浮动垃圾:由于CMS并发清理阶段用户线程还在运行着,伴随着程序运行自然就会有新的垃圾不断产生,这部分垃圾出现的标记过程之后,CMS无法在当次收集中处理掉它们,只好留待下一次GC中再清理。这些垃圾就是“浮动垃圾”。

由于垃圾收集阶段用户线程还需要运行,就需要有足够的内存空间给用户线程使用,因此CMS收集器不能像其他收集器那样等到老年代几乎完全被填满了在进行收集,需要预留一部分空间提供并发收集时的程序运作使用,所以CMS的运行时机就需要好好把握。JDK1.5中当老年代使用了68%的空间之后CMS收集器会被激活,而这个值在JDK6中是92%,当然可以通过参数参数-XX:CMSInitiatingOccupancyFraction的值来设置触发百分比。
要是CMS运行期间预留的内存无法满足程序需要,就会出现一次”Concurrent Mode Failure”失败,这时虚拟机将启动后预案:临时使用Serial Old收集器来重新进行老年代的垃圾收集,这样停顿时间就更长了。所以-XX:CMSInitiatingOccupancyFraction参数要是设置得太高很容易导致大量“Concurrent Mode Failure”失败,性能反而降低。

上面也提到了CMS采用得算法是”标记-清除“,那么也意味者收集结束时会有大量空间碎片产生,当空间碎片过多,将会给大对象分配带来很大的麻烦,往往会出现老年代还有很大空间剩余,但是无法找到足够大的连续空间来分配当前对象,不得不提前触发一次Full GC。为了解决这个问题,CMS收集器提供了一个-XX:+UseCMSCompactAtFullCollection开关参数(默认开启)用于在CMS收集器顶不住要进行FullGC时开启内存碎片的合并整理过程,内存整理的过程是无法并发执行的,空间碎片问题没有了,但是停顿时间不得不变长。虚拟机设计者还提供了另外一个参数-XX:CMSFullGCsBeforeCompaction,这个参数是用于设置执行多少次不压缩的Full GC后,跟着来一次带压缩的(默认为0,表示每次进入Full GC时都进行碎片整理),这也算是一个折中方案了。

CMS收集器由于并发收集、低停顿,在现在大量的应用中还是处理主流地位的。

G1收集器

G1(Garbage-First),它是一款面向服务端应用的垃圾收集器,在多 CPU 和大内存的场景下有很好的性能。HotSpot 开发团队赋予它的使命是未来可以替换掉 CMS 收集器。
堆被分为新生代和老年代,其它收集器进行收集的范围都是整个新生代或者老年代,而 G1 可以直接对新生代和老年代一起回收。
G1 把堆划分成多个大小相等的独立区域(Region),新生代和老年代不再物理隔离。

G1收集器划分

通过引入 Region 的概念,从而将原来的一整块内存空间划分成多个的小空间,使得每个小空间可以单独进行垃圾回收。这种划分方法带来了很大的灵活性,使得可预测的停顿时间模型成为可能。通过记录每个 Region 垃圾回收时间以及回收所获得的空间(这两个值是通过过去回收的经验获得),并维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的 Region。
每个 Region 都有一个 Remembered Set,用来记录该 Region 对象的引用对象所在的 Region。通过使用 Remembered Set,在做可达性分析的时候就可以避免全堆扫描。
g1-safepoint

如果不计算维护 Remembered Set 的操作,G1 收集器的运作大致可划分为以下几个步骤:* 初始标记* 并发标记* 最终标记:为了修正在并发标记期间因用户程序继续运作而导致标记产生变动的那一部分标记记录,虚拟机将这段时间对象变化记录在线程的 Remembered Set Logs 里面,最终标记阶段需要把 Remembered Set Logs 的数据合并到 Remembered Set 中。这阶段需要停顿线程,但是可并行执行。* 筛选回收:首先对各个 Region 中的回收价值和成本进行排序,根据用户所期望的 GC 停顿时间来制定回收计划。此阶段其实也可以做到与用户程序一起并发执行,但是因为只回收一部分 Region,时间是用户可控制的,而且停顿用户线程将大幅度提高收集效率。具备如下特点:* 空间整合:整体来看是基于“标记 - 整理”算法实现的收集器,从局部(两个 Region 之间)上来看是基于“复制”算法实现的,这意味着运行期间不会产生内存空间碎片。* 可预测的停顿:能让使用者明确指定在一个长度为 M 毫秒的时间片段内,消耗在 GC 上的时间不得超过 N 毫秒。
关于G1收集器的其他参考文章:G1收集器

回收方式的选择

jvm有client和server两种模式,这两种模式的gc默认方式是不同的:

client模式下,新生代选择的是串行gc,旧生代选择的是串行gc

server模式下,新生代选择的是并行回收gc,旧生代选择的是并行gc

一般来说我们系统应用选择有两种方式:吞吐量优先和暂停时间优先,对于吞吐量优先的采用server默认的并行gc方式,对于暂停时间优先的选用并发gc(CMS)方式。

总结

记住各个收集器之间如何搭配有助于查看线上GC日志的时候帮助定位问题,也是对JVM的一次深入了解。了解各个收集器的特性以及使用场景更有助于优化JVM。

欢迎关注我的其它发布渠道