JVM之垃圾回收机制


1.到底谁是垃圾?

引用计数法

日常生活中,如果一个东西经常没被使用,那么这个对象可以说就是垃圾。在 Java 中也是如此,如果一个对象不可能再被引用,那么这个对象就是垃圾,应该被回收。

根据这个思想,我们很容易想到使用引用计数的方法来判断垃圾。在一个对象被引用时加一,被去除引用时减一,这样我们就可以通过判断引用计数是否为零来判断一个对象是否为垃圾。

上面的这种方法虽然简单,但是其存在一个致命的问题,那就是循环引用。

A 引用了 B,B 引用了 C,C 引用了 A,它们各自的引用计数都为 1。但是它们三个对象却从未被其他对象引用,只有它们自身互相引用。从垃圾的判断思想来看,它们三个确实是不被其他对象引用的,但是此时它们的引用计数却不为零。这就是引用计数法存在的循环引用问题。

JVM没有选择使用引用计数法作为垃圾回收算法

GC Root Tracing 算法

其大概的过程是这样:从 GC Roots 出发,所有可达的对象都是存活的对象,而所有不可达的对象都是垃圾。

GC Roots是一组特殊的引用,它们被认为是程序中可访问对象的起始点,即从这些引用开始,可以追踪到所有仍然被程序引用的对象。

GC Roots通常包括:

  • 局部变量引用:当前执行方法中的局部变量或参数引用的对象
  • 活动线程引用:正在运行的线程中引用的对象必须是存活的
  • 静态变量引用:静态变量是类的一部分,它们的引用也被视为GC Roots。静态变量存在于类加载器的内存中。
  • JNI 引用:通过Java Native Interface(JNI)创建的本地代码引用也可以被视为GC Roots。这些引用连接了Java堆内存和本地代码的内存。
  • 虚拟机引导类加载器:虚拟机内部使用的类加载器引用也是GC Roots。它们通常是一些核心类或库。

在Java中提供了4个级别的引用,强引用、软引用、弱引用、虚引用。

  • 强引用所指向的对象在任何时候都不会被系统回收,虚拟机宁愿抛出OOM异常,也不会回收强引用所指向的对象
  • 软引用是比强引用弱一点的引用类型,GC未必会回收弱引用的对象,但是当内存资源紧张时,软引用对象就会被回收
  • 弱引用是一种比软引用弱的引用类型,在系统GC时,只要发现弱引用,不管系统堆空间使用情况如何,都会将对象进行回收。但是由于垃圾回收器的线程优先级比较低,很难快速发现弱引用的对象。
  • 虚引用是所有引用类型中最弱的一种,随时都可能被垃圾回收器回收。

三色标记算法

三色标记算法是JVM垃圾回收中用于实现并发标记的核心技术,旨在减少因垃圾回收导致的程序停顿(STW,Stop The World)。

什么是三色:
首先我们需要知道三色标记法就是根据可达性分析,从GC Roots开始进行遍历访问,在遍历对象过程中,按“是否检查过”这个条件将对象标记成三种颜色:

  • 黑色: 对象及其所有引用链均被扫描完成(存活对象)
  • 灰色:对象已被扫描,但其引用的子对象未被完全扫描(中间状态)。
  • 白色:代表对象没有被检查。 在可达性分析刚刚开始的阶段,所有的对象都是白色的,若在分析结束的阶段,仍然是白色的对象,即代表不可达。

假设现在有白、灰、黑三个集合(表示当前对象的颜色),其遍历访问过程为:

  1. 初始时,所有对象都在【白色集合】中;

  2. 将 GC Roots直接引用到的对象挪到【灰色集合】中;

  3. 从灰色集合中获取对象:

    • 将本对象引用到的其他对象全部挪到【灰色集合】中;

    • 将本对象挪到【黑色集合】里面。

  4. 重复步骤3,直至【灰色集合】为空时结束

  5. 结束后,仍在【白色集合】的对象即为GC Roots不可达,可以进行回收。

需要注意,传统标记方式发生Stop The World时,对象间的引用是不会发生变化的,可以轻松完成标记。
而并发标记在标记期间应用线程还在继续跑,对象间的引用可能发生变化,就会出现错标和漏标的情况就有可能发生。

存在问题:

