JVM详解——垃圾回收算法

Posted 匠心

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了JVM详解——垃圾回收算法相关的知识,希望对你有一定的参考价值。

一、概述

1、什么是垃圾

  垃圾收集,不是Java语言的伴生产物。早在1960年,第一门开始使用内存动态分配和垃圾收集技术的Lisp语言诞生。关于垃圾收集的三个经典问题:
  (1)哪些内存需要回收?
  (2)什么时候回收?
  (3)如何回收?

  垃圾收集机制是Java的招牌能力,极大地提高了开发效率。如今,垃圾收集几乎成为现代语言的标配,即使经过如此长时间的发展,Java的垃圾收集机制仍然在不断的演进中,不同大小的设备、不同特征的应用场景,对垃圾收集提出了新的挑战。
  垃圾是指在运行程序中没有任何指针指向的对象。如果不及时对内存中的垃圾进行清理,那么,这些垃圾对象所占的内存空间会一直保留到应用程序结束,被保留的空间无法被其他对象使用,就可能导致内存溢出。

2、为什么需要GC

  对于高级语言来说,不断的分配内存空间而又不进行回收,内存迟早会被消耗完。
  除了释放没用的对象,垃圾回收还可以清除内存里的记录碎片。碎片整理将所占用的堆内存移到堆的一端,以便JVM将整理出的内存分配给新的对象。
  随着应用程序所应付的业务越来越庞大,复杂,用户越来越多,没有GC就不能保证应用程序的正常进行。而经常造成STW的GC又跟不上实际的需求,所以才会不断的尝试对GC进行优化。

3、早期垃圾回收

  在早期的C/C++时代,垃圾回收基本上是手工进行的。开发人员可以使用new关键字进行内存申请,使用delete关键字进行内存释放。
  代码示例:

  这种方式可以灵活控制内存释放的时间,但是会给开发人员带来频繁申请和释放内存的管理负担,如果有一处内存区间由于程序员编码的问题忘记被回收,那么就会产生内存泄漏,垃圾对象永远无法被清除。随着系统运行时间的不断增长,垃圾对象所耗内存可能持续上升,直到出现内存溢出并造成应用程序崩溃。
  内存泄漏:这个对象不用了,但是Java程序试图gc回收的时候,回收不了。因为它还有相关的引用指向。
  在有了垃圾回收机制后,上述代码有可能变成这样:

  现在,除了Java以外,C#、Python、Ruby等语言都使用了自动垃圾回收的思想,也是未来发展趋势。这种自动化的内存分配垃圾回收机制已经成为现代开发语言必备的标准。

4、Java垃圾回收机制

  好处:自动内存管理,无需开发人员手动参与内存的分配与回收,这样降低内存泄漏和内存溢出的风险。也将程序员从繁重的内存管理中释放出来,可以更专注于业务开发。
  Oracle官网关于垃圾回收的介绍

  https://docs.oracle.com/javase/8/docs/technotes/guides/vm/gctuning/toc.html

  坏处:对于Java开发人员而言,自动内存管理就像一个黑匣子,如果过度依赖于"自动",就会弱化Java开发人员在程序出现内存溢出时定位问题和解决问题的能力。
  垃圾回收器可以对新生代,老年代回收,也可以是全堆和方法区的回收。其中,Java堆是垃圾收集器的工作重点。
  从频率上说:频繁收集新生代,较少收集老年代,基本不动方法区。

二、相关算法

  判断哪些是垃圾?——标记阶段:引用计数算法、可达性分析算法。
  是垃圾后,怎么清除?——清除阶段:标记-清除算法、复制算法、标记-压缩算法。
  清除阶段的算法:分代收集算法、增量收集算法、分区算法。
  JVM中如何判断一个对象已经死亡呢?简单来说,当一个对象已经不再被任何存活对象继续引用时,就可以判定为死亡。判断对象是否存活一般有两种方式:引用计数算法和可达性分析算法。

1、标记阶段:引用计数算法

  引用计数算法,对每个对象保存一个整型的引用计数器属性,用于记录对象被引用的情况。
  思想:对于对象A,只要有对象引用了A,则A的引用计数器加1;当引用失效时,减1;当为0时,即表示对象A不再被使用,可进行回收。
  优点:实现简单,垃圾对象便于辨识;判定效率高,回收没有延迟性。
  缺点:需要单独的字段存储计数器,增加了空间开销;每次赋值需要更新计数器,增加了时间开销;无法处理循环引用的情况,致命,导致在Java垃圾回收器中没有使用这类算法。

  代码示例:证明Java使用的不是引用计数算法

 1 // -XX:+PrintGCDetails
 2 public class RefCountGC {
 3     // 这个成员属性唯一的作用就是占用一点内存.5MB
 4     private byte[] bigSize = new byte[5 * 1024 * 1024];
 5 
 6     Object reference = null;
 7 
 8     public static void main(String[] args) {
 9         RefCountGC obj1 = new RefCountGC();
10         RefCountGC obj2 = new RefCountGC();
11 
12         obj1.reference = obj2;
13         obj2.reference = obj1;
14 
15         obj1 = null;
16         obj2 = null;
17 
18         // 显式的执行垃圾回收行为
19         // 这里发生GC,obj1和obj2能否被回收?
20         System.gc();
21 
22         try {
23             Thread.sleep(1000000);
24         } catch (InterruptedException e) {
25             e.printStackTrace();
26         }
27     }
28 }

  引用计数算法:如果把obj1 = null;obj2 = null;则在Java堆当中,两块内存依然保持着相互引用,无法回收。

  结论:如果采用了引用计数算法,那么,这里就不会发生gc才对。从而证明没有采用。

