JVM_01_内存与垃圾回收_07_垃圾回收


本文介绍垃圾回收。不像堆内存划分那样,在JDK8之后几乎没有变化。在JDK历代版本中垃圾回收都在不停的变化。

1. 概述

垃圾收集,并不是Java语言的产物。早在1960年,第一门开始使用内存动态分配和垃圾收集技术的Lisp语言诞生。关于垃圾回收有三个经典问题:

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

垃圾收集机制是Java的招牌能力,极大地提高了开发效率。如今,垃圾收集几乎成为现代语言的标配,即使经过如此长时间的发展,Java的垃圾收集机制仍然在不断的演进中,不同大小的设备、不同特征的应用场景,对垃圾收集提出了新的挑战。

1.1 什么是垃圾

垃圾是指在运行程序中没有任何指针指向的对象,这个对象就是需要被回收的垃圾

如果不及时对内存中的垃圾进行清理,那么,这些垃圾对象所占的内存空间会一直保留到应用程序结束,被保留的空间无法被其他对象使用。甚至可能导致内存溢出

1.2 为什么需要GC

  1. 对于高级语言来说,一个基本认知是如果不进行垃圾回收,内存迟早都会被消耗完,因为不断地分配内存空间而不进行回收,就好像不停地生产生活垃圾而从来不打扫一样
  2. 除了释放没用的对象,垃圾回收也可以清除内存里的记录碎片。碎片整理将所占用的堆内存移到堆的一端,以便JVM将整理出的内存分配给新的对象。
  3. 随着应用程序所交付的业务越来越庞大、复杂,用户越来越多,没有GC就不能保证应用程序的正常进行。而经常造成STW的GC又跟不上实际的需求,所以才会不断地尝试对GC进行优化。

1.3 Java垃圾回收机制

Java提供了垃圾回收机制。

  1. 自动内存管理,无需开发人员手动参与内存的分配与回收,这样降低内存泄露和内存溢出的风险。
  2. 自动内存管理机制,将程序员从繁重的内存管理中释放出来,可以更专心地专注于业务开发。

Java的垃圾回收机制主要针对方法区和堆。垃圾回收器可以对年轻代回收,也可以对老年代回收,甚至是全堆和全方法区的回收。其中,Java堆是垃圾回收器的工作重点

从次数上讲:

  1. 频繁收集年轻代(堆)
  2. 较少收集老年代(堆)
  3. 基本不动永久代(方法区)

2. 垃圾回收相关算法

垃圾回收,首先要标记垃圾(标记阶段),然后是垃圾回收(清除阶段)。

2.1 标记阶段:对象存活判断

在堆里存放者几乎所有的Java对象实例,在GC执行垃圾回收之前,首先需要区分出内存中哪些是存活对象,哪些是已经死亡的对象。只有被标记为已经死亡的对象,GC才会在执行垃圾回收时,释放掉其所占用的内存空间,因此这个过程我们可以称为垃圾标记阶段

那么在JVM中究竟是如何标记一个死亡对象呢?简单来说,当一个对象已经不再被任何的存活对象继续引用时,就可以宣判为已经死亡对象。总之,标记阶段就是判断对象是否还被引用,是否还存活。

判断对象存活一般有两种方式:引用计数算法和可达性分析算法。

2.1.1 引用计数算法

引用计数算法(Reference Counting)比较简单,对每个对象保存一个整型的引用计数器属性。用于记录对象被引用的情况。

对于一个对象A,只要有一个变量引用了A,则A的引用计数器就加1;当引用失效时,引用计数器就减1。只要对象A的引用计数器的值为0,即表示对象A不可能再被使用,可进行回收。

优点:

  1. 实现简单,垃圾对象便于辨识;判定效率高,回收没有延迟性(不需要等待内存不够时再回收,只要引用计数器为0,随时回收)。

缺点:

  1. 它需要单独的字段存储计数器,这样的做法增加了存储空间的开销

  2. 每次赋值都需要更新计数器,伴随着加法和减法操作,这就增加了时间开销

  3. 引用计数器有一个严重的问题,即无法处理循环引用的情况。这是一条致命缺陷,导致在Java的垃圾回收器中没有使用这类算法

    循环引用如下图所示,就是程序中P引用了对象A,该对象又引用B,B引用C,C引用A。此时外部P失效后【赋值为null】,显然A、B、C的引用并没有断开,而且各自的计数器没有是0,这就造成了内存泄露。

    image-20220804112909871

2.1.2 可达性分析算法

具体实现,可参考三色标记算法。

可达性分析算法也被称为根搜索算法追踪性垃圾收集。相对于引用计数算法而言,可达性分析算法不仅同样具备实现简单和执行高效等特点,更重要的是该算法可以有效地解决在引用计数算法中循环引用的问题,防止内存泄露的发生。

相较于引用计数算法,这里的可达性分析就是Java、C#所采用的。

引入概念:GC Roots根集合,是一组必须活跃的引用。

基本思路:

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

image-20220804135245513

