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

1.1 垃圾标记阶段:对象是否存活

  • 在堆里存放着几乎所有的Java对象实例,在GC执行垃圾回收之前,首先需要区分出内存中哪些是存活对象,哪些是已经死亡的对象。只有被标记为己经死亡的对象,GC才会在执行垃圾回收时,释放掉其所占用的内存空间,因此这个过程我们可以称为垃圾标记阶段
  • 那么在JVM中究竟是如何标记一个死亡对象呢?简单来说,当一个对象已经不再被任何的存活对象继续引用时,就可以宣判为已经死亡
  • 判断对象存活一般有两种方式:引用计数算法可达性分析算法

1.2 方式一:引用计数算法

  1. 引用计数算法(Reference Counting)比较简单,对每个对象保存一个整型的引用计数器属性。用于记录对象被引用的情况
  2. 对于一个对象A,只要有任何一个对象引用了A,则A的引用计数器就加1;当引用失效时,引用计数器就减1。只要对象A的引用计数器的值为0,即表示对象A不可能再被使用,可进行回收。
  3. 优点:实现简单,垃圾对象便于辨识;判定效率高,回收没有延迟性
  4. 缺点:
    1. 它需要单独的字段存储计数器,这样的做法增加了存储空间的开**销**。
    2. 每次赋值都需要更新计数器,伴随着加法和减法操作,这增加了时间开销
    3. 引用计数器有一个严重的问题,即无法处理循环引用的情况。这是一条致命缺陷,导致在Java的垃圾回收器中没有使用这类算法。

1.3 循环引用

image.png
当p=null后,由于这三个对象的计数并非0,而不能被回收。但是呢?由于Java的标记阶段没有选用引用计数法,因此这个不算是真正的内存泄漏的例子。

1.4 证明Java语言使用的并非引用计数算法

  1. /**
  2. * -XX:+PrintGCDetails
  3. * 证明:java使用的不是引用计数算法
  4. */
  5. public class RefCountGC {
  6. //这个成员属性唯一的作用就是占用一点内存
  7. private byte[] bigSize = new byte[5 * 1024 * 1024];//5MB
  8. Object reference = null;
  9. public static void main(String[] args) {
  10. RefCountGC obj1 = new RefCountGC();
  11. RefCountGC obj2 = new RefCountGC();
  12. obj1.reference = obj2;
  13. obj2.reference = obj1;
  14. obj1 = null;
  15. obj2 = null;
  16. //显式的执行垃圾回收行为
  17. //这里发生GC,obj1和obj2能否被回收?
  18. System.gc();
  19. }
  20. }

image.png

  • 如果不小心直接把obj1.referenceobj2.reference置为null。则在Java堆中的两块内存依然保持着互相引用,无法被回收。

首先:我们将System.gc();这个显示回收代码注释掉

  1. D:\developer_tools\Java\jdk-8u221\bin\java.exe -XX:+PrintGCDetails -javaagent:C:\Users\YCKJ3911\AppData\Local\JetBrains\Toolbox\apps\IDEA-U\ch-0\202.8194.7\lib\idea_rt.jar=52565:C:\Users\YCKJ3911\AppData\Local\JetBrains\Toolbox\apps\IDEA-U\ch-0\202.8194.7\bin -Dfile.encoding=UTF-8 -classpath D:\developer_tools\Java\jdk-8u221\jre\lib\charsets.jar;D:\developer_tools\Java\jdk-8u221\jre\lib\deploy.jar;D:\developer_tools\Java\jdk-8u221\jre\lib\ext\access-bridge-64.jar;D:\developer_tools\Java\jdk-8u221\jre\lib\ext\cldrdata.jar;D:\developer_tools\Java\jdk-8u221\jre\lib\ext\dnsns.jar;D:\developer_tools\Java\jdk-8u221\jre\lib\ext\jaccess.jar;D:\developer_tools\Java\jdk-8u221\jre\lib\ext\jfxrt.jar;D:\developer_tools\Java\jdk-8u221\jre\lib\ext\localedata.jar;D:\developer_tools\Java\jdk-8u221\jre\lib\ext\nashorn.jar;D:\developer_tools\Java\jdk-8u221\jre\lib\ext\sunec.jar;D:\developer_tools\Java\jdk-8u221\jre\lib\ext\sunjce_provider.jar;D:\developer_tools\Java\jdk-8u221\jre\lib\ext\sunmscapi.jar;D:\developer_tools\Java\jdk-8u221\jre\lib\ext\sunpkcs11.jar;D:\developer_tools\Java\jdk-8u221\jre\lib\ext\zipfs.jar;D:\developer_tools\Java\jdk-8u221\jre\lib\javaws.jar;D:\developer_tools\Java\jdk-8u221\jre\lib\jce.jar;D:\developer_tools\Java\jdk-8u221\jre\lib\jfr.jar;D:\developer_tools\Java\jdk-8u221\jre\lib\jfxswt.jar;D:\developer_tools\Java\jdk-8u221\jre\lib\jsse.jar;D:\developer_tools\Java\jdk-8u221\jre\lib\management-agent.jar;D:\developer_tools\Java\jdk-8u221\jre\lib\plugin.jar;D:\developer_tools\Java\jdk-8u221\jre\lib\resources.jar;D:\developer_tools\Java\jdk-8u221\jre\lib\rt.jar;D:\IDEAworkspace\shangguigu\JVMDemo\chapter15\target\classes com.atguigu.java.RefCountGC
  2. Heap
  3. PSYoungGen total 75776K, used 15456K [0x000000076b800000, 0x0000000770c80000, 0x00000007c0000000)
  4. eden space 65024K, 23% used [0x000000076b800000,0x000000076c7182d8,0x000000076f780000)
  5. from space 10752K, 0% used [0x0000000770200000,0x0000000770200000,0x0000000770c80000)
  6. to space 10752K, 0% used [0x000000076f780000,0x000000076f780000,0x0000000770200000)
  7. ParOldGen total 173568K, used 0K [0x00000006c2800000, 0x00000006cd180000, 0x000000076b800000)
  8. object space 173568K, 0% used [0x00000006c2800000,0x00000006c2800000,0x00000006cd180000)
  9. Metaspace used 3131K, capacity 4496K, committed 4864K, reserved 1056768K
  10. class space used 341K, capacity 388K, committed 512K, reserved 1048576K
  11. Process finished with exit code 0