2、标记阶段:可达性分析算法

  相比引用计数算法,可达性分析算法不仅同样具备实现简单,执行高效等特点,更重要的是它可以有效的解决循环引用的问题,防止内存泄漏的发生。这种类型的垃圾收集通常也叫作追踪性垃圾收集。
  思想:以根对象集合(GC Roots)为起始点,按照从上至下的方式搜索被根对象集合所连接的目标对象是否可达。可达:存活;不可达,死亡。
  所谓"GC Roots"根集合就是一组必须活跃的引用。

  那么,如何确定 GC Roots?即:哪些元素可以作为GC Roots?
  (1)虚拟机栈中引用的对象。比如,各个线程被调用的方法中使用的参数、局部变量等。
  (2)本地方法栈内JNI(本地方法)引用的对象。
  (3)方法区中类静态属性引用的对象。比如,Java类的引用类型静态变量。
  (4)方法区中常量引用的对象。比如,字符串常量池(String Table)里的引用。
  (5)所有被同步锁synchronized持有的对象。
  (6)Java虚拟机内部的引用。基本数据类型对应的Class对象,一些常驻的异常对象,系统类加载器。
  (7)反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。
  除了这些固定的GC Roots集合以外,根据用户所选用的垃圾收集器以及当前回收的内存区域不同,还可以有其他对象"临时性"的加入,共同构成完整GC Roots集合。比如:分代收集和局部回收(Partial GC)。
  比如,只针对新生代的回收,那么被老年代的对象所引用的对象也应该加入到GC Roots中考虑,才能保证可达性分析的准确性。
  小技巧:由于Root采用栈方式存放变量和指针,所以,如果一个指针保存了堆内存里面的对象,但是自己又不存放在堆内存里,那它就是一个Root。

  注意:如果使用可达性分析算法,那么分析工作必须在一个能保障一致性的快照中进行,这点不满足的话分析结果的准确性就无法保证。这点也是导致GC时必须STW的一个重要原因。即使是号称几乎不会发生停顿的CMS收集器中,枚举根节点时也必须要停顿。
  即,打扫门店的时候,必须关门(停止营业)。

3、对象的finalization机制

  Java语言提供了对象终止机制来允许开发人员提供对象被销毁前的自定义处理逻辑。垃圾回收之前,总会先调用这个对象的finalize()方法。finalize()方法允许在子类中被重写,用于在对象被回收时进行资源释放。通常进行一些资源释放和清理工作,比如关闭文件、套接字和数据库连接等。
  永远不要主动调用对象的finalize()方法。应该交给垃圾回收机制调用,理由:
  (1)在finalize()时可能会导致对象复活。
  (2)finalize()方法的执行时间没有保障,它完全由GC线程决定,极端情况下,若不发生GC,则finalize()方法将没有执行机会。
  (3)一个糟糕的finalize()会严重影响GC的性能。
  由于finalize()方法的存在,对象有三种可能的状态。如果某个对象不可达,一般来说,此对象需要被回收。但事实,它们暂处理"缓刑"阶段,有可能在某个条件下,复活自己。如果这样,那对它的回收就是不合理的。三种状态:
  可触及的:从根节点开始,可达,存活对象。
  可复活的:对象的所有引用都被释放,但是有可能在finalize()中复活。
  不可触及的:finalize()被调用,且没有复活。则必须死!
  finalize()只会被调用一次!
  总结:不可达,是垃圾。在回收之前,会调用finalize()方法。finalize()方法是由垃圾回收器自己来调,且只会被调用一次。
  代码示例:演示可复活的对象

 1 public class CanReliveObj {
 2 
 3     // 类变量,属于 GC Root
 4     public static CanReliveObj obj;
 5 
 6     //此方法只能被调用一次
 7     @Override
 8     protected void finalize() throws Throwable {
 9         super.finalize();
10         System.out.println("调用当前类重写的finalize()方法");
11         obj = this;//当前待回收的对象在finalize()方法中与引用链上的一个对象obj建立了联系
12     }
13 
14     public static void main(String[] args) {
15         try {
16             obj = new CanReliveObj();
17             // 对象第一次成功拯救自己
18             obj = null;
19             System.gc();//调用垃圾回收器
20             System.out.println("第1次 gc");
21             // 因为Finalizer线程优先级很低,暂停2秒,以等待它
22             Thread.sleep(2000);
23             if (obj == null) {
24                 System.out.println("obj is dead");
25             } else {
26                 System.out.println("obj is still alive");
27             }
28             
29             System.out.println("第2次 gc");
30             // 下面这段代码与上面的完全相同,但是这次自救却失败了
31             obj = null;
32             System.gc();
33             // 因为Finalizer线程优先级很低,暂停2秒,以等待它
34             Thread.sleep(2000);
35             if (obj == null) {
36                 System.out.println("obj is dead");
37             } else {
38                 System.out.println("obj is still alive");
39             }
40         } catch (InterruptedException e) {
41             e.printStackTrace();
42         }
43     }
44 }
45 
46 // 结果
47 第1次 gc
48 调用当前类重写的finalize()方法
49 obj is still alive
50 第2次 gc
51 obj is dead