在Java语言中,GC Roots包括以下几类元素:

  1. 虚拟机栈中引用的对象

    比如各个线程被调用的方法中使用到的参数、局部变量等。

  2. 本地方法栈引用的对象

  3. 方法区中类静态属性引用的对象

    比如Java类的引用类型静态变量

  4. 方法区中常量引用的对象

    比如字符串常量池(String Table)里的对象

  5. 所有被同步锁(synchronized)持有的对象

  6. Java虚拟机内部的引用

    基本数据类型对应的Class对象,一些常驻的异常对象(NullPointerException、OutOfMemoryError),类加载器等等。

注意,如果要使用可达性分析算法来判断内存是否可回收,那么分析工作必须在一个能保障一致性的快照中进行的。这点不满足的话,分析结果的准确性就无法保证。【即在分析期间,对象的引用关系是该是冻结不变的,这样分析的结果才有保证】

这也是导致GC进行时必须STW(Stop The World)的一个重要原因,即使是号称(几乎)不会发生停顿的CMS收集器中,枚举根节点时也是必须要停顿的。

2.2 对象的finalization机制

  1. Java语言提供了对象终止(finalization)机制来允许开发人员提供对象被销毁之前的自定义处理逻辑
  2. 当垃圾回收器发现没有引用指向一个对象,即:垃圾回收此对象之前,总会先调用这个对象的finalize()方法。
  3. finalize()方法允许在子类中被重写,用于在对象被回收时进行资源释放。通常在这个方法中进行一些资源释放和清理的工作,比如关闭文件、套接字、数据库连接等。

注意,永远不要主动调用对象的finalize()方法,应该交给垃圾回收机制调用。理由包括下面三点:

  1. 在finalize()时可能会导致对象复活。
  2. finalize()方法的执行时间是没有保障的,它完全由GC线程决定,极端情况下,若不发生GC,则finalize()方法将没有执行机会。
  3. 一个糟糕的finalize()会严重影响GC的性能。

由于finalize()方法的存在,虚拟机中的对象一般处于三种可能的状态。如果从所有的根节点集合对象都无法访问到某个对象,说明对象已经不再使用了。一般来说,此对象需要被回收。但事实上,也并发是“非死不可”的,这时候它们暂时处于“缓刑”阶段。一个无法触及的对象有可能在某一个条件下“复活”自己,如果这样,那么对它的回收就是不合理的,为此,定义虚拟机中的对象可能的三种状态:

  • 可触及的:从根节点开始,可以到达这个对象。
  • 可复活的:对象的所有引用都被释放,但是对象有可能在finalize()复活。
  • 不可触及的:对象的finalize()被调用,并且没有复活,那么就会进入不可触及状态。不可触及状态的对象不可能被复活,因为finalize()只会被调用一次

以上三种状态中,是由于finalize()方法的存在,而进行的区分。只有对象在不可触及状态才可以被回收。

2.2.1 具体过程

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

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

注意,复活其实就是对该变量重新赋值引用即可。

2.3 MAT与JProfiler的GC Roots溯源

MAT和JVisual VM、JProfiler等是类似的。

2.5 清除阶段

在标记好之后,接下来就是清除对象了。清除对象主要有以下三种算法。

2.5.1 标记-清除算法(Mark-Sweep)

当堆中的有效内存空间(available memory)被耗尽的时候,就会停止整个程序(Stop The World),然后进行两个工作,第一个是标记,第二项是清除。

  1. 标记:Collector从引用根节点开始遍历,标记所有被引用的对象。一般在对象的Header中标记中记录可达对象。【注意,标记的是被引用的对象;清除的是没有被标记的对象】
  2. 清除:Collector对堆内存从头到尾进行线性遍历,如果发现某个对象在其Header中没有标记为可达对象,则将其回收。

优点:简单易实现。

缺点:

  • 效率不够高。(两次遍历全部内存)
  • 在进行GC的时候,需要停止整个应用程序,导致用户体验差。(STW)
  • 这种方式清理出来的空闲内存是不连续的,产生内存碎片。需要维护一下空闲列表

注意,该方法的清除,并不是真的置空,而是把需要清除的对象地址保存在空闲列表里。下次有新对象需要加载时,判断垃圾的位置空间是否够,如果够就存放,不够就新开辟空间。显然内存碎片太多

2.5.2 标记-复制算法(Copying)

为了解决标记-清除算法在垃圾收集效率方面的缺陷,提出了复制算法(Copying)。核心思想:

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

这样做,即节省了时间开销,在复制后的空间内,开辟还是连续的,也节省了空间开销,内存碎片几乎没有。类似堆内存中的新生代区中的幸存者0区、幸存者1区。

优点:

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

缺点:

  • 此算法的缺点也是很明显的,就是需要两倍的内存空间。
  • 对于G1这种差分成为大量的region的GC,复制而不是移动,意味着GC需要维护region之间对象引用关系【栈空间中的引用地址此时也许改变】,不管是内存占用或者时间开销,其实也不小。

