被说烂了的Java垃圾回收算法,我带来了最“清新脱俗”的详细图解

被说烂了的Java垃圾回收算法,我带来了最“清新脱俗”的详细图解_第1张图片

 

一、概况

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

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

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

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

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

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

  • 虚拟机栈帧上本地变量表中的引用对象(方法参数、局部变量、临时变量)
  • 方法区中的静态属性引用类型对象、常量引用对象
  • 本地方法栈中的引用对象(Native方法的引用对象)
  • Java虚拟机内部的引用对象,如异常对象、系统类加载器等
  • 所以被同步锁(synchronize)持有的对象
  • Java虚拟机内部情况的注册回调、本地缓存等

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

被说烂了的Java垃圾回收算法,我带来了最“清新脱俗”的详细图解_第2张图片

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

被说烂了的Java垃圾回收算法,我带来了最“清新脱俗”的详细图解_第3张图片

可达性分析

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

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

三、垃圾回收算法

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

3.1 标记-清除算法

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

被说烂了的Java垃圾回收算法,我带来了最“清新脱俗”的详细图解_第4张图片

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

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

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

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

 

3.2 标记-复制算法

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

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

被说烂了的Java垃圾回收算法,我带来了最“清新脱俗”的详细图解_第5张图片

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

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

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

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

 

3.3 标记-整理算法

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

被说烂了的Java垃圾回收算法,我带来了最“清新脱俗”的详细图解_第6张图片

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

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

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

 

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

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

被说烂了的Java垃圾回收算法,我带来了最“清新脱俗”的详细图解_第7张图片

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

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

垃圾回收的理论基础

  • 绝大多数的对象都是“朝生夕灭”的,既创建不久即可消亡;
  • 熬过越多此垃圾回收过程的对象就越难以消亡;

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

五、堆内存回收过程详解

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

5.1 内存初始状态

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

被说烂了的Java垃圾回收算法,我带来了最“清新脱俗”的详细图解_第8张图片

垃圾回收初始状态

5.2 第1次执行MinorGC后状态

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

被说烂了的Java垃圾回收算法,我带来了最“清新脱俗”的详细图解_第9张图片

​第一次MinorGC内存状态

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

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

被说烂了的Java垃圾回收算法,我带来了最“清新脱俗”的详细图解

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

5.4 执行第2次MinorGC后状态

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

被说烂了的Java垃圾回收算法,我带来了最“清新脱俗”的详细图解_第10张图片

​执行第二次MinorGC后状态

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

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

被说烂了的Java垃圾回收算法,我带来了最“清新脱俗”的详细图解

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

5.6 第15MinorGC后内存状态

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

被说烂了的Java垃圾回收算法,我带来了最“清新脱俗”的详细图解_第11张图片

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

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

5.7 对象如何进入老年代

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

被说烂了的Java垃圾回收算法,我带来了最“清新脱俗”的详细图解_第12张图片

​对象晋升入老年代

第一种类型--直接分配:对象创建时直接分配到老年代具体分为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使用的算法是上面说的标记-整理算法。

被说烂了的Java垃圾回收算法,我带来了最“清新脱俗”的详细图解_第13张图片

​完整堆内存回收过程

六、总结

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

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

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

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

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

作者:攀岩飞鱼

原文链接:https://xie.infoq.cn/article/9d4830f6c0c1e2df0753f9858

你可能感兴趣的:(Java,算法,垃圾回收,数据结构,JVM)