4、MAT与JProfiler的GC Roots溯源

  一个程序当中,到底有多少个GC Roots ,分别又是什么呢?可以通过一些工具来查看。
  MAT是Memory Analyzer Tool的简称,它是一款功能强大的Java堆内存分析器。用于查找内存泄漏以及查看内存消耗情况,是一款免费的性能分析工具。
  官网下载:http://www.eclipse.org/mat/

  代码示例:用MAT查看GC Roots

 1 // 在输入前后分别获取一次dump文件
 2 public class GCRootsTest {
 3     public static void main(String[] args) {
 4         List<Object> list = new ArrayList<>();
 5         Date date = new Date();
 6 
 7         for (int i = 0; i < 100; i++) {
 8             list.add(String.valueOf(i));
 9             try {
10                 Thread.sleep(10);
11             } catch (InterruptedException e) {
12                 e.printStackTrace();
13             }
14         }
15 
16         System.out.println("数据添加完毕,请操作:");
17 
18         new Scanner(System.in).next();
19         list = null;
20         date = null;
21 
22         System.out.println("list、birth已置空,请操作:");
23         new Scanner(System.in).next();
24 
25         System.out.println("结束");
26     }
27 }

  获取dump文件
  方式一:命令行使用jmap

  jps // 获取pid
  jmap -dump:format=b,live,file=test1.bin #{pid}

  方式二:使用JVisualVM
  应用程序—>监视—>堆dump
  用MAT打开两个dump文件,对比就可知道。

  代码示例:同上。用JProfiler查看GC Roots

  代码示例:用JProfiler分析OOM

 1 package com.lx.jvm;
 2 
 3 import java.util.ArrayList;
 4 
 5 // -Xms8m -Xmx8m -XX:+HeapDumpOnOutOfMemoryError
 6 public class HeapOOM {
 7     // 1MB
 8     byte[] buffer = new byte[1 * 1024 * 1024];
 9 
10     public static void main(String[] args) {
11         ArrayList<HeapOOM> list = new ArrayList<>();
12 
13         int count = 0;
14         try {
15             while (true) {
16                 list.add(new HeapOOM());
17                 count++;
18             }
19         } catch (Throwable e) {
20             System.out.println("count = " + count);
21             e.printStackTrace();
22         }
23     }
24 }
25 
26 // 结果
27 java.lang.OutOfMemoryError: Java heap space
28 Dumping heap to java_pid12820.hprof ...
29 Heap dump file created [7834867 bytes in 0.011 secs]
30 count = 6
31 java.lang.OutOfMemoryError: Java heap space
32     at com.lx.jvm.HeapOOM.<init>(HeapOOM.java:8)
33     at com.lx.jvm.HeapOOM.main(HeapOOM.java:16)

  用JProfiler打开java_pid12820.hprof

5、清除阶段:标记-清除算法(Mark-Sweep)

  当成功判定是否是垃圾后,接下来就是释放掉无用对象所占用的内存空间,以便有足够的可用内存空间为新对象分配内存。思想:
  标记:Collector从引用根节点开始遍历,标记所有被引用的对象。一般是在对象的Header中记录为可达对象。
  清除:Collector对堆内存从头到尾进行线性的遍历,如果某个对象在其Header中没有标记为可达对象,则将其回收。清除的是死亡对象。

  缺点:效率不算高;GC时,需要停止整个应用程序,导致用户体验差;这种方式清理出来的内存是不连续的,会产生内存碎片,需要维护一个空闲列表。
  注意:何为清除?
  这里所谓的清除并不是真的置空,而是把需要清除的对象地址保存在空闲的地址列表中。下次有新的对象需要加载时,判断垃圾的位置空间是否够,如果够,就存放。

6、清除阶段:复制算法(Copying)

  思想:将活着的内存空间分为两块,每次只使用其中一块,在垃圾回收时将正在使用的内存中的存活对象复制到未被使用的内存块中,之后清除正在使用的内存块中的所有对象,交换两个内存的角色,完成垃圾回收。
  复制的是存活对象。

  优点:没有标记和清除过程,实现简单,运行高效;保证空间的连续性,不会出现"碎片"问题。
  缺点:需要两倍的内存空间;对于G1这种分拆成为大量region的GC,复制而不是移动,意味着GC需要维护region之间对象引用关系,不管是内存占用或者时间开销也不小。
  注意:此算法存活对象数量很越少,效果越好!不然就白瞎折腾嘛!适用于垃圾很多,存活对象很少,那么需要复制过去的对象就很少,就最理想。比如:S0/S1区。
  如果从根节点标记的存活对象很多(垃圾很少),极端的都存活的话,那么就会把所有的对象都复制过来一遍,完了之后还什么垃圾也没回收到,还把栈上的引用地址也全都修改一遍。就很得不偿失了。
  应用场景:在新生代,对常规应用的垃圾回收,一次通常可以回收70%~99%的内存空间,回收性价比很高。所以现在的商业虚拟机都是用这种收集算法回收新生代。

  结论:在新生代用复制算法是很理想的。在老年代用复制算法,就不理想了,因为很多对象都是要持久存活的。

