JVM之GC

428 阅读7分钟

GC 全称garbage collect,垃圾收集,是用来处理java中内存自动回收的。

垃圾判定

可达性分析

jvm中判定一个对象可被回收的算法是可达性分析算法。即一个对象如果是某个GC Root引用可达的,那么这个对象是不被回收的;反之,要回收。

GC Root

GC Root是可达性分析的起点。主要包含两类:全局性对象和执行上下文。

  • 类的静态属性
  • 常量
  • 虚拟机栈中的变量
  • 本地方法栈中的变量

垃圾收集算法

标记-清除算法

即将堆中所以要被回收的对象标记出来,然后将其对应的内存区域清楚。

缺点:

会产生大量的内存碎片。

复制算法

将内存空间分成大小相等的两部分,首先使用其中一部分存放对象,当要回收时,将不需要回收的对象复制到另一块空间中,然后清除原先的空间。

缺点:

  • 空间使用率低,只有50%
  • 如果存活对象比例较高,复制操作比较耗费资源

标记-整理算法

首先将要回收的对象标记出来,从一端开始,如果发现对象可以被回收,记住位置;如果发现对象不可以被回收,则将对象向前移动,覆盖可以被回收的对象。依次遍历。

缺点:

如果存活对象比例较高,整理操作比较耗费资源(要将不可以回收的对象一个一个向前移动)

分代收集算法

分代收集不是一个具体的垃圾收集算法。而是指的在jvm的不同内存区域上采用上述不同的垃圾收集算法。

一般来讲,由于年轻代的对象会“朝生夕死”,往往会采用复制算法(年轻代也一般划分为一个Eden区和两个Suvivor区)。而老年代则会采用标记-整理或者标记清楚算法。

垃圾收集器

常见的垃圾收集器主要可以分为三类:

  • 年轻代的垃圾收集器

  • Serial

  • Parallel Scavenge

  • ParNew

  • 老年代的垃圾收集器

  • Serial Old

  • Parallel Old

  • CMS

  • 全面的垃圾收集器

  • G1

下边我们来一个一个认识这些收集器

Serial 和 Serial Old

Serial是年轻代的收集器,采用的复制算法,单线程收集,整个过程产生STW(stop the word)的时间较长。

Serial Old是年老代的收集器,采用标记-整理算法,单线程收集。

Parallel Scavenge 和 Parallel Old

前者是年轻代的收集器,复制算法,是并行的多线程收集器。它关注于整个JVM的吞吐量,而不关注用户线程的停顿时间。因此往往用于处理批量任务等的场景下。

吞吐量即CPU用于运行用户代码的时间与CPU总时间的比值。

Parallel Old是Parallel Scavenge的老年代版本,多线程收集,采用标记-整理算法。注重于高吞吐。

ParNew 和 CMS

ParNew其实就是Serial的多线程版本,同样采用复制算法。往往与CMS搭配使用。

CMS

CMS全称Consurrent Mark Sweep,是采用标记-清楚算法的一个垃圾收集器。

CMS的几个阶段:

  • 初始标记
  • 并发标记
  • 重新标记
  • 并发清除

其中,初始标记和重新标记会产生STW。

  • 初始标记只标记被GC Roots直接引用的对象,时间较短;
  • 并发标记会并发的标记剩余全部的对象,时间长,但不会导致STW;
  • 重新标记是用来处理并发标记阶段因用户线程继续执行导致标记发生变化的对象的标记;
  • 并发清楚会清楚可回收对象的内存空间,也不会产生STW。

由于整个过程,只有初始标记和重新标记会产生STW,但时间均较短;而执行时间较长的并发标记和并发清除并不会产生STW。因此CMS在专注于短停顿时间的场景表现比较优异。

但是CMS也是有缺点的:

  • 由于CMS会并行的标记和清楚,会抢占CPU资源;
  • 由于并发标记和清除阶段,用户线程还在继续执行,因此需要预留一些内存空间。也即CMS收集器不能像其他老年代收集器一样等待老年代空间快满时才进行回收,而是需要预留一些空间,提前进行回收。这会导致回收次数变多。且如果预留的内存空间在并行阶段不够用户线程实际使用,会“Concurrent Mode Failure”失败。从而触发后备方案,使用Serial Old进行垃圾收集。
  • CMS是采用的标记-清除的算法,会产生大量的内存碎片,给大对象分配带来麻烦。

由上边第二点也可以知道,GC时机是跟具体的垃圾回收器相关的,比如Serial Old这种就可以等待老年代快满了才进行回收;而像CMS则需要预留空间,提前执行。

G1

与其他的垃圾收集器相比,G1不再需要配合其它收集器使用,它可以独自完成整个堆的垃圾回收。

G1的堆空间也与之前的发生了变化。之前的堆分为年轻代和老年代,年轻代又分为新生代和两个suvivor区。这里边每个内存区域都是连续的。而在G1时,整个堆的内存空间被分割成了一系列大小相等的块,称为Region。而G1虽然还保留了年轻代和老年代的概念,但他们只是一些Region区的集合,他们本身不再是连续的。

G1的垃圾收集算法从总体上来看是基于标记-整理的,从两个Region的角度来看则是基于复制算法的。

一般来讲,一个Region的大小约为1M-32M,数量一般在2000个左右。

优点:

  • 并行与并发:利用多核优势进行垃圾收集,缩短STW的时间
  • 分代收集:不需要配合其他收集器,自己就可以完成不同年龄对象的收集
  • 完整的空间:不管是基于标记-整理还是复制,都不会产生内存碎片
  • 可预测的停顿时间模型:不对全部的region进行回收,而只回收可回收对象占比较高的性价比高的region。

问题:

G1是按照Region进行对象回收的,但是位于某个region上的对象完全可以被其他region上的对象引用。因此做可达性分析的话,岂不是还要对全堆进行扫描分析。如果是这样的话,那效率岂不是不高?

这个问题其实不仅是G1碰到的,只不过G1更明显。原先的垃圾收集对年轻代收集时,年轻代的对象是可以被年老代的对象引用的。

这个问题其实是通过一个 remember set 来解决的。每个Region都对应一个remember set,它里边记录了本Region的对象被其他region的对象引用的关系。通过这个remember set,在对该region的对象做可达性分析的时候,只需要用GC root加上这个remember set即可。即该region的存活对象要么被GC Root引用,要么被remember set中的引用关系记录。

G1回收的几个阶段

  • 初始标记
  • 并发标记
  • 最终标记
  • 筛选回收:按优先级回收region,而不是全部回收

其它

分配担保机制

前边讲复制算法时,说的是要分成大小相等的两块空间,互相转移。但是这样的机制空间利用率太低。

年轻代采用的是复制算法,由于年轻代中的对象绝大部分朝生夕死,因此需要复制的对象往往比例不高。为了充分利用空间,因此采用eden:s0:s1=8:1:1的比例(默认比例),其实每次都是把9成空间(eden+一个survivor)的对象中的存活对象复制到剩余的一成空间(另一个survivor)中。

那万一存活的对象,另一个survivor放不下怎么办?这就需要分配担保机制了。由老年代进行担保,放不下的对象放入老年代。

大对象直接进入老年代

大对象会直接放入老年代。因为年轻代会频繁的进行回收,如果大对象不立刻死亡的话,会频繁复制,效率比较低,因此放入老年代。但是也有缺点,会导致老年代消耗比较快,出发老年代的回收。