Java 垃圾回收算法及详细过程(一)
小明 Lv6
本文距离上次更新已过去 0 天,部分内容可能已经过时,请注意甄别。

😬 图文并茂才能更快掌握

概况

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

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

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

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

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

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

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

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

image

可达性分析从理论上很好理解,但在垃圾收集器具体运行时,要考虑的问题不知道要复杂多少倍,因为在可达性分析的同时,程序也是在并行运行着,整个内存堆的状态随着程序的运行是实时变化的,要实现分析结果与内存状态的一致性,就必须要暂停用户线程,在一个快照去进行分析。

垃圾回收算法

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

  • 标记-清除算法
  • 标记-复制算法
  • 标记-整理算法

标记-清除算法

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

image

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

  • 执行效率不可控,试想一下如果堆中大部分的对象都可回收的,收集器要执行大量的标记、收集操作。
  • 产生了许多内存碎片,通过回收后的内存状态图可以知道,被回收后的区域内存并不是连续的,当有大对象要分配而找不到满足大小的空间时,要触发下一次垃圾收集。

标记-复制算法

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

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

image

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

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

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

标记-整理算法

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

标记整理算法解决了内存碎片问题,也不存在空间的浪费问题,看上去挺美好的。但是,当内存中存活对象多,并且都是一些微小对象,而垃圾对象少时,要移动大量的存活对象才能换取少量的内存空间。可见:不同的垃圾回收算法都有各自的优缺点,适应于不同的垃圾回收场景

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

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

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

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

因此,可以根据对象生命周期特征,将其划分到不同的区域,再对特定区域使用特定的垃圾回收算法,只有这样才能将垃圾算法的优点发挥到极致,这种组合的垃圾回收算法叫:分代垃圾算法。比如:

  • 在新生代使用标记-复制算法
  • 在老年代使用标记-整理算法

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

关注获取更多资源

image
 评论