其次:将System.gc();这个显示回收代码暴露出

  1. [GC (System.gc()) [PSYoungGen: 14155K->808K(75776K)] 14155K->816K(249344K), 0.0010654 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
  2. [Full GC (System.gc()) [PSYoungGen: 808K->0K(75776K)] [ParOldGen: 8K->594K(173568K)] 816K->594K(249344K), [Metaspace: 3122K->3122K(1056768K)], 0.0049524 secs] [Times: user=0.00 sys=0.00, real=0.01 secs]
  3. Heap
  4. PSYoungGen total 75776K, used 1951K [0x000000076b800000, 0x0000000770c80000, 0x00000007c0000000)
  5. eden space 65024K, 3% used [0x000000076b800000,0x000000076b9e7c70,0x000000076f780000)
  6. from space 10752K, 0% used [0x000000076f780000,0x000000076f780000,0x0000000770200000)
  7. to space 10752K, 0% used [0x0000000770200000,0x0000000770200000,0x0000000770c80000)
  8. ParOldGen total 173568K, used 594K [0x00000006c2800000, 0x00000006cd180000, 0x000000076b800000)
  9. object space 173568K, 0% used [0x00000006c2800000,0x00000006c2894b78,0x00000006cd180000)
  10. Metaspace used 3129K, capacity 4496K, committed 4864K, reserved 1056768K
  11. class space used 341K, capacity 388K, committed 512K, reserved 1048576K
  12. Process finished with exit code 0

此时显式回收后,发现这个Eden区的内存空间占用变少了,说明有对象回收了。这就证明了GC用的并非引用计数算法。


1.5 小结

  • 引用计数算法,是很多语言的资源回收选择,例如因人工智能而更加火热的Python,它更是同时支持引用计数和垃圾收集机制。不是说有缺陷吗?为什么Python还用?是解决了吗?那是因为Pathon更看重其优点。
  • 具体哪种最优是要看场景的,业界有大规模实践中仅保留引用计数机制,以提高吞吐量的尝试。
  • Java并没有选择引用计数,是因为其存在一个基本的难题,也就是很难处理循环引用关系。Java更看重其缺点。
  • Python如何解决循环引用
    • 手动解除:很好理解,就是在合适的时机,解除引用关系。
    • 使用弱引用weakref,weakref是Python提供的标准库,旨在解决循环引用。

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

      2.1 方式二:可达性分析(或根据搜索算法、追踪性垃圾收集)

  1. 相对于引用计数算法而言,可达性分析算法不仅同样具备实现简单和执行高效等特点,更重要的是该算法可以有效地解决在引用计数算法中循环引用的问题,防止内存泄漏的发生。说是简单,但其实比引用计数算法还是复杂点,但是功能更强了。
  2. 相较于引用计数算法,这里的可达性分析就是Java、C#选择的。这种类型的垃圾收集通常也叫作追踪性垃圾收集(Tracing Garbage Collection)

2.2 可达性分析实现思路

  • 所谓”GCRoots”根集合就是一组必须活跃的引用
  • 其基本思路如下:
    1. 可达性分析算法是以根对象集合(GCRoots)为起始点,按照从上至下的方式搜索被根对象集合所连接的目标对象是否可达**。**
    2. 使用可达性分析算法后,内存中的存活对象都会被根对象集合直接或间接连接着,搜索所走过的路径称为引用链(Reference Chain)
    3. 如果目标对象没有任何引用链相连,则是不可达的,就意味着该对象己经死亡,可以标记为垃圾对象。
    4. 在可达性分析算法中,只有能够被根对象集合直接或者间接连接的对象才是存活对象。

image.png


2.3 GC Roots可以是哪些元素?

  1. 虚拟机栈中引用的对象(局部变量表中的引用)
    • 比如:各个线程被调用的方法中使用到的参数、局部变量等。
  2. 本地方法栈内JNI(通常说的本地方法)引用的对象
  3. 方法区中类静态属性引用的对象(这里涉及静态了,就不在虚拟机栈里了,原来是在方法区,JDK7及以后到了堆空间)
    • 比如:Java类的引用类型静态变量
  4. 方法区中常量引用的对象(常量池的引用后来也在堆空间)
    • 比如:字符串常量池(StringTable)里的引用
  5. 所有被同步锁synchronized持有的对象(销毁之后,同步则会失效)
  6. Java虚拟机内部的引用。
    • 基本数据类型对应的Class对象,一些常驻的异常对象(如:NullPointerException、OutofMemoryError),系统类加载器。
  7. 反映java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。
  • 除了这些固定的GC Roots集合以外,根据用户所选用的垃圾收集器以及当前回收的内存区域不同,还可以有其他对象“临时性”地加入,共同构成完整GC Roots集合。比如:分代收集和局部回收(PartialGC)。
    • 如果只针对Java堆中的某一块区域进行垃圾回收(比如:典型的只针对新生代),必须考虑到内存区域是虚拟机自己的实现细节,更不是孤立封闭的,这个区域的对象完全有可能被其他区域的对象所引用,这时候就需要一并将关联的区域对象也加入GC Roots集合中去考虑,才能保证可达性分析的准确性。

上面这句话是什么意思呢?对于上面7条,我们是考虑收集堆空间中的垃圾,是站在堆外面看的。此时虚拟机栈、本地方法栈引用的堆中的对象;方法区中的静态属性,常量池引用的对象(当然此时外面默认静态属性和常量池在堆外了,其实就在堆内)都是堆中对象。但是呢?如果我们只是想收集堆空间年轻代中的Eden区的话,这个GC Roots中的元素范围就要扩大了,比如老年代和新生代的Survivor1中引用到Eden对象的元素也是根元素的一部分了。
小技巧
由于Root采用栈方式存放变量和指针,所以如果一个指针,它保存了堆内存里面的对象,但是自己又不存放在堆内存里面,那它就是一个Root。
说人话:总结一句话就是,除了堆空间的周边,比如:虚拟机栈、本地方法栈、方法区、静态变量、字符串常量池等地方对堆空间进行引用的,都可以作为GC Roots进行可达性分析。当然这是考虑收集整个堆空间,如果是收集Eden,则GC Roots要扩大范围,把Survivor和老年代的引用也要算进去。
注意

  1. 如果要使用可达性分析算法来判断内存是否可回收,那么分析工作必须在一个能保障一致性的快照中进行。这点不满足的话分析结果的准确性就无法保证。其实说白了,进行可达性分析时,要让这个时间点截至,就像冻结了一样,不能还让对象之间引用的关系变化。
  2. 这点也是导致GC进行时必须“Stop The World”的一个重要原因。即使是号称(几乎)不会发生停顿的CMS收集器中,枚举根节点时也是必须要停顿的

    3. 对象的finalization机制

    3.1 finalize()方法介绍

  3. Java语言提供了对象终止(finalization)机制来允许开发人员提供对象被销毁之前的自定义处理逻辑。也就是在该对象销毁前,Java允许开发人员做一些事情。

  4. 当垃圾回收器发现没有引用指向一个对象,即:垃圾回收此对象之前,总会先调用这个对象的finalize()方法。
  5. finalize() 方法允许在子类中被重写,用于在对象被回收时进行资源释放。通常在这个方法中进行一些资源释放和清理的工作,比如关闭文件、套接字和数据库连接等。

finalize(): Called by the garbage collector on an object when garbage collection determines that there are no more references to the object. 该方法定义在Object中,当我们看这个方法时其声明为:protected void finalized() throws Throwable {},这个方法没有声明final,说明我们可以重写这个方法。


3.2 不要主动调用finalize()方法

  1. 永远不要主动调用某个对象的finalize()方法,应该交给垃圾回收机制调用(即使你重写了,也不要主动调)。理由包括下面三点:
    1. 在finalize()时可能会导致对象复活。
    2. finalize()方法的执行时间是没有保障的,它完全由GC线程决定,极端情况下,若不发生GC,则finalize()方法将没有执行机会。
    3. 一个糟糕的finalize()会严重影响GC的性能。比如finalize是个死循环
  2. 从功能上来说,finalize()方法与C++中的析构函数比较相似,但是Java采用的是基于垃圾回收器的自动内存管理机制,所以finalize()方法在本质上不同于C++中的析构函数。
  3. finalize()方法对应了一个finalize线程,因为优先级比较低,即使主动调用该方法,也不会因此就直接进行回收

3.3 生存还是死亡?

由于finalize()方法的存在,虚拟机中的对象一般处于三种可能的状态**。**

  1. 如果从所有的根节点都无法访问到某个对象,说明对象己经不再使用了。一般来说,此对象需要被回收。但事实上,也并非是“非死不可”的,这时候它们暂时处于“缓刑”阶段。一个无法触及的对象有可能在某一个条件下“复活”自己,如果这样,那么对它立即进行回收就是不合理的。为此,定义虚拟机中的对象可能的三种状态。如下:
    1. 可触及的:从根节点开始,可以到达这个对象。
    2. 可复活的:对象的所有引用都被释放,但是对象有可能在finalize()中复活。此时不可触及,但可能会复活。
    3. 不可触及的:对象的finalize()被调用,并且没有复活,那么就会进入不可触及状态。不可触及的对象不可能被复活,因为finalize()只会被调用一次
  2. 以上3种状态中,是由于finalize()方法的存在,进行的区分。只有在对象不可触及时才可以被回收。

3.4 可达性分析判断垃圾的具体过程

判定一个对象objA是否可回收,至少要经历两次标记过程:

  1. 如果对象objA到GC Roots没有引用链,则进行第一次标记。
  2. 进行筛选,判断此对象是否有必要执行finalize()方法
    1. 如果对象objA没有重写finalize()方法,或者finalize()方法已经被虚拟机调用过,则虚拟机视为“没有必要执行”,objA被判定为不可触及的。
    2. 如果对象objA重写了finalize()方法,且还未执行过(这是我们不要手动执行finalize()方法的原因),那么objA会被插入到F-Queue队列中,由一个虚拟机自动创建的、低优先级的Finalizer线程触发其finalize()方法执行。
    3. finalize()方法是对象逃脱死亡的最后机会,稍后GC会对F-Queue队列中的对象进行第二次标记。如果objA在finalize()方法中与引用链上的任何一个对象建立了联系,那么在第二次标记时,objA会被移出“即将回收”集合。之后,对象会再次出现没有引用存在的情况。在这个情况下,finalize()方法不会被再次调用,对象会直接变成不可触及的状态,也就是说,一个对象的finalize()方法只会被调用一次。

通过JVisualVM查看Finalize的线程

  1. public class RefCountGC {
  2. public static void main(String[] args) throws InterruptedException
  3. {
  4. Thread.sleep(1000000000L);
  5. }
  6. }

image.png
代码演示:finalize()方法可复活对象
我们重写 CanReliveObj 类的 finalize()方法,在调用其 finalize()方法时,将 obj 指向当前类对象 this
下面代码中类变量,属于GC Roots的一部分。之所以有休眠是因为finalize线程的级别比较低,怕还没有回收,因此等一下。

  1. /**
  2. * 测试Object类中finalize()方法,即对象的finalization机制。
  3. *
  4. */
  5. public class CanReliveObj {
  6. public static CanReliveObj obj;//类变量,属于 GC Root
  7. //此方法只能被调用一次
  8. @Override
  9. protected void finalize() throws Throwable {
  10. super.finalize();
  11. System.out.println("调用当前类重写的finalize()方法");
  12. obj = this;//当前待回收的对象在finalize()方法中与引用链上的一个对象obj建立了联系
  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. System.out.println("第2次 gc");
  29. // 下面这段代码与上面的完全相同,但是这次自救却失败了
  30. obj = null;
  31. System.gc();
  32. // 因为Finalizer线程优先级很低,暂停2秒,以等待它
  33. Thread.sleep(2000);
  34. if (obj == null) {
  35. System.out.println("obj is dead");
  36. } else {
  37. System.out.println("obj is still alive");
  38. }
  39. } catch (InterruptedException e) {
  40. e.printStackTrace();
  41. }
  42. }
  43. }

