JVM垃圾收集

如何判断一个对象是否可以被回收

  1. 引用计数算法

    给对象添加一个引用计数器,当对象增加一个引用的时候计数器加1,引用失效的时候计数器减1。当引用计数为0的时候证明对象可以被回收。但是如果存在循环引用的情况下,会导致对象永远不能被回收。看下面的例子。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    public class Test {

    public Object ins = null;

    public static void main(String[] args) {
    Test a = new Test();
    Test b = new Test();
    a.ins = b;
    b.ins = a;
    a = null;
    b = null;
    }
    }

    此时虽然去除了a对象和b对象的引用,但是两个对象之间还是存在互相引用,这样就导致a、b两个对象无法被回收,但实际上这两个对象已经无法被访问。

  2. 可达性分析算法

    这个算法的基本原理就是通过一系列的称为”GC Roots”的对象作为起始点,从这些节点向下开始搜索,搜索所走过的路径称为引用链。当一个对象到GC Roots没有任何引用链相连时,则证明对象无法被访问了。

    在Java语言中,可以作为GC Roots的对象包括下面几种

    • 虚拟机栈(栈帧中的本地变量表)引用的对象。

    • 方法区中类静态属性引用的对象。

    • 方法区中常量引用的对象

    • 本地方法栈中JNI(Native方法)引用的对象

引用类型

无论是通过引用计数算法判断对象的引用数量,还是通过可达性分析算法判断对象是否可达,判定对象是否可被回收都与引用有关。

Java 提供了四种强度不同的引用类型。

  1. 强引用

被强引用关联的对象不会被回收。

使用 new 一个新对象的方式来创建强引用。

1
Object obj = new Object();
  1. 软引用

被软引用关联的对象只有在内存不够的情况下才会被回收。

使用 SoftReference 类来创建软引用。

1
2
3
Object obj = new Object();
SoftReference<Object> sf = new SoftReference<Object>(obj);
obj = null; // 使对象只被软引用关联
  1. 弱引用

被弱引用关联的对象一定会被回收,也就是说它只能存活到下一次垃圾回收发生之前。

使用 WeakReference 类来创建弱引用。

1
2
3
Object obj = new Object();
WeakReference<Object> wf = new WeakReference<Object>(obj);
obj = null;
  1. 虚引用

又称为幽灵引用或者幻影引用,一个对象是否有虚引用的存在,不会对其生存时间造成影响,也无法通过虚引用得到一个对象。

为一个对象设置虚引用的唯一目的是能在这个对象被回收时收到一个系统通知。

使用 PhantomReference 来创建虚引用。

1
2
3
Object obj = new Object();
PhantomReference<Object> pf = new PhantomReference<Object>(obj, null);
obj = null;

垃圾收集算法

标记-清除算法

在标记阶段,程序会检查每个对象是否为活动对象,如果是活动对象,则程序会在对象头部打上标记。

在清除阶段,会进行对象回收并取消标志位,另外,还会判断回收后的分块与前一个空闲分块是否连续,若连续,会合并这两个分块。回收对象就是把对象作为分块,连接到被称为 “空闲链表” 的单向链表,之后进行分配时只需要遍历这个空闲链表,就可以找到分块。

在分配时,程序会搜索空闲链表寻找空间大于等于新对象大小 size 的块 block。如果它找到的块等于 size,会直接返回这个分块;如果找到的块大于 size,会将块分割成大小为 size 与 (block - size) 的两部分,返回大小为 size 的分块,并把大小为 (block - size) 的块返回给空闲链表。

不足:

  • 标记和清除过程效率都不高;
  • 会产生大量不连续的内存碎片,导致无法给大对象分配内存。

复制算法

将内存划分为大小相等的两块,每次只使用其中一块,当这一块内存用完了就将还存活的对象复制到另一块上面,然后再把使用过的内存空间进行一次清理。

主要不足是只使用了内存的一半。

现在的商业虚拟机都采用这种收集算法回收新生代,但是并不是划分为大小相等的两块,而是一块较大的 Eden 空间和两块较小的 Survivor 空间,每次使用 Eden 和其中一块 Survivor。在回收时,将 Eden 和 Survivor 中还存活着的对象全部复制到另一块 Survivor 上,最后清理 Eden 和使用过的那一块 Survivor。