7、清除阶段:标记-压缩算法(Mark-Compact)

  思想:第一阶段和标记-清除算法一样,从根节点开始标记所有被引用的对象。第二阶段将所有的存活对象压缩到内存的一端,按顺序排放,之后,清理边界外所有的空间。

  标记-压缩算法的最终效果等同于标记-清除后,再进行一次内存碎片整理。因此,也把它称为标记-清除-压缩算法。
  二者的本质差异在于标记-清除是一种非移动式的回收算法,标记-压缩是移动式的。是否移动回收后的存活对象是一项优缺点并存的风险决策。
  被整理后,当需要给新的对象分配内存时,JVM只需要持有一个内存的起始地址即可,这比维护一个空闲列表显然少了很多开销。
  解决了标记清除和复制算法的缺点。
  优点:解决了标记-清除中,内存分散的缺点;解决了复制中,内存减半的代价。
  缺点:效率上,低于复制算法;移动对象的同时,如果对象被其他对象引用,则还需要调整引用的地址;移动过程中,需要全程停止用户应用程序,即STW。

8、三种算法的对比

 
标记-清除
复制
标记-压缩
速度
中等
最快
最慢
空间开销
少(会堆积碎片)
需要活对象的2倍大小(不堆积碎片)
少(不堆积碎片)
移动对象

  效率上说,复制算法最好,但是却浪费了太多内存。为了兼顾各个指标,标记-压缩相对来说更平滑一些,但是效率不尽人意。它比复制多了一个标记的阶段,比标记-清除多了一个整理内存的阶段。
  难道就没有一种最优的算法吗?没有最优的!只有最适合的!

9、分代收集算法

  分代收集算法,基于这样一个事实:不同对象的生命周期是不一样的。因此,不同生命周期的对象采取不同的收集方式,以便提高回收效率。
  一般地,把Java堆分为新生代和老年代,这样就可以根据各个年代的特点使用不同的回收算法,以提高垃圾回收效率。
  在Java程序中,有些对象是与业务信息相关,比如:Http请求中的Session对象、线程、Socket连接,这类对象跟业务直接挂钩,生命周期比较长;但是有一些对象,主要是程序运行过程中生成的临时变量,这些对象生命周期比较短,比如:String对象,由于其不变类的特性,系统会产生大量的这些对象,有些对象甚至只用一次即可回收。
  目前,几乎所有的GC都是采用分代收集算法执行垃圾回收的。在Hotspot中,基于分代的概念,GC所使用的内存回收算法必须结合新生代、老年代各自的特点。
  新生代:对象生命周期短、存活率低,回收频繁。这种情况复制算法的回收整理,速度是最快的。
  老年代:区域较大,对象生命周期长、存活率高,回收不及新生代频繁。这种情况复制算法明显不合适,一般由标记-清除或标记-清除与标记-整理的混合实现。
  (1)Mark阶段:开销与存活对象的数量成正比。
  (2)Sweep阶段:开销与所管理区域的大小成正比。
  (3)Compact阶段:开销与存活对象的数据成正比。

10、增量收集算法、分区算法

  增量收集算法:
  上述现有的算法,在垃圾回收过程中,应用程序将处于一种STW(stop the world)的状态。在这个状态下,应用程序所有的线程都会挂起,暂停一切正常工作,等待垃圾回收的完成。如果垃圾回收时间过长,势必将严重影响用户体验或系统的稳定性。为了解决STW时间过长的问题。对实时垃圾收集算法的研究直接导致了增量收集算法的诞生。
  思想:如果一次性将所有的垃圾进行处理,会造成系统长时间的停顿,那么就可以让垃圾收集线程和应用程序线程交替执行。每次,垃圾收集线程只收集一小片区域的内存空间,接着切换到应用程序线程,依次反复,直到垃圾收集完成。
  总的来说,增量收集算法的基础仍是传统的标记-清除和复制算法。增量收集算法通过对线程间冲突的妥善处理,允许垃圾收集线程以分阶段的方式完成标记、清理或复制工作。
  为解决,或者说平衡, 用户线程与gc线程之间的矛盾(gc时会停止所有用户线程,会严重影响用户体验)。增量收集算法,就是为了在二者之间找到平衡,使其最佳。
  缺点:使用这种方式,由于在垃圾回收过程中,间断性的还执行了应用程序代码,所以能减少系统的停顿时间。但是,因为线程上下文切换的消耗,会使得垃圾回收的总体成本上升,造成系统吞吐量的下降。
  分区算法:
  一般地,在相同条件下,堆空间越大,一次GC所需要的时间就越长,有关GC产生的停顿也越长。为了更好的控制GC产生的停顿时间,将一块大的内存区域分割成多个小块,根据目标的停顿时间,每次合理的回收若干个小区间,而不是整个堆空间,从而减少一次GC所产生的停顿。
  分代算法将按照对象的生命周期长短划分成两个部分,分区算法将整个堆空间划分成连续的不同小区间region。
  每一个小区间都独立使用,独立回收。这种算法的好处是可以控制一次回收多少个小区间。

  注意:这些只是基本的算法思路,实际GC实现过程要复杂的多,目前还在发展中的前沿GC都是复合算法,并且并行和并发兼备。