如果注释掉重写的finalize方法的运行结果

  1. 1 gc
  2. obj is dead
  3. 2 gc
  4. obj is dead

加上finalzie方法的运行结果

  1. 调用当前类重写的finalize()方法
  2. 1 gc
  3. obj is still alive
  4. 2 gc
  5. obj is dead

第一次自救成功,但由于 finalize() 方法只会执行一次,所以第二次自救失败。

4. MAT与JProfiler的GC Roots溯源

4.1 MAT介绍

  1. MAT是Memory Analyzer的简称,它是一款功能强大的Java堆内存分析器。用于查找内存泄漏以及查看内存消耗情况。
  2. MAT是基于Eclipse开发的,是一款免费的性能分析工具。
  3. 大家可以在MAT下载

虽然Jvisualvm很强大,但是在内存分析方面,还是MAT更好用一些
此小节主要是为了实时分析GC Roots是哪些东西,中间需要用到一个dump的文件


4.2 获取dump文件方式

方式一:命令行使用jmap生成离线的dump文件
image.png
方式二:使用JVisualVM导出

  • 捕获的heap dump文件是一个临时文件,关闭JVisualVM后自动删除,若要保留,需要将其另存为文件。可通过以下方法捕获heap dump:
  • 可通过以下方法捕获heap dump:
    • 在左侧”Application”(应用程序)子窗口中右击相应的应用程序,选择Heap Dump(堆Dump)。
    • 在Monitor(监视)子标签页中点击Head Dump(堆Dump)按钮。
  • 本地应用程序Head dumps作为应用程序标签页的一个子标签页打开。同时,head dump在左侧的Application(应用程序)栏中对应一个含有时间戳的节点。右击这个节点选择save as(另存为)即可将head dump保存到本地。

