投过简历的都知道:简历池就像 Eden 区,每天新进成千上万,但 98% 都是“朝生夕死”。 能面过一轮进 Survivor 区的是少数,能熬过 15 轮面试最终入职 老年代 拿 Offer 的,那是真的稳。 但最扎心的是:总有些“背景通天”的大对象,连面都不用面,直接空降老年代。

JVM 的内存管理,其实就是一场残酷的职场生存游戏。今天这篇文章,咱们不聊虚的,直接硬核拆解面试高频八股文——垃圾回收(GC)。看完这篇,保你下次面试被问到 GC 时,能像垃圾收集器一样,精准定位、快速回收,不给面试官留一丝“内存碎片”。


1. 垃圾回收的概念

垃圾回收(Garbage Collection,GC),顾名思义就是释放垃圾占用的空间,防止内存爆掉。有效的使用可以使用的内存,对内存堆中已经死亡的或者长时间没有使用的对象进行清除和回收。

垃圾回收的历史远远比 Java 久远,在 1960 年诞生于麻省理工学院的 Lisp 是第一门开始使用内存动态分配和垃圾收集技术的语言。当 Lisp 还在胚胎时期时,其作者 John McCarthy 就思考过垃圾收集需要完成的三件事情:

  1. 哪些内存需要回收?
  2. 什么时候回收?
  3. 如何回收?

经过半个世纪的发展,今天的内存动态分配与内存回收技术已经相当成熟,一切看起来都进入了 “自动化” 时代,那为什么我们还要去了解垃圾收集和内存分配?答案很简单:当需要排查各种内存溢出、内存泄漏问题时,当垃圾收集成为系统达到更高并发量的瓶颈时,我们就必须对这些“自动化”的技术实施必要的监控和调节。

2. 垃圾判断算法

在堆里面存放着 Java 世界中几乎所有的对象实例,垃圾收集器在对堆进行回收前,第一件事就是要确定这些对象之中哪些还 “存活” 着,哪些已经 “死去”(“死去” 既不可能再被任何途径使用的对象)。

2.1 引用计数算法

引用计数算法(Reachability Counting)是通过在对象头中分配一个空间来保存该对象被引用的次数(Reference Count)。

如果该对象被其它对象引用,则它的引用计数加 1,如果删除对该对象的引用,那么它的引用计数就减 1,当该对象的引用计数为 0 时,那么该对象就会被回收。

阶段1:创建对象A              阶段2:两个变量引用A           阶段3:释放一个引用            阶段4:全部释放,可回收

Stack                         Stack                        Stack                        Stack
 var1                         var1                      var1 (null)                  var1 (null)
  │                               │                          	
  ▼                               ▼                          	
┌──────────┐                 ┌──────────┐                ┌──────────┐                ┌──────────┐
│ Object A │                 │ Object A │                │ Object A │                │ Object A │
│ ref = 1  │                 │ ref = 2  │                │ ref = 1  │                │ ref = 0  │
└──────────┘                 └──────────┘                └──────────┘                └──────────┘
                                 ▲                           ▲
                                 │                           │
                               var2                        var2 (null)

引用计数算法将垃圾回收分摊到整个应用程序的运行当中,而不是集中在垃圾收集时。因此,采用引用计数的垃圾收集不属于严格意义上的 “Stop-The-World” 的垃圾收集机制(后续介绍)。

引用计数算法看似很美好,但实际上它存在一个很大的问题,那就是无法解决循环依赖的问题。来看下面的代码:

class Node {
    Node ref;
}

public class RefCountCycleDemo {

    public static void main(String[] args) {

        Node a = new Node();
        Node b = new Node();

        // 互相引用
        a.ref = b;
        b.ref = a;

        // 断开外部引用
        a = null;
        b = null;

        // 如果GC使用引用计数:
        // a.refCount = 1 (来自b)
        // b.refCount = 1 (来自a)
        // 两个对象都不会被回收
        System.gc();
    }
}
时间 →
────────────────────────────────────────────────────────────────────────────