作者:Craftsman-L

本博客所有文章仅用于学习、研究和交流目的,版权归作者所有,欢迎非商业性质转载。

如果本篇博客给您带来帮助,请作者喝杯咖啡吧!点击下面打赏,您的支持是我最大的动力!

详解JVM 的垃圾回收算法和垃圾回收器

详解JVM

开篇

我们知道JVM的垃圾回收机制实际上是对JVM内存的操作,回收的目的是为了避免内存溢出和内存泄漏的问题。而JVM内存由方法区、堆、虚拟机栈、本地方法栈以及程序计数器5块区域组成,虚拟机栈、本地方法栈、程序计数器是随着Java线程建立而建立,当Java 线程完成之后这三个部分的内存就会被释放掉。

而方法区和堆属于共有线程,是随着JVM启动而建立的,而且这两个区域与另外三个区域也有所不同,一个接口中有多少个实现类(方法区)以及每次程序运行需要创建多少对象(堆)是动态的,也就是说在程序运行时才能知道。

为了让这部分动态的内存分配能够进行合理的回收,就需要垃圾回收算法和垃圾回收器来帮忙了。下面让我们进入今天的主题。

如何判断对象“存活”?

JVM 垃圾回收机制是对堆中没有使用的对象进行回收,那么判断对象是否“存活”就至关重要。在判断对象是否“存活”的方法中,我们会介绍引用计数算法和可达性分析法。

引用计数算法

Java 中针对每个对象都设置一个引用计数器。当一个对象被创建并初始化赋值后,该变量计数设置为1。每当有一个地方引用它时,计数器值就加1。当引用失效时,即一个对象的某个引用超过了生命周期(出作用域后)或者被设置为一个新值时,计数器值就减1。任何引用计数为0的对象可以被当作垃圾回收。当一个对象被垃圾回收时,它引用的任何对象计数减1。

这种方法的优点很明显,引用计数回收器执行简单,判定效率高,对程序不被长时间打断的实时环境比较有利。不过缺点也很明显,对于对象循环引用的场景难以判断,同时引用计数器增加了程序执行的开销。Java语言并没有选择这种算法进行垃圾回收。

可达性分析法

可达性分析法也叫根搜索算法,通过称为 GC Roots 的对象作为起点,从上往下进行搜索。搜索所走过的路径称为引用链 (Reference Chain), 当发现某个对象与 GC Roots之间没有任何引用链相连时, 即认为该对象不可达,该对象也就成了垃圾回收的目标。如图1 所示,从GC Roots 开始没有引用链和Obejct5、Object6 和Object7 相连,因此这三个对象对于GC Roots 而言就是不可达的,会被垃圾回收,即便他们互相都有引用。

详解JVM

图1 可达性分析法

  • 在Java中,可作为GC Roots的对象包括如下四种:

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

  • 本地方法栈 中 JNI (Native方法)引用的变量

  • 方法区 中类静态属性引用的变量

  • 方法区 中常量引用的变量

前面谈到的可达实际上是在判断对象是否被引用,如果没有被引用,垃圾回收器会将其进行回收。不过我们希望存在这样一些对象,当内存空间足够的情况下尽量将其保留在内存中,当内存不够的情况下,再回收这些对象。下面看看如何对如下对象进行处理:

强引用(Strong Reference):例如,Object obj = new Object()这类引用,只要强引用存在,垃圾回收器永远不会回收掉被引用的对象。

软引用(Soft Reference):在系统将要出现内存溢出之前,会将软引用对象列进回收范围之中进行二次回收。如果这次回收还没有足够的内存,才会抛出内存溢出异常。

弱引用(Weak Reference):被弱引用关联的对象只能生存到下一次垃圾回收发生之前,无论当前内存是否足够,用软引用相关联的对象都会被回收掉。

虚引用(Phantom Reference):虚引用也称为幽灵引用或幻影引用,是最弱的一种引用关系,为一个对象设置虚引用的唯一目的是:能在这个对象在垃圾回收器回收的时候收到一个系统通知。

垃圾回收算法

上面讲解了如何发现“存活”对象,JVM中会使用可达性分析法,说白了就是看GC Roots在引用链上是否有对应的对象被引用到了。接下来就在这个背景下看看有哪些垃圾回收的算法,这里我们列举出常见的几种:

标记清除算法

该算法分为标记和清除两个阶段,首先通过可达性分析法找到要回收的对象,也就是没有被引用的对象,对其进行标记,然后再对该对象进行清除也就是回收了。