4.3 JVismalVM导出dump文件

具体操作如下:
代码:
下面对这个代码的含义解释一下:这里只有一个main方法,因此栈里面的第一个栈帧即是这个方法,然后里面会有局部变量表。显然这个局部变量表中是有args、numList个birth的;然后下面是一个循环,在这个List中放数据,这句话没什么含义;接下来就是阻塞,此时我们生成一个dump文件;然后将局部变量表中的numList和birth都置为null;此时再阻塞并生成dump文件。这样我们就可以比较两个dump文件的区别了。

  1. public class GCRootsTest {
  2. public static void main(String[] args) {
  3. List<Object> numList = new ArrayList<>();
  4. Date birth = new Date();
  5. for (int i = 0; i < 100; i++) {
  6. numList.add(String.valueOf(i));
  7. try {
  8. Thread.sleep(10);
  9. } catch (InterruptedException e) {
  10. e.printStackTrace();
  11. }
  12. }
  13. System.out.println("数据添加完毕,请操作:");
  14. new Scanner(System.in).next();
  15. numList = null;
  16. birth = null;
  17. System.out.println("numList、birth已置空,请操作:");
  18. new Scanner(System.in).next();
  19. System.out.println("结束");
  20. }
  21. }

运行上述代码;然后代码JvisualVM;选择本地中的当前进程;点击右侧监视并选择堆Dump;此时会发现左侧进程下出现一个快照(这个就是dump快照),当然,此时你可以不懂程序,再次点击监视,堆(Dump),又会生成一个快照;但是由于你程序没有动,这两个快照按道理内容应该是一样的。
image.png
此时,在程序控制台任意输入,让程序执行;此时再按上述方式生成一个快照。但是还不能让程序结束,因为这个快照是要保存的,如果不保存,其会随着程序的结束而毁灭。保存的方式是:右击快照,另存为即可。
image.png


