Java垃圾收集器

181 阅读17分钟

概述

垃圾收集(通常称为GC)这个概念并非是Java诞生后的产物,早在1960年的Lisp语言就已经有垃圾收集的概念了。现如今,不仅Java语言,Python、JS、Golang也有垃圾收集的机制。可见,垃圾收集已经成为了开发语言的一个分支,因此,了解常见的垃圾收集算法对于成为一名合格的软件工程师来讲是非常有必要的。


对象存活判断

在垃圾收集器对堆内存进行回收之前,首先要做的就是确定内存中的对象是否存活,也就是是否还存在引用。

引用计数算法

引用计数算法就是给每个对象中添加一个引用计数器,每当有地方引用它时,计数器加1。当引用失效时,计数器的值减1。当计数器值为0时,就可以判断该对象是可以被回收的。
但是,引用计数算法有一个弊端,就是很难解决对象之间循环引用的问题,比如下面的代码:

public class ReferenceCounting {

    private static final int _1MB = 1024 * 1024;

    private byte[] occupy = new byte[50 * _1MB];

    public Object reference = null;

    public static void main(String[] args) {
        ReferenceCounting objA = new ReferenceCounting();
        ReferenceCounting objB = new ReferenceCounting();

        objA.reference = objB;
        objB.reference = objA;

        objA = null;
        objB = null;

        System.gc();
    }
}

如果虚拟机是采用"引用计数算法"来判断对象是否存活的话,上面代码中reference对象是无法被回收的。

可达性分析

这个算法是通过一系列"GC Roots"对象为起始点向下搜索,搜索走过的路径被称为引用链,当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可用的。
在Java中,可作为GC Roots的对象包括下面几种:

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象。
  • 方法区中的类静态属性引用的对象。
  • 方法区中常量引用的对象。
  • 本地方法栈中JNI(即一般说的Native方法)中引用的对象。

对象的引用

在上述两种算法中,判断对象存活都与"引用有关"。那么什么是引用呢?
自JDK1.2以后,Java将引用分为强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)、虚引用(Phantom Reference)4种,这4种引用强度依次逐渐减弱。

  • 强引用是在程序代码种普遍存在的,类似 "Object obj = new Object()" 这类的引用,只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象。
  • 软引用关联着的对象,在系统将要发生内存溢出异常之前,将会把这些对象列入回收范围内进行第二次回收。如果这次回收后还没有足够的内存,才会抛出内存溢出异常。JDK提供了SoftReference类来实现软引用。软引用可用来实现内存敏感的高速缓存。
  • 弱引用关联的对象只能生存到下一次GC发生前,在发生GC时,无论当前内存是否足够,弱引用对象都会被回收。可使用WeakReference类实现弱引用。
  • 虚引用也称为幽灵引用。与其他几种引用都不同,虚引用并不会决定对象的生命周期。如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收器回收。可使用PhantomReference类实现弱引用。


垃圾收集算法

标记 - 清除算法

"标记-清除"(Mark-Sweep)算法,如它的名字一样,算法分为 "标记" 和 "清除" 两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收掉所有被标记的对象。之所以说它是最基础的收集算法,是因为后续的收集算法都是基于这种思路并对其缺点进行改进而得到的。
它的主要缺点有两个:一个是效率问题,标记和清除过程的效率都不高;另外一个是空间问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致,当程序在以后的运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。

gc_garbage1.png


复制算法

"复制"(Copying)的收集算法,它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。
这样使得每次都是对其中的一块进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。只是这种算法的代价是将内存缩小为原来的一半,代价太高。

gc_garbage2.png


标记 - 整理算法

复制收集算法在对象存活率较高时就要执行较多的复制操作,效率将会变低。更关键的是,如果不想浪费50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况,所以在老年代一般不能直接选用这种算法。
根据老年代的特点,有人提出了另外一种 "标记-整理"(Mark-Compact)算法,标记过程仍然与 "标记-清除" 算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。

gc_garbage3.png

分代收集算法

"分代收集"(Generational Collection)算法,把Java堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用“标记-清理”或“标记-整理”算法来进行回收。