如图2 所示,该算法会对内存空间进行扫描,发现GC Roots 对Object1 和Object2 进行引用,但是对Object2 没有引用。首先标记Object2 没有被引用。

详解JVM图2

如图3 所示,算法再次对内存进行扫描,清除Object2 对象占用的空间,将其设置为空闲空间。详解JVM

图3 标记清除算法

该算法的优点就是简单粗暴,没有引用的对象会被清除掉,但是缺点是效率问题。标记和清除操作会扫描整个空间两次(第一次:标记存活对象;第二次:清除没有标记的对象)才能完成清理工作。同时清理过程容易产生内存碎片,这些空闲的空间无法容纳大对象,如果此时有一个比较大的对象进入内存,由于该内存中没有连续的容纳大对象的空间,就会提前触发垃圾回收。

复制算法

为了解决标记清除法带来的问题,复制算法将内存划分为大小相等的两块,每次使用其中的一块,当这块的内存使用完毕以后,再将对象复制到另外一块上面,然后对已经使用过的内存空间进行清理。这样每次对内存的一半区域进行回收,不用考虑内存碎片的问题。

如图4 所示,上面的区域是垃圾回收之前的内存空间,我们用黑色的虚线将内存分为两个部分。左边的部分是正在使用的空间,右边是预留空间。左边区域中红色的部分是不可回收的内存,也就是说这里面有被GC Roots 引用的对象,另外灰色的部分是可回收的区域,也就是没有被GC Roots 引用的对象,白色区域是未分配的。

如果通过复制算法进行垃圾回收,顺着绿色的箭头向下,在回收后的内存区域可以看到,将左侧红色的内存对象移动到了右侧预留的区域,并且按照顺序排放。然后对左侧运行的内存区域进行清理,成为预留区域等待第二次垃圾回收的执行。

详解JVM

图4 复制算法

复制算法的优点是简单高效,不会出现内存碎片。缺点也明显,内存利用率低,只有一半的内存被利用。特别是存活对象较多时效率明显降低,因为需要移动每个不可回收数据的内存实际位置。

标记整理算法

该算法和标记清除算法相似,但是后续步骤并不是直接对可回收对象进行清理,而是让所有存活对象都移动到内存的前端,然后再清除掉其他可回收的对象所占用的内存空间。如图5 所示,回收前的内存中红色为不可回收的内存空间,灰色是可回收空间,白色是未分配空间。执行标记整理算法的垃圾回收之后,将不可回收的内存空间整理到内存的前端,同时清除掉可回收的内存空间,此时不可回收空间之后存放的都是白色的未分配空间,供由新对象存放。详解JVM图5 标记整理算法

标记整理算法优点是解决了标记清理算法存在的内存碎片问题。缺点也是非常明显,需要进行局部对象移动,一定程度上降低了效率。

分代收集算法

分代收集算法,顾名思义是根据对象的存活周期将内存划分为几块,然后定义回收规则。如图6所示,从左到右分别是年轻代(Young Generation)、老年代(Old Generation) 和 永久代(Permanent Generation),另外年轻代又分为了Eden Space(伊甸空间) 、Survivor Space(幸存者空间)。分代收集的算法在当前商业虚拟机算法中被广泛采用。

详解JVM

图6 分代收集法

上面对分代收集法做了字面的解释,现将该算法的执行过程描述如下:

1)新产生的对象优先分配在Eden区(除非配置了-XX:PretenureSizeThreshold,大于该值的对象会直接进入老年代)。有这样一种情况,当对象刚刚在新生代创建就被回收了,对象从这个区域消失的过程我们称之为 minor GC。

2)当Eden区满了或放不下了,这时候其中存活的对象会复制到from区。如果此时存活下来的对象在from 区都放不下,就会放到老年代,之后Eden 区的内存会全部回收掉。

3)之后产生的对象继续分配在Eden区,当Eden区又满了或放不下了,这时候将会把Eden区和from区存活下来的对象复制到to区,此时如果存活下来的对象to区也放不下,会将其移动到年老代,同时会回收掉Eden区和from区的内存。

4)如果按照如上操作将对象在几个区域中移动,会出现对象被多次复制的情况,对象被复制一次,对象的年龄就会+1。默认情况下,当对象被复制了15次(通过:-XX:MaxTenuringThreshold来配置),该对象就会进入老年代了。

5)当老年代满了的情况下,就会发生一次Full GC。

备注:Minor GC指发生在新生代的垃圾收集动作,因为Java对象大多都具备朝生夕灭的特性,所以Minor GC非常频繁,一般回收速度也比较快。Full GC指发生在老年代的GC,出现了Full GC,经常会伴随至少一次的Full GC,Full GC的速度一般会比Minor GC慢10倍以上。

垃圾回收器

如果垃圾回收算法是内存回收的方法论的话,那么垃圾回收器就是内存回收的具体实现了。下面会针对JDK1.7 Update 14 之后的HotSpot虚拟机给大家做介绍。

如图7所示,这里将内存分为新生代和老年代,将7种不同垃圾回收器分布于其间,垃圾回收器之间存在连线,说明它们可以搭配使用。