4.4 MAT查看GC Roots的内容

步骤如下:
1.安装MAT软件:略
2.点击File,open file打开第一个dump文件
3.如图操作,可看到GC Roots的元素共有1691个。上面共有四类,这里你可能会疑惑,这和我们上面对应GC Roots讲解的分类不是非常一致,这是为什么呢?这里是Ecloipse的划分方式。我大致说一下:其中System Class是系统类加载器中的GC Roots,比如类加载器等等;Native Stack是本地栈中的GC Roots;Thread就是对应的线程了。
image.png
image.png
4.在这个线程中,我们可以看到Finalize线程;当然我们关心的是java.lang.Thread下的main线程;点开后看到main线程中GC Roots共有21个元素,其中就包含了形参args和numList以及birth。
image.png
image.png
5.按照上述方式打开第二份dump文件;发现里面只有19个实体;少的正好是上面局部变量表中没有再引用任何内容的numList和birth。
image.png


4.5 使用Jprofiler进行GC Roots溯源

怎么说呢?根据上面JVisualVM配合MAT可以查看所有的GC Roots包含的元素。但是呢?我们实际上并不需要查看所有,甚至我们只关心某一个属性是否在GR Roots这一些列集合的链接中,即看某个元素是否能溯源到GC Roots。如果不能,则该元素会被GC,如果可以,则不会GC。这有利于我们排查OOM。
步骤如下:
1.安装JProfile查看,过程略。之后打开可以看到内存、类和线程等信息。
image.png
2.点击LIve Memory可以看到内存信息,每个对象占多少。
image.png
3.选择一个,右键Show Selection In Heap Walker可以看到这单个对象信息。
image.png
image.png
4.然后点击References,如下,可以看到这些。哎,闹半天不知道什么意思,就先这样了。
image.png


4.6 使用JProfiler查找OOM的原因

这里简单讲一下用JProfiler分析OOM,后面还会讲。
下面程序不断向List中加HeapOOM对象,后面肯定会对象满的,因为每个对象至少有1M.

  1. /**
  2. * -Xms8m -Xmx8m
  3. * -XX:+HeapDumpOnOutOfMemoryError 这个参数的意思是当程序出现OOM的时候就会在当前工程目录生成一个dump文件
  4. */
  5. public class HeapOOM {
  6. byte[] buffer = new byte[1 * 1024 * 1024];//1MB
  7. public static void main(String[] args) {
  8. ArrayList<HeapOOM> list = new ArrayList<>();
  9. int count = 0;
  10. try{
  11. while(true){
  12. list.add(new HeapOOM());
  13. count++;
  14. }
  15. }catch (Throwable e){
  16. System.out.println("count = " + count);
  17. e.printStackTrace();
  18. }
  19. }
  20. }

运行结果

  1. java.lang.OutOfMemoryError: Java heap space
  2. Dumping heap to java_pid17948.hprof ...
  3. Heap dump file created [7800636 bytes in 0.030 secs]
  4. count = 6
  5. java.lang.OutOfMemoryError: Java heap space
  6. at com.atguigu.java.HeapOOM.<init>(HeapOOM.java:10)
  7. at com.atguigu.java.HeapOOM.main(HeapOOM.java:18)

然后找到dump文件,用JProfiler打开。然后点击可以看到超大对象,原来是List对象太大了。
image.png
同时可以在线程中看出是哪个线程出问题了,对应的代码在哪里。
image.png

5. 清除阶段:标记-清除算法

5.1 垃圾清除阶段

当成功区分出内存中存货或对象和死亡对象后,GC接下来的任务就是执行垃圾回收,释放掉无用对象锁占用的内存空间,以便有足够的可用内存空间对新对象分配内存。
目前在JVM中比较常见的三种垃圾收集算法是标记-清除算法(Mark-Sweep)、复制算法(Copying)、标记-压缩算法(Mark-Compact)。


5.2 背景及执行过程