特别的

  • 如果系统中的存活对象很多,显然复制算法开销就比较大,不太适合。
  • 复制算法适合存活对象少,垃圾对象多的前提下。【所以,是适合新生代的。】

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

前面提到过,复制算法的高效性是建立在存活对象少、垃圾对象多的前提下。这种情况在新生代经常发生,但是在老年代,更常见的情况是大部分对象都是存活对象。如果依然使用复制算法,由于存活对象较多,复制的成本也将很高。因此,基于老年代垃圾回收的特性,需要使用其他的算法。

复制算法适合新生代。那么标记-清除算法,会产生很严重的内存碎片。因此JVM对其进行改进,标记-压缩算法由此而生。

算法执行过程:

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

可以看到,标记-压缩算法的最终效果等同于标记-清除算法执行完成后,再执行一次内存碎片整理,因此,也可以把它称为标记-清除-压缩(Mark-Sweep-Compact)算法。二者的本质差异在于标记-清除算法是一种非移动式的回收算法,标记-压缩算法是移动式的。是否移动回收后的存活对象是一项优缺点并存的风险决策【移动对象,那么所有引用该对象的地址都需要改变】。

可以看到,标记的存活对象将会被整理,按照内存地址依次排列,而未被标记的内存会被清理掉。如此一来,当我们需要给新对象分配内存时,JVM只需要持有一个内存的起始地址即可,这比维护一个空闲列表显然少了许多开销。

优点:

  • 消除了标记-清除算法中的内存碎片问题
  • 消除了复制算法中的内存减半的代价

缺点:

  • 从效率上来说,标记-整理算法要低于复制算法
  • 移动对象的同时,如果对象被其他对象引用,则还需要调整引用的地址。
  • 移动过程中,需要全称暂停用户应用程序,即STW。

注意,三种算法都需要标记,都需要STW。

2.6 总结

到这里,可以发现,前面的标记、清除算法其实划分的不是特别好。清除算法其实就是垃圾回收算法,标记算法只是垃圾回收的一部分,或者说某种算法的一部分。

而且上面的三种算法各有优缺点,并不是适合各种情况。因此,对于垃圾收集,应该具体情况具体分析。比如年轻代、老年代、永久代。又或者伊甸园区、幸存者0区、幸存者1区等等。

2.7 分代收集算法

前面的所有这些算法中,并没有一种算法可以完全替代其他算法,他们都具有自己独特的优势和缺点。因此分代收集算法应运而生。

分代收集算法基于这样一个事实:不同的对象的生命周期是不一样的。因此,不同生命周期的对象可以采取不同的收集方式,以便提高回收效率。一般是把Java堆分为新生代和老年代,这样就可以根据各个年代的特点使用不同的回收算法,以提高垃圾回收的效率。

几乎所有的GC都是采用分代收集(Generational Collecting)算法执行垃圾回收的。

  1. 年轻代采用标记复制算法。
  2. 老年代采用标记-清除算法和标记-整理算法的集合。(比如CMS回收器就是基于标记清除算法)

2.8 增量收集算法

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

增量收集算法的基本思想:

如果一次性将所有的垃圾进行清理,需要造成系统长时间的停顿,那么就可以让垃圾收集线程和应用程序线程交替执行。每次,垃圾收集线程只收集一小片区域的内存空间,接着切换到应用程序线程。依次反复,直到垃圾收集完成

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

其实就是垃圾收集分阶段,及时让给用户线程,及时响应,并发执行。

缺点:

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

2.9 分区收集算法

增量收集算法是从执行时间上及时响应用户线程(即时间片)。而分区算法则是从空间上,降低垃圾收集的区域,从而及时响应用户线程。

一般来说,在相同条件下,堆空间越大,一次GC时所需要的时间就越长,有关GC产生的停顿也越长。为了更好地控制GC产生的停顿时间,将一块大的内存区域分割成多个小块,根据目标的停顿时间,每次合理地回收若干个小区间,而不是整个堆空间,从而减少一个GC所产生的停顿。

分代算法将按照对象的生命周期长短划分成两个部分,分区算法则将整个堆空间划分成连续的不同小区间。每个小区间都独立使用,独立回收。这种算法的好处是可以控制一次回收多少个小区间。

注意,分代和分区的区别。

image-20220804175827262

2.10 总结

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

3. 垃圾回收相关概念

这里介绍一些其他域垃圾回收相关的概念。

3.1 System.gc()的理解

在默认情况下,通过System.gc()或者Runtime.getRuntime().gc()的调用,会显式触发Full GC,同时对老年代和新生代进行回收,尝试释放被丢弃对象占用的对象。

然后System.gc()调用附带一个免责声明,只是提醒JVM启动垃圾回收器,但是无法保证对垃圾回收器的调用

System.runFinalization()方法会手动调用失去引用的对象的finalize()方法,即,这时候即使垃圾回收器没有收集垃圾,此时该方法就相当于显示调用finalize()方法。

3.2 内存溢出与内存泄露

内存溢出(OOM):没有空闲内存,并且垃圾收集器也无法提供更多内存。【一定是GC之后,才会报OOM】