阶段1:还有外部引用                     阶段2:外部引用消失,只剩循环引用

        root
         │
         ▼
        A ───▶ B                          A ───▶ B
        ▲      │                          ▲      │
        │      │                          │      │
        └──────┘                          └──────┘

   A.ref = 2  (root + B)             A.ref = 1  (来自 B)
   B.ref = 1  (A)                    B.ref = 1  (来自 A)

2.2 可达性分析算法

可达性分析(Reachability Analysis)是一种带有明显图论思想的垃圾回收算法。在 JVM 中,堆内存中的对象可以看作一张由引用关系连接起来的有向图(Directed Graph):对象是图中的节点,对象之间的引用关系则相当于节点之间的有向边。

垃圾回收器需要解决的核心问题是:哪些对象仍然存活,哪些对象已经不再被使用。为此,JVM 会选择一组特殊的对象作为搜索起点,这些起点被称为 GC Roots(垃圾回收根节点)。随后垃圾回收器会从 GC Roots 出发,沿着对象之间的引用关系向下遍历整个对象图。

如果某个对象能够通过引用链从 GC Roots 到达,则说明该对象仍然是可达对象(Reachable),因此会被认为是存活的;反之,如果一个对象无法从 GC Roots 到达,则说明它已经不可达(Unreachable),在后续的垃圾回收过程中就可能被回收。

常见的 GC Roots 包括:

  1. 栈中的局部变量(方法里的变量)
  2. 静态变量(static)
  3. 常量池引用
  4. JNI 引用(本地方法)

3. Stop The World

“Stop The World” 是 Java 垃圾收集中的一个重要概念。在垃圾收集过程中,JVM 会暂停所有的用户线程,这种暂停被称为 “Stop The World” 事件。

这么做的主要原因是为了防止在垃圾收集过程中,用户线程修改了堆中的对象,导致垃圾收集器无法准确地收集垃圾。

值得注意的是,“Stop The World” 事件会对 Java 应用的性能产生影响。如果停顿时间过长,就会导致应用的响应时间变长,对于对实时性要求较高的应用,如交易系统、游戏服务器等,这种情况是不能接受的。

因此,在选择和调优垃圾收集器时,需要考虑其停顿时间。Java 中的一些垃圾收集器,如 G1 和 ZGC,都会尽可能地减少了 “Stop The World” 的时间,通过并发的垃圾收集,提高应用的响应性能。

总的来说,“Stop The World” 是 Java 垃圾收集中必须面对的一个挑战,其目标是在保证内存的有效利用和应用的响应性能之间找到一个平衡。

4. 垃圾回收算法

4.1 标记-清除算法

最早出现也是最基础的垃圾收集算法是“标记-清除”(Mark-Sweep)算法,在1960年由Lisp之父 John McCarthy 所提出。

如它的名字一样,算法分为 “标记” 和 “清除” 两个阶段:首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回收所有未被标记的对象。标记过程就是对象是否属于垃圾的判定过程(即前面介绍的可达性分析算法)。

它的主要缺点有两个:

  • 第一个是执行效率不稳定,如果Java堆中包含大量对象,而且其中大部分是需要被回收的,这时必须进行大量标记和清除的动作,导致标记和清除两个过程的执行效率都随对象数量增长而降低。
  • 第二个是内存空间的碎片化问题,标记、清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致当以后在程序运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。