浮动垃圾(多标):并发标记期间,已标记为黑色或灰色的对象可能变成垃圾(如引用被用户线程删除)。影响:少量内存浪费,但可在下次GC回收。

漏标(错杀):一个白色对象的成为黑色对象的新增引用,同时灰色对象删除了对该白色对象的引用。影响:存活对象被错误回收,导致程序异常


2.如何进行垃圾回收?

到这里,我们了解了什么是垃圾以及 JVM 是如何判断垃圾对象的。那么识别出垃圾对象之后,JVM 是如何进行垃圾回收的呢?这就是我们下面要讲的内容:如何进行垃圾回收?

垃圾回收算法简单地说有三种算法:标记清除算法、复制算法、标记压缩算法。

  • 标记清除算法(Mark-Sweep):从名字可以看到其分为两个阶段:标记阶段和清除阶段。在标记阶段,标记所有由 GC Root 出发的可达对象。此时,所有未被标记的对象就是垃圾。之后在清除阶段,清除所有未被标记的对象。标记清除算法最大的问题就是会产生空间碎片。如果空间碎片过多,则会导致内存空间的不连续。虽说大对象也可以分配在不连续的空间中,但是效率要低于连续的内存空间。
  • 复制算法(Copying):复制算法的核心思想是将原有的内存空间分为两块,每次只使用一块,在垃圾回收时,将正在使用的内存中的存活对象复制到未使用的内存块中。之后清除正在使用的内存块中的所有对象,之后交换两个内存块的角色,完成垃圾回收。如果垃圾很多,复制算法需要复制的存活对象就很少,效率也很高,并且不会产生内存碎片。因此复制算法比较适合新生代,因为新生代垃圾对象会多于存活对象。该算法的缺点是要将内存空间折半,极大地浪费了内存空间。
  • 标记压缩算法(Mark-Compact):标记压缩生一种老年代的回收算法,它在标记清除的基础上做了一些优化,其同样需要经历两个阶段,分别是:标记阶段、压缩阶段。在标记阶段,从 GC Root 引用集合出发去标记所有对象。在压缩阶段,其则是将所有存活的对象压缩在内存的一边,之后清理边界外的所有空间。这种方法既避免了碎片的产生,又不需要两块相同的内存空间。标记压缩算法的最终效果等同于标记清楚法执行完成后再进行一次内存碎片整理。

3.分代思想(Generational Collecting)

JVM内存为什么要分代呢?内存分代的的主要原因是为了提高垃圾回收的效率和性能,这种策略是因为大部分对象在内存中存活时间很短,少数存活时间长。

新生代

  • 结构:分为Eden区(80%)和两个Survivor区(From/To,各10%)
  • 回收算法:复制算法
  • 流程:eden区的存活对象会被复制到s1区(假设未使用的是s1区,在使用的是s2区),s2区的年轻对象也会被复制到s1区(大对象或者老年对象会直接进入老年代,如果s1区满了,对象也会直接进入老年代),此时eden区和s2区剩余的对象就是垃圾,可以直接清空,s1区存放的就是这次回收后的存活对象。
  • 优势:避免内存碎片,适合低存活率场景

那么为什么 JVM 的新生代没有 50% 等分为两个内存块?

根据IBM公司的统计研究表明,在新生代中的对象 98% 是朝生夕死的,所以并不需要按照1:1的比例来划分内存空间。Eden和两块Survivor空间,其大小占比是8:1:1,当回收时,将Eden和Survivor中还存活的对象一次性复制到另外一块Survivor空间上,最后清理掉Eden和刚才用过的Eden空间。提高空间利用率。

老年代

  • 对象晋升条件:Survivor区对象年龄达到阈值(默认15次,通过-XX:MaxTenuringThreshold调整)。大对象直接进入老年代,避免多次复制。
  • 回收算法:标记清除或标记整理

4.分区思想(Region)

分代思想按照对象的生命周期长短将其分为了两个部分(新生代、老年代),但 JVM 中其实还有一个分区思想,即将整个堆空间划分成连续的不同小区间。

每一个小区间都独立使用,独立回收,这种算法的好处是可以控制一次回收多少个区间,可以较好地控制 GC 时间。


5.总结


文章作者: Fuchanglai
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 Fuchanglai !
赏
  目录