Java垃圾回收机制详解(万字总结!一篇入魂!)

26 篇文章 6 订阅
17 篇文章 0 订阅

Java垃圾回收机制详解

之前在《Java内存区域详解》这篇文章中,详细介绍了JVM内存区域的划分,以及创建对象时内存的分配情况。Java的自动内存管理机制,除了自动申请内存还会自动释放内存,这篇文章就来说一说Java内存回收机制。

首先我们要明确几点,问什么要回收内存?哪些内存是需要回收的?什么时候回收?应该怎样回收?(Why?What?When?How?)

举个例子,垃圾桶,你平时制造出来的垃圾都随手扔到了垃圾桶里,这垃圾桶就好比堆空间,你扔进去一个垃圾,就好比在堆空间自动申请的一块内存来存放这个垃圾,可以垃圾桶的容量毕竟有限,一旦垃圾桶堆满了垃圾,你需要将垃圾桶里面的垃圾打包清理掉,然后才能继续往垃圾桶里扔垃圾。一样的道理,你如果不断的创建对象,而不及时释放掉那些没有的内存的话,相同内存迟早会被消耗完,造成OOM进而拖垮整个程序,所以内存需要回收,将回收回来的内存整理重新分配给需要的对象使用。怎样判定堆空间里面的哪些内存需要被回收?不可能闭着眼一下子把堆空间里面对象都给回收掉吧,当然不行,只有那些被JVM视为垃圾,才会被回收掉。那么何为垃圾?垃圾是指在运行程序中没有任何指针指向的对象,这个对象就是需要被回收的垃圾,没有任何指针指向的对象,也就是说不会再有任何其它对象使用它了,那么它还占用着内存干嘛,直接把它回收掉,腾出来内存给别的对象使用。

接下来就来说说Java垃圾回收机制,看看是怎么进行自动回收的,什么时候回收的。

因为几乎所有的对象都在堆空间中分配内存,堆空间又划分成了新生代和老年代,所以说堆空间是垃圾回收机制的重点关注对象。至于永久代/元空间,基本上很少发生垃圾回收,因为它触发垃圾回收机制的条件比较苛刻。之前我们在讲堆空间内存结构划分的时候,已经简单介绍过堆空间的垃圾回收,现在的垃圾回收机制大部分都是基于分代收集理论实现的。再来回顾下堆空间的内存结构划分:

在这里插入图片描述

我们说垃圾回收机制回收的是那些被JVM视为垃圾的对象,那么JVM该如何判断一个对象是不是垃圾呢?这就涉及到了垃圾回收的相关一些算法,主要可以分为垃圾标记阶段的算法和垃圾清除阶段的算法,只有先标记出来哪些是垃圾,才能进行后续的清除操作。

标记阶段算法:

  • 引用计数算法
  • 可达性分析算法

清除阶段算法:

  • 标记-清除算法
  • 复制算法
  • 标记-压缩算法
垃圾标记阶段主要是来判断对象是否存活,先来介绍一下引用计数算法

引用计数算法比较简单,对每个对象保存一个整型的引用计数器属性,用来记录对象被引用的情况。如果当前对象被其它任何一个对象引用了,那么当前对象的引用计数器的值就加1,如果其它对象取消了对当前对象的引用,那么引用计数器的值就减1。如果当前对象的引用计数器的值为0,说明当前对象不再被任何其它对象所引用,JVM就会把这个对象标记为垃圾,可以进行回收。引用计数算法的优点可以出来,实现方式非常简单,只需要维护一个引用计数器即可,但是它也存在很多缺点,维护一个单独的引用计数器,需要额外的内存开销,每次引用改变都需要更新计数器,同样也有额外的时间开销,最严重的一个问题就是,引用计数器无法处理循环引用的情况,当几个对象的引用形成一个闭环的时候,无法将其标记为垃圾,从而导致内存泄漏。由于这个问题的存在,因此Java垃圾回收机制并没有采用引用计数算法。

在这里插入图片描述

再来说一下Java选择的标记阶段的算法,可达性分析算法

可达性分析算法,有的地方也称为根搜索算法,相对于引用计数算法,有效的解决了循环引用的问题,防止了内存泄漏的发生。这个根搜索算法维护了一个GC Roots根集合,这个集合就是一组活跃的引用。你想一下,如果几个对象存在着引用关系,那么从一个根对象出发,沿着引用即可到达下一个对象。

可达性分析算法的基本思路就是:

  • 以根对象GC Roots为起点,按照从上到下的方式搜索被根对象集合所连接的目标对象是否可达

  • 使用可达性分析算法后,内存中的存活对象都会被根对象集合直接或者间接连接着,搜索所走过的路径被称为引用链

  • 如果目标对象没有任何引用链相连,则是不可达的,说明该对象可以被视为垃圾进行回收

  • 在可达性分析算法中,只有能够被根对象集合直接或者间接连接的对象才是存活对象

    在这里插入图片描述

你可能会想这个GC Roots里面的对象是什么?哪些对象可以被当做根对象?

GC Roots一般包括以下几类元素:

  • 虚拟机栈中引用的对象

    栈帧中的局部变量表中的变量

  • 本地方法栈中引用的对象

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

    java类的引用类型静态变量

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

    字符串常量池里面的引用

  • 所有被同步锁synchronized持有的对象

  • JVM内部的引用

    基本数据类型对应的Class对象,异常对象,系统类加载器等等运行时所必须的对象

