JVM 又曾放过谁,垃圾终将被回收!

背景

在Java中有一个很重要的概念,即 一切皆对象 。所谓对象,就是将现实中的事物抽象出来,进而可以通过 继承 实现 组合 的方式把万事万物都给容纳,所以理解对象的概念在学习Java(包括所有的面向对象的语言)的过程中至关重要。

当我们在程序中需要使用某个对象的时候,它就是爷爷,即使采用反射的方法也得把它创建出来;当我们不需要它的时候,它就是个垃圾,即使它能逃过新生代,在老年代也要依然追杀你。

今天,我们要学习的是JVM如何处理 垃圾(对象) ,在学习之前,我们先思考以下几个问题:

  • 对象的生存周期有那些阶段?

  • 如何判断一个对象已经成为了垃圾?

  • 垃圾如何标记?

  • 如何回收垃圾(对象),有哪些策略?

1、对象的生存周期

通常情况下,作为一名Java开发者,我们可以肆无忌惮的new对象,而不需要管理这些对象的 生存周期, 因为JVM会帮我们打扫卫生( 管理对象 )。但是,作为进阶的Coder,我们还是要认真理解一下的。下面我们先了解下的对象的生存周期。

1.1宏观视角

从宏观的角度看,对象的生存周期可以是: 对象创建 —> 对象的使用- –> 对象的回收

  • 对象创建

对象创建可以采用new指令、反序列化、反射等方法,这一步骤主要是为对象分配内存并初始化。

  • 对象的使用

对象的使用是将JVM栈中的对象的引用去定位、访问堆中的对象的具体位置,常采用的方法有句柄和直接指针。

  • 对象的回收

对象的回收就是垃圾回收,我们在下文中详解

仅仅看这三大块还是有点不明所以,太粗糙,下面我们以微观的角度分析对象的生存周期。

1.2微观视角

从微观视角来看,对象的生存的周期可以大致分为七个阶段: 创建阶段 应用阶段 不可见阶段 不可达阶段 可收集阶段 终结阶段 对象空间重分配阶段



  • 创建阶段

对象创建阶段主要是为对象分配内存空间、开始构造对象并完成对static成员的初始化。对象一旦被创建,并且分派给某些变量赋值,这个对象的状态就被切换到了 应用阶段

  • 应用阶段

应用阶段就是对象至少被一个强引用关联着。(莫慌,强引用的概念在下文中讲解)

  • 不可见阶段

当一个对象处于不可见阶段时,说明程序不再持有该对象的任何强引用,但是这些引用可能还存在着,一般情况下是指程序的执行已经超过该作用域了。

boolean flag= false;
if(flag){
flag = 0;
num++;
}
System.out.println(num);

在上面的程序中,本地变量num在System.out.println(num)时已经超出了其作用域,程序就认为变量num处于不可见阶段。

  • 不可达阶段

对象处于不可达阶段是指该对象不再被任何强引用所持有,但是该对象仍可能被JVM等系统下的某些已装载的静态变量或线程或JNI等强引用持有着,这些特殊的强引用被称为”GC root”。存在着这些GC root会导致对象的内存泄露情况,无法被回收.

  • 可收集阶段

当垃圾回收器发现该对象已经处于“不可达阶段”而且垃圾回收器已经对该对象的内存空间又一次分配做好准备时,则对象进入了“收集阶段”。

  • 终结阶段

当对象运行完finalize方法后仍然处于不可达状态时,则该对象进入终结阶段。在该阶段是等待垃圾回收器对该对象空间进行回收。

  • 对象空间重分配阶段

对象空间又一次分配阶段,垃圾回收器对该对象的所占用的内存空间进行回收或者再分配了,则该对象彻底消失了,称之为“对象空间又一次分配阶段”。

上面落落索索一大堆,看的小伙伴似懂非懂,而小伙伴只想知道什么时候 对象变垃圾 以及 垃圾(对象)被清除 的。

事实上,这些阶段伴随着对 象的创建 对象的使用 对象失效 对象被标记为垃圾 对象被回收 的整个流程。为了满足你们的要求,我们把问题聚焦到 垃圾(对象) 的问题上,不过,在聚焦问题之前,我们最先理解下 对象引用 的概念,因为这对于对象变垃圾非常有帮助!