背景
标记-清除算法(Mark-Sweep)是一种非常基础和常见的垃圾收集算法,该算法被J.McCarthy等人在1960年提出并应用于Lisp语言。其实前面我们提到过这个人和语言,该语言是第一款使用内存分配和垃圾回收的语言。
执行过程:
当堆中的有效内存空间(available memory)被耗尽的时候,就会停止整个程序(也被称为stop the world),然后进行两项工作,第一项则是标记,第二项则是清除

  1. 标记:Collector从引用根节点开始遍历,标记所有被引用的对象。一般是在对象的Header中记录为可达对象。
    • 注意:标记的是被引用的对象,也就是可达对象,即标记的是垃圾的反面。并非标记的是即将被清除的垃圾对象
  2. 清除:Collector对堆内存从头到尾进行线性的遍历(即所有的都遍历),如果发现某个对象在其Header中没有标记为可达对象,则将其回收

image.png
这幅图非常生动地展示了了标记-清除算法的过程。首先是从根节点开始遍历,记住这里并非遍历整个内存空间,只是将在根节点上可以寻找到的标记下来,并放入集合;然后是清除,此时是遍历整个空间,如果不在集合中的,则视为垃圾,清除掉。


5.4 标记-清除算法缺点及注意

缺点

  1. 标记清除算法的效率不算高。主要是两次遍历,第二次清除遍历当然是O(n)级别的,第一次递归查找,虽然没有全遍历,但是也是O(n)级别的。
  2. 在进行GC的时候,需要停止整个应用程序,用户体验较差。这是上面提到的STW。
  3. 这种方式清理出来的空闲内存是不连续的,产生内碎片,且需要维护一个空闲列表
  4. 其实还有一个缺点,上面没有说,由于你内存是不连续的,即不规整的。如果有一个大的都西昂来了,空闲列表中的空闲内存都放不下,则更容易报OOM(相较于内存规整),导致大对象放不下。

注意:何为清除?
这里所谓的清除并不是真的置空,而是把需要清除的对象地址保存在空闲的地址列表里。下次有新对象需要加载时,判断垃圾的位置空间是否够,如果够,就存放(也就是覆盖原有的地址)。
这里我们就提一下格式化:比如我们将电脑D盘格式化了,那么这些数据真的丢失了吗?其实没有,只是指向这些内存空间的指针丢失了。但是内存孩子内存上。此时是可以回复的。但是呢?你千万不要往这个盘中放东西,放了之后就会抹除之前的内存区域信息,此时就无法恢复了。
关于空闲列表是在为对象分配内存的时候提过:

  1. 如果内存规整
    • 采用指针碰撞的方式进行内存分配
  2. 如果内存不规整
    • 虚拟机需要维护一个空闲列表
    • 采用空闲列表分配内存

image.png
何为内存规整:内存区域比如是一个长条,长度是100。前30是已经被占用的内存,无法分配内存,后70是空闲的,如果你现在有一个对象占10,则从31-40会被占用。这就是内存规整和指针碰撞。
何为内存不规整:内存区域占用和空闲交错,因此你需要维护一个列表来知道哪些是空闲的,当对象来的时候,去查看列表然后分配。

6. 清除阶段:复制算法

6.1 背景及核心思想

背景
解决标记-清除算法在垃圾收集效率方面的缺陷,M.L.Minsky于1963年发表了著名的论文,“使用双存储区的Lisp语言垃圾收集器CA LISP Garbage Collector Algorithm Using Serial Secondary Storage)”。M.L.Minsky在该论文中描述的算法被人们称为复制(Copying)算法,它也被M.L.Minsky本人成功地引入到了Lisp语言的一个实现版本中。
核心思想
将活着的内存空间分为两块,每次只使用其中一块,在垃圾回收时将正在使用的内存中的存活对象复制到未被使用的内存块中,之后清除正在使用的内存块中的所有对象,交换两个内存的角色,最后完成垃圾回收
image.png
这幅图很好的说明了复制算法:从根节点遍历,此时已不需要标记,遍历到后,直接复制到另一半空闲区域,还能规律的排序,当然遍历不到的就是垃圾,不用处理。之后交换双方身份即可。就是这么简单。其实我们可以看到对于堆空间新生代的回收,其中from和to区就是这种方式。


6.2 复制算法优缺点

优点

  1. 没有标记和清除过程,实现简单,运行高效
  2. 复制过去以后保证空间的连续性,不会出现“碎片”问题。

缺点

  1. 此算法的缺点也是很明显的,就是需要两倍的内存空间。因为只有一半是使用的,另一半永远是空闲的。这个缺点还可以接受,如果嫌空间小,我们无非开大一点,我们更关心时间快一点。
  2. 对于G1这种分拆成为大量region的GC,复制而不是移动,意味着GC需要维护region之间对象引用关系,不管是内存占用或者时间开销也不小

