个人成长博客

纸上得来终觉浅,绝知此事要躬行

0%

Java垃圾收集

概述

前面介绍了Java内存运行时区域的各个部分,其中程序计数器、虚拟机栈、本地方法栈3个区域随着线程而生,随线程而灭。这几个区域的内存分配和回收都具备确定性,因此在这几个区域中就不需要过多考虑回收的问题,因为方法结束或者线程结束时,内存自然就跟着回收了。而Java堆和方法区则不一样,一个接口中的多个实现类需要内存可能不一样,一个方法中的多个分支需要的内存也可能不一样,只有在程序处于运行期间时才能知道会创建哪些对象,这部分内存的分配都是动态的,垃圾收集器(Garbage Collection,GC)所关注的时这部分的内存。

总体来说,垃圾收集主要解决三个问题:

  1. 哪些内存需要回收?
  2. 什么时候回收?
  3. 如何回收?

判断对象为垃圾的标准

在堆中存放着几乎所有的对象实例,垃圾回收器在对堆进行回收前,第一件事情就是要确定这些对象哪些是还“存活”着,哪些已经“死去”。

引用计数法

引用计数法给对象添加一个引用计数器,通过判断对象的引用数量来决定对象是否被回收。每当有一个地方引用该对象,计数器值加1;当引用失效时,计数器值就减1;任何时刻计数器为0的对象可以被当做垃圾回收。

引用计数法的优缺点:

  • 优点:执行效率高,程序执行受影响小
  • 缺点:无法检测出循环引用的情况,导致内存泄漏

因为缺点过于致命,在现在的 JVM 中,并没有使用这个算法的。

可达性分析法

在主流的商用语言中通过可达性分析法来判断对象是否存活。这个算法的基本思想就是通过一系列的称为 “GC Roots” 的对象作为起始点,从这些节点开始向下搜索,所有所走过的路径称为引用链(Reference Chain),当一个对象到 GC Roots 没有任何引用链相连(也就是对象不可达)时,则证明此对象是不可用的。如下图:

可以作为GC Roots的对象包括以下几种:

  1. 虚拟机栈中引用的对象(栈帧中的本地变量表)
  2. 方法区中的常量引用对象
  3. 方法区中类静态属性引用对象
  4. 本地方法栈中JNI(Native方法)的引用对象
  5. 活跃线程的引用对象