内存泄露:

  • 严格来说,只有对象不会再被程序用到了,但是GC又不能回收他们,这种情况才会被称为内存泄露。(自己忘了断开引用链,比如数据库连接collection、IO连接、socket连接等等;比如单例对象,声明周期非常长,但是如果单例对象的某个属性指向了某个对象,并且该属性本来就用的不多,此时完全可以断开。)
  • 实际情况,很多时候一些不太好的实践(或疏忽)会导致对象的生命周期变得很长甚至导致OOM,也可以叫做宽泛意义上的“内存泄露”。(比如本来局部变量就可以解决,非要定义成全局变量、甚至静态变量)

尽管内存泄露并不会立刻引起程序崩溃,但是一旦发生内存泄露,程序中的可用内存就会被逐步蚕食,直至耗尽所有内存,最终出现OOM,导致程序崩溃。

3.3 Stop The World

Stop The World,简称STW,指的是GC事件发生过程中,会产生用户线程的停顿。停顿产生时整个应用程序都会被暂停(除了垃圾回收线程),没有任何响应。有点像卡死的感觉,这个停顿称为STW。

比如可达性分析算法中枚举根节点(GC Roots)会导致所有Java执行线程停顿。

  • 因为工作必须在一个能确保一致性的快照中进行。
  • 一致性指整个分析期间,整个执行系统看起来像被冻结在某个时间点上
  • 如果出现分析过程中对象引用关系还在不断变化,则分析结果的准确性无法保证

被STW中断的用户线程会在完成GC之后恢复,频繁中断会让用户感觉像是网速不快造成电影卡带一样,所以我们需要减少STW的发生。

STW事件和采用哪款GC无关,所有的GC都有这个事件。只不过GC越来越优秀,回收的效率越来越高,尽可能地缩短了暂停事件。

3.4 垃圾回收的并行与并发

程序中并发和并行。针对垃圾回收与用户线程,二者其实是并发执行,即宏观上一起执行,微观上交替执行。

并行垃圾回收:只多条垃圾收集线程并行工作,此时用户线程仍然处于等待状态。比如ParNewParallel ScavengeParallel Old。(多核情况下)

3.5 安全点与安全区域

3.5.1 安全点

程序执行时并非在所有地方都能停顿下来开始GC,只有在特定的位置才能停顿下来开始GC,这些位置称为“安全点(SafePoint)”。

Safe Point的选择很重要,如果太少可能导致GC等待的时间太长,如果太频繁可能导致运行时的性能有问题。大部分指令的执行时间都非常短暂,通常会根据“是否具有让程序长时间执行的特征”为标准。比如:选择一些执行时间较长的指令作为Safe Point,如方法调用、循环跳转和异常跳转等等。

换句话说,安全点就是一段时间内执行GC的次数,既不能多,也不能少。同时安全点的设置必须巧妙,让用户察觉不出来是在执行程序还是在执行垃圾回收(因此,上面的标准是有意义的)。

如何在GC发生时,检查所有线程都跑到最近的安全点停顿下来呢?

  • 抢先式中断:(目前没有虚拟机采用了)

    首先中断所有线程。如果还有线程不在安全点,就恢复线程,让线程跑到安全点。

  • 主动式中断:

    设置一个中断标志,各个线程运行到Safe Point的时候主动轮询这个标志,如果中断标志位真,则将自己进行中断挂起。

3.5.2 安全区域

用户线程可能会处于sleep状态或者block状态,这时候线程无法响应JVM的中断请求,“走”到安全点去中断挂起,JVM也不太可能等待线程被唤醒。对于这种情况,就需要安全区域(Safe Region)来解决。

安全区域是指在一段代码片段中,对象的引用关系不会发生变化,在这个区域中的任何位置开始GC都是安全的。其实就是安全点扩成了安全区域。

实际执行时:

  1. 当线程运行到Safe Region的代码时,首先标识已经进入了Safe Region,如果这段时间内发生GC,JVM会忽略标识为Safe Region的线程,即不执行该线程。
  2. 当线程即将离开Safe Region时,会检查JVM是否已经完成GC,如果完成了,则继续执行该线程,否则线程必须等待直到收到可以安全离开Safe Region的信号位置。

3.6 引用

有时候,需要这样一类对象:当内存空间足够时,则保留在内存中;如果内存空间在进行垃圾收集后还是很紧张,则可以抛弃这些对象。比如缓存中的对象,当内存足够时,无需清空,当内存不够时,可删除缓存中的对象。

为了满足上述要求,对对象的引用设置了不同级别:强引用、软引用、弱引用、虚引用。四种引用强度依次递减。大部分情况下,程序使用的是强引用;在缓存时,会使用软引用和弱引用。

下面所提到垃圾回收时,都默认引用关系存在。

3.6.1 强引用(Strong Reference)

最传统的“引用”的定义,是指在程序代码之中普遍存在的引用赋值,即类似“Object obj = new Object()”这种引用关系。无论任何情况下,只要强引用关系存在,垃圾收集器就永远不会回收掉被引用的对象