方法区的回收

在JDK1.7以前,HotSpot虚拟机的方法区是以永久代的方式实现的。在永久代中也存在垃圾回收,主要是回收废弃常量和无用的类,但是在这个区域中垃圾回收的效率很低。
目前很多应用都在JDK1.8环境下运行,而JDK1.8中以Metaspace替换掉了永久代,在上一篇文章 Java内存结构与内存溢出 中我们说到可以通过 -XX:MetaspaceSize 这个参数控制Metaspace区域的初始化大小(默认通常是20M),达到该值就会触发垃圾收集进行类型卸载,同时GC会对该值进行调整:如果释放了大量的空间,就适当降低该值;如果释放了很少的空间,那么在不超MaxMetaspaceSize时,适当提高该值。


垃圾收集器

Serial 收集器

Serial收集器是一个单线程的收集器,它在进行垃圾收集时,只使用一个线程,同时必须暂停其他所有的工作线程,直到它收集结束。这种行为又被称为 "Stop The World"。
相关参数:

  • **-XX:+UseSerialGC **开启使用 Serial + Serial Old 收集器组合进行内存回收(client模式下默认)

Serial收集器.png


ParNew 收集器

ParNew收集器其实就是Serial收集器的多线程版本,新生代并行,老年代串行。除了使用多线程进行垃圾收集外,其他的都和Serial相同。
相关参数:

  • -XX:+UseParNewGC  开启使用 ParNew + Serial Old 收集器组合进行内存回收。
  • -XX:ParallelGCThreads  设置并行GC时进行内存回收的线程数(默认线程数········为CPU核心数)。

ParNew收集器.png


Parallel Scavenge 收集器

Parallel Scavenge 收集器是一个新生代收集器,它和ParNew一样,新生代使用复制算法,老年代使用标记-整理算法,并行的多线程收集器。不同的是,Parallel Scavenge 收集器更关注系统的吞吐量。

吞吐量(Throughput)就是CPU用于运行用户代码的时间与CPU总消耗时间的比值, 吞吐量 = 运行用户代码时间 / ( 运行用户代码时间 + 垃圾收集时间 ), 比如,虚拟机总共运行100分钟,其中垃圾收集花掉1分钟,那么吞吐量就是 (100-1) / 100 = 99%

  • **-XX:+UseParallelOldGC  **开启使用 Parallel Scavenge + Serial Old 的收集器组合进行垃圾回收(Server模式下默认)
  • -XX:MaxGCPauseMillis  设置GC最大停顿时间。(这里需要注意的是,该参数允许的值是一个大于0的毫秒数,另外,GC停顿时间缩短是以牺牲吞吐量和新生代空间来换取的)
  • -XX:TimeRatio  GC时间占总时间的比率,默认值为99,即允许最大1%的垃圾收集时间。
  • -XX:+UseAdaptiveSizePolicy  开启后,虚拟机会动态调整Java堆中各个区域的大小以及进入老年代的年龄。


关于 -XX:+UseAdaptiveSizePolicy 参数,这是一个开关参数,当这个参数开启后,就不需要手动指定新生代的大小(-Xmn)、Eden与Survivor区的比例(-XX:SurvivorRatio)、晋 升 老 年 代 对 象 大 小(-XX:PretenureSizeThreshold)等细节参数了,虚拟机会根据当前系统的运行情况,动态调整这些参数,以达到最合适的停顿时间或者最大的吞吐量。我们可以把基本的内存数据设置好,如**-Xmx设置最大堆,然后使用-XX:MaxGCPauseMillis**(更关注最大停顿时间)** **或 -XX:TimeRatio(更关注吞吐量)参数给虚拟机设立一个优化目标,具体的细节参数的调节工作就由虚拟机完成了。



Serial Old 收集器

Serial Old 是 Serial 收集器的老年代版本,它同样是一个单线程的收集器。它主要的用途是作为 CMS 收集器的在发生 Concurrent Mode Failure 时使用的备选。

Parallel Old 收集器