虚拟机所处的区域,则表示它是属于新生代收集器还是老年代收集器。Hotspot实现了如此多的收集器,正是因为目前并无完美的收集器出现,只是选择对具体应用最适合的收集器。

详解JVM

图7垃圾回收器的分类

下面就来介绍这几个垃圾回收器:

Serial回收器

Serial(串行)回收器采用复制算法的新生代收集器,它是一个单线程回收器,针对一个CPU或一条收集线程去完成垃圾收集工作,它在进行垃圾收集时,必须暂停其他所有的工作线程,直至Serial收集器收集结束为止,这个做法也称为 “Stop The World”。

如图8 所示,左边多个应用程序线程在执行, 当Serial 回收器的GC线程(虚线部分)执行的时候,应用程序线程(左边多个实线)都会暂停,只有在回收器线程执行完毕以后,应用程序线程(右边多个实线)才会继续执行。

详解JVM

图 8 串行垃圾回收器

该回收器的问题就是在进行垃圾回收时其他工作线程必须停顿,不过它在HotSpot虚拟机运行的Client模式下可以为新生代回收器服务。它的简单而高效对于限定单个CPU的环境来说,Serial回收器没有线程交互的开销。在用户的桌面应用场景中,分配给虚拟机管理的内存不大,停顿时间可以控制在几十毫秒以内,还是可以接收。它对于运行在Client模式下的虚拟机来说是一个很好的选择。

ParNew 回收器

ParNew回收器是Serial回收器的多线程版本,它也是一个新生代回收器。除了使用多线程进行垃圾收集外,其余行为包括Serial收集器可用的所有控制参数、收集算法(复制算法)、Stop The World、对象分配规则、回收策略等。

如图9 所示,与Serial 不同的是ParNew 使用多个线程(中间多条虚线)的方式进行垃圾回收。

详解JVM

图9 ParNew 并行回收器

ParNew 回收器在多CPU环境下垃圾回收的效率会有明显提高。它默认开启的收集线程数与CPU的数量相同,在CPU非常多的情况下可使用-XX:ParallerGCThreads参数设置。反过来,如果针对单个CPU的环境 ParNew 和Serial 回收器的效果就难分伯仲了。

Serial Old回收器

Serial Old 是 Serial回收器的老年代版本,是单线程收集器,使用标记整理(Mark-Compact)算法。它可以给Client模式下的虚拟机使用。如果在Server模式下,它还有两大用途:在JDK1.5 以及之前版本(Parallel Old诞生以前)中与Parallel Scavenge收集器搭配使用。作为CMS收集器的后备预案,在并发收集发生Concurrent Mode Failure时使用。

Parallel Old回收器

Parallel Old回收器是Parallel Scavenge的老年代版本,使用多线程的标记整理算法。在JDK 1.6中才开始提供,如果新生代选择了Parallel Scavenge收集器,老年代除了Serial Old以外别无选择。Parallel Old回收器的工作流程与Parallel Scavenge相同。

Parallel Scavenge 回收器

Parallel Scavenge回收器是并行的多线程新生代回收器,它使用复制算法。Parallel Scavenge回收器的目标是达到一个可控制的吞吐量(Throughput)。

这里稍微说明一下, 吞吐量就是CPU运行用户代码时间与CPU总消耗时间的比值,表现成工时是:吞吐量 = 用户代码运行时间 /(用户代码运行时间 + 垃圾回收时间)。用户代码运行时间95 分钟,垃圾回收时间为5分钟,那吞吐量就是95/(95+5)=95%。

高吞吐量说明垃圾回收器高效率地利用CPU时间,尽快完成程序的运算任务。从而让垃圾回收造成的停顿时间变短,适合与用户交互的程序提升用户体验。

Parallel Scavenge会提供精确控制吞吐量的参数,此外还通过对参数-XX:+UseAdaptiveSizePolicy 设置来自动调节新生代的大小(-Xmn)、Eden和Survivor区的比例(-XX:SurvivorRatio)、晋升老年代对象年龄(-XX:PretenureSizeThreshold)等信息。

此外Parallel Scavenge 回收器还有一个特点就是,会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量,我们称之为GC自适应的调节策略(GC Ergonomics)。

CMS收集器

CMS(Concurrent Mark Sweep)收集器是以获取最短回收停顿时间为目标的回收器,它适用于重视响应速度的应用场景,它是基于标记清除算法而实现的。

如图10 所示,从左往右CMS工作流程分为以下4个步骤:

  • 初始标记(CMS initial mark):标记GC Roots直接关联到的对象,需要执行“Stop The World”,也就是让工作线程暂停。

  • 并发标记(CMS concurrent mark):从GC Roots 查找所有可达的对象,这个过程耗时比较长,此时用户线程依旧在运行。

  • 重新标记(CMS remark):修正并发标记期间,用户程序继续运作而导致标记的对象,并且调整标记记录,此阶段也需要“Stop The World”,因为不暂停工作线程的话还可能有标记不准确的情况发生。

  • 并发清除(CMS concurrent sweep):对于标记不可用的对象进行并发清除操作,这个过程耗时会比较长,此时工作线程依旧可以运行。