!](https://img-home.csdnimg.cn/images/20230724024159.png?origin_url=%2FUsers%2Fzhuxulong%2FLibrary%2FApplication%20Support%2Ftypora-user-images%2Fimage-20260312213638610.png&pos_id=img-Wl2yByN6-1774427034200)

4.2 标记-复制算法

标记-复制算法常被简称为复制算法,用于解决标记-清除算法的内存碎片问题。

它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。这样就保证了内存的连续性,逻辑清晰,运行高效。

但复制算法也存在一个很明显的问题,意味着我的 190 平的大平层四居室,只能当 90 平米的小两居室来居住?代价实在太高。

在这里插入图片描述

4.3 标记-整理算法

标记-整理算法(Mark-Compact),标记过程仍然与标记-清除算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,再清理掉端边界以外的内存区域。

标记整理算法一方面在标记-清除算法上做了升级,解决了内存碎片的问题,也规避了复制算法只能利用一半内存区域的弊端。看起来很美好,但内存变动更频繁,需要整理所有存活对象的引用地址,在效率上比复制算法差很多。

在这里插入图片描述

4.4 分代收集算法

分代算法和上⾯讲的 3 种算法不同,分代算法是通过区域划分,实现不同区域和不同的垃圾回收策略,从⽽实现更好的垃圾回收。这就好⽐中国的⼀国两制⽅针⼀样,对于不同的情况和地域设置更符合当地的规则,从⽽实现更好的管理,这就是分代算法的设计思想。

当前 JVM 垃圾收集都采⽤的是 “分代收集(Generational Collection)” 算法,这个算法并没有新思想,只是根据对象存活周期的不同将内存划分为⼏块。⼀般是把 Java 堆分为新⽣代和⽼年代。在新⽣代中,每次垃圾回收都有⼤批对象死去,只有少量存活,因此我们采⽤复制算法;⽽⽼年代中对象存活率⾼、没有额外空间对它进⾏分配担保,就必须采⽤"标记-清理"或者"标记-整理"算法。

在 JVM 的堆内存中,通常会将空间划分为 新生代(Young Generation)老年代(Old Generation)。由于新生代中的对象具有明显的 “朝生夕死” 特性——大约 98% 的对象会在短时间内被回收,因此在设计内存结构时,并不会按照传统复制算法 1:1 的方式划分空间。

在新生代内部,JVM 将内存进一步划分为 一块较大的 Eden(伊甸园)区域两块较小的 Survivor(幸存者)区域。程序在运行时,绝大多数对象会首先在 Eden 中创建;同时只会使用其中一个 Survivor 区域。两个 Survivor 区域分别被称为 From 区To 区

当发生垃圾回收时,JVM 会将 Eden 区和 From 区中仍然存活的对象 一次性复制到 To 区,随后清空 Eden 和 From 区。完成回收后,两个 Survivor 区会交换角色,原来的 To 区变为新的 From 区,继续参与下一轮对象存活的筛选。

如果在复制过程中 To 区空间不足以容纳所有存活对象,JVM 会通过 分配担保机制(Allocation Guarantee),将部分对象直接晋升到老年代,以确保本次垃圾回收能够顺利完成。

|-------- Young (1/3) ---------|---------------- Old (2/3) ----------------|
|----- Eden -----|--S0--|--S1--| 8 : 1 : 1

HotSpot实现的复制算法流程如下:

  1. 当Eden区满的时候,会触发第⼀次Minor gc,把还活着的对象拷⻉到Survivor From区;当Eden区再
    次触发Minor gc的时候,会扫描Eden区和From区域,对两个区域进⾏垃圾回收,经过这次回收后还存
    活的对象,则直接复制到To区域,并将Eden和From区域清空。
  2. 当后续Eden⼜发⽣Minor gc的时候,会对Eden和To区域进⾏垃圾回收,存活的对象复制到From区域,
    并将Eden和To区域清空。
  3. 部分对象会在From和To区域中复制来复制去,如此交换15次(由JVM参数MaxTenuringThreshold决
    定,这个参数默认是15),最终如果还是存活,就存⼊到⽼年代。

哪些对象会进⼊新⽣代?哪些对象会进⼊⽼年代?

• 新⽣代:⼀般创建的对象都会进⼊新⽣代。
• ⽼年代:⼤对象和经历了 N 次(⼀般情况默认是 15 次)垃圾回收依然存活下来的对象会从新⽣代
移动到⽼年代。

在这里插入图片描述


尾声:在 JVM 的世界里,活下来就是胜利,作为一名开发者,我们其实也是在进行一场长期的 GC

  • 过滤掉那些消耗精力的“垃圾碎片”;
  • 在每一次项目迭代(Minor GC)中沉淀技术栈;
  • 最终把自己磨炼成那个能抗住压力、常驻内存的“老年代核心”。

想要通关更多 JVM 隐藏关卡?别错过同系列硬核解析:

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