1
Object obj = new Object();  // 强引用

3.6.2 软引用(Soft Reference)

继承java.lang.ref.Reference抽象类。

在系统将要发生内存溢出之前,将会把这些对象列入回收范围之中进行第二次回收。如果这次回收后还没有足够的内存,才会抛出内存溢出异常。

即内存不足就会被回收。【注意,这里的前提是,没有其他强引用指向该对象】。因此说,软引用不会导致内存溢出。

软引用通常用来实现内存敏感的缓存。当内存足够时,不会回收;只有当内存不够时,才会回收。

1
2
3
4
5
6
7
Object obj = new Object();  // 强引用

SoftReference<Object> sr = new SoftReference<>(obj); // 软引用
// SoftReference<Object> sr = new SoftReference<>(new Object()); // 或者这样也可

// 注意 obj对象,此时既有强引用,也有软引用。要想其在内存不足时回收,必须清除强引用
obj = null;

3.6.3 弱引用(Weak Reference)

继承java.lang.ref.Reference抽象类。

被弱引用关联的对象只能生存到下一次垃圾收集之前。当垃圾收集器工作时,无论内存空间是否足够,都会回收掉被软引用关联的对象。

即垃圾回收时回收(GC发现就回收)。【同样,这里指的是只被弱引用关联的对象,才会发现就回收】

类似的数据结构有WeakHashMap。

1
WeakReference<Object> wr = new WeakReference<>(new Object());   // 弱引用

3.6.4 虚引用(Phantom Reference)

继承java.lang.ref.Reference抽象类。

一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来获得一个对象的实例。为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知【跟踪垃圾回收过程】

它不能单独使用,只能作为其他引用的附属。

1
2
3
4
5
6
7
8
9
10
Object obj = new Object();  // 强引用

// 虚引用,必须传入引用队列参数。
// 可新开一个线程,查看phantomQueue队列中的信息
// 当该虚引用的对象被清除时,会在引用队列中添加该对象信息。
ReferenceQueue phantomQueue = new ReferenceQueue();
PhantomReference<Object> pr = new PhantomReference<>(obj, phantomQueue);

// obj = null;
// 清除垃圾

3.6.5 终结器引用(Final Reference)

继承java.lang.ref.Reference抽象类。default修饰,该类只能在包内使用。

4. 垃圾回收器

垃圾收集器没有在规范中进行过多的规定,可以由不同的厂商、不同版本的JVM来实现。由于JDK的版本处于高速迭代过程中,因此Java发展至今已经衍生出了众多的GC版本。从不同角度分析垃圾回收器,可以将GC分为不同的类型。

  • 按线程数分:串行回收器(一个垃圾回收线程)、并行回收器(多个垃圾回收线程)。
  • 按工作模式分:独占式垃圾回收器(STW,用户线程不工作)、并发式垃圾回收器(用户线程和垃圾回收线程交替)
  • 按碎片处理方式分:压缩式垃圾回收器(对存活对象进行压缩整理,消除内存碎片)、非压缩式垃圾回收器(不整理)
  • 按工作的内存区间分:年轻代垃圾回收器、老年代垃圾回收器。

4.1 性能指标

  • 吞吐量:运行用户代码的时间占总运行时间的比例。(总运行时间:用户程序的运行时间+内存回收的时间)。
  • 暂停时间:执行垃圾收集时,程序的工作线程被暂停的时间(STW)。
  • 内存占用:Java堆区所占的内存大小。
  • 垃圾收集开销:垃圾收集所用时间占总运行时间的比例。
  • 收集频率:相对于应用程序的执行,收集操作发生的频率。
  • 快速:一个对象从诞生到被回收所经历的时间。

前三个指标是最重要的指标。三个指标显然时候互斥的,此消彼长。目前随着硬件技术的提升,内存越来越大,此时内存占用和吞吐量也就提升了,但是,当垃圾回收时,显然STW就会变长。因此目前的趋势就是降低STW,即低延迟。但是低延迟也就意味着频繁垃圾回收,使得单次STW时间变小。但是这样的话,吞吐量就会下降,因此频繁线程更替会有额外开销,用户线程工作时间必定减少。

因此,最重要的指标就是吞吐量暂停时间。优化目标就是:在最大吞吐量优先的情况下,降低停顿时间。

4.2 垃圾回收器分类

不同的场景所使用的GC是不同的,因此目前主要有以下种类的垃圾回收器,可根据不同的场景使用不同的垃圾回收器

  • 串行回收器:Serial、Serial Old
  • 并行回收器:ParNew、Parallel Scavenge、Parallel Old
  • 并发回收器:CMS、G1

针对回收区域划分,如下所示:

image-20220805104521537

因为分区域垃圾收集,所以JVM整体的垃圾回收器,必定是两个垃圾回收器组合分工实现的,各种组合情况如下所示:

image-20220805104646214

  • 所有的线组合,就是JDK8以前的可能组合。
  • 去掉红线之后,就是JDK9的组合,(JDK8可以用,但是不建议使用)。
  • 再去掉绿色之后,就是JDK14的组合,同时也去掉了CMS GC。