HotSpot 虚拟机的 Eden 和 Survivor 大小比例默认为 8:1,保证了内存的利用率达到 90%。如果每次回收有多于 10% 的对象存活,那么一块 Survivor 就不够用了,此时需要依赖于老年代进行空间分配担保,也就是借用老年代的空间存储放不下的对象。

标记-整理算法

让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。

优点:

  • 不会产生内存碎片

不足:

  • 需要移动大量对象,处理效率比较低。

分代收集算法

现在的商业虚拟机采用分代收集算法,它根据对象存活周期将内存划分为几块,不同块采用适当的收集算法。

一般将堆分为新生代和老年代。

  • 新生代使用:复制算法
  • 老年代使用:标记 - 清除 或者 标记 - 整理 算法

垃圾收集器

垃圾收集器是内存回收算法具体实现,JVM为我们提供了多种垃圾收集器,不同垃圾收集器有着不同的垃圾回收效果。下面简单介绍一下JVM的垃圾收集器。我们知道Java运行时数据区域分为程序计数器、虚拟机栈、本地方法栈、堆、方法区(JDK1.8用元空间来替代方法区)。垃圾回收主要就发生在堆内存中。堆内存又分为新生代和老年代,默认比例是1:2,即新生代占1/3,老年代占用2/3。可以通过-XX:NewRatio=2参数设置新生代和老年代的比例。新生代又包括Eden和两个Survivor区(SurvivorFrom和SurvivorTo),一般来说新创建的对象都会在Eden区,少部分进入老年代。SurvivorFrom存放的是上一次GC后存活的对象(再次发生GC之后仍然存活的会被移动到SurvivorTo,年龄加1,达到年龄阈值的会被移动到老年代(年龄阈值默认15,CMS默认6,参考Oracle官方文档,通过 -XX:MaxTenuringThreshold=threshold可以设置)否则会被移动到到老年代,然后From和To会交换,即From->To,To->From),SurvivorTo存放的这一次GC之后存活的对象,而这一切工作都是垃圾收集器来帮我们完成的,不需要我们手动去管理内存。下图展示了各种垃圾收集器之间的搭配关系。

Serial收集器

Serial(串行)收集器是最基本、历史最悠久的垃圾收集器了。它是一个单线程收集器。它的 单线程的意义不仅仅意味着它只会使用一个垃圾收集线程去完成垃圾收集工作,更重要的是它在进行垃圾收集工作的时候必须暂停其它所有的工作线程(也就是Stop The World),直到它收集结束。

Serial收集器由于没有线程交互的开销,可以获得较高的单线程收集效率,适合运行在Client模式下的虚拟机。

-XX:+UseSerialGC(使用Serial + Serial Old来进行垃圾回收)

ParNew收集器

ParNew收集器其实就是 Serial收集器的多线程版本,除了使用多线程进行垃圾收集以外,其余行为(控制参数、收集算法、回收策略等等)和 Serial 收集器完全一样。

1
-XX:+UseParNewGC(使用ParNew + Serial Old的收集器组合进行垃圾回收。)

它是许多运行在Server模式下的虚拟机的首要选择,除了Serial收集器外,只有它能与CMS收集器配合工作。

Parallel Scavenge收集器

Parallel Scavenge 收集器也是使用复制算法的多线程收集器,它看上去几乎和ParNew一样。它是JDK1.7和JDK1.8的默认新生代垃圾收集器。

1
-XX:+UseParallelGC(使用`Parallel Scavenge + Serial Old(PS MarkSweep)`的收集器组合进行垃圾收集。)

Parallel Scavenge收集器关注点是吞吐量(高效率的利用CPU)。CMS 等垃圾收集器的关注点更多的是用户线程的停顿时间(提高用户体验)。所谓吞吐量就是 CPU 中用于运行用户代码的时间与 CPU 总消耗时间的比值。

Serial Old收集器