所以,从总体上来说,CMS收集器的内存回收过程是与用户线程一起并发执行的。通过下图可以比较清楚地看到CMS收集器的运作步骤中并发和需要停顿的时间:

详解JVM

图10 CMS 垃圾回收器

CMS的优点明显,并发收集、低停顿。不过他对CPU资源非常敏感,在并发阶段虽然不会导致用户线程暂停,但会因为占用了一部分线程(或者说CPU资源)而导致应用程序变慢,总吞吐量会降低。

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

无法处理浮动垃圾(Floating Garbage) 可能出现“Concurrent Mode Failure”失败而导致另一次Full GC的产生。在垃圾回收阶段,用户线程还在运行,还需要预留有足够的内存空间给用户线程使用,因此CMS需要预留一部分空间提供并发收集时的程序运作使用。标记清除算法本身也会导致产生大量的空间碎片。

G1回收器

G1(Garbage-First)回收器是面向服务端应用的垃圾回收器,它具备如下特点:

  • 充分利用多CPU缩短“Stop The World”停顿时间,可以通过并发的方式让Java程序继续执行。

  • 不需要其他回收器配合就能独立管理整个GC堆,采用不同方式去处理新创建的对象和已存活一段时间、对于经历过多次GC的旧对象来说会有更好的回收效果。

  • G1基本上是基于标记整理算法实现的,在局部(两个Region之间)是基于复制算法实现的。这意味着G1运行期间不会产生内存空间碎片,收集后能提供规整的可用内存。此特性有利于程序长时间运行,分配大对象时不会因为无法找到连续内存空间而提前触发下一次GC。

  • 可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在GC上的时间不得超过N毫秒。

与其他垃圾回收器不同的是,G1回收的范围横跨整个堆内存。

如图11所示,G1将堆划分为多个大小相等的区域(Region),虽然还保留新生代和老年代的概念,但新生代和老年代不再是物理隔离的了,而是Region的集合。

详解JVM

图11 G1 将堆进行分Region

前面提到G1回收器可以预测的停顿时间,是因为它避免在整个Java堆中进行全区域的垃圾回收。G1会跟踪各个Region的垃圾堆积的价值大小(回收的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的回收时间,优先回收价值最大的Region。

虽然G1把Java堆分为多个Region,在某个Region中的对象可以与位于其他Region中的任意对象发生引用关系。在做可达性分析时仍然需要扫描整个堆,显然这样做效率是不高的。为了避免全堆扫描, G1为每个Region维护了一个记忆集(Remembered Set)。当发现程序在对引用(Reference)类型的数据进行写操作时,会产生一个Write Barrier暂时中断写操作。然后检查引用(Reference)对象是否处于不同的Region之中,如果是便通过CardTable把相关引用信息记录到被引用对象所属的Region的记忆集(Remembered Set)之中。当进行内存回收时,在GC根节点的枚举范围中加入Remembered Set即可保证不对全堆扫描也不会有遗漏。说白了就是通过Remembered Set 记录跨Region引用的对象,其目的是避免全堆扫描。

如图12所示,Region2 中的两个对象分配被Region1 和Region3 中的对象引用,因此在Region2中的记忆集(Remembered set)就会记录这两个引用的信息,在垃圾回收的时候只需要收集记忆集的信息就知道对象在每个Region 的引用关系了,并不需要扫描所有堆的Region。

详解JVM

图12 跨Region的对象引用 说了G1 的特点和机制,下面通过图13 来看看它的执行过程:

  • 初始标记(Initial Marking):标记GC Roots 能直接引用的对象,让下一阶段用户程序并发运行时,能在正确的Region中创建对象,此阶段需要停顿线程,但耗时很短。

  • 并发标记(Concurrent Marking) :从GC Root 开始对堆中对象进行可达性分析,找到存活对象,此阶段耗时较长,但可与用户程序并发执行。

  • 最终标记(Final Marking):为了修正在并发标记期间因用户程序继续运作而导致标记产生变动的那一部分标记记录,虚拟机将这段时间对象变化记录在线程的Remembered Set Logs里面,最终标记阶段需要把Remembered Set Logs的数据合并到Remembered Set中,这阶段需要停顿线程,但是可并行执行。

  • 筛选回收(Live Data Counting and Evacuation) :首先对各个Region中的回收价值和成本进行排序,根据用户所期望的GC 停顿时间来制定回收计划。此阶段其实也可以做到与用户程序一起并发执行,但是因为只回收一部分Region,时间是用户可控制的,而且停顿用户线程将大幅度提高收集效率。

总结

今天给大家介绍了垃圾回收的算法和JVM的垃圾回收器,算法作为思路和方法论的指导,而垃圾回收器是方法论的最佳实践,这里通过一张表格将两者进行一个总结:

详解JVM

以上是关于JVM详解——垃圾回收算法的主要内容,如果未能解决你的问题,请参考以下文章

史诗级详解面试中JVM的垃圾回收

JVM的内存区域划分以及垃圾回收机制详解

JVM详解——垃圾回收算法(补充)

(超详解)JVM-垃圾回收

JVM 架构解释 + 垃圾回收机制 详解(基于JDK8版本)

JVM垃圾回收机制及算法详解