这里说一下:引用关系维护。其实我们之前讲过栈中的栈帧中的局部变量表要链接堆空间对象,堆空间对象要链接方法去中的对象类型数据。有两种方式:
方式一:采用句柄池
image.png
这种句柄池的方式呢?缺点很明显,需要额外的维护句柄池,为此开辟空间;其次要想从栈找到对象,要经过两次,先到句柄池,再到实例池。但是优点是如果实例池中的实例在内存中位置改变,只需要变句柄池,不需要变reference(其实我就纳闷了,你变句柄池和变reference的花销不是差不多吗?).
方式二:直接链接,一步到位。
image.png
这个直接用引用的好处就是:不需要开辟新的空间;一步到位找到对象实例。缺点是如果对象在内存中位置变化,则reference要变化。
上面都提到了对象实例的内存位置会变化
你是不是很纳闷,为什么会变呢?这里的复制算法不就变了吗?
特别的:
对于上面需要两倍空间这个缺点还可以接受,如果嫌空间小,我们无非开大一点,我们更关心时间快一点。
但是呢?如果系统中的垃圾非常少,即用GC Roots遍历后,大部分都还活着,那么此时需要复制的对象就特别多,优点就成了缺点了。因此,复制算法需要复制的存活对象并不会太大,火鹤说非常低才行。


6.3 复制算法应用场景

在新生代,对常规应用的垃圾回收,一次通常可以回收70%~99%的内存空间。回收性价比很高。所以现在的商业虚拟机都是用这种收集算法回收新生代。
image.png

当然对于老年代就不应用这种算法了,因为很多对象都是存活的。

7. 清除阶段:标记-压缩算法

标记-压缩(或标记-整理、Mark-Compact)算法

7.1 标记-压缩算法背景

  1. 复制算法的高效性是建立在存活对象少、垃圾对象多的前提下的。这种情况在新生代经常发生,但是在老年代,更常见的情况是大部分对象都是存活对象。如果依然使用复制算法,由于存活对象较多,复制的成本也将很高。因此,基于老年代垃圾回收的特性,需要使用其他的算法**。**
  2. 标记-清除算法的确可以应用在老年代中,但是该算法不仅执行效率低下,而且在执行完内存回收后还会产生内存碎片(碎片就碎片嘛,有什么大不了的,其实不然。老年大中会有一些大对象,我们知道当对象大的时候会直接进入老年代。如果有过多碎片,则没有足够连续空间用于老年代的大对象),所以JVM的设计者需要在此基础之上进行改进。标记-压缩(Mark-Compact)算法由此诞生。
  3. 1970年前后,G.L.Steele、C.J.Chene和D.s.Wise等研究者发布标记-压缩算法。在许多现代的垃圾收集器中,人们都使用了标记-压缩算法或其改进版本。

7.2 执行过程

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

image.png


7.3 标记-清除算法与标记-压缩算法比较

  1. 标记-压缩算法的最终效果等同于标记-清除算法执行完成后,再进行一次内存碎片整理,因此,也可以把它称为标记-清除-压缩(Mark-Sweep-Compact)算法。
  2. 二者的本质差异在于标记-清除算法是一种非移动式的回收算法,标记-压缩是移动式的。是否移动回收后的存活对象是一项优缺点并存的风险决策。既然是移动式的,就是考虑哪些引用的变化,比如栈中引用该对象,或者是其它对象引用该对象,都需要去改变其引用,这就存在一定的风险。
  3. 可以看到,标记的存活对象将会被整理,按照内存地址依次排列,而未被标记的内存会被清理掉。如此一来,当我们需要给新对象分配内存时,JVM只需要持有一个内存的起始地址即可,这比维护一个空闲列表显然少了许多开销。

通过上面的学习我们知道,关于对象分配,根据内存规整与否分为指针碰撞和空闲列表分配。其中指针碰撞用于复制算法和标记-压缩算法,因为这两个算法的空闲空间是连续的;而空闲列表分配用于标记-清除算法。


7.4 标记-压缩算法优缺点

优点
其实标记-压缩算法的优点就是解决了标记-清除算法和复制算法的缺点。但其实我疑惑的是,其不是页要把所有存货对象复制吗?怎么解决复制算法缺点了。但是呢?内存不用减半。

  1. 消除了标记-清除算法当中,内存区域分散的缺点,我们需要给新对象分配内存时,JVM只需要持有一个内存的起始地址即可。再分配内存,指针碰撞即可。
  2. 消除了复制算法当中,内存减半的高额代价。

缺点

  1. 从效率上来说,标记-整理算法要低于复制算法。因为涉及碎片的整理。
  2. 移动对象的同时,如果对象被其他对象引用,则还需要调整引用的地址(因为HotSpot虚拟机采用的不是句柄池的方式,而是直接指针)
  3. 移动过程中,需要全程暂停用户应用程序。即:STW。因为涉及复制,STW的时间长一点。

    8. 小结

  4. 效率上来说,复制算法是当之无愧的老大,但是却浪费了太多内存。

  5. 而为了尽量兼顾上面提到的三个指标,标记-整理算法相对来说更平滑一些,但是效率上不尽如人意,它比复制算法多了一个标记的阶段,比标记-清除多了一个整理内存的阶段。
    | | Mark-Sweep | Mark-Compact | Copying | | :—-: | :—-: | :—-: | :—-: | | 速度 | 中等 | 最慢 | 最快 | | 空间开销 | 少(但会堆积碎片) | 少(不堆积碎片) | 通常需要活对象的2倍大小(不堆积碎片) | | 移动对象 | 否 | 是 | 是 |

9. 分代收集算法

Q:难道就没有一种最优的算法吗?
A:无,没有最好的算法,只有最合适的算法