可用JVM参数:-XX:+PrintCommandLineFlags来输出使用的GC类别。

4.2.1 Serial回收器:串行回收

Serial收集器是最基本,历史最悠久垃圾收集器了。Serial收集器作为Hotspot中Client模式下的默认新生代垃圾收集器。

  • Serial GC是应用在新生代,采用标记复制算法、串行回收和STW机制方式执行内存回收。
  • Serial Old GC是应用在老年代,采用了标记压缩算法、串行回收和STW机制方式执行内存回收。

image-20220805115627213

Serial Old是运行在Client模式下默认的老年代的垃圾回收器。

Serial Old GC是Server模式下主要有两个用途

  1. 与新生代的Parallel Scavenge配合使用;
  2. 作为老年代CMS收集器的后备垃圾收集方案;

这个收集器是一个单线程的收集器(适合单CPU),并且触发STW机制,暂停其他所有的工作线程

优点:在单CPU下,简单而高效。目前在多核下已经不再使用了。

4.2.2 ParNew回收器:并行回收

ParNew可看成是Serial的多线程版本。Par指的是Parallel、New指的是新生代。

ParNew收集器除了采用并行回收的方式执行内存回收外,两款垃圾收集器之间几乎没有任何区别。只能用在新生代,且同样采用复制算法、STW机制。

ParNew是很多JVM运行在Server模式下新生代的默认垃圾收集器。

如下图所示,新生代采用ParNew,老年代随意搭配。

image-20220805120509055

ParNew在多CPU下,是优于Serial的;但是在单CPU下,是无法真正并行的,只能线程切换,此时会劣于Serial的,因为切换线程显然会有额外开销。

优点:仅仅是Serial的并行版本。目前JDK14以后,已经被孤立了。

4.2.3 Parallel Scavenge回收器:吞吐量优先

Parallel Scavenge和ParNew是一样,也是采用复制算法、并行回收和STW机制。那么为什么还要出现该收集器呢?

  • ParNew只是强调并发,而Parallel Scavenge收集器的目标则是达到一个可控制的吞吐量(ThroughOut),也被称为吞吐量优先的垃圾收集器。
  • 自适应调节策略(低延迟还是高吞吐量)也是Parallel Scavenge与ParNew的一个重要区别。

在老年代,则有Parallel Old GC。

image-20220805122744224

在吞吐量优先的应用场景中,Parallel收集器和Parallel Old收集器的组合,在Server模式下的内存回收性能很不错。

在JDK8中,是默认垃圾回收器。可设置并行回收的线程数量、最大停顿时间(STW)、垃圾收集时间占总时间的比例、是否开启自适应调节等。

4.2.4 CMS回收器:低延迟

CMS是Concurrent-Mark-Sweep的简称,是Hotspot中第一款真正意义上的并发收集器(垃圾线程和用户线程并发),第一次实现了垃圾收集线程与用户线程同时工作。CMS针对老年代垃圾收集。

CMS收集器的关注点是尽可能缩短垃圾收集时用户线程的停顿时间。停顿时间越短(低延迟)就越适合与用户交互的程序,良好的响应速度能提升用户体验。

  • 目前很大一部分的Java应用集中在互联网站或者B/S系统的服务端,这类应用尤其重视服务的响应速度,希望系统停顿时间最短,以给用户带来较好的体验。CMS收集器就非常适合这类应用的需求。

CMS收集器采用的是标记-清除算法,并且也会STW。在G1出现之前(JDK9),CMS使用还是非常广泛的。在JDK14开始,CMS已经被弃用了。

image-20220805140246412

具体流程如下:

  1. 初始标记(Initial-Mark)阶段:在这个阶段中,程序中所有的工作线程都将会因为STW机制而出现短暂的暂停,这个阶段的主要任务是仅仅标记出GC Roots能直接关联到的对象。一旦标记完成之后就会恢复之前被暂停的所有应用线程。由于直接关联对象比较少,所以这里的速度非常快
  2. 并发标记(Concurrent-Mark)阶段:从GC Roots的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长,但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行。
  3. 重新标记(Remark)阶段:由于在并发标记阶段,程序的工作线程会和垃圾收集线程同时运行或交叉运行,因此为了修正并发标记期间,因用户线程继续运作导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间通常会比初始标记阶段稍长一些,但也远比并发标记阶段的时间短。(也需要STW)
  4. 并发清除(Concurrent-Sweep)阶段:此阶段清理删除掉标记阶段判断的已经死亡的对象,释放内存空间。由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的。

由于垃圾回收中最耗费时间的并发标记与并发清理阶段都不需要暂停工作,所以整体的回收是低停顿的。

但是由于并发标记和并发清理过程中,用户线程仍然可以运行,这就需要必须保证内存空间足够,即CMS垃圾回收并不是在内存空间不够时触发,而是可设置一个阈值,当内存使用率达到这个阈值时,就需要垃圾回收了。

