图解Java垃圾回收算法及详细过程!

发布于: 2020 年 06 月 07 日
图解Java垃圾回收算法及详细过程!

一、概况

理解Java虚拟机垃圾回收机制的底层原理,是系统调优与线上问题排查的基础,也是一个高级Java程序员的基本功,本文就针对Java垃圾回收这一主题做一些整理与记录。Java垃圾回收器的种类繁多,它们的设计要在吞吐量(内存空间)与实时性(用户线程中断)方面进行权衡,各个垃圾回收器的适应场景也不尽相同(如:桌面应用,web应用),因此,这里我们只讨论JDK8下的默认垃圾回收器,毕竟目前JDK8版本是业界的主流(占80%),并且我们只讨论堆内存空间的垃圾回收。

JDK8下的默认垃圾回收器:UseParallelGC : Parallel (新生代)+ (老年代)堆内存回收机制

二、如何判断对象是否可回收?

首先思考一个问题,内存堆中那么多对象,回收器要回收哪些对象?怎么判断出这些要回收的对象呢?因此对于垃圾回收,判断并标识对象是否可回收是第一步。从理论层面来说,判断对象是否可回收一般两种方法。

第一种、引用计数器算法:每当对象被引用一次计数器加1,对象失去引用计数器减1,计数器为0是就可以判断对象死亡了。这种算法简单高效,但是对于循环引用或其他复杂情况,需要更多额外的开销,因此Java几乎不使用该算法。

第二种、根搜索算法-可达性分析算法:所谓可达性分析是指顺着GCRoots根一直向下搜索(用一个成语概括就是“顺藤摸瓜”),整个搜索的过程就构成了一条“引用链”,只要在引用链上的对象叫做可达,在引用链之外的(说明跟GCRoots没有任何关系)叫不可达,不可达的对象就可以判断为可回收的对象。 哪些对象可作为GCRoots对象呢? 包括如下:

  •   虚拟机栈帧上本地变量表中的引用对象(方法参数、局部变量、临时变量)

  •   方法区中的静态属性引用类型对象、常量引用对象

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

  •   Java虚拟机内部的引用对象,如异常对象、系统类加载器等

  •   所以被同步锁(synchronize)持有的对象

  •   Java虚拟机内部情况的注册回调、本地缓存等

如果对虚拟机的内存布局与运行流程有所了解的话,这些作为GCRoots都很好理解,它们是程序运行时的源头,程序的正常运行必须依赖它们,而与这些源头没有任何关系的对象,即可视为可回收对象。就好比“瓜从藤上掉下来了, 那这瓜肯定也没有用了”        

​GCRoots可达性分析 不可达对象

可达性分析

可达性分析从理论上很好理解,但在垃圾收集器具体运行时,要考虑的问题不知道要复杂多少倍,因为在可达性分析的同时,程序也是在并行运行着,整个内存堆的状态随着程序的运行是实时变化的,要实

现分析结果与内存状态的一致性,就必须要暂停用户线程,在一个快照去进行分析。

三、垃圾回收算法

可达性分析解决了判断对象是否可回收的问题,那么在垃圾回收时内存空间会发生哪些变化呢?这就是垃圾回收算法要讨论的问题,我们根据算法对内存采取的不同操作,可将垃圾回收算法分为3种,标记-清除算法、标记-复制算法、标记-整理算法。

3.1 标记-清除算法

根据名称就可以理解改算法分为两个阶段:首先标记出所有需要被回收的对象,然后对标记的对象进行统一清除,清空对象所占用的内存区域,下图展示了回收前与回收后内存区域的对比,红色的表示可回收对象,橙色表示不可回收对象,白色表示内存空白区域。

标记-清除算法 垃圾回收前后内存区域对比

标记-清除算法的两个缺点:

第一个:是执行效率不可控,试想一下如果堆中大部分的对象都可回收的,收集器要执行大量的标记、收集操作。

第二个:产生了许多内存碎片,通过回收后的内存状态图可以知道,被回收后的区域内存并不是连续的,当有大对象要分配而找不到满足大小的空间时,要触发下一次垃圾收集。

3.2 标记-复制算法

针对标记-清除算法执行效率与内存碎片的缺点,计算机科学家又提出了一种“半复制区域”的算法。