9.1 为什么要使用分代收集算法

  1. 面所有这些算法中,并没有一种算法可以完全替代其他算法,它们都具有自己独特的优势和特点。分代收集算法应运而生。
  2. 分代收集算法,是基于这样一个事实:不同的对象的生命周期是不一样的。因此,不同生命周期的对象可以采取不同的收集方式,以便提高回收效率**。**一般是把Java堆分为新生代和老年代,这样就可以根据各个年代的特点使用不同的回收算法,以提高垃圾回收的效率。
  3. 在Java程序运行的过程中,会产生大量的对象,其中有些对象是与业务信息相关:
    • 比如Http请求中的Session对象、线程、Socket连接,这类对象跟业务直接挂钩,因此生命周期比较长。
    • 但是还有一些对象,主要是程序运行过程中生成的临时变量,这些对象生命周期会比较短,比如:String对象,由于其不变类的特性,系统会产生大量的这些对象,有些对象甚至只用一次即可回收。

9.2 为什么几乎所有的GC都采用分代收集算法执行垃圾回收的

目前几乎所有的GC都采用分代收集(Generational Collecting)算法执行垃圾回收的。
在HotSpot中,基于分代的概念,GC所使用的内存回收算法必须结合年轻代和老年代各自的特点。

  1. 年轻代(Young Gen)
    • 年轻代特点:区域相对老年代较小,对象生命周期短、存活率低,回收频繁。
    • 这种情况复制算法的回收整理,速度是最快的。复制算法的效率只和当前存活对象大小有关,因此很适用于年轻代的回收。而复制算法内存利用率不高的问题,通过hotspot中的两个survivor的设计得到缓解。
  2. 老年代(Tenured Gen)
    • 老年代特点:区域较大,对象生命周期长、存活率高,回收不及年轻代频繁。
    • 这种情况存在大量存活率高的对象,复制算法明显变得不合适。一般是由标记-清除或者是标记-清除与标记-整理的混合实现。
      • Mark阶段的开销与存活对象的数量成正比。
      • Sweep阶段的开销与所管理区域的大小成正相关。因为要整个走一遍。
      • Compact阶段的开销与存活对象的数据成正比。把存活的压缩。
  3. 以HotSpot中的CMS回收器(是针对老年代的垃圾回收器,基于标记-清除)为例,CMS是基于Mark-Sweep实现的,对于对象的回收效率很高(但又碎片)。对于碎片问题,CMS采用基于Mark-Compact算法的Serial Old回收器**(是针对老年代的垃圾回收器,基于标记-压缩)**作为补偿措施:当内存回收不佳(碎片导致的Concurrent Mode Failure时),将采用Serial Old执行Full GC以达到对老年代内存的整理。
  4. 分代的思想被现有的虚拟机广泛使用。几乎所有的垃圾回收器都区分新生代和老年代

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

    10.1 增量收集算法

    上述现有的算法,在垃圾回收过程中,应用软件将处于一种Stop the World的状态。在Stop the World状态下,应用程序所有的线程都会挂起,暂停一切正常的工作,等待垃圾回收的完成。如果垃圾回收时间过长,应用程序会被挂起很久,将严重影响用户体验或者系统的稳定性为了解决这个问题(STW),即对实时垃圾收集算法的研究直接导致了增量收集(Incremental Collecting)算法的诞生。
    增量收集算法基本思想

  5. 如果一次性将所有的垃圾进行处理,需要造成系统长时间的停顿,那么就可以让垃圾收集线程和应用程序线程交替执行。每次,垃圾收集线程只收集一小片区域的内存空间,接着切换到应用程序线程。依次反复,直到垃圾收集完成**。这样停的时间就相对比较短,虽然总时间还是差不多。**

  6. 总的来说,增量收集算法的基础仍是传统的标记-清除和复制算法。增量收集算法通过对线程间冲突的妥善处理,允许垃圾收集线程以分阶段的方式完成标记、清理或复制工作。让垃圾线程和用户线程并发执行。

增量收集算法的缺点
使用这种方式,由于在垃圾回收过程中,间断性地还执行了应用程序代码,所以能减少系统的停顿时间。但是,因为线程切换和上下文转换的消耗,会使得垃圾回收的总体成本上升,造成系统吞吐量的下降


10.2 分区算法

主要针对G1收集器来说的。
这个算法也是为了解决STW时间过长,和上面的增量收集算法目的一样。

  1. 一般来说,在相同条件下,堆空间越大,一次GC时所需要的时间就越长,有关GC产生的停顿也越长。为了更好地控制GC产生的停顿时间,将一块大的内存区域分割成多个小块,根据目标的停顿时间,每次合理地回收若干个小区间,而不是整个堆空间,从而减少一次GC所产生的停顿。
  2. 分代算法将按照对象的生命周期长短划分成两个部分(新生代和老年代),分区算法将整个堆空间划分成连续的不同小区间。每一个小区间都独立使用,独立回收。这种算法的好处是可以控制一次回收多少个小区间。

image.png
这样每个区域是独立的,独立使用,独立回收。如果你给我的STW时间短,我就少回收几个块。如果时间长,我多回收几个块。
写在最后:
注意,这些只是基本的算法思路,实际GC实现过程要复杂的多,目前还在发展中的前沿GC都是复合算法,并且并行和并发兼备。