除了上述的这些固定的GC Roots集合以外,根据不同的垃圾回收器以及当前回收的内存区域不同,还可以有其他对象临时性加入进来,共同构成完整GC Roots集合。比如分代收集和布局回收(Partial GC),如果只针对堆空间中某一块区域进行垃圾回收,像是Minor GC指对新生代区域进行垃圾回收,必须考虑到新生代区域的对象完全有可能被老年代等其他内存区域里面的对象所以用,此时就需要一并将关联的其它区域里面的对象也加入到GC Roots集合中去考虑,才能保证可达性分析的准确性。要注意一点!如果要使用可达性分析算法来判断内存是否可以回收,那么分析工作必须在一个能保证一致性的快照中进行,这点不满足的话分析结果的准确性就无法保证。就比如当前时间点正在判断哪些对象是垃圾,但是对象之间的引用是在不断变化的,有可能没有被引用的对象在这个时间点突然被引用了,而原来被引用的对象突然又被取消引用了。所以说我们必须在保证一致性快照中进行可达性分析,这点也是导致GC进行时必须STW的一个重要原因,即使是号称几乎不会发生停顿的CMS垃圾回收器,在枚举根节点时也是必须要停顿的,至于STW是什么我们之后会提及到。

说一下对象的finalization机制

JVM垃圾回收机制可以说是给内存的管理带来了极大的方便,但是某些时候,我们需要在对象被回收销毁之前需要再做一些其它自定义的处理操作,JVM考虑到这一点,当回收垃圾对象之前,总会先调用这个对象的finalize()方法,这个方法是Object类中定义的空方法,目的就是允许在子类中被重写,用来在对象销毁之前自定义一些处理逻辑。一般主要用来在对象回收之前进行资源释放操作,比如关闭文件、套接字和数据库连接等等。

对于finalize()方法,不要主动去调用这个方法,交给垃圾回收机制就好了,因为如果你主动去调用这个方法,很有可能会导致对象复活,而且方法的执行是没有保障的,它完全由GC线程决定,极端情况下如果没有发生垃圾回收,这个方法就得不到执行的机会,并且执行这个方法还会影响垃圾回收的性能。

由于这个方法的存在,虚拟机中的对象一般处于三种可能的状态:

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

如果说从所有的根对象都无法访问到某个对象时,也就是说这个对象不可达,说明这个对象已经是个垃圾了,可以进行回收。但事实上,也并非非得被回收,可以理解为允许尝试去回收这个对象,但不一定必须回收,很有可能这个对象在某个条件下会“复活”自己。

判定一个对象是否真正可被回收,需要至少进行两次标记:

  1. 如果说GCRoots到某个对象没有引用链,判定这个对象是不可达的,进行第一次标记

  2. 判断这个对象是否需要执行finalize()方法

    (1)如果这个对象没有重写finalize()方法,或者finalize方法已经被JVM调用过了,则判定这个对象的finalize方法不需要执行,对象被判定为不可触及状态

    (2)如果对象重写了finalize方法并且还没执行过,这个对象会被插入到队列中,判定为可复活状态,有一个JVM自动创建的、低优先级的Finalizer线程触发其finalize方法

    (3)finalize方法可以说是对象逃脱回收命运的最后机会,因为稍后垃圾回收机制会对队列中的可复活状态的对象进行第二次标记。如果队列中的对象在finalize方法中与引用链上的任何一个对象重新建立了联系,那么在进行第二次标记时,这个对象会被移除队列,说明“复活”成功。之后如果这个对象再次出现没有被任何对象引用的情况,也就是说再次被视为垃圾,那么finalize方法不会被再次调用,这个对象“必死无疑”,直接变成不可触及状态。

说完了垃圾标记阶段,再来说一下垃圾清除阶段的算法,标记-清除算法

垃圾标记阶段,标记了那些被视为垃圾的对象,接下来垃圾回收机制就需要执行垃圾回收,回收那些被视为垃圾的对象,释放出垃圾对象占用的内存。

当堆空间中的有效内存空间被耗尽时,比如新生代、老年代满了,就会停止整个程序(stop the world,即STW),然后进行两项工作,第一是标记,第二是清除。

  • 标记:垃圾回收器从引用根节点开始遍历,标记所有被引用的对象,一般是在对象的对象头的运行时元数据中记录为可达对象。
  • 清除:垃圾回收器对堆空间进行全盘扫描,如果发现某个对象是不可达对象,就进行清除,将其回收。我们这里所说的清除并不是真的将其内存置空,而是把需要清除的对象地址保存在空闲列表中,下次有新对象申请内存时,可以直接申请这块内存。
    在这里插入图片描述

标记-清除算法也存在一些缺点,效率不是很高,在进行垃圾回收时,需要STW停止整个程序,目的是为了进行标记那些所有被引用的对象,而且从上图中可以看出来,在清除后,清理出来的内存是不连续的,产出了内存碎片,并且还需要唯一一个空闲列表。

第二种垃圾清除阶段的算法,复制算法

复制算法就是将内存空间分为两块,每次只使用其中的一块,在垃圾回收时将正在使用的那块内存中的还存活的对象复制到未被使用的另一块内存中,之后清除正在使用的那块内存中所有对象,然后这两个内存块的角色,最后完成垃圾回收。这让你想起来了什么?没错,就是堆空间中新生代中的Survivor区,Survivor区划分了from区和to区,垃圾回收时它们使用的就是复制算法。

在这里插入图片描述

复制算法,没有标记和清除过程,只需要讲那些还存活着的对象直接复制到另一块内存中即可,复制过去以后是连续的,不会产生内存碎片,然后将原来内存中所有对象回收即可。但是这个复制算法最明显的缺点就是需要2倍的内存空间,而且像对于G1这种拆分为大量region的垃圾回收器来说,复制意味着G1还需要维护region之间对象引用关系。复制算法在堆空间中垃圾对象多的情况下,才会发挥出优势,试想一下,如果说在进行垃圾回收,存活的对象要远大于垃圾对象,那么相当于把大部分的对象复制到了另一块内存中,只清除了原来内存中很少的垃圾对象,内存释放很少,导致垃圾回收效率低下。但是也不用担心这种情况,大多数程序中的对象生命周期都很短,垃圾对象占比远大于存活对象。

第三种垃圾清除阶段算法,标记-压缩算法

之前我们说过复制算法需要在存活对象少、垃圾对象多的新生代内存区域中才会发挥出优势,但是对于老年代,大部分都是存活对象,如果在老年代使用复制算法效率就会不高。所以对于老年代,我们可以使用标记-压缩算法,这个算法是基于标记-清除算法的优化改进。标记-压缩算法第一阶段和标记-清除算法一样,从根节点开始标记所有被引用的对象,第二阶段将所有的存活对象压缩到内存的一端,按顺序排放,之后,清除压缩边界以外的所有空间

在这里插入图片描述

标记-压缩算法相当于标记-清除算法+内存碎片整理。标记存活的对象会被集体移动到一端,按照内存地址一次排列,而未被标记的内存会被清理掉,这要只需要维护一个标识内存的起始地址的指针,方便新对象内存的申请,无需再维护一个空闲列表,这种分配方式称为指针碰撞。标记-压缩算法,清除了标记-清除算法产生的内存碎片,也消除了复制算法需要2倍内存空间的代价,但是缺点是仅从效率上来讲,效率是要低于复制算法的,而且压缩移动存活对象的同时,还需要调整对象之间引用的地址,并且移动过程中也会造成STW暂停。

分代收集算法

对比这三种清除阶段的算法,从效率上来说,最高的是复制算法,相当于用空间换时间,统筹时间和空间的话,标记-压缩算法会更有优势。只能说根据具体的实际情况来选择合适的回收算法。堆空间划分是按照分代划分的,垃圾回收也是按照分代收集的。不同的对象的生命周期也是不一样的,因此不同生命周期的对象可以采取不同的收集方式,以便于提高回收效率,这就是分代收集算法的思想

目前几乎所有的垃圾回收器都是采用分代收集算法执行垃圾回收的。比如新生代区域,大多数对象生命周期都很短,垃圾回收频繁,可以使用复制算法进行回收,同时Survivor区的设计使得复制算法内存利用率低的问题得到缓解。而老年代区域,大多数对象生命周期比较长,垃圾回收并不频繁,比较适合使用标记-清除或者标记-压缩算法。

增量收集算法

上述所说的所有基于分代收集的算法,在垃圾回收过程中,程序都会处于STW状态,程序所有的线程都会挂起,暂停一切正常的工作,等待垃圾回收的完成。如果垃圾回收时间过长,程序会被挂起很久,甚至会出现卡顿。为了解决STW的问题,增量收集算法属于一种实时垃圾收集算法。

其基本思想就是,如果一次性将所有的垃圾进行处理,会造成程序长时间停顿的话,那么就可以让垃圾回收线程和程序线程交替执行。每次,垃圾回收线程只收集一小片区域的内存空间,接着却换到应用程序线程,依次反复,直到垃圾回收完成。实际上增量收集算法的基础还是标记-清除和复制算法,只不过采取了类似于CPU时间片轮询的方式的,将垃圾回收分阶段完成。这种算法虽然降低了STW停顿时间,但是由于垃圾回收线程和应用程序线程不断切换,也造成了系统额外开销。

分区算法

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

分代算法是根据对象生命周期的长短划分,增量收集算法相当于划分垃圾回收的时间,而分区算法相当于划分整个堆空间内存。每一个小块区域都独立使用,独立回收,可以控制一次回收多少个小块区域,从而控制STW的停顿时间。注意!分区算法,堆空间的分代划分还在,只不过是将新生代、老年代区域给打散了。在回收完复用小块内存区域的时候,该小块内存区域的角色可以发生改变。

在这里插入图片描述

Stop The World(STW)

我们在将分代收集算法的时候,多次提及到了STW这个概念,指的是在GC过程中,会产生应用程序的停顿,停顿产生时整个应用程序线程都会被暂停,没有任何响应,这个停顿被称为STW。顾名思义,整个世界都停止了。

像是之前我们说的可达性分析算法,在枚举GC Roots集合中所有根节点时,会导致应用程序所有线程停顿,之所以要停顿下来,是因为分析工作必须在一个能确保一致性的快照中进行,一致性指的是在整个分析期间,整个应用程序看起来像是被冻结住某个时间点上,如果说在分析过程中对象引用关系在不断发生变化,那么分析结果的准确性将无法保证。被STW停止的应用程序线程会在完成垃圾回收之后恢复。STW的发生是不可避免的,只能说是尽可能缩短STW的停顿时间,STW是JVM在后台自动发起和结束的,如果说我们调用System.gc()方法手动触发垃圾回收,会导致STW的发生。

当STW发生时,应用程序正在执行的线程并不是说立即被暂停,并不是所有的地方都能够停顿下来然后开始执行垃圾回收,只有在特定的位置才能停顿下来开始GC,这些特定的位置称为**“安全点”。安全点的选择非常重要,如果安全点太少可能导致GC等待的时间多长,一直等不到下一个安全点来进行垃圾回收,如果安全点太多,那么GC的太频繁可能导致运行时性能问题。通常会选择一些执行时间较长的指令作为安全点,比如方法调用、循环跳转、异常跳转等指令。那么如何在GC发生时,检查所有线程都已经到达最近的安全点停顿下来了呢?有两种方式可以检查,一种是抢先式中断**,首先中断所有线程,如果还有线程不再安全点,就恢复这个线程,让这个线程自己跑到安全点去;另一种是主动式中断,设置一个中断标志,各个线程运行到安全点的时候主动轮询这个标志,如果中断标志为真,就将自己中断挂起。而对于那些无法响应JVM的中断请求的线程,比如处于Sleep状态或者Blocked阻塞状态的线程,它们无法自己走到安全点去中断挂起,JVM也不可能等待线程被唤醒,对于这种情况,就需要安全区域来解决。安全区域指的是在一段代码片段中,对象的引用关系不会发生变化,在这个区域中的任何位置开始GC都是安全的。可以把安全区域看成是n多个安全点连成的线构成的区域。所以应用程序线程在实际执行时,当线程运行到安全区域的时候,首先标识已经进入了安全区域,如果这段时间内发生了GC,JVM会忽略标识为安全区域的线程;当线程即将离开安全区域,会检查JVM是否已经完成GC,如果完成则继续执行,否则线程必须等待直到收到可以离开安全区域的信号为止。可以理解为GC可以等待线程任何时间到达安全区域,但是一旦到达可就没有这么容易离开了,必须等待GC执行完之后,允许你离开的时候,这个线程才可以离开。

我们所说的垃圾回收指的是当对象不被任何其它对象所引用,即被视为垃圾,然后垃圾回收机制会自动回收这个对象。垃圾回收会销毁额外的系统开销,如果说此时内存空间非常充裕呢?反正内存够用,我们是否能暂时不进行垃圾回收,等到内存紧张的时候,再统一进行垃圾回收呢?可以,既然垃圾回收回收的是那些被视为垃圾的对象,而判定某个对象是否是垃圾,是根据对象之间的引用来判定的。接下来就来讲一下各类型的引用。

强引用

指的是程序中最常见的引用赋值,例如String s = new String()。无论任何情况下,只要强引用关系存在,垃圾回收器就永远不会回收掉这个被引用的对象。

强引用是最常见的引用类型,通过强引用可以直接访问目标对象,强引用的对象是可触及的,即使抛出OOM异常,也不会回收这个对象。如果引超出了引用的作用域或者说被赋值为null,就是可以当做垃圾被回收了。强引用也是造成内存泄漏的主要原因之一。

软引用

在系统将要发生OOM之前,就会把这些对象列入回收范围之中进行第二次回收,如果这次回收之后还没有足够的内存空间,就直接OOM

软引用是用来描述一些还有用,但并非必需的对象。通过用来实现内存敏感的缓存,比如高速缓存就用到了软引用如果还有空闲内存,就可以暂时保留缓存,当内存不足时清理掉,这样就保证了使用缓存的同时,不会耗尽内存。

弱引用

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

由于垃圾回收器的线程优先级很低,因此弱引用对象可以存在较长的时间,也非常适合保存那些非必需的对象。

虚引用

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

虚引用必须和引用队列一起使用,虚引用在创建时必须要提供一个引用队列作为参数,当垃圾回收器准备回收一个对象时,如果发生它还有虚引用,基本上可以无视虚引用的存在,但是在回收对象之后,需要将这个虚引用加入到引用队列中,以此来通知系统这个对象被回收了。由于虚引用可以追踪对象的回收时间,所以可以将一些资源释放操作放置在虚引用中执行和记录。

我们上述所说的所有垃圾回收机制、回收算法必须要有落地的实现,接下来就讲一讲各种垃圾回收器

从不同的角度分析垃圾回收器,可以将其分为不同的类型。

  • 按照垃圾回收器回收时的线程数分,可以分为串行GC并行GC。串行回收指的是在同一时间段内只允许有一个CPU用于执行垃圾回收操作,此时工作线程被暂停,直到垃圾回收结束。并行回收可以运用多个CPU同时执行垃圾回收,但是也是会暂停工作线程,直至垃圾回收结束。

    在这里插入图片描述

  • 按照GC工作模式分,可以分为并发式GC独占式GC。并发式垃圾回收器与应用程序线程交替执行,以此来尽可能减少STW的停顿时间。独占式垃圾回收器一旦运行,就会停止应用程序中的所有线程,直到垃圾回收结束。

    在这里插入图片描述

  • 按照GC对内存碎片处理方式分,可以分为压缩式GC非压缩式GC。压缩式垃圾回收器会在回收完成后,对存活对象进行压缩整理,消除内存碎片。非压缩垃圾回收器不会进行内存碎片的整理。

  • 按照工作的内存区域分,可以分为新生代GC老年代GC

这么多种类的垃圾回收器,我们如何评价一个GC的好坏?或者说衡量一个GC的性能高低看什么?主要看两个性能指标,一个是吞吐量,一个是STW暂停时间

吞吐量=运行用户代码时间 /(运行用户代码时间+垃圾回收时间),追求吞吐量高的GC一般应用程序能够容忍较高的STW暂停时间,注重吞吐量的高低,不考虑应用程序的快速响应,也就是在一长段时间内,每次的STW暂停长点无所谓,只要能保证在这段时间内,到达预期的吞吐量就可以。

STW暂停时间指的是一个时间段内应用程序线程被暂停,让垃圾回收器的线程执行垃圾回收操作。追求STW暂停时间的GC要尽可能减少每次STW的暂停时间,主要低延迟,每次暂停时间不能超过预期停顿时间。

这两个性能指标是相互矛盾的,因为如果保证吞吐量,那么必然要降低内存回收的执行频率,但是这样就会导致GC需要更长的STW暂停时间来执行垃圾回收,相反,如果尽可能保证STW暂停时间短,那么只能提高执行垃圾回收的次数,但这样又会导致降低了吞吐量。对此我们应该根据实际情况去衡量这两个性能指标,一般来说,在保证最大吞吐量的情况下,尽可能降低STW停顿时间。

接下来将挨个来讲一讲以下7种最经典的垃圾回收器

  • 串行GC:Serial、Serial Old
  • 并行GC:ParNew、Parallel Scavenge、Parallel Old
  • 并发GC:CMS、G1

这7种垃圾回收器按照工作的内存区域分,又可分为

  • 新生代GC:Serial、ParNew、Parallel Scavenge
  • 老年代GC:Serial Old、Parallel Old、CMS
  • 整堆GC:G1

在这里插入图片描述

你可能会想问啥有这么多种GC,这其实JVM根据不同的应用场景来提供不同的垃圾回收器,以此来提高垃圾回收的性能,没有最好的GC,只有最适合的GC。

Serial GC

serial垃圾回收器算是出现最早的GC了,Serial的工作内存区域是新生代,采用复制算法、串行回收、STW机制来执行垃圾回收。

Serial垃圾回收器是一个单线程的回收器,只会使用一个CPU或者一条回收线程去完成垃圾回收工作,在进行垃圾回收时必须暂停其它所有的工作线程,直到回收结束。适用于单核CPU,内存不大的应用场景

在这里插入图片描述

Serial Old GC

Serial Old垃圾回收器主要是回收老年代的垃圾,采用了标记-压缩算法、串行回收、STW机制来执行垃圾回收。

ParNew GC

ParNew垃圾回收器相当于Serial垃圾回收器的多线程版本,主要也是回收新生代的垃圾,采用了复制算法、并行回收、STW机制来执行垃圾回收。

对于新生代,回收垃圾的次数频繁,使用并行方式高效,对于老年代,垃圾回收的次数比较少,所以使用串行方式更节省资源,因为CPU并行需要切换线程,串行可以省去切换线程的资源。所以可以选择ParNew搭配Serial Old来回收新生代和老年代。
在这里插入图片描述

Parallel Scavenge GC

Parallel Scavenge垃圾回收器也是回收新生代的垃圾,并且和ParNew垃圾回收器一样,同样也采用了复制算法、并行回收、STW机制来进行垃圾回收。

那么既然已经有了ParNew垃圾回收器,那为什么还要Parallel Scavenge垃圾回收器呢?因为Parallel Scavenge和ParNew的目标不同,Parallel Scavenge垃圾回收器的目标是到达一个可控制的吞吐量,即吞吐量优先。高吞吐量则可以高效率的利用CPU时间,尽可能快的完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务,例如批量处理、订单处理、科学计算等应用程序。在吞吐量优先的应用场景下,可以使用Parallel Scavenge搭配Parallel Old来回收新生代和老年代的垃圾。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-2avAOWMn-1631069213032)(C:\Users\Jian\AppData\Roaming\Typora\typora-user-images\image-20210907131238813.png)]

Parallel Old GC

既然新生代都有了Parallel Scavenge垃圾回收器,那么老年代也要看齐,Parallel Old就是用来回收老年代的垃圾,采用了标记-压缩算法、并行回收、STW机制来执行垃圾回收。

CMS GC

我们说过衡量一个垃圾回收器性能的指标主要有两个,一个是吞吐量,一个是STW暂停时间。而CMS垃圾回收器主打的就是实现低延迟。CMS垃圾回收器可以算是第一个真正意义上的并发回收器,它第一次实现了让GC线程和用户工作线程同时工作。这里要强调一点,我们所说的并行回收器指的是GC线程之间并行执行,而所说的并发回收器指的是GC线程和工作线程并发执行。CMS垃圾回收器是用来回收老年代的垃圾,采用标记-清除算法、并发回收、STW机制来执行垃圾回收。由于高吞吐量和低延迟是一对相斥的指标,所以追求低延迟的CMS垃圾回收器不能和追求高吞吐量的Parallel Scavenge垃圾回收器搭配使用,只能和ParNew或者Serial搭配使用,来回收新生代和老年代的垃圾。

CMS回收垃圾的整个过程比之前的垃圾回收器都要复杂,主要分为4个阶段:

  • 初始标记阶段:

    在这个阶段中,程序中所有的工作线程都会因为STW机制而出现短暂的暂停,这个阶段的任务主要是**仅仅只是标记出GC Roots能直接关联到的对象。**一旦标记完成之后就会回复之前被暂停的所有应用线程。由于直接关联对象比较少,所以这个初始标记阶段速度非常快,不会导致STW暂停时间过长。

  • 并发标记阶段:

    从GC Roots的直接关联对象开始沿着引用链遍历所有对象,这个过程耗时比较长,但是不需要暂停用户工作线程GC线程和工作线程并发执行。这个并发标记阶段相当于标记出来了所有存活着的对象。

  • 重新标记阶段:

    由于在并发标记阶段,GC线程和工作线程并发交替执行,有可能引用链上的对象之间的引用关系发生了变化,所以为了修正并发标记期间,因工作线程继续运行而导致标记产生变动的那一部分对象,需要重新标记,这个阶段为了保证标记的准确性,会暂停所有的工作线程,暂停时间比并发标记阶段要短的多。

  • 并发清除阶段:

    这个阶段会清理删除掉标记阶段判断已经死亡的对象,释放内存空间。由于不需要移动存活下来的对象,所以这个阶段GC线程和用户工作线程也是并发执行的。

    在这里插入图片描述

尽管CMS采用的是并发回收,但是在初始标记阶段和重新标记阶段,仍然需要执行STW机制暂停所有工作线程,不过暂停时间不会太长,所以说垃圾回收器不可能完全消除掉STW机制,只能尽可能减少STW暂停时间。而CMS低延迟体现在并发标记阶段和并发清除阶段不需要暂停工作线程,所以整体的回收效率还是很好的。由于CMS在垃圾回收阶段工作线程并没有中断,所以在CMS回收垃圾时,还需要确保有足够的内存给工作线程使用,所以CMS垃圾回收器不能像其他垃圾回收器那样等到老年代满了的时候才进行垃圾回收,而是**当堆空间内存使用率达到某一个阈值时,便要开始进行垃圾回收。**如果说CMS在执行垃圾回收期间,出现了OOM异常,CMS就会暂停GC的工作,临时启用Serial Old垃圾回收器重新对老年代进行垃圾回收,停顿时间也因此变长。

CMS具有并发回收低延迟的有点,但是也存在一些缺点。由于使用的时标记-清除算法,所以在垃圾回收之后会产生内存碎片;在并发阶段虽然不会暂停工作线程,但是会因为占用了一部分线程而导致应用程序变慢,总吞吐量变低;CMS回收垃圾时可能会失败,回临时切换到Serial Old垃圾回收器重新进行回收。

需要注意一点,CMS在JDK 14已经被移除掉了。

G1 GC

G1垃圾回收器进一步降低了STW暂停时间,在延迟可控的情况下获得尽可能高的吞吐量。

G1垃圾回收器是一个并行回收器,它把堆空间分割为很多不相关的区域(region),这些region物理上可以是不连续的,使用不同的region来表示Eden区、Survivor区、老年代等。G1垃圾回收器会跟踪各个region里面的垃圾堆积的价值大小(回收空间大小、回收时间长短),在后台维护一个优先列表,每次根据允许的回收时间,优先回收价值最大的region。

G1使用了分区算法,G1在回收垃圾时,可以有多个GC线程并行执行,此时由于STW机制会暂停所有工作线程。同时也兼顾了并发,G1的多个GC线程可以和工作线程并发的交替执行,一般来说,不会在整个回收阶段发生完全阻塞工作线程的情况。所以说G1兼顾了并行性和并发性。从分代角度来看,G1仍然属于分代垃圾回收器,他会区分新生代和老年代,新生代仍然进一步划分为Eden区和Survivor区,但是与其他分代垃圾回收器不同的是,G1不要求分代在物理内存上是连续的,可以打散分布在堆空间的各个位置。将堆空间分为若干个小的region区域,逻辑上包含了新生代和老年代,所以G1可以兼顾回收新生代和老年代的垃圾

G1垃圾回收器还增加了一种新的内存区域Humongous,主要是用来存储大对象,如果对象大小超出1.5个region就会放到Humongous里面。之所以新增加了Humongous内存区域,是因为对于堆空间中的大对象,默认直接会被分配到老年代,但是如果它是一个短期存在的大对象,就会对垃圾回收造成影响,我们说一般生命周期短的应该存放在新生代,新生代GC频繁能够及时回收,但是由于这个生命周期短的对象太大,所以只能放在了老年代,老年代一般来说GC不频繁,会导致这个大对象难以回收。为了解决这个问题,G1才划分出了一个Humongous区域,用来专门存放大对象,如果一个Humongous区装不下这个大对象,就会使用连续的Humongous区来存放这个大对象。为了能够找到连续的Humongous区,有时候需要进行Full GC来回收整个堆空间的垃圾,来腾出足够的内存空间,G1大多数行为都把Humongous区当做老年代来看待。

虽然G1把整个堆空间化整为零,分割成一个个region,但是单个region还是使用指针碰撞的方式来存放数据,region里面也是划分了已经的内存空间和未使用的内存空间,但是由于各个region之间物理上不要求连续,所以region之间不能使用指针碰撞来存放数据。而且每个region依然有线程私有缓存区域TLAB,这样可以保证多个线程并行操作。

在这里插入图片描述

G1将内存划分为一个个的region,内存回收是以region作为基本单位,**region之间是复制算法,但是整体上实际上可以看作是标记-压缩算法,**二者都能避免内存碎片的产生。

G1除了具有低延迟的优点以外,还可以建立可预测的停顿时间模型。即可能指定在一个长度为M毫秒的时间片段内,消耗在垃圾回收上的时间不得超过N毫秒。这是由于分区的原因,G1可以只选取一部分region进行垃圾回收,这样就缩小了回收的范围,进而对停顿时间做到可控。相比于CMS,G1未必能做到CMS在最好情况下的延迟停顿,进行高效率回收,但是最差的情况下,要比CMS好很多,G1在大内存应用上才能发挥其优势。G1除了使用内置JVM线程执行GC的多线程操作,还可以使用应用线程承担后台运行的垃圾回收工作,帮助加速垃圾回收过程

G1垃圾回收器的回收过程主要分为三个环节:

  • 新生代GC
  • 老年代并发标记过程
  • 混合回收

在这里插入图片描述

当新生代的Eden区用尽时就会开始新生代回收过程,G1在新生代回收阶段采取并行的独占式的垃圾回收,多个GC线程回收垃圾时会暂停所有工作线程,然后从新生代区域移动存活对象到Survivor区或者老年代。当堆空间使用率达到阈值时(默认45%),就开始老年代的并发标记过程。标记完成后马上开始混合回收,在混合回收期间,G1把老年代中存活的对象移动到空闲的region,这些空闲的region也就变成了老年代。与其他垃圾回收器不同,也不同于新生代,G1对老年代的回收不需要回收整个老年代,一次只需要回收一小部分老年代的region就可以了,老年代region和新生代region是一起被回收的。

在回收垃圾对象时,一个对象很可能被不同内存区域所引用,比如在回收新生代中垃圾时,新生代中的对象很有可能被老年代中的对象引用,这种跨内存区域引用的对象,我们该如何判定对象存活,难道在回收新生代垃圾时不得不同时也扫描老年代?这样的话会降低Minor GC的效率。尤其是G1垃圾回收器,一个个region并不是孤立的,一个region中的对象很可能被其他region中的对象引用,难道每次判定一个对象是否存活,需要扫描整个堆空间吗?实际上对于所有类型的垃圾回收器,JVM都是使用Remembered Set来避免全局扫描的。G1垃圾回收器的每一个region都有一个对应的Remembered Set,每次引用类型数据写操作时,都会产生一个Write Barrier写屏障来暂时中断写操作,然后检查将要写入的引用指向的对象是否和该引用类型数据在不同的region(如果是其它垃圾回收器,检查的是老年代对象是否引用了新生代对象),如果不同,通过CardTable把相关引用信息记录到引用指向对象的所在region对应的Remembered Set,当进行垃圾回收时,在GC根节点的枚举范围加入Remembered Set,这就是我们之前所说的“临时性”加入的根节点,这样就可以保证不进行全局扫描了。说白了Remembered Set就是用来记录当前region中哪些对象被哪些外部对象所引用,并把哪些外部对象的相关信息存放到被引用的对象对应的region的Remembered Set中,当扫描这个region时也就不同扫描外部对象所在的region了,这样就实现了region之间的隔离,避免了全局扫描。

  • 新生代GC

    JVM启动时,G1先准备好Eden区,程序在运行过程中不断创建对象到Eden区,当Eden区空间耗尽的时候,就会触发新生代垃圾回收,新生代垃圾回收只会回收Eden区和Survivor区。首先G1执行STW机制停止所有工作线程,扫描所有的Eden区和Survivor区进行垃圾回收。
    在这里插入图片描述

    • 第一阶段:扫描根节点

      扫描GC Roots集合里面的所有根节点,联通Remembered Set临时加入的外部引用节点,一起作为扫描存活对象的入口。

    • 第二阶段:更新Remembered Set

      更新Remembered Set,保证Remembered Set可以准确外部引用。Remembered Set的更新需要线程同步,不能在执行引用赋值语句时实时更新Remembered Set,这样开销会很大,所有我们需要把一个对象被其它对象引用的关系放在一个dirty card queue(脏卡表队列)中,当新生代回收垃圾执行STW停顿时,我们正好可以利用这段时间,把脏卡表队列中的值更新到Remembered Set中,这样不仅没有涉及到开销的问题,还可以保证Remembered Set中数据的准确性。

    • 第三阶段:处理Remembered Set

      识别哪些外部对象,也就是老年代中的指向的Eden中的对象,这些被外部引用的Eden区中对象也被认为是存活的对象。

    • 第四阶段:复制对象

      新生代使用复制算法,沿着根节点出发的引用链遍历所有可达的对象,Eden区存活的对象会被复制到Survivor区,Survivor区中存活的对象如果年龄未达到阈值,年龄就会加1,如果达到阈值就会被复制到老年代中。如果Survivor区空间不够,从直接从Eden区复制到老年代。

    • 第五阶段:处理引用

      垃圾回收操作回收的都是那些强引用对象,而这个处理引用阶段,就是处理那些非强引用对象,包括软引用、弱引用、虚引用等等。最终Eden区的数据为空,把这个region记录到空闲列表汇总,GC停止工作,垃圾回收完成。因为使用的是复制算法,所以没有产生内存碎片,无需进行压缩整理。

  • 并发标记过程

    • 初始标记阶段

      新生代GC的第一阶段已经扫描了所有根节点,初始标记阶段会标记从根节点直接可达的对象,这个阶段也是会产生STW停顿,并且会触发一次新生代GC。

    • 根区域扫描

      G1会扫描Survivor区中可以直接可达老年代对象的那些对象,也就是扫描Survivor中那些引用老年代对象的的对象,并标记被引用的老年代对象,这一过程必须在新生代GC之前完成,因为新生代GC会移动Survivor区,移动之后就找不到哪些老年代对象是可达的了。

    • 并发标记

      在整个堆空间进行并发标记,这个并发标记过程可能会被新生代GC打断,如果说在并发标记阶段发现当前region中所有对象都是垃圾,那么这个region就会立即被回收。同时并发标记阶段在标记的同时,会计算每个region的对象存活比例。

    • 再次标记

      和CMS的重新标记阶段有点类似,由于并发标记阶段工作线程并没有被暂停,所以需要修正上一次并发标记的结果。这个再次标记为了保证标记的正确性,必须得是STW暂停所有工作线程,但是G1采用了比CMS更快地初始快照算法。

    • 独占清理统计

      计算每个region存活对象和GC回收的比例,并进行排序,同时还识别可以混合回收的区域。这个阶段时STW的,因为要计算每个region所以不允许再有变动。这个阶段实际上只是一个统计计算过程,不会涉及到垃圾清理。

    • 并发清理阶段

      这个阶段的任务是如果发生region中的所有对象都是垃圾,那么直接将这个region立即回收。

    我们可以看到,这个并发标记标记过程,最主要的是标记、识别、统计那些垃圾对象,意图并不是去真正清理垃圾对象,而是为下一阶段混合回收做准备。

  • 混合回收

    当越来越多的对象晋升到老年代时,为了避免堆内存被耗尽,JVM会触发一个混合垃圾回收器Mixed GC,除了回收整个新生代,还会回收一部分老年代,是一部分老年代,而不是整个老年代!我们可以选择哪些老年代region被回收,从而可以控制垃圾回收的时间。Mixed GC不等于Full GC。

    并发标记过程结束以后,老年代中哪些全部为垃圾对象的region被立即回收掉了,而那些有部分垃圾的region也被计算统计了出来,默认情况下会分8次将这些region回收。每次混合回收回收的是Eden区、Survivor区以及1/8老年代,回收算法和新生代回收算法完全一样。由于老年代的region默认分为8次回收,所有G1会优先回收那些垃圾比较多的region,也可以设置一个阈值,只有当这个老年代region中垃圾对象比例达到阈值时,才会回收这个region。
    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-XCxd6WeQ-1631069213052)(C:\Users\Jian\AppData\Roaming\Typora\typora-user-images\image-20210908095938422.png)]

  • Full GC阶段(可选)

    G1垃圾回收器目标是为了避免触发Full GC,但是如何上述所说的垃圾回收机制不能正常工作,为了保底,G1还是会触发Full GC,停止所有工作线程,使用单线程的内存回收算法进行垃圾回收,此时回收效率非常差。

    那么导致G1触发Full GC的原因是什么?主要有3个原因:

    1. 堆内存太小,在复制存活对象的时候没有足够多的空闲region存放。可以加大堆空间内存来解决
    2. 并发处理过程完成之前堆空间耗尽,因为工作线程和GC线程并发执行,很有可能工作线程产生垃圾的速度大于GC回收的速度。此时可以加大堆内存,或者调小触发GC的堆占用阈值,使得GC能够尽早执行。
    3. 最大GC停顿时间设置的太短,虽然我们可以设置停顿时间,但是如果太短的话,G1根本来不及回收垃圾,导致在规定的时间内无法完成垃圾回收,也就是回收时间到了,但是堆空间垃圾还是很多,没有清理出足够的内存,也会导致Full GC。我们应该调整加大停顿时间。

G1垃圾回收器无论回收新生代还是老年代的垃圾,都是选择完全暂停工作线程,这是为了保证吞吐量,但是延迟停顿时间是可控的!

至此,详细讲述了Java垃圾回收机制以及各种垃圾回收器的原理与实现。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值