Parallel Old 是 Parallel Scavenge 收集器的老年代版本,在老年代上使用多线程和 "标记-整理" 算法,这个收集器从JDK1.6才开始提供。

Parallel Old 收集器.png


CMS 收集器

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。因此,CMS是适合后端Web应用的一种收集器。

CMS 的回收过程

CMS收集器是基于 "标记 - 清除" 算法实现的,它的运作过程主要分为7个步骤:
1.初始标记
会发生GC停顿,初始标记仅仅是标记一下GC Roots能直接关联到的对象,速度很快。
2.并发标记
进行GC Roots Tracing的过程,不会发生停顿,与用户线程一起运行。
3.并发预清理
与用户线程一起运行,处理新生代已经发现的引用,可-**XX:-**CMSPrecleaningEnabled 参数禁用该阶段,默认是启用的。
4.可终止的预清理
这一阶段主要是为了承担下一阶段"重新标记"足够多的工作,以保证尽可能的减少SWT的时间。
5.重新标记
该阶段会导致SWT,由于前面预清理步骤都是并发执行的,程序还是会出现垃圾对象,在这一阶段会重新扫描整个堆,当此阶段耗时较长的时候,可以加入参数
-XX:+CMSScavengeBeforeRemark
,在重新标记之前,先执行一次ygc,回收掉年轻带的对象无用 的对象,并将对象放入幸存带或晋升到老年代,这样再进行年轻带扫描时,只需要扫描幸存区的对象即可,一般幸存带非常小,这大大减少了扫描时间。
6.并发清除
这阶段也是和用户线程一起运行的,用于清理已标记出的无用对象,耗时较长。
整个过程中耗时最长的并发标记和并发清除这两个阶段都是可以和用户线程一起运行的,因此从整体上来说,CMS收集器的性能还是不错的。
7.并发重置
并发重置状态。