如果在并发标记过程中,出现了内存不够,此时CMS运行失败,启动备用方案,Serial Old GC串行执行垃圾回收。

注意,CMS采用的是标记-清除算法,因此垃圾回收时会产生内存碎片,无法使用指针碰撞算法为新对象分配内存,只能采用空闲列表算法。那么为什么不采用标记-压缩算法呢?

其实很简单,因为在并发清除的过程中,用户线程也是在执行,如果用压缩的话,显然会改变对象内存地址,即对象发生了改变,显然用户线程就是受到影响。因此标记-压缩算法适用于STW场景下。

优点:并发收集、低延迟。

缺点:

  1. 会产生内存碎片,保留的超大空间不足,可能会提前触发Full GC。【因此,高峰期就会发生卡顿】

  2. CMS收集器对CPU资源非常敏感,占用了一部分线程资源,导致用户程序资源变慢,总吞吐量会降低。

  3. CMS收集器无法处理浮动垃圾

    重新标记阶段,修正的是:怀疑是垃圾,但是还不确定的那些对象;但是在并发标记阶段,本来不是垃圾,可能就会变成垃圾,这些垃圾就是浮动垃圾,CMS是无法清理的

CMS可设置一些参数:开启CMS垃圾回收器(自动触发新生代开启Serial或者ParNew)、设置堆内存使用率的阈值(68%、92%)、是否在FullGC后进行压缩整理(以及多少次)、设置CMS的线程数量(默认是(CPU+3)/4)。

4.2.5 G1回收器:区域化分代式

既然有了前面几个强大的GC,为什么还要有Garbage First(G1)呢?

因为业务越来越庞大、复杂,用户越来越多。同时为了适应不断扩大的内存和不断增加的处理器数量,进一步降低暂停时间(STW),同时兼顾良好的吞吐量。

官方给G1设定的目标是在延迟可控的情况下获得尽可能高的吞吐量,所以采担当起“全功能收集器”的重任与期望(即适用新生代、也适用老年代)。

为什么名字称为Garbage First(G1)?

  1. 因为G1是一个并行回收器(垃圾回收线程并行),它把堆内存分割成很多不相关的区域(Region)(物理上不连续),适用不同的Region来表示Eden、幸存者0区、幸存者1区,老年代等。
  2. G1有计划地避免在整个Java堆中进行全区域的垃圾收集。G1跟踪各个Region里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region。
  3. 由于这种方式的侧重点在于回收垃圾最大量的区间(Region),也就是垃圾优先,即Garbage First。

G1是一款面向服务器端应用的垃圾收集器,主要针对配备多核CPU及大容量内存的机器,以极高概率满足GC停顿时间的同时,还具高吞吐量的性能特征。

image-20220805191555898

GC步骤如上图所示:

  1. 应用程序分配内存,当年轻代的Eden区用尽时开始年轻代回收线程:G1的年轻代收集阶段是一个并行的独占式收集器。在年轻代回收期,G1 GC暂停所有应用程序线程,启动多线程执行年轻代回收。然后从年轻代区间移动存活对象到Survivor区或者老年区,也有可能是两个区间都涉及。【这里和前面的堆内存类似】
  2. 当堆内存使用达到一定值时(默认为45%),开始老年代并发标记过程【和CMS标记一样】。
  3. 标记完成后马上开始混合回收过程。对于一个混合回收期,G1 GC从老年区间移动存活对象到空闲区间,这些空闲区间也就成为了老年代的一部分。和年轻代不同,老年代的G1回收器和其他GC不同,G1的老年代回收器不需要整个老年代被回收,一次只需要扫描/回收一小部分的Region就可以了。同时,这个老年代Region时候和年轻代一起被回收的。

注意,年轻代的回收涉及到上面3个步骤,而老年代,则只有第二个阶段标记,第三个阶段回收。

注意,一个Region不可能是独立的,该区域内的对象,既有可能被其他年轻代Region中的对象引用,也有可能被其他老年代Region中的对象引用,这时候,就出现了问题。如果在年轻代垃圾回收,显然还需要遍历所有Region,来判断是否可达。这个问题在其他分代收集器中也是存在的,只不过G1适合大堆,显然扫描的区域更多,更费时。效率降低。

因此,需要避免扫描全部堆对象,即对某个Region,保存一个集合,记录所有引用该Region对象的对象。如果想要回收这个区域,此时就需要查看这个集合即可。这个集合就是Remembered Set。

  1. 每个Region都有一个对应的Remembered Set;
  2. 每个Reference类型数据写操作时,都会产生一个Write Barrier(写屏障)暂时中断操作;
  3. 然后检查将要写入的引用指向的对象是否和该Reference类型数据在不同的Region(如果在同一个,就无需记录)
  4. 如果不同,通过CardTable把相关引用信息记录到引用指向对象的所在Region对应的Remembered Set中。
  5. 当进行垃圾收集时,在GC根节点的枚举范围加入Remembered Set,就可以保证不进行全局扫描,也不会有遗漏。