即使是在可达性分析算法中不可达的对象,也并非是”非死不可的”,这时候他们实际上是处于 “缓刑” 阶段。因为要真正宣告一个对象的死亡,至少需要经历两次标记过程:

  1. 如果对象在进行可达性分析后发现没有与 GC Roots 相连接的引用链,那他将会被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行 finalize 方法(当对象没有覆盖finalize 方法或者finalize 方法已经被JVM调用过,视为非必要执行)。如果这个对象被判定为有必要执行 finalize 方法,那么这个对象将会放置在一个叫做 F-Queue 的队列之中,并在稍后由一个虚拟机自动建立的,低优先级的 Finalizer 线程去执行它。
  2. finalize 方法是对象逃脱死亡命运的最后一道关卡。稍后 GC 将对队列中的对象进行第二次规模的标记,如果对象要在 finalize 中 “拯救” 自己,只需要将自己关联到引用上即可,通常是 this。如果这个对象关联上了引用,那么在第二次标记的时候他将被移除出 “即将回收” 的集合;如果对象这时候还没有逃脱,那基本上就是真的被回收了。(注意:一个对象如果重写了 finalize 方法,那么这个方法最多只会被执行一次,所以finalize 自救也只能有一次机会,因为逃生后第二次判断的时候,finalize 被JVM执行过一次了,会被视为非必要执行。

再谈引用

刚才的两种算法判断对象是否存活都使用到了引用,JDK1.2之前对于Java的引用定义很传统:如果reference类型的数据中存储的数值代表的是另一外块内存的起始地址,就称这块内存代表着一个引用。JDK1.2对Java引用的概念进行了补充,将引用分为如下4种,4种引用强度依次逐渐递减:

  1. 强引用(Strong Reference):类似Object obj = new Object()这类的引用,只要引用还存在,即使OOM也不会被回收,通过将对象设置为null来弱化引用,使其被回收

  2. 软引用(Soft Reference):对象处在有用但非必需状态。当要发生OOM之前,会对软引用对象进行第二次回收,回收后仍然不足才报OOM。软引用如下:

    1
    2
    String str = new String("abc");
    SoftReference<String> softRef = new SoftReference<String>(str);
  3. 弱引用(Weak Reference):也是用来描述非必需对象的。比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生之前。当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。

    1
    2
    String str = new String("abc");
    WeakReference<String> weakRef = new WeakReference<String>(str);
  4. 虚引用(Phantom Reference):虚引用是最弱的一种引用。一个对象是否有虚引用的存在,完全不会对其生命周期有任何影响。任何时候都可以被垃圾收集器回收。主要可以跟踪对象被垃圾收集器回收的活动,起到哨兵的作用。我们希望当一个对象被gc掉的时候通知用户线程,进行额外的处理时,就需要使用引用队列(ReferenceQueue)了。虚引用必须和引用队列联合使用。

    1
    2
    3
    String str = new String("abc");
    ReferenceQueue queue = new ReferenceQueue();
    PhantomReference ref = new PhantomReference(str,queue);
引用类型 被垃圾回收时间 用途 生存时间
强引用 从来不会 对象的一般状态 JVM停止运行时终止
软引用 在内存不足时 对象缓存 内存不足时终止
弱引用 当垃圾回收时 对象缓存 GC运行后终止
虚引用 Unknown 标记、哨兵 Unknown

常见垃圾回收算法

标记-清除算法

标记-清除算法是最基础的收集算法,它分为“标记”和“清除”两个阶段:首先标记出所需回收的对象,在标记完成后统一回收掉所有被标记的对象,它的标记过程其实就是前面的可达性分析算法中判定垃圾对象的标记过程。标记—清除算法的执行情况如下图所示:

回收前:

回收后:

该算法有如下缺点:

  • 标记和清除过程的效率都不高
  • 标记清除后会产生大量不连续的内存碎片,空间碎片太多可能会导致,当程序在以后的运行过程中需要分配较大对象时无法找到足够的连续内存而不得不触发另一次垃圾收集动作

复制算法

复制算法是针对标记—清除算法的缺点,在其基础上进行改进而得到的,它将可用内存按容量分为大小相等的两块,每次只使用其中的一块,当这一块的内存用完了,就将还存活着的对象复制到另外一块内存上面,然后再把已使用过的内存空间一次清理掉

回收前:

回收后:

复制算法有如下优点:

  • 每次只对一块内存进行回收,运行高效,适用于对象存活率较低的场景
  • 只需移动栈顶指针,按顺序分配内存即可,实现简单
  • 内存回收时不用考虑内存碎片的出现

它的缺点是:可一次性分配的最大内存缩小了一半

标记整理算法

复制算法比较适合于新生代,在老年代中,对象存活率比较高,如果执行较多的复制操作,效率将会变低,所以老年代一般会选用其他算法,如标记—整理算法。该算法标记的过程与标记—清除算法中的标记过程一样,但对标记后出的垃圾对象的处理情况有所不同,它不是直接对可回收对象进行清理,而是让所有的对象都向一端移动,然后直接清理掉端边界以外的内存。标记—整理算法的回收情况如下所示:

回收前:

回收后:

分代收集算法

当前商业虚拟机的垃圾收集都采用分代收集,它根据对象的存活周期的不同将内存划分为几块,一般是把Java堆分为新生代老年代

  • 新生代中,每次垃圾收集时都会发现有大量对象死去,只有少量存活,因此可选用复制算法来完成收集,新生代可以划分为一个Eden区和两个survivor区。HotSpot虚拟机默认Eden和Survivor的大小比例是8:1。当回收时,将Eden和Survivor中还存活着的对象一次性地复制到另外一块Survivor空间上,最后清理掉Eden和刚才用过的Survivor空间。每清理一次,存活的对象的年龄都会加1,周而复始。对象晋升老年代条件
    • 对象经历了一定次数Minor GC依然存活,年龄大于-XX:MaxTenuringThreshold(默认15岁)
    • Survivor区存放不下的对象(或者Eden区放不下,因为Eden区放不下会触发Minor GC,最后还是存储在Survivor)
    • 新生成的大对象直接进入老年代(-XX:+PretenuerSizeThreshold)
  • 老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用标记—清除算法标记—整理算法来进行回收

常用的调优参数:

  • -XX:SurvivorRatio:调整Eden区和Survivor区的默认比值
  • -XX:NewRatio:调整老年代和新生代内存大小的比值
  • -XX:MaxTenuringThreshold:调整对象从年轻代晋升到老年代经历GC次数的最大阈值

把不同生命周期的对象放在不同代上,不同代上采用最适合它的垃圾回收方式进行回收,分代的好处:。

  • 简化了新对象的分配(只在新生代分配内存)
  • 可以更有效的清除不再需要的对象(死对象)。

GC有两种类型:Minor GC和Full GC

  • Minor GC(新生代回收)的触发条件比较简单,Eden空间不足就开始进行Minor GC回收新生代
  • Full GC(老年代回收,一般伴随一次Minor GC)则有几种触发条件:
    • 老年代空间不足
    • PermSpace空间不足(JDK8之前有永久代)
    • 统计得到的Minor GC晋升到老年代的平均大小大于老年代的剩余空间
    • 调用System.gc(),这是显示告诉虚拟机需要Full GC,但最终执行取决于虚拟机

另外,当执行垃圾收集算法时,Java应用程序的其他所有除了垃圾收集收集器线程之外的线程都被挂起,这种称为Stop the World机制,简称STW。多数的GC优化都是通过减少Stop the World发生的时间来提高程序性能,从而使系统拥有高吞吐,低停顿的特点。垃圾收集时,还有一个重要的知识点就是安全点(Safepoint)。在可达性分析时,是基于一个快照来的。分析过程中对象关系不会发生变化的点成为安全点。从全局的角度来看,所有线程必须在GC运行之前在安全点阻塞。总的来说,安全点就是指,当线程运行到这类位置时,堆对象状态是确定一致的,JVM可以安全地进行操作,如GC,偏向锁解除等。安全点主要在,方法返回之前、调用某个方法之后、抛出异常的位置、循环的末尾。

常见的垃圾收集器

首先提下JVM的运行模式。JVM有两种运行模式Server与Client。两种模式的区别在于,Client模式启动速度较快,Server模式启动较慢;但是启动进入稳定期长期运行之后Server模式的程序运行速度比Client要快很多。这是因为Server模式启动的JVM采用的是重量级的虚拟机,对程序采用了更多的优化;而Client模式启动的JVM采用的是轻量级的虚拟机。所以Server启动慢,但稳定后速度比Client远远要快。如果是64位的jdk,只能运行在Server模式下。有些垃圾收集器只能运行在某些运行模式下。

垃圾收集器可以根据运行在新生代和老年代作为一个划分,常见的垃圾收集器如下图:

其中显示了垃圾收集器的适用区域,同时连线代表,这两个垃圾收集器可以搭配使用。垃圾收集器的选择主要根据不同的JVM实现有关。

年轻代垃圾收集器

Serial收集器

历史比较悠久,在JDK1.3.1之前唯一选择。Serial收集器是单线程收集器,采用复制算法,使用-XX:+UseSerialGC 这个参数就是可以指定新生代使用Serial收集器,老年代使用Serial Old收集器。在串行收集器进行垃圾回收时,Java 应用程序中的线程都要暂停,等待垃圾回收的完成,即Stop the World。在实时性较高的应用场景中,这种现象往往是不能接受的。但Serial收集器因为简单高效,JVM在 client 模式下,它还是默认的垃圾收集器。在收集几十M到一两百M的内存时,停顿时间在几十ms到100ms之间,这个还是可接受范围,所以一般适用于适用于堆内存 256M 以下的 JVM。

ParNew收集器

ParNew收集器是多线程收集器,是Serial收集器的多线程版本,采用复制算法。它是虚拟机运行在server模式下的首选的新生代垃圾收集器,其中有一个原因是除了Serial收集器外,只有它能与CMS(Cocurrent Mark Sweep)搭配使用,目前CMS+ParNew的搭配组合用的的挺多的。当老年代选用CMS收集器后默认的新生代收集器就是ParNew。其中:

  1. -XX:+UseParNewGC ,新生代使用 ParNew 收集器,老年代使用Serial Old。
  2. -XX:+UseConcMarkSweepGC: 新生代使用 ParNew 回收器,老年代使用CMS。
  3. -XX:ParallelGCThreads={value} 这个参数是指定并行 GC 线程的数量,一般最好和 CPU 核心数量相当。默认情况下,当 CPU 数量小于8, ParallelGCThreads 的值等于 CPU 数量,当 CPU 数量大于 8 时,则使用公式:3+((5*CPU)/ 8);同时这个参数只要是并行 GC 都可以使用,不只是 ParNew。

Parallel Scavenge收集器

Parallel Scavenge 收集器,又称 PS 收集器,也是多线程的,采用复制算法,和 ParNew 类似。但是,PS 收集器更关注吞吐量(运行用户代码时间/(运行用户代码时间+垃圾收集时间))。PS 处理器特意提供了连个参数用于设置吞吐量相关。相关参数

  1. -XX:MaxGCPauseMillis 设置最大垃圾收集停顿时间,他的值是一个大于0的整数。ParallelGC 工作时,会调整 Java 堆大小或者其他的一些参数,尽可能的把停顿时间控制在 MaxGCPauseMillis 以内。如果为了将停顿时间设置的很小,将此值也设置的很小,那么 PS 将会把堆设置的也很小,这将会到值频繁 GC ,虽然系统停顿时间小了,但总吞吐量下降了。
  2. -XX:GCTimeRatio 设置吞吐量大小,他的值是一个0 到100之间的整数,假设 GCTimeRatio 的值是 n ,那么系统将花费不超过 1/(1+n) 的时间用于垃圾收集,默认 n 是99,即不超过1% 的时间用于垃圾收集。
  3. -XX:UseAdaptiveSizePolicy: 打开自适应策略。在这种模式下,新生代的大小,eden 和 Survivor 的比例,晋升老年代的对象年龄等参数会被自动调整。以达到堆大小,吞吐量,停顿时间的平衡点。
  4. -XX:+UseParallelGC:新生代使用Parallel Scavenge收集器,老年代使用Serial Old收集器。
  5. -XX:+UseParallelOldGC: 新生代使用Parallel Scavenge收集器,老年代使用Parallel Old收集器。

老年代垃圾收集器

Serial Old收集器

Serial Old收集器是Serial老年代的版本,也是单线程收集,采用标记整理算法。进行垃圾收集时,必须暂停所有工作线程。Serial Old具有简单高效的优点,Client模式下默认的老年代收集器。使用-XX:+UseSerialOldGC指定老年代使用Serial Old收集器。其还有一个重要作用是作为CMS收集器的后备方案,在并发收集发生Concurrent Mode Failure时使用。

Parallel Old收集器

Parallel Old收集器是Parallel Scavenge收集器老年代实现版本,多线程,并且以吞吐量优先,采用标记整理算法。在注重吞吐量及CPU资源敏感的场合,可以优先考虑 Parallel Old 加Parallel Scavenge 的组合。可以使用-XX:+UseParallelOldGC指定老年代使用Parallel Old收集器。

CMS收集器

CMS垃圾收集器的全称是Concurrent Mark-Sweep Collector,从名字上可以看出两点,一个是使用的是并发收集,第二个是使用的收集算法是标记清除算法Mark-Sweep。该收集器的特点是低延迟低停顿,不过因为采用的是标记清除算法所以会产生浮动垃圾的问题。CMS收集器是为了低延迟低停顿而生,通过尽可能的并行执行垃圾回收的几个阶段来把延迟控制到最低。使用-XX:+UseConcMarkSweepGC 此参数将启动 CMS 回收器。默认新生代是 ParNew,也可以设置 Serial 为新生代收集器。

G1收集器

作为 Java 9 的默认垃圾收集器,该收集器和之前的收集器大不相同,该收集器可以工作在新生代,也可以工作在 老年代。同优秀的CMS垃圾回收器一样,G1也是关注最小时延的垃圾回收器,也同样适合大尺寸堆内存的垃圾收集,整个垃圾收集处理过程并行和并发,并且做到了可预测停顿,官方也推荐使用G1来代替选择CMS。G1最大的特点是引入分区的思路,将整个Java堆内存划分成多个大小相等的Region,弱化了分代的概念,新生代和老年代不再物理隔离,合理利用垃圾收集各个周期的资源,解决了其他收集器甚至CMS的众多缺陷。通过参数-XX:+UseG1GC来启用。