下图描述了CMS的运行过程(引用自 www.jianshu.com/p/2a1b2f17d…


CMS 的缺点

但是CMS还远达不到完美的程度,它有以下缺点:

  • CMS收集器无法处理浮动垃圾,可能出现 "Concurrent Mode Failure" 失败,而导致另一次Full GC产生。由于在 "并发清除" 阶段用户线程还在运行,此时产生新的垃圾CMS无法处理他们,只好留待下一次GC时再清理掉,这一部分垃圾就被称为 "浮动垃圾"。也是由于在垃圾收集阶段用户线程还在运行,那就需要预留一部分空间提供并发收集时的程序运作使用。在JDK1.6中,CMS收集器的启动阈值为92%,如果CMS运行期间预留的内存无法满足程序需要,就会出现 "Concurrent Mode Failure" 失败,这时虚拟机将启动后备预案:临时启动Serial Old收集器来进行老年代的垃圾收集。我们可以通过 -XX:CMSInitiatingOccupancyFraction 设置触发回收的内存百分比。
  • CMS是一款基于 "标记 - 清除" 算法的收集器,这个算法会带来大量的内存碎片。为了解决这个问题,CMS收集器提供了一个 -XX:+UseCMSCompactAtFullCollection 开关参数(默认开启),用于CMS收集器在顶不住要进行FullGC时开启内存碎片的合并整理,内存整理的过程是不能并发的,因此停顿的时间会比较长。另外,还提供了一个参数 -XX:CMSFullGCsBeforeCompaction,这个参数用于设置执行多少次不压缩的Full GC后,跟着来一次带压缩的(默认值为0,表示每次进入Full GC时都会进行碎片整理)。


相关参数:

  • -XX:+UseConcMarkSweepGC  开启后,使用 ParNew + CMS + Serial Old 的收集器组合进行内存回收。
  • **-XX:CMSInitiatingOccupancyFraction  **设置CMS收集器在老年代空间被使用多少后触发垃圾收集。JDK1.5中默认值68%,JDK1.6中默认值92%。
  • **-XX:+UseCMSCompactAtFullCollection ** 设置CMS收集器在Full GC后是否要进行一次内存碎片整理。(默认开启)
  • **-XX:CMSFullGCsBeforeCompaction  **设置CMS收集器在进行若干次垃圾收集后再启动一次内存碎片整理。(默认值为0,表示每次进入Full GC时都会进行碎片整理)


G1 收集器

G1收集器的设计目标是为了取代CMS收集器,相比CMS:

  1. 它有内存整理的过程,不会产生内存碎片。
  2. G1的SWT更加可控,它在停顿时间上添加了预测机制,用户可指定期望停顿的时间。
  3. 它不需要更大的堆内存。
  4. 更高的吞吐量。

使用 **-XX:+UseG1GC **参数可开启G1收集器。

Region

Region是G1收集器中新增的概念,通过上一篇文章 我们知道Java的内存结构划分为新生代、老年代和永久代。在G1收集器中,又将每一代划分成了N个不连续但大小相同的Region,每个Region占有一块连续的虚拟内存地址。
如下图所示:


(引用自 tech.meituan.com/2016/09/23/…
图中每一个方格代表了一个Region,H表示大小大于等于Region区域一半的巨大对象(humongous object,H-obj)。H-obj对象会被直接分配到老年代中一个新的或多个连续的Region中。

G1 的回收过程

在了解了什么是Region后,下面我们看一下G1的内存回收过程是怎样的。G1提供了两种回收模式:Young GC Mixed GC ,这两种回收模式都会 Stop The World。
**

  • young gc

当Eden空间被占满而无法分配内存时就会发生young gc,G1中发生的YGC和之前差不多,执行完一次YGC后活跃的对象会被复制到survivor region或者晋升到old region中。G1会通过控制young region的数量,并且采用多线程并发复制对象,来降低young GC的时间开销。
     在YGC时,是不会回收老年代的对象的。但我们知道,判断对象存活需要从GC Roots出发,如果老年代对象引用了新年代中的对象,那这个时候我们该如何判断新生代的对象是否可以回收呢?
     在G1中,有一种新的数据结构Remembered Set 简称Rset**,**RSet记录了其他Region中的对象引用本Region中对象的关系,属于points-into结构(谁引用了我的对象)。
     如下图:
                    


     图中红色虚线表示Region2中RSet对其他Region的引用。
     RSet究竟是怎么辅助GC的呢?在做YGC的时候,只需要选定young generation region的RSet作为根集,这些RSet记录了old->young的跨代引用,避免了扫描整个old generation。 而mixed gc的时候,old generation中记录了old->old的RSet,young->old的引用由扫描全部young generation region得到,这样也不用扫描全部old generation region。所以RSet的引入大大减少了GC的工作量。

  • Mixed GC

Mixed GC会选定所有年轻代里的Region,外加根据global concurrent marking统计得出收集收益高的若干老年代Region。在用户指定的开销目标范围内尽可能选择收益高的老年代Region。
Mixed GC不是full GC,它只能回收部分老年代的Region,如果mixed GC实在无法跟上程序分配内存的速度,导致老年代填满无法继续进行Mixed GC,就会使用Serial Old 来(full GC)来收集整个堆内存。
上文中,多次提到了global concurrent marking,它的执行过程类似CMS,但是不同的是,在G1 GC中,它主要是为Mixed GC提供标记服务的,并不是一次GC过程的一个必须环节。global concurrent marking的执行过程分为四个步骤:

  1. 初始标记(initial mark)- 它标记了从GC Root开始直接可达的对象。会产生SWT,它伴随着一次普通的 Young GC 发生,然后对 Survivor 区(root region)进行标记,因为该区可能存在对老年代的引用。
  2. 扫描根引用区(Root Region Scanning)- 扫描 Survivor 到老年代的引用,该阶段必须在YGC开始前完成。
  3. 并发标记(Concurrent Marking)。对整个堆的存活对象标记,标记线程与应用程序线程并行执行,该阶段可以被 Young GC 中断。
  4. 最终标记(Remark)。会长生SWT,标记那些在并发标记阶段发生变化的对象。使用了比 CMS 收集器更加高效的 snapshot-at-the-beginning (SATB) 算法
  5. 清除垃圾(Cleanup)。清除空Region(没有存活对象的),并加入到空闲列表。





参考: