首页 > 编程语言 > JVM知识总结之垃圾收集算法
2021
08-08

JVM知识总结之垃圾收集算法

一、什么是垃圾

本文要讲的是垃圾收集算法,那么首先要确定的问题就是什么是垃圾,也就是哪些对象是要被回收的,对此有两种判断方式:

1.1 引用计数算法

什么样的对象是要被回收的,很明显,没有被引用的对象才要被回收。因此在对象中加一个引用计数器,当有一个对象引用该对象的时候,计数器就加一,当引用结束后,计数器就减一,当计数器为0的时候,对象就可以被回收了。

1.1.1 优点

  • 原理简单
  • 判断效率高

1.1.2 缺点

  • 需要花费额外的内存空间(引用计数器)
  • 无法回收相互循环引用的对象:比如有对象A和对象B,A引用B,B引用A,两对象的引用计数器都为1,理论上来说,没有其他对象能够引用到A和B了,因此这两个对象应该被回收,然后按照引用计数算法的判断,这两个对象无法被回收。(要克服这个缺点,需要在代码中做很多特殊处理)

1.2 可达性分析算法

因为引用计数算法的缺陷,各大主流的商用程序语言都采用可达性分析算法来判断对象是否需要被回收。可达性顾名思义,是指对象跟对象之间有引用关系,此处有两种引用关系:

  • 对象A引用对象B,则称对象A到对象B可达
  • 对象A引用对象B,对象B引用对象C,对象A可以通过若干个对象(此处为对象B)引用到对象C,则称对象A到对象C可达。

要判断一个对象是否可达,首先要有一个根对象,在Java中有一系列被称为“GC Roots”的根对象作为起始节点集,任何从“GC Roots”不可达的对象都是需要被垃圾收集器回收的垃圾。

1.2.1 优点

可以有效解决引用计数算法的相互循环引用问题

 二、什么是引用

在讨论什么是垃圾的时候,多次提到引用一词,那么什么是引用呢?

2.1 JDK1.2以前

按照书中的说法,在JDK1.2以前,引用的意思是:如果reference类型的数据中存储的数值代表的是另外一块内存的起始地址,就称该reference数据是代表某块内存、某个对象的引用。
在这种定义下可以发现,对于一个对象来说,就只有未被引用和被引用两种状态了,但其实可以发现,在实际应用中,并不是一定要把对象回收掉的,书中有个词就很贴切,“食之无味,弃之可惜”,我们想要的是当内存空间足够的时候,把这部分本该回收的对象留着不回收,当内存不够的时候,就将其回收。

2.2 JDK1.2之后

因此在JDK1.2之后,引用的概念就扩张到了以下四种:

  • 强引用:指传统意义上的引用,有强引用的对象是肯定不被回收的;
  • 软引用:用于描述一些还有用,但非必须的对象,当要发生内存溢出的时候,就会回收软引用对象;
  • 弱引用:用于描述非必须对象,强度比软引用弱一点,当垃圾收集器开始工作,无论内存够不够都会回收弱引用对象;
  • 虚引用:虚引用意思就是这个引用跟没有一样,对对象完全没有印象,其存在的唯一作用就是在对象被垃圾收集器回收时能收到一个系统通知。

三、垃圾判断全流程

按照书中所述,我画了个流程图,如下:

垃圾判断全流程

一个对象在被回收前,需要进行两次标记,第一次进行可达性分析后,对象被垃圾收集器认为是垃圾,则对对象进行第一次标记,然后垃圾收集器会给予对象一次自救的机会,不然就没必要两次标记了,一次标记直接回收就好了。
我们都知道对象有个finalize()方法,自救的机会就在这个方法中,当第一次标记后,垃圾收集器会对对象做一次筛选,筛选条件是要不要执行对象的finalize()方法,如果开发者未对finalize()方法进行覆写或者虚拟机已经执行过该对象的finalize()方法了,那么自然就不用再执行了,反之则需要执行。
将筛选出来的需要执行finalize()方法的对象放入一个特定的队列中,由虚拟机统一执行,如果finalize()方法中使得对象被别的对象引用了,导致可达性分析认为对象是可用的,那么自救就成功了。
根据筛选的条件可以知道,对象的自救机会在整个程序中只有一次,因为finalize()方法只会被执行一次。
需要注意的是,官方明确申明不推荐使用finalize()方法,因为使用它的不确定性太大。对于资源清理等操作,try…catch语法可以做的更好。

四、垃圾收集算法

大多数虚拟机的垃圾收集都采用了分代收集的形式,这是因为三条经验法则:

  • 弱分代假说:绝大多数对象都是朝生夕死的;
  • 强分代假说:熬过越多次垃圾收集过程的对象就越难以消亡
  • 跨代引用假说:跨代引用相对于同代引用来说仅占极少数

因为对象的生存周期是不一样的,所以我们不能对所有对象采用同一种垃圾收集算法,采用分代收集,将有共性的对象放在一个集合里,会大大地提高垃圾收集效率。
按照上述经验法则,可以将堆内存分为两代:

  • 新生代:对应弱分代假说
  • 老年代:对应强分代假说

下面分别介绍三种垃圾收集算法:

4.1 标记-清除算法

标记-清除算法是最基础的垃圾收集算法,顾名思义,标记就是判断对象是否是垃圾,也就是前面第四节讲到的内容,清除就是统一回收垃圾,该算法有两种执行过程:

  • 标记所有需要被回收的对象,统一回收所有标记的对象;
  • 标记所有存活对象,统一回收所有未被标记的对象。

标记-清除算法示意图:

标记-清除算法

标记-清除算法有两大缺点:

  • 执行效率不稳定:标记和清除两个过程的执行效率随对象数量的增加而降低
  • 内存空间碎片化:执行完标记和清除后,会产生大量的不连续的内存碎片,当分配大对象的时候,如果找不到足够的连续内存,那么会提前触发下一次垃圾收集。

4.2 标记-复制算法

基于标记-清除算法的缺点,标记-复制算法将内存空间一分为二,两块内存空间等大,每次只使用其中一块内存空间,当这一块内存空间用完了,就把存活的对象复制到另一块内存空间中,然后一次性清理所有已使用的内存空间。

标记-复制算法示意图:

标记-复制算法

标记-复制算法解决了标记-清除算法面对大量可回收对象场景下的不足之处,面对这种情况,标记-复制算法只需要将内存空间中的存活对象复制到另一半内存空间中,可以有效解决内存碎片的问题,在给对象分配内存的时候,只需要移动堆顶指针按顺序分配即可,不过这个算法也有缺点:

  • 面对大量不可回收对象的时候,会产生大量内存间对象复制的开销;
  • 原先的内存空间缩小了一半,会造成严重的空间浪费

4.3 标记-整理算法

标记-复制算法不足以应对有大量存活对象的场景,因此就有了标记-整理算法,该算法的执行流程如下:

  • 与其他算法一样,首先对对象进行标记;
  • 将所有存活对象往内存的一个方向移动;
  • 直接清理掉边界以外的内存

标记-整理算法示意图:

标记-整理算法

标记-整理算法同样可以解决内存碎片化问题,并且不会造成空间浪费,不过它也有缺点:

在大量对象存活的情况下,移动对象并更新引用也会花费大量时间

4.4 应用

不同的场景适用不同的垃圾收集算法,像标记-复制算法就适用于存活对象少的情况下,也就是新生代区域,像标记-整理算法就适用于存活对象多的情况下,也就是老年代。
这里有点需要注意的是,标记-整理算法对于老年代来说也不是完美的,在5.3节我们说过,在大量对象存活的情况下,移动对象和更新引用也是要花费大量时间的,不过算法这个东西吧,它比的是谁更适合,对于标记-复制算法来说,我把区域一分为二,如果大量对象存活,我要把对象全部复制到另一块内存区域,这个开销不见得比标记-整理算法少,并且它还有个缺点就是可用内存一下子少了一半,这个问题在标记-整理算法中是没有的。也有的虚拟机采用标记-清除算法标记-整理算法协作的垃圾收集方案,没有最适合,只有更适合

4.5 优化

前面讲标记-复制算法的时候说到要把内存区域等半分,这是在没有规定场景的情况下,在新生代中采用该垃圾收集算法可以做更好的优化。

众所周知,新生代中的对象都是朝生夕死的,因此当标记完成后的存活对象肯定是少量的,根据这个现象,可以将内存区域非等半分,比如说9:1的分法,这里我们将90%的内存区域称为Eden空间,将10%的内存区域称为Survivor空间,一开始使用Eden空间的内存,当垃圾收集时,将Eden空间的存活对象复制到Survivor空间中。
这里肯定有人要问了,那下一次使用Survivor空间不是就只有10%的内存了吗?

对的,所以这里有两种解决方案:

  • 将存活对象从Eden空间复制到Survivor空间后,再从Survivor空间复制回Eden空间
  • Eden空间再分离出一个Survivor空间,每次可使用的内存为一个Eden空间一个Survivor空间,当垃圾收集时,将使用的内存区域中的存活对象复制到另一个Survivor空间中,下一次的可用内存则为Eden空间和这个Survivor空间,如此循环往复。

第二种方法就是大名鼎鼎的半区复制分代策略,现在叫Appel式回收,因为提出这个策略的人叫Apple,目前很多虚拟机在新生代的垃圾收集算法中采用这个策略。

4.5.1 缺点

半区复制分代策略也是有缺点的,从上面的叙述中我们可以知道,Eden空间Survivor空间的内存占比为8:1:1,如果当垃圾收集后的存活对象所需要的内存空间大于一个Survivor空间时,那就难办了。

4.5.2 补丁

既然Survivor空间的内存不够放存活对象了,那就去借内存区域,这个借当然不能跟Eden空间Survivor空间借,不然会影响到整个算法,增加算法的复杂度。新生代不能借,那就跟老年代借,这里就有一个所谓的内存分配担保,放不下的存活对象将直接通过分配担保机制进入到老年代中。有了这个“逃生门”一样的设计,这个策略才算是没有漏洞。

五、写在后面

几个垃圾收集算法的图是我直接截了书里面的图,因为我觉得它讲的很详细了,第四节垃圾判断过程在书中实际上是一长串的代码,看懂不难,不过我想画个流程图可能更清楚点,这个流程图是用plantUML画出来的,这个工具可以用代码画出各种图,功能强大,有兴趣的可以百度搜搜,下面是这个流程图的代码:

@startuml
start
:对对象进行可达性分析;
if (对象是否为垃圾?) then (是)
    :进行第一次标记;
    if (对象没有覆盖finalize()方法 或 finalize()方法已经被虚拟机调用) then(是)
        :没必要执行对象的finalize()方法;
    else (否)
        :将对象放入队列F-Queue中;
        :等待虚拟机的Finalizer线程执行对象的finalize()方法;
        :执行对象的finalize()方法;
    endif
    if (对象是否为垃圾?) then (是)
        :进行第二次标记;
        :垃圾回收;
    else (否)
        stop
    endif
else (否)
    stop
endif
stop
@enduml

到此这篇关于JVM知识总结之垃圾收集算法的文章就介绍到这了,更多相关JVM垃圾收集算法内容请搜索自学编程网以前的文章或继续浏览下面的相关文章希望大家以后多多支持自学编程网!

编程技巧