Serial 收集器的老年代版本,它是一个单线程收集器。它主要有两大用途:一种用途是在 JDK1.5 以及以前的版本中与 Parallel Scavenge 收集器搭配使用,另一种用途是作为 CMS 收集器的后备方案。需要注意的是,没有直接的参数可以开启Serial Old收集器,只能通过UseSerialGC、UseParallelGC、UseConcMarkSweepGC、UseParNew开启。

Parallel Old 收集器

Parallel Scavenge 收集器的老年代版本。使用多线程和“标记-整理”算法。在注重吞吐量以及 CPU 资源的场合,都可以优先考虑 Parallel Scavenge 收集器和 Parallel Old 收集器。它是JDK1.7和JDK1.8默认的老年代收集器。

1
-XX:+UseParallelOldGC(使用Parallel Scavenge + Parallel Old的组合进行垃圾回收。)

CMS 收集器

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。它非常符合在注重用户体验的应用上使用。

CMS(Concurrent Mark Sweep)收集器是 HotSpot 虚拟机第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程(基本上)同时工作。

从名字中的Mark Sweep这两个词可以看出,CMS 收集器是一种 “标记-清除”算法实现的,它的运作过程相比于前面几种垃圾收集器来说更加复杂一些。整个过程分为四个步骤:

  • 初始标记: 暂停所有的其他线程,并记录下直接与 root 相连的对象,速度很快 ;

  • 并发标记: 同时开启 GC 和用户线程,用一个闭包结构去记录可达对象。但在这个阶段结束,这个闭包结构并不能保证包含当前所有的可达对象。因为用户线程可能会不断的更新引用域,所以 GC 线程无法保证可达性分析的实时性。所以这个算法里会跟踪记录这些发生引用更新的地方。

  • 重新标记: 重新标记阶段就是为了修正并发标记期间因为用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段的时间稍长,远远比并发标记阶段时间短

  • 并发清除: 开启用户线程,同时 GC 线程开始对未标记的区域做清扫。

从它的名字就可以看出它是一款优秀的垃圾收集器,主要优点:并发收集、低停顿。但是它有下面三个明显的缺点:

  • 对 CPU 资源敏感;

  • 无法处理浮动垃圾;

  • 它使用的回收算法-“标记-清除”算法会导致收集结束时会有大量空间碎片产生。

1
CMS: -XX:+UseConcMarkSweepGC -XX:+UseParNewGC(使用ParNew + CMS + Serial Old进行垃圾回收)

CMS垃圾收集器不能像其它垃圾收集器一样等到老年代几乎被占满了再进行垃圾收集,需要预留一部分内存空间并发收集时的用户线程使用,因为在垃圾收集阶段,用户线程还是继续在运行的,如果预留的内存空间无法满足需要,那么就会出现一次 Concurrent Mode Failure 失败。这时虚拟机将启动后备预案,临时启用Serial Old收集器来重新进行老年代的垃圾收集,这样停顿时间就比较长了。这里需要注意的是 -XX:CMSInitiatingOccupancyFraction 参数设置的太高容易导致Concurrent Mode Failure。性能反而降低。

G1收集器

G1 (Garbage-First) 是一款面向服务器的垃圾收集器,主要针对配备多颗处理器及大容量内存的机器. 以极高概率满足 GC 停顿时间要求的同时,还具备高吞吐量性能特征,在JDK1.9中默认启用。

被视为 JDK1.7 中 HotSpot 虚拟机的一个重要进化特征。它具备一下特点:

  • 并行与并发:G1 能充分利用 CPU、多核环境下的硬件优势,使用多个 CPU(CPU 或者 CPU 核心)来缩短 Stop-The-World 停顿时间。部分其他收集器原本需要停顿 Java 线程执行的 GC 动作,G1 收集器仍然可以通过并发的方式让 java 程序继续执行。
  • 分代收集:虽然 G1 可以不需要其他收集器配合就能独立管理整个 GC 堆,但是还是保留了分代的概念。
  • 空间整合:与 CMS 的“标记–清理”算法不同,G1 从整体来看是基于“标记整理”算法实现的收集器;从局部上来看是基于“复制”算法实现的。
  • 可预测的停顿:这是 G1 相对于 CMS 的另一个大优势,降低停顿时间是 G1 和 CMS 共同的关注点,但 G1 除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为 M 毫秒的时间片段内。