1.3对象引用

从JDK1.2开始,Java的设计人员将对象的引用分为四类,分别是: 强引用 软引用 弱引用 虚引用

  • 强引用

强引用表示一个对象处在 【有用,必须】 的状态,它是使用最普遍的引用。如果一个对象具有强引用,那么垃圾回收器绝不会回收它。就算在内存空间不足的情况下,Java虚拟机宁可抛出OutOfMemoryError错误,使程序异常终止,也不会通过回收具有强引用的对象来解决内存不足的问题。

Student student =  new  Student();  // 这就是强引用



  • 软引用

软引用表示一个对象处在 【有用,但非必须】 的状态。在内存空间足够的情况下,如果一个对象只具有软引用,那么垃圾回收器就不会回收它,但是如果内存空间不足,垃圾回收器就会回收这个对象(回收发生在OutOfMemoryError错误之前)。只要垃圾回收器没有回收它,这个对象就能被程序使用。

软引用用来实现内存敏感的 高速缓存 ,比如说: 网页缓存 图片缓存 等。

Student student = new Student();
SoftReference softReference = new SoftReference(student);



  • 弱引用

弱引用表示一个对象处在 【可能有用,但非必须】 的状态。类似于软引用,但是强度比软引用更弱一些:只具有弱引用的对象拥有更短暂的生命周期。GC线程在扫描它所管辖的内存区域的过程中,一旦发现只具有弱引用的对象,就会回收掉这些被弱引用关联的对象。也就是说,无论当前内存是否紧缺,GC都会回收被弱引用关联的对象。不过,由于GC是一个优先级很低的线程,因此不一定会很快发现那些只具有弱引用的对象。

经典实用案列 java.langThreadLocal



  • 虚引用

虚引用表示一个对象处在 【无用】 的状态,意味着虚引用等同于没有引用,在任何时候都可能被GC回收。设置虚引用的目的是为了被虚引用关联的对象在被垃圾回收器回收的时候,能够收到一个系统通知(用来跟踪对象被GC回收的活动)。

ReferenceQueue referenceQueue = new ReferenceQueue();
PhantomReference phantomReference = new PhantomReference(object,queue);



2、垃圾(对象)判断

垃圾回收的第一步就是要判断对象是否是垃圾。事实上,一个对象是否是垃圾不是由我们程序员来判断的,更准确的说是我们Java Developer不需要的关心的,如果你真的是有代码洁癖或者强迫症,你调用gc方法也无可厚非。

事实上,垃圾回收主要有两中算法: 引用计数算法 可达性分析算法

2.1引用计数算法

引用计数算法是一种很古老的算法,现在在很多java版本已经废弃掉了,作为一名学习者,还有是有必要了解一下的。

定义 :引用计数算法是在对象中添加了一个引用计数器,当引用这个对象时,计数就加1;当引用失效的时候,计数器就减1,当引用计数数为0的时候,该对象也就失效了,变成了垃圾,JVM也就开始回收它了。

从定义上来看,引用计数算法还是很容易理解的,它是一个优缺点很明显的一种算法,我们接着往下看。

  • 优点

①引用计数算法原理简单,并且实时性较强,当引用计数器为0的时候,JVM就可以直接对它进行回收。

②引用计数器只作用于单个对象,即JVM扫描时,只会扫描该对象,不会顺着引用扫描全部对象。

  • 缺点

①对象每次被引用时,都需要耗费一定的时间去更新引用计数器。

②会出现引用循环问题,即对象A引用对象B,对象B引用对象A,由于A和B相互引用,计数它们不被需要了,它们也不会被JVM当做垃圾回收。

为了解决引用计数算法产生的问题,优秀的Java开发者提出了另一种算法:可达性分析算法。

2.2可达性分析算法

可达性分析算法的思路是通过一系列的 “GC Roots” 对象作为起点进行搜索,如果在“GC Roots”和一个对象之间没有可达路径,则称该对象是不可达的,则认为该对象可以被回收。

可达性分析算法如下图所示:



与引用计数算法的不同的是,引用计数算法判断的是对象是否死亡,而可达性分析算法分析的对象是否还活着,可达性分析算法可以有效解决引用计数算法中的循环引用问题。可达性分析算法是判断对象是否为垃圾的主流算法。

3、垃圾标记

上文中我们已经讲解了如何判断一个对象是否是垃圾,及采用的是引用计数法和可达性分析法。小伙伴肯定发现这两种算法是即判断对象是否为垃圾,同时标记了对象。

是的,更准确的说:如果一个对象不存在引用,那么这个对象就是垃圾。而引用计数算法中的计数器和可达性分析算法中的 引用链 都是标记对象的方式。

我们也在上面说过,由于引用计数算法存在循环引用问题,所以现有的主流标记算法是可达性分析算法。下面,我们将详细分析下可达性分析算法标记垃圾对象的过程。

在Java中,可以作为 GC Roots 的对象通常包含以下几种:

  • 虚拟机栈中引用的对象

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

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

  • 本地方法中(Native)引用的对象

在可达性分析算法中,即使存在不可达的对象,该对象也不一定是非死不可的,一个对象要真正的死亡,要经历两次标记的过程。

标记过程分析:

当使用可达性分析算法分析对象时,若发现一些对象与GC Root链不可达,那么该对象就会被 第一次标记 ,然后进行筛选,筛选的条件是判断该对象有没有必要执行finalize()方法(此方法每个对象默认都有),但如果对象没有重写finalize()方法或者对象的finalize方法已经被虚拟机调用过一次了,则都将视为“没有必要执行”,垃圾回收器可以直接回收。

如果该对象被判定为有必要执行finalize()方法,那么虚拟机会把这个对象放置在一个的队列中,然后由一个专门的Finalizer线程去执行这个对象的finalize()方法。如果此时存在某些对象重新与引用链上的任何一个对象建立了关联,那么在第二次标记时它将被移这个队列。

4、垃圾回收

内存是很宝贵的资源,对于不需要的垃圾对象,我们要尽管把它驱逐出去。上文讲解的 垃圾(对象)判断 垃圾标记 步骤都是为 垃圾回收 做铺垫。

在文章 JVM内存 中,我们详细分析JVM的运行时数据区,不熟悉的同学可以再去学习下。

4.1堆的布局结构分析

事实上,垃圾回收的地方是运行时数据区的堆中,我们都知道对象的存活是有周期的,如果一个对象没有被引用,那么就可以认为该对象可以被清除掉了,就是我们认为的垃圾。由于每个对象存活的时间不同,为了减少GC线程扫描垃圾时间及频率,我们可以将存活时间较长的对象单独放一个区域。因此,堆的布局也就确定下来了。总的来说,堆被划分成两部分: 新生代 老年代

image-20210205113109427

新生代和老年代比值为 1:2 ,这个比例并不是唯一的,我们可以可以通过参数 –XX:NewRatio 按照具体的场景来指定,如果再细粒度的划分,新生代又可以分为E den区 Survivor区 ,而 Survivor区 又可以分为 FromSurvivor ToSurvivor ,默认比值为 8:1:1

这时问题又来了,为什么要将Survivor分为两块相等大小的空间啊?好问题,我先说答案,这两分为两部分主要是为了解决 内存碎片化 的问题,如果内存碎片化严重,也就是两个对象占用不连续的内存,已有的连续内存不够新对象存放,就会触发垃圾回收(GC)。

4.2垃圾回收算法

上文中我们分析了运行时数据区中的 堆的布局结构 ,按照对象的生存周期将堆分成了 新生代 老年代 ,新生代又细分为了三个区: Eden , From Survivor , To Surviver ,比例是 8:1:1 。理解 的布局结构对理解JVM中的垃圾回收算法的流程非常有帮助。为什么这么说呢?因为垃圾对象主要是在堆中,又因为堆切分了不同的分区,根据每块分区特性采用的垃圾回收算法也是不同的。

在下面的学习中,我们先学习下常用的垃圾回收算法,最后根据堆中不同的分区内存的特性选择合适的垃圾回收算法。

通常情况下,常用的垃圾回收算法有 标记-清除算法 标记复制算法 标记整理算法 分代回收算法

标记-清除算法

标记清除算法分为 标记 清除 两个阶段,即首先标记出需要回收的对象,标记完成后统一清理对象。它的优点是效率高,缺点是容易产生内存碎片。

标记阶段是以根节点(GC Roots)为起点在引用链上进行扫描,对存活的对象进行标记。清除阶段是扫描整个对象集合,清除集合中未被标记的对象。

垃圾标记 垃圾回收 阶段如下图所示:

标记-复制算法

标记复制算法是将内存划分两个空间,在任意的时间点,所有动态分配的对象都只能分配在其中一个空间,这个空间可以被称为活动空间,而另外一个空间则是空闲的。当有效内存空间耗尽时,JVM将暂停程序运行,开启复制算法GC线程。接下来GC线程会将活动区间内的存活对象,全部复制到空闲区间,且严格按照内存地址依次排列,与此同时,GC线程将更新存活对象的内存引用地址指向新的内存地址。

​ 此时,空闲区间已经与活动区间交换,而垃圾对象现在已经全部留在了原来的活动区间,也就是现在的空闲区间。事实上,在活动区间转换为空间区间的同时,垃圾对象已经被一次性全部回收。

标记整理算法

标记整理算法与标记清除算法很相似,也是分为两个阶段:标记和整理,。

标记 :它的第一个阶段与标记/清除算法是一模一样的,均是遍历GC Roots,然后将存活的对象标记。

整理 :移动所有存活的对象,且按照内存地址次序依次排列,然后将末端内存地址以后的内存全部回收。因此,第二阶段才称为整理阶段

分代回收算法

目前大多数JVM垃圾收集器采用的算法都是分代回收算法,其思想是根据对象存活的生命周期将内存划分成三个区域: 新生代 老年代 永久代 。划分区域可以参考堆的内存结构,新生代和老年代和堆中的划分是一一对应,而永久代在Java8中已经用元空间代替了。

  • 新生代回收算法

新生代主要存放生命周期较短的对象,所有新生成的对象首先都应该放在新生代中的Eden区,回收时将Eden区存活对象复制到To Survivor区,然后清空eden区。当To Survivor区也存放满了时,则将Eden区和To Survivor区存活对象复制到From Survivor区,然后清空Eden和这个From Survivor区,此时To Survivor区为空,然后将To Survivor区和From Survivor区交换,即保持From Survivor区为空, 如此往复。

当From Survivor区不足以存放 Eden区和To Survivor区的存活对象时,就将存活对象直接存放到老年代。若是老年代也满了就会触发一次Full GC,也就是新生代、老年代都进行回收。

一般情况下,新生代发生的GC也叫做Minor GC,MinorGC发生频率比较高(不一定等Eden区满了才触发)。

  • 老年代回收算法

在新生代中经历了N次垃圾回收后仍然存活的对象,就会被放到年老代中。因此,可以认为年老代中存放的都是一些生命周期较长的对象。老年代的内存比新生代也大很多(大概比例是1:2),当老年代内存满时触发Major GC即Full GC,Full GC发生频率比较低,老年代对象存活时间比较长,存活率标记高。

  • 永久代回收算法

永久代用于存放静态文件,如Java类、方法等。永久代对垃圾回收没有显著影响,但是有些应用可能动态生成或者调用一些class,例如Hibernate 等,在这种时候需要设置一个比较大的永久代空间来存放这些运行过程中新增的类。在JDK1.8之前,永久代称方法区,在JDK1.8后,永久代被称为元空间。

堆结构中的不同分区中的算法选择

事实上,JVM对堆中的不同区域(新生代和老年代)采用不同的算法。

新生代比较适合复制算法,新生代有Eden、From Survivor和To Survivor三个区,因为Eden区中对象会被复制到To Survivor,且From Survivor和To Survivor相互交换比较频繁,所以采用复制算法。

老年代中的对象的生命周期比较长,所以不适合复制算法,在老年代一般采用的是标记-整理/清除算法。

参考文献

[1] https://www.sohu.com/a/359103068_675634

[2] https://www.cnblogs.com/widget90/p/12932172.html

[4] https://www.cnblogs.com/jichi/p/11139437.html

[5]深入理解JVM虚拟机值(第三版).周志华