标记-复制算法将内存分为大小相同的两个区域,运行区域,预留区域,所有创建的新对象都分配到运行区域,当运行区域内存不够时,将运作区域中存活对象全部复制到预留区域,然后再清空整个运行区域内存,这时两块区域的角色也发生了变化,每次存活的对象就像皮球一下在运行区域与预留区域踢来踢出,而垃圾对象会随着整个区域内存的清空而释放掉,内存前后的状态参考下图:

​标记-复制算法回收前后内存对比

标记-复制算法在大量垃圾对象的情况下,只需复制少量的存活对象,并且不会产生内存碎片问题,新内存的分配只需要移动堆顶指针顺序分配即可,很好的兼顾了效率与内存碎片的问题。

标注-复制算法也存在缺点

预留一半的内存区域未免有些浪费了,并且如果内存中大量的是存活状态,只有少量的垃圾对象,收集器要执行更多次的复制操作才能释放少量的内存空间,得不偿失。

3.3 标记-整理算法

标记-复制算法要浪费一半内存空间,且在大多数状态为存活状态时使用效率会很低,针对这一情况计算机科学家又提出了一种新的算法“标记-整理算法”,标记整理算法的标记阶段与其他算法一样,但是在整理阶段,算法将存活的对象向内存空间的一端移动,然后将存活对象边界以外的空间全部清空,如下图所示:

​标记-整理算法回收前后内存对比

标记整理算法解决了内存碎片问题,也不存在空间的浪费问题,看上去挺美好的。但是,当内存中存活对象多,并且都是一些微小对象,而垃圾对象少时,要移动大量的存活对象才能换取少量的内存空间。

不同的垃圾回收算法都有各自的优缺点,适应于不同的垃圾回收场景

四、新生代、老年代堆内存结构

 Java 堆内存空间新生代、老年代是如何划分的?对象创建后是如何分配到不同的区域的?结合下图可以知道,整个堆内存被分为了2个大的区域,新生代,老年代,默认情况下新生代占1/3的空间,老年代占2/3的空间,新生代又分为两个区 Eden区Survial区,Survial又分为S0、S1区 默认各占8/10与1/10,1/10的空间

​年轻代 老年代 堆空间结构

为什么要这么设计呢?为什么要分那么多不同的内存区域干嘛?这是由对象的生命周期特征、与各类垃圾回收算法的优缺点所决定的,这正式垃圾回收器设计的理论基础。经过统计分析,大多数应用程序对象生命周期符合两个特征:

垃圾回收的理论基础

  • 绝大多数的对象都是“朝生夕灭”的,既创建不久即可消亡;

  • 熬过越多此垃圾回收过程的对象就越难以消亡;

一块独立的内存区域只能使用一种回收算法,根据对象生命周期特征,将其划分到不同的区域,再对特定区域使用特定的垃圾回收算法,只有这样才能将垃圾算法的优点发挥到极致,这种组合的垃圾回收算法叫:分代垃圾算法。。比如:在新生代使用标记-复制算法,在老年代使用标记-整理算法。

五、堆内存回收过程详解

我们分析了如何判断对象是否可回收,还有3中基础的垃圾回收算法,以及年轻代、老年代的内存区域划分与原因。接下来我们就一步一步来分析堆内存的回收流程。

5.1 内存初始状态

假设在第一垃圾回收之前,内存中的状态如图所示Eden区有2个存活对象,3个垃圾对象,内存的可用区域已经所剩无几,Survivor区因为还没有进行任何MinorGC所以是空的,有1个大对象直接分配到了老年代,

​垃圾回收初始状态

5.2 第1次执行MinorGC后状态

当新的对象分配到Eden区,发现内存空间不够,于是触发第一次MinorGC,垃圾回收器首先将Edne区中的两个存活对象复制到S0区,然后在清空Eden区的空间,如下图:

​第一次MinorGC内存状态

5.3 程序运行一段时间后状态

经过第1次MinorGC程序再运行一段时间后,堆内存状态如下:Eden区又产生了大量的对象,并且大部分对象都可回收状态,这也符合对象“朝生夕灭”的特征,S0区中也有1个对象可以回收,S1与老年代没有变化,在这种状态下,如果新对象分配再次触发MinorGC会发生什么呢?

​程序运行一度时间后的状态

5.4 执行第2次MinorGC后状态

新对象分配Eden区空间不足,又触发了第二次MinorGC,第二次MinorGC与第一次GC时在Eden区的操作是一样的:将Eden区存活的对象复制到S1区,然后在清空整个Eden区,同时也将S0区存活的对象复制到S1区并将对象的年龄加1,再清空S0区,GC后的状态如下图所示:

​执行第二次MinorGC后状态

5.5 第2MinorGC程序运行一段时间后状态

  经过第二MinorGC后程序又运行了一段时间,Eden区中有生成了很多对象,S1区也有一个对象可回收。

第二MinorGC程序运行一段时间后状态

5.6 第15MinorGC后内存状态

在接下来的每次MinorGC时,都是第二次一样,从Eden区和survivor非空白区移动存活对象到survivor区中空白区域,并清空这两个区域内存空间,存活对象每此从survivor两个区域移动一次,对象年龄加1,下图表示经过了15次MinorGC后的堆内存状态。

​经过15次MinorGC后的内存状态

对于年轻代区域的内存收集,使用的是标记-复制算法,只是为了减少复制算法空白区域的内存浪费,并不是将内存一份为二,而是巧妙的将内存分为三个区域,预留的空白区域只占整个年轻代区域的1/10。

5.7 对象如何进入老年代

以上是年轻代的分配与回收问题,那对象如何进入老年代呢?个人认为对象进入老年代,可以分为2种类型6种情况。

​对象晋升入老年代

第一种类型--直接分配:对象创建时直接分配到老年代具体分为3种情况。

  • 超过虚拟机PretenureSizeThreshold参数设置大小的对象,该参数的默认值是0,也就是说任何大小的对象都会先分配到Eden区。

  • 超过Eden大小的对象

  • 如果新生代分配失败,一个大数组或者大字符串

第二种类型--从年轻代晋升:从年轻代空间晋升到老年代也可分为3种情况。

  • 新生代分配担保,在执行MinorGC时要将Eden区存活的对象复制到Survivor区,但是Survivor区默认空间是只有新生代的2/10,实际使用的只有1/10,当Survivor区内存不够所有存活对象分配时,就需要将Survivor无法容纳的对象分配到老年代去,这种机制就叫分配担保。

  • 对象年龄超过虚拟机MaxTenuringThreshold的设置值,最大为15,

  • Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半(TargetSurvivorRatio),年龄大于或等于该年龄的对象直接进入老年代。

内存分配担保机制

在执行MinorGC时要将Eden区存活的对象复制到Survivor区,但是Survivor区默认空间是只有新生代的2/10,实际使用的只有1/10,当Survivor区内存不够所有存活对象分配时,就需要Survivor无法容纳将对象分配到老年代空间中,这种机制就叫分配担保,但是,老年代的空间也是有限的,如果老年代中空间也不够的话,那只能乖乖的执行一次FullGC了。

5.8 老年代回收算法-FullGC

当有对象要进入老年代,而老年代空间又不足时就会触发FullGC,当然,反过来说触发FullGC的条件不仅仅只是老年代空间不足,FullGC使用的算法是上面说的标记-整理算法。

​完整堆内存回收过程

六、总结

  •  判断对象是否可以回收是垃圾回收的基础与前提,通过可达性分析从GCRoots开始进行"顺藤摸瓜"找到不可达对象(可回收)

  • 对象生命周期的特征"朝生夕灭"与"越战越强"是垃圾回收算法的理论基础

  • 基础的垃圾回收算法有3种分别是 标记-清除算法、标记-负责算法、标记整理算法,都有各自的适应场合与优缺点

  • 分代垃圾算法根据对象生命周期的特征,将其划分到不同的区域,从而使用最适合的垃圾算法来进行优化

  • 在JDK8默认的配置下使用 新生代,老年代的垃圾回收策略,新生代区域使用标记-复制算法,老年代区域使用标记-整理算法

发布于: 2020 年 06 月 07 日 阅读数: 1203
用户头像

攀岩飞鱼

关注

任何一步都不会决定成功,但可以决定失败! 2017.10.19 加入

记录技术,项目,管理,读书,挣钱,养娃等点点滴滴可以记录的事情。

评论 (2 条评论)

发布
用户头像
"跟搜索算法-可达性分析算法" 是否错字了
2020 年 06 月 09 日 00:14
回复
是的,感谢指正,已修改,谢谢!!
2020 年 06 月 09 日 14:05
回复
用户头像
非常详细的分享,感谢支持,InfoQ首页推荐。
2020 年 06 月 08 日 08:59
回复
谢谢小编姐姐,继续加油!!
2020 年 06 月 08 日 10:05
回复
没有更多了
图解Java垃圾回收算法及详细过程!