G1 收集器的运作大致分为以下几个步骤:

  • 初始标记
  • 并发标记
  • 最终标记
  • 筛选回收

G1 收集器在后台维护了一个优先列表,每次根据允许的收集时间,优先选择回收价值最大的 Region(这也就是它的名字 Garbage-First 的由来)。这种使用 Region 划分内存空间以及有优先级的区域回收方式,保证了 G1 收集器在有限时间内可以尽可能高的收集效率(把内存化整为零)。

1
-XX:+UseG1GC

内存分配与回收策略

内存分配策略

  1. 对象一般分配在新生代。

    当Eden空间不足时,发起Young GC(Minor GC),这里需要注意的是,Young GC也会造成Stop The World。

  2. 大对象直接进入老年代。

    大对象是指需要连续内存空间的对象,比如说很长的数组以及字符串。如果老年代剩余空间不足以存放大对象,那么会触发一次垃圾收集以获取足够的空间分配给大对象。我们可以设置一个阈值-XX:PretenureSizeThreshold,大于这个值的对象直接在老年代分配。

  3. 长期存活的对象进入老年代。

    对象经过多次GC之后依然存活,相应的年龄也会增加,增加到一定年龄则晋升到老年代。我们可以通过 -XX:MaxTenuringThreshold来设置晋升的阈值。

  4. 动态对象年龄判定。

    有时候可能出现未达到晋升年龄,但是Survivor空间已经满了,这个时候如果Survivor空间中相同年龄对象的大小大于Survivor空间的一半,那么大于或等于该年龄的对象直接进入老年代,但是有时候,可能所有某个年龄的对象的大小都没有超过Survivor空间的一半,这个时候其实Survivor空间已经被占满。

    JVM碰到这种情况,还是会让一部分对象晋升到老年代,有这么一个参数 -XX:TargetSurvivorRatio,目标存活率,默认是50%。总体JVM的表现就是年龄从小到大进行累加,当Survivor空间中新加入某个年龄段的对象时,累加超过Survivor区域大小*TargetSurvivorRatio的时候。大于或等于此年龄的对象将会晋升到老年代。

  5. 空间分配担保

    在发生Young GC的时候,JVM会先检查老年代最大连续可用的内存空间是否大于新生代所有对象总空间,如果条件成立的话,那么此次Young GC是安全的。如果不成立的话虚拟机会查看 HandlePromotionFailure 的值是否允许担保失败,如果允许那么就会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次 Minor GC;如果小于,或者 HandlePromotionFailure 的值不允许冒险,那么就要进行一次 Full GC。

Full GC触发条件

  1. 调用 System.gc(),jmap -histo:live,heap dump命令

    System.gc()建议虚拟机执行垃圾回收,不一定真的执行。

  2. 老年代空间不足

    为了避免以上原因引起的 Full GC,应当尽量不要创建过大的对象以及数组。除此之外,可以通过 -Xmn 虚拟机参数调大新生代的大小,让对象尽量在新生代被回收掉,不进入老年代。还可以通过 -XX:MaxTenuringThreshold 调大对象进入老年代的年龄,让对象在新生代多存活一段时间。

  3. 空间分配担保失败

    发生Young GC的时候,需要老年代空间作为担保,如果老年代的内存空间大小小于此次将要回收的对象大小,那么会发生一次Full GC。

  4. JDK 1.7 及以前的永久代空间不足或者元空间不足

    系统中加载的类,反射的类或者调用的方法太多,永久代或者元空间会被占满。

  5. Concurrent Mode Failure

    执行 CMS GC 的过程中同时有对象要放入老年代,而此时老年代空间不足(可能是 GC 过程中浮动垃圾过多导致暂时性的空间不足),便会报 Concurrent Mode Failure 错误,并触发 Full GC。

参考

  1. JVM full GC的奇怪现象,求解惑?
  2. Major GC和Full GC的区别是什么?触发条件呢?
显示评论