优点:

  1. 并行与并发

    并行指的就是多个GC线程同时工作。并发指的是部分垃圾回收工作与用户线程交替执行。一般情况下,不会在整个回收阶段完全发生阻塞应用程序的情况。

  2. 分代收集

    从分代上看,G1依然属于分代型垃圾回收器,它会区分年轻代和老年代,年轻代依然有Eden区和Survivor区。但从堆的结构上看,它不要求整个Eden区、年轻代或者老年代都是连续的,也不再坚持固定大小和固定数量【即可以动态调整】。

    将堆空间分为若干个区域(Region),这些区域中包含了逻辑上的年轻代和老年代【即不再是前面连续空间了】。而且和之前的各类回收器不同,它同时兼顾年轻代和老年代

    image-20220805192036384

  3. 空间整合

    从微观上看,不同Region之间,是采用复制算法来进行内存回收的。从宏观整体上看,可看成是标记-整理算法来进行内存回收的。

  4. 可预测的停顿时间模型

    这是G1相对于CMS的另一大优势,G1除了追求低停顿之外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间步超过N毫秒(即软实时,N在M周边浮动)。

    • 由于分区的原因,G1可以只选取部分区域进行内存回收,这样缩小了回收的范围,因此对于全局停顿情况的发生也能得到较好的控制。
    • G1跟踪各个Region里面的垃圾堆积的价值大小(回收所获得的空间大小和回收时间开销),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region。保证了G1收集器在有限的时间内可以获取尽可能高的收集效率。
    • 相比于CMS GC,G1未必能做到CMS在最好情况下的延时停顿,但是最差情况要好很多。

缺点:

  • 额外开销较CMS大一点(Remembered Set)。但是内存越大,G1的性能就越好。

G1收集器可设置的参数有:

  1. 设置是否使用G1收集器;
  2. 设置每个Region的大小,值是2的幂,范围是1MB~32MB;
  3. 设置最大停顿时间(注意,这里不是越少越好,时间大了,回收的region就越多,越不容易触发Full GC);
  4. 设置STW工作时,并行垃圾回收线程数;
  5. 设置并发标记的线程数;
  6. 设置内存占用率阈值(和CMS一样,超过阈值时才会开启G1),默认为45%;

G1回收器的适用场景:

  1. 面向服务端应用,针对具有大内存、多处理器的机器。(在普通大小的堆里表现并不惊喜)
  2. 当低延迟、并且内存比较大时,适合G1。

Region区域:

  1. 所有的Region大小相同,且在JVM生命周期内不会被改变。虽然还保留新生代和老年代的概念,但新生代和老年代不再是物理隔离了,他们都是一部分Region(不需要连续)的集合。通过Region的动态分配方式实现逻辑上的连续。
  2. 一个Region只能属于一个角色(Eden、S0、S1、Old),但是在垃圾回收后,该Region可能就会被分配其他角色。
  3. 除了上面四个角色,其实还有一个H角色,即大对象,当超过Region区域后,就会被分配到H区域中。如果一个H区不够,那么就会连续H区存储。

4.2.6 垃圾回收器总结

image-20220805205114694

如何选择垃圾回收器?

  1. 优先调整堆的大小让JVM自适应完成
  2. 如果内存小于100M,使用串行收集器
  3. 如果是单核、单机程序,并且没有停顿时间的要求,使用串行收集器
  4. 如果是多CPU、需要高吞吐量、允许停顿时间超过一秒,选择并行或者JVM自己选择
  5. 如果是多CPU、追求停顿时间,需要快速响应,使用并发收集器,官方推荐G1。

4.2.3 GC日志分析

可用到的参数如下所示:

  1. -XX:+PrintGC:输出GC日志
  2. +XX:+PrintGCDetails:输出GC的详细日志
  3. +XX:+PrintHeapAtGC:在进行GC的前后打印出堆的信息
  4. +Xloggc:../logs/gc.log:日志文件的输出路径

常见的日志分析工具有GCViewer、GCEasy等,可以打开上面的log文件。

4.2.4 垃圾回收器的新发展

JDK11中出现了Epsilon、Shenandoah GC、ZGC三款新的垃圾收集器。

  • Epsilon,略。

  • Shenandoah GC

    这款GC是OpenJDK中的,RedHat研发的【Oracle并没有集成到JVM中】。

    旨在针对JVM上的内存回收实现低停顿的需求。确实低延迟,但是总吞吐量下降了。

  • ZGC

    目标和Shenandoah GC类似,实现低停顿。同样基于Region。并发标记-并发预备重分配-并发重分配-并发重映射

5. 总结

6. Java不同版本的新特性

这里补充,从以下几个角度看待不同版本的新特性:

  1. 语法层面:如Lambda表达式、switch、自动装箱、自动拆箱、enum关键字。
  2. API层面:比如Stream API、新的日期时间、Optional类、String底层、集合底层
  3. 底层优化:JVM的优化、GC的变化、元空间、字符串常量池等等。

7. 备注

参考B站《尚硅谷》。


文章作者: 浮云
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 浮云 !
  目录