《深入理解Java虚拟机》| 垃圾回收算法与垃圾回收器

**摘要:**本文系统解析《深入理解 Java 虚拟机》中的垃圾回收核心内容,剖析对象存活判定方法、三大经典垃圾回收算法,以及主流垃圾收集器的原理、优缺点与适用场景。

书摘内容

垃圾收集算法与垃圾收集器

名言:Java与C++之间有一堵由内存动态分配和垃圾收集技术所围成的高墙,墙外面的人想进去,墙里面的人却想出来。

维度 Java(墙内) C++(墙外)
内存管理责任 JVM 自动管理(GC) 开发者手动管理
核心优势 省心、低出错率、开发效率高 极致性能、精准控制、无 GC 开销
核心痛点 GC 停顿、性能损耗、无法精准控制 内存泄漏 / 野指针、开发成本高
典型应用场景 企业级应用、互联网后端、快速开发 高性能服务器、游戏引擎、嵌入式

对象是否可回收

引用计数算法
  1. 很多教科书判断对象是否存活的算法是引用计数算法,其核心逻辑是:在对象中添加引用计数器,有新引用时计数器加一,引用失效时计数器减一,计数器为零的对象即判定为不再被使用。

  2. 引用计数算法的特点是原理简单、判定效率高,只是会占用额外内存空间来计数,在多数情况下是一种不错的算法。

  3. 主流 Java 虚拟机并未采用引用计数算法管理内存,主要原因是该算法存在诸多例外情况,需要大量额外处理才能保证正确运行。

  4. 引用计数算法的典型问题是无法解决对象间的循环引用,例如两个对象互相引用且无其他外部引用时,它们的引用计数器都不为零,算法就无法识别并回收这些已无法访问的对象。

可达性分析算法
  1. 可达性分析算法是当前Java内存管理子系统判定对象是否存活的核心算法。

  2. 该算法的基本思路是:以一系列名为 "GC Roots" 的根对象作为起始节点集,从这些节点出发,根据引用关系向下搜索,搜索路径被称为 "引用链"。

  3. 如果一个对象与 GC Roots 之间不存在任何引用链相连,或者说从 GC Roots 到该对象是不可达的,那么这个对象会被判定为不可能再被使用。

  4. 以图 3-1 为例,对象 object 5、object 6、object 7 虽然彼此之间存在关联,但它们与 GC Roots 之间没有引用链相连,因此会被判定为可回收对象。

  5. 与引用计数算法相比,可达性分析算法的优势在于可以解决对象间循环引用的问题。

在 Java 技术体系中,固定可作为 GC Roots 的对象包含以下几类:

  1. 虚拟机栈(栈帧中的本地变量表)中引用的对象,例如各线程被调用方法的参数、局部变量、临时变量等。

  2. 方法区中类静态属性引用的对象,例如 Java 类的引用类型静态变量。

  3. 方法区中常量引用的对象,例如字符串常量池(String Table)里的引用。

  4. 本地方法栈中 JNI(Native 方法)引用的对象。

  5. Java 虚拟机内部的引用,如基本数据类型对应的 Class 对象、常驻异常对象(如 NullPointExcepiton、OutOfMemoryError)、系统类加载器等。

  6. 所有被同步锁(synchronized 关键字)持有的对象。

  7. 反映 Java 虚拟机内部情况的 JMXBean、JVMTI 中注册的回调、本地代码缓存等。

GC Roots 集合并非完全固定,除了上述固定对象外,还会根据垃圾收集器类型和当前回收的内存区域,加入一些 "临时性" 对象。

以分代收集和局部回收(如仅针对新生代的垃圾收集)为例,由于堆中某一区域的对象可能被其他区域的对象引用,此时需要将这些关联区域的对象也加入 GC Roots 集合,才能保证可达性分析的正确性。

四大引用
  1. 判定对象是否存活的核心是 "引用",在 JDK 1.2 之前,Java 的引用定义较为狭隘,对象只有 "被引用" 或 "未被引用" 两种状态,无法描述那些 "食之无味、弃之可惜" 的对象(如内存充足时保留、内存紧张时可回收的缓存对象)。

  2. JDK 1.2 之后,Java 对引用概念进行了扩充,分为强引用、软引用、弱引用和虚引用 4 种,引用强度依次减弱。

  3. 强引用是最传统的引用,指程序代码中普遍存在的引用赋值(如Object obj=new Object()),只要强引用存在,垃圾收集器就不会回收被引用的对象。

  4. 软引用用于描述有用但非必须的对象,在系统即将发生内存溢出前,这类对象会被列入回收范围进行二次回收,若仍不足才会抛出内存溢出异常,通过SoftReference类实现。

  5. 弱引用同样用于描述非必须对象,但强度比软引用更弱,被弱引用关联的对象只能生存到下一次垃圾收集发生时,无论内存是否充足都会被回收,通过WeakReference类实现。

  6. 虚引用是最弱的引用,也叫 "幽灵引用" 或 "幻影引用",它不影响对象的生存时间,也无法通过虚引用获取对象实例,设置虚引用的唯一目的是在对象被回收时收到系统通知,通过PhantomReference类实现。

  1. 在可达性分析算法中被判定为不可达的对象,并非立即死亡,而是会进入 "缓刑" 阶段,要真正宣告对象死亡,需要经历两次标记过程。

  2. 第一次标记:对象在可达性分析后,若发现没有与 GC Roots 相连的引用链,就会被第一次标记,随后进行筛选,判断该对象是否有必要执行finalize()方法。

  3. 筛选的判定标准:如果对象没有覆盖finalize()方法,或者该方法已经被虚拟机调用过,就会被判定为 "没有必要执行",这类对象会直接被标记为即将回收。

  4. 若判定为有必要执行finalize()方法,该对象会被放入 F-Queue 队列,由虚拟机自动建立的低优先级 Finalizer 线程去触发执行它们的finalize()方法。虚拟机不承诺等待该方法执行结束,避免因方法执行缓慢或死循环导致内存回收子系统崩溃。

  5. finalize()方法是对象逃脱死亡的最后一次机会,在该方法中,若对象重新与引用链上的任何对象建立关联(例如将this赋值给某个类变量或成员变量),在后续的第二次标记中,它会被移出 "即将回收" 的集合。

  6. 第二次标记:收集器会对 F-Queue 中的对象进行第二次小规模标记,若对象在finalize()方法中成功自救,就会被移出回收集合;若未能逃脱,则会被正式标记为待回收对象。

垃圾收集算法

GC 范围
  1. 垃圾收集可根据收集范围分为部分收集(Partial GC)和整堆收集(Full GC)两大类。

  2. 部分收集(Partial GC):指目标不是完整收集整个 Java 堆的垃圾收集,包含以下三类:

    1. 新生代收集(Minor GC/Young GC):仅针对新生代区域的垃圾收集,是最频繁发生的 GC 类型。

    2. 老年代收集(Major GC/Old GC):仅针对老年代区域的垃圾收集,目前只有 CMS 会有单独收集老年代。

    3. 混合收集(Mixed GC):目标是收集整个新生代以及部分老年代的垃圾收集,目前只有 G1 收集器支持。

  3. 整堆收集(Full GC):指收集整个 Java 堆和方法区的垃圾收集,这类收集通常耗时较长,会导致明显的应用停顿,应尽量避免其频繁发生。

标记 - 清除算法

标记 - 清除算法是最早出现、最基础的垃圾收集算法,由 John McCarthy 于 1960 年提出,后续多数垃圾收集算法都是在它的基础上改进而来。

该算法分为两个核心阶段:

  • 标记阶段:判定并标记出所有需要回收的对象,或反向标记所有存活的对象。

  • 清除阶段:统一回收所有被标记的垃圾对象,或统一回收所有未被标记的垃圾对象。

它的主要缺点有两个:

  • 执行效率不稳定:如果 Java 堆中包含大量对象且大部分是垃圾,标记和清除的动作会随着对象数量的增长而变慢,执行效率随之降低。

  • 内存 空间碎片化:标记和清除后会产生大量不连续的内存碎片,当程序需要分配大对象时,可能因找不到足够的连续内存而提前触发另一次垃圾收集。

从图示可以直观看到,回收后存活对象分散在内存中,产生了大量不连续的空闲空间,这就是内存碎片化的表现。

标记 - 复制算法
  1. 标记 - 复制算法(简称复制算法)是为解决标记 - 清除算法在大量可回收对象场景下执行效率低的问题,由 Fenichel 在 1969 年提出。

  2. 它的核心实现是 "半区复制"(Semispace Copying):

    1. 将可用内存划分为大小相等的两块,每次只使用其中一块。

    2. 当这块内存耗尽时,将其中存活的对象复制到另一块内存中。

    3. 清理掉原内存块的全部内容,完成一次回收。

  3. 该算法的优点:

    1. 当多数对象是可回收状态时,只需要复制少量存活对象,执行效率高。

    2. 回收后内存是连续的,分配新对象时只需移动堆顶指针,无需处理内存碎片。

    3. 实现简单,运行高效。

  4. 该算法的缺点:

    1. 代价是可用内存被压缩为原来的一半,空间浪费较大。

    2. 如果内存中多数对象都是存活状态,会产生大量复制开销。

  5. 实际应用:

    1. 现代商用 Java 虚拟机优先用它回收新生代,因为新生代对象 "朝生夕死",约 98% 的对象在第一轮就会被回收。

    2. 因此新生代的内存划分不需要严格按照 1:1 比例,而是分为一块较大的 Eden 区和两块较小的 Survivor 区,以减少空间浪费。

标记 - 整理算法

标记 - 复制算法在对象存活率较高时,会因大量复制操作导致效率降低,且需要额外空间做分配担保,因此老年代一般不直接使用该算法。

针对老年代对象的存活特征,1974 年 Edward Lueders 提出了标记 - 整理(Mark-Compact)算法。

该算法的标记阶段与标记 - 清除算法完全相同,但后续步骤是让所有存活对象向内存一端移动,再清理掉边界以外的内存,而非直接清理可回收对象。

标记 - 清除算法是非移动式 回收,会产生内存碎片;标记 - 整理算法是移动式回收,回收后内存连续,无碎片。

  • 优点:消除内存碎片,避免因分配大对象而提前触发垃圾收集。

  • 缺点:移动对象会带来额外性能开销,且需要暂停应用线程以保证引用正确性。

经典垃圾收集器

Serial收集器
  1. Serial 收集器是最基础、历史最悠久的垃圾收集器,在 JDK 1.3.1 之前是 HotSpot 虚拟机新生代收集器的唯一选择。

  2. 它是一个单线程工作的收集器,不仅只会用一条收集线程完成垃圾收集,更关键的是在收集过程中必须暂停其他所有工作线程,这就是 "Stop The World"(STW)现象。

  3. 在垃圾收集期间,用户线程会被不可控地暂停,这对需要低延迟的应用来说是难以接受的。

  4. 它在新生代采用标记 - 复制算法 ,老年代采用标记 - 整理算法

  5. 从 JDK 1.3 至今,HotSpot 团队一直在努力降低或消除垃圾收集带来的停顿,后续发展出了 Parallel、CMS、G1、Shenandoah 和 ZGC 等更优秀的收集器,虽然停顿时间在持续缩短,但目前仍无法彻底消除。

ParNew收集器
  1. ParNew 收集器本质上是 Serial 收集器的多线程并行版本,除了使用多条线程进行垃圾收集外,其控制参数、收集算法、Stop The World 机制、对象分配规则等行为,都与 Serial 收集器完全一致,两者在实现上也共用了大量代码。

  2. 它在新生代采用标记 - 复制算法 ,老年代搭配 Serial Old 收集器采用标记 - 整理算法,收集时同样会触发 "Stop The World",暂停所有用户线程。

  3. 它是 JDK 7 之前服务端模式下 HotSpot 虚拟机的首选新生代收集器,核心原因是:除 Serial 外,它是当时唯一能与 CMS 收集器配合工作的新生代收集器。

  4. CMS 是 HotSpot 第一款真正意义上支持并发的垃圾收集器,但它无法与 Parallel Scavenge 配合工作,因此在 JDK 5 中使用 CMS 时,新生代只能选择 ParNew 或 Serial。

  5. 当通过-XX:+UseConcMarkSweepGC启用 CMS 时,ParNew 会被默认选为新生代收集器,也可以通过-XX:+/-UseParNewGC选项强制开启或禁用它。

Parallel Scavenge收集器
  1. Parallel Scavenge 收集器是一款新生代收集器,基于标记 - 复制算法实现,支持多线程并行收集,表面特性与 ParNew 相似,但核心关注点不同。

  2. 它的目标是可控的 吞吐量,而非像 CMS 等收集器那样优先缩短用户线程的停顿时间。吞吐量的计算公式为:吞吐量=运行用户代码时间 + 运行垃圾收集时间 / 运行用户代码时间

  3. 吞吐量与停顿时间是一对权衡关系:

    1. 停顿时间短适合需要高响应的交互式应用。

    2. 高吞吐量适合后台运算任务,可更高效地利用 CPU 资源。

  4. 它提供了两个核心参数来控制性能:

    1. -XX:MaxGCPauseMillis:设置最大垃圾收集停顿时间。设置过小会导致收集频率增加,吞吐量下降。

    2. -XX:GCTimeRatio:设置垃圾收集时间占总时间的最大比例,默认 99,即允许垃圾收集时间最多占 1%。

  5. 由于其吞吐量优先的特性,Parallel Scavenge 也被称为 "吞吐量优先收集器"。

Serial Old收集器
  1. Serial Old 收集器 是 Serial 收集器老年代版本,一个单线程收集器,采用标记 - 整理算法

  2. 它的主要设计目的是供客户端模式下的 HotSpot 虚拟机使用。

  3. 在服务端模式下,它有两种主要用途:

    1. 在 JDK 5 及之前的版本中,与 Parallel Scavenge 收集器搭配使用(实际上是与 PS MarkSweep 收集器配合,但该收集器与 Serial Old 实现几乎一致,官方资料通常以 Serial Old 代称)。

    2. 作为 CMS 收集器的后备预案,当 CMS 发生 "Concurrent Mode Failure" 并发失败时,会临时切换到 Serial Old 来完成垃圾收集。

  4. 它在执行时同样会触发 "Stop The World",暂停所有用户线程,直到收集完成。

Parallel Old收集器
  1. Parallel Old 收集器 是 Parallel Scavenge 收集器的老年代版本,支持多线程并发收集,基于标记 - 整理算法实现,在 JDK 6 中才正式推出。

  2. 在它出现之前,新生代的 Parallel Scavenge 收集器只能搭配单线程的 Serial Old(或 PS MarkSweep)收集器使用,导致整体性能受限:

    1. 单线程的老年代收集无法充分利用服务器多核 CPU 的并行能力。

    2. 在老年代内存大、硬件配置高的场景下,这种组合的吞吐量甚至不如 ParNew 加 CMS 的组合。

  3. Parallel Old 的推出解决了这一问题,使得 "吞吐量优先" 收集器有了真正匹配的组合,即Parallel Scavenge + Parallel Old

  4. 这个组合适合注重吞吐量,或者处理器资源较为稀缺的场景,能够最大化利用 CPU 资源以提升整体效率。

CMS收集器
  1. CMS(Concurrent Mark Sweep)收集器是一款以获取最短回收停顿时间为目标的收集器,特别适合互联网网站或 B/S 系统这类对响应速度要求高的场景。

  2. 它基于标记 - 清除算法实现,运作过程分为四个步骤:

    1. 初始标记:仅标记 GC Roots 能直接关联到的对象,速度很快,但会触发 "Stop The World"。

    2. 并发标记:从 GC Roots 的直接关联对象开始遍历整个对象图,耗时较长,但可以与用户线程并发执行,无需停顿。

    3. 重新标记:修正并发标记期间因用户程序运行而导致标记产生变动的对象,会触发 "Stop The World",停顿时间比初始标记稍长,但远短于并发标记阶段。

    4. 并发清除:清理并删除标记为死亡的对象,无需移动存活对象,可与用户线程并发执行。

  3. 由于耗时最长的并发标记和并发清除阶段都可以与用户线程并行执行,CMS 的整体回收过程与用户线程是并发的,因此能显著降低应用的停顿时间。

G1 垃圾收集器
  1. Garbage First(G1)收集器是垃圾收集器技术发展史上的里程碑,它开创了面向局部收集的设计思路和基于 Region 的内存布局形式。

  2. 它从 JDK 6 Update 14 开始提供早期预览版本,直到 JDK 7 Update 4 才达到商用成熟度,在 JDK 8 Update 40 后成为 "全功能的垃圾收集器",并在 JDK 9 中成为服务端模式下的默认垃圾收集器。

  3. G1 的设计目标是替代 CMS 收集器,目前该目标已基本实现,CMS 在 JDK 9 中被标记为 "不推荐使用(Deprecate)",后续会被彻底移除。

  4. 为解决 CMS 等老收集器与 HotSpot 子系统耦合过深的问题,HotSpot 团队在 JDK 10 中规划了 "统一垃圾收集器接口",将垃圾回收的 "行为" 与 "实现" 分离,便于后续收集器的维护和替换。

  5. G1 的核心特性是建立了停顿时间模型(Pause Prediction Model),可以支持设定在一个 M 毫秒的时间片段内,垃圾收集时间大概率不超过 N 毫秒的目标,具备了接近实时垃圾收集器的特征。

课程内容 - 垃圾回收

C/C++无自动垃圾回收机制的语言中,一个对象如果不使用,需手动释放,否则会出现内存泄漏。

内存泄漏指的是不再使用的对象在系统中未被回收,内存泄漏的积累可能会导致内存溢出。

我们称这种释放对象的过程为垃圾回收 ,而需要程序员编写代码进行回收 的方式为手动回收 。手动回收的方式相对来说回收比较及时,删除代码执行之后对象就被回收了,可以快速释放内存。缺点是对程序员要求比较高,很容易出现创建完对象之后,程序员忘记释放对象。

Java中为了简化对象的释放,引入了自动的垃圾回收 (Garbage Collection简称GC)机制。通过垃圾回收器来对不再使用的对象完成自动的回收,**垃圾回收器主要负责对堆上的内存进行回收。**其他很多现代语言比如C#、Python、Go都拥有自己的垃圾回收器。

垃圾回收器如果发现某个对象不再使用,就可以回收该对象。

  • 自动垃圾回收,自动根据对象是否使用由虚拟机来回收对象

    • 优点:降低程序员实现难度、降低对象回收bug的可能性

    • 缺点:程序员无法控制内存回收的及时性

  • 手动垃圾回收,由程序员编程实现对象的删除

    • 优点:回收及时性高,由程序员把控回收的时机

    • 缺点:编写不当容易出现悬空指针、重复释放、内存泄漏等问题

那么垃圾回收器需要负责对哪些部分的内存进行回收呢?

首先是线程不共享的部分,都是伴随着线程的创建而创建,线程的销毁而销毁。而方法的栈帧在执行完方法之后就会自动弹出栈并释放掉对应的内存。所以这一部分不需要垃圾回收器负责回收。

1.1 方法区的回收

方法区中能回收的内容主要就是不再使用的类。

判定一个类可以被卸载,需要同时满足下面三个条件:

1. 此类所有实例对象都已经被回收,在堆中不存在任何该类的实例对象以及子类对象。

这段代码中就将局部变量对堆上实例对象的引用去除了,所以对象就可以被回收。

2. 加载该类的类加载器已经被回收。

这段代码让局部变量对类加载器的引用去除,类加载器就可以回收。

3. 该类对应的**java.lang.Class 对象没有** 在任何地方被引用

1.2 如何判断对象可以回收

垃圾回收器要回收对象的第一步就是判断哪些对象可以回收。Java 中的对象是否能被回收,是根据对象是否被引用来决定的。如果对象被引用了,说明该对象还在使用,不允许被回收。

比如下面代码的内存结构图:

第一行代码执行后,堆上创建了 Demo 类的实例对象,同时栈上保存局部变量引用堆上的对象。

第二行代码执行之后,局部变量对堆上的对象引用去掉,那么堆上的对象就可以被回收了。

一个更复杂的案例:

这个案例中,如果要让对象 a 和 b 回收,必须将局部变量到堆上的引用去除。

那么问题来了,A和B互相之间的引用需要去除吗?答案是不需要,因为局部变量都没引用这两个对象了,在代码中已经无法访问这两个对象,即便他们之间互相有引用关系,也不影响对象回收。

判断对象是否可以回收,主要有两种方式:引用计数法和可达性分析法。

1.2.1 引用计数法

引用计数法会为每个对象维护一个引用计数器,当对象被引用时加 1,取消引用时减 1。

比如下图中,对象 A 的计数器初始为 0,局部变量 a1 对它引用之后,计数器加 1 就变成了 1。同样 A 对 B 产生了引用,B 的计数器也是 1。

引用计数法的优点是实现简单,C++ 中的智能指针就采用了引用计数法,但是它也存在缺点,主要有两点:

  1. 每次引用和取消引用都需要维护计数器,对系统性能会有一定的影响

2.存在循环引用问题,所谓循环引用就是当 A 引用 B,B 同时引用 A 时会出现对象无法回收问题。

复杂的案例中:

这张图上,由于 A 和 B 之间存在互相引用,所以计数器都为 1,两个对象都不能被回收。但是由于没有局部变量对这两个代码产生引用,代码中已经无法访问到这两个对象,理应可以被回收。

1.2.2 可达性分析法

Java 使用的是可达性分析算法来判断对象是否可以被回收。可达性分析将对象分为两类:垃圾回收的根对象(GC Root)和普通对象,对象与对象之间存在引用关系。

下图中 A 到 B 再到 C 和 D,形成了一个引用链,可达性分析算法指的是如果从某个到 GC Root 对象是可达的,对象就不可被回收。

哪些对象被称之为GC Root对象呢?

1. 线程Thread对象,引用线程栈帧中的方法参数、局部变量等。

2. 系统类加载器加载的java.lang.Class对象 ,引用类中的静态变量

3. 监视器对象 ,用来保存同步锁synchronized关键字持有的对象。

**4.**本地方法调用时使用的全局对象。

1.3 常见的引用对象

1.3.1 软引用

软引用相对于强引用是一种比较弱的引用关系,如果一个对象只有软引用关联到它,当程序内存不足时,就会将软引用中的数据进行回收。在 JDK 1.2 版之后提供了SoftReference类来实现软引用,软引用常用于缓存中。

如下图中,对象A被GC Root对象强引用了,同时我们创建了一个软引用SoftReference对象(它本身也是一个对象),软引用对象中引用了对象A。

接下来强引用被去掉后,对象 A 暂时还处于不可回收 状态,因为有软引用存在并且内存还够用

如果内存出现不够用的情况,对象 A 就处于可回收状态,可以被垃圾回收器回收。

这样做有什么好处?如果对象 A 是一个缓存,平时会保存在内存中,如果想访问数据可以快速访问。但是如果内存不够用了,我们就可以将这部分缓存清理掉释放内存。即便缓存没了,也可以从数据库等地方获取数据,不会影响到业务正常运行,这样可以减少内存溢出产生的可能性。

特别注意:软引用对象本身,也需要被强引用,否则软引用对象也会被回收掉。

软引用的使用方法

软引用的执行过程如下:

  1. 将对象使用软引用包装起来,new SoftReference<对象类型>(对象)

  2. 内存不足时,虚拟机尝试进行垃圾回收。

  3. 如果垃圾回收仍不能解决内存不足的问题,回收软引用中的对象。

  4. 如果依然内存不足,抛出OutOfMemory异常。

代码

java 复制代码
/**
 * 软引用案例2 - 基本使用
 */
public class SoftReferenceDemo2 {
    public static void main(String[] args) throws IOException {

        byte[] bytes = new byte[1024 * 1024 * 100];
        SoftReference<byte[]> softReference = new SoftReference<byte[]>(bytes);
        bytes = null;
        System.out.println(softReference.get());

        byte[] bytes2 = new byte[1024 * 1024 * 100];
        System.out.println(softReference.get());
//
//        byte[] bytes3 = new byte[1024 * 1024 * 100];
//        softReference = null;
//        System.gc();
//
//        System.in.read();
    }
}
软引用对象的回收

如果软引用对象里边包含的数据已经被回收了,那么软引用对象本身其实也可以被回收了。

SoftReference提供了一套队列机制:

  1. 软引用创建时,通过构造器传入引用队列
  1. 软引用中包含的对象被回收时 ,该软引用对象会被放入引用队列
  1. 通过代码遍历 引用队列,将**SoftReference的强引用删除**

代码:

java 复制代码
/**
 * 软引用案例3 - 引用队列使用
 */
public class SoftReferenceDemo3 {

    public static void main(String[] args) throws IOException {

        ArrayList<SoftReference> softReferences = new ArrayList<>();
        ReferenceQueue<byte[]> queues = new ReferenceQueue<byte[]>();
        for (int i = 0; i < 10; i++) {
            byte[] bytes = new byte[1024 * 1024 * 100];
            SoftReference studentRef = new SoftReference<byte[]>(bytes,queues);
            softReferences.add(studentRef);
        }

        SoftReference<byte[]> ref = null;
        int count = 0;
        while ((ref = (SoftReference<byte[]>) queues.poll()) != null) {
            count++;
        }
        System.out.println(count);

    }
}
软引用的缓存案例

使用软引用实现学生信息的缓存,能支持内存不足时清理缓存。

代码

1. 整体设计目标

使用 软引用(SoftReference) 实现一个学生信息缓存系统,核心目标是:

  • 内存充足时,缓存学生对象以提高访问效率
  • 内存不足时,自动释放缓存对象以避免 OOM(内存溢出)
  • 缓存失效后能自动清理无效引用,避免内存泄漏

一、StudentCache 设计思路与代码解析

1. 整体设计目标

使用 软引用(SoftReference) 实现一个学生信息缓存系统,核心目标是:

  • 内存充足时,缓存学生对象以提高访问效率
  • 内存不足时,自动释放缓存对象以避免 OOM(内存溢出)
  • 缓存失效后能自动清理无效引用,避免内存泄漏

2. 核心组件与设计思路

1)单例模式(StudentCache 实例管理)

java 复制代码
private static StudentCache cache = new StudentCache();
private StudentCache() {} // 私有构造器
public static StudentCache getInstance() { return cache; }

作用:保证缓存全局唯一,避免重复创建缓存实例浪费资源

为什么:缓存本质是共享资源,单例模式能确保所有操作都针对同一缓存容器


2)软引用包装类(StudentRef 内部类)

java 复制代码
private class StudentRef extends SoftReference<Student> {
    private Integer _key; // 缓存的 key(与 HashMap 的 key 一致)
    public StudentRef(Student em, ReferenceQueue<Student> q) {
        super(em, q); // 绑定引用队列
        _key = em.getId();
    }
}

作用 :用软引用包装 Student 对象,同时保存对应的缓存 key

为什么

特性:内存不足时,Student 对象会被 GC 回收,但 StudentRef 本身(引用对象)不会立即回收

保存 _key 是为了后续通过引用队列清理 HashMap 中的无效 key


3)缓存容器与引用队列

java 复制代码
private Map<Integer, StudentRef> StudentRefs; // 缓存容器(key:学生ID,value:软引用)
private ReferenceQueue<Student> q; // 引用队列(存储被回收的软引用)

作用

StudentRefs:存储缓存的核心容器,通过 ID 快速查找学生对象的软引用

q:当软引用中的 Student 对象被 GC 回收时,软引用本身会被放入此队列

为什么

仅用HashMap无法感知缓存对象是否被回收,引用队列是 "通知机制",让系统知道哪些缓存失效


4)缓存清理机制(cleanCache 方法)

java 复制代码
private void cleanCache() {
    StudentRef ref = null;
    while ((ref = (StudentRef) q.poll()) != null) {
        StudentRefs.remove(ref._key); // 移除 HashMap 中的无效 key
    }
}

作用:从引用队列中取出已失效的软引用,删除 HashMap 中对应的 entry

为什么

软引用中的 Student 被回收后,HashMap 中仍会保留 key->StudentRef 的映射,导致内存泄漏

每次操作缓存前调用 cleanCache,确保无效缓存及时清理


5)缓存操作方法(cacheStudentgetStudent

复制代码
// 存入缓存
private void cacheStudent(Student em) {
    cleanCache(); // 先清理无效缓存
    StudentRef ref = new StudentRef(em, q);
    StudentRefs.put(em.getId(), ref);
}

// 获取缓存
public Student getStudent(Integer id) {
    Student em = null;
    if (StudentRefs.containsKey(id)) {
        em = StudentRefs.get(id).get(); // 从软引用中获取对象
    }
    if (em == null) { // 缓存失效,重新获取并缓存
        em = new Student(id, String.valueOf(id));
        cacheStudent(em);
    }
    return em;
}

作用:实现缓存的存入与获取,缓存失效时自动重建

为什么

存入前清理无效缓存,避免内存占用膨胀

获取时先查缓存,缓存失效则重新创建对象并缓存,保证数据可用性

拓展:与 ThreadLocal 底层原理的相似性

ThreadLocal 用于实现线程本地变量(每个线程有独立副本),其底层设计与 StudentCache 有以下核心相似点:

1. 引用管理:避免内存泄漏的机制

StudentCache

  • 软引用 包装缓存对象,内存不足时自动释放
  • 通过 引用队列 跟踪失效引用,清理 HashMap 中的无效 entry

ThreadLocal

  • 弱引用(WeakReference) 包装 ThreadLocal 实例(作为 ThreadLocalMap 的 key)
  • ThreadLocal 外部强引用消失后,key 会被 GC 回收,避免 ThreadLocalMap 内存泄漏
  • 线程运行中会清理 ThreadLocalMap 中 key 为 null 的 entry(类似 cleanCache

2. 容器设计:键值对映射与生命周期管理

StudentCache

  • 核心容器是 HashMap<Integer, StudentRef>,key 是学生 ID,value 是软引用
  • 缓存对象的生命周期由软引用和内存状况决定

ThreadLocal

  • 核心容器是 ThreadLocalMap(每个线程独立持有),key 是 ThreadLocal 弱引用,value 是线程本地变量
  • 变量生命周期与线程绑定,线程销毁后自动释放

1.3.2 弱引用

弱引用的整体机制和软引用基本一致,区别在于弱引用包含的对象在垃圾回收时,不管内存够不够都会直接被回收。 JDK 1.2 版之后提供了WeakReference类来实现弱引用,弱引用主要在ThreadLocal中使用。

弱引用对象本身也可以使用引用队列进行回收

代码

java 复制代码
package chapter04.weak;

import java.io.IOException;
import java.lang.ref.WeakReference;

/**
 * 弱引用案例 - 基本使用
 */
public class WeakReferenceDemo2 {
    public static void main(String[] args) throws IOException {

        byte[] bytes = new byte[1024 * 1024 * 100];
        WeakReference<byte[]> weakReference = new WeakReference<byte[]>(bytes);
        bytes = null;
        System.out.println(weakReference.get());

        System.gc();

        System.out.println(weakReference.get());
    }
}

执行之后发现gc执行之后,对象已经被回收了。

1.3.3 虚引用和终结器引用

这两种引用在常规开发中是不会使用的。

虚引用 也叫幽灵引用 / 幻影引用,不能通过虚引用对象获取到包含的对象。虚引用唯一的用途是当对象被垃圾回收器回收时可以接收到对应的通知 。Java 中使用 PhantomReference 实现了虚引用,直接内存中为了及时知道直接内存对象不再使用,从而回收内存,使用了虚引用来实现。

终结器引用 指的是在对象需要被回收时,终结器引用会关联对象并放置在Finalizer类中的引用队列中,在稍后由一条由FinalizerThread线程从队列中获取对象,然后执行对象的finalize方法,在对象第二次被回收时,该对象才真正的被回收。在这个过程中可以在finalize方法中再将自身对象使用强引用关联上,但是不建议这样做。

代码

java 复制代码
package chapter04.finalreference;

/**
 * 终结器引用案例
 */
public class FinalizeReferenceDemo {
    public static FinalizeReferenceDemo reference = null;

    public void alive() {
        System.out.println("当前对象还存活");
    }

    @Override
    protected void finalize() throws Throwable {
        try{
            System.out.println("finalize()执行了...");
            //设置强引用自救
            reference = this;
        }finally {
            super.finalize();
        }
    }

    public static void main(String[] args) throws Throwable {
        reference = new FinalizeReferenceDemo();
       test();
       test();
    }

    private static void test() throws InterruptedException {
        reference = null;
        //回收对象
        System.gc();
        //执行finalize方法的优先级比较低,休眠500ms等待一下
        Thread.sleep(500);
        if (reference != null) {
            reference.alive();
        } else {
            System.out.println("对象已被回收");
        }
    }
}

1.4 垃圾回收算法

Java 是如何实现垃圾回收的呢?简单来说,垃圾回收要做的有两件事:

1. 找到内存中存活的对象

2. 释放不再存活对象的内存,使得程序能再次利用这部分空间

1.4.1 垃圾回收算法的分类

1960 年 John McCarthy 发布了第一个 GC 算法:标记 - 清除算法

1963 年 Marvin L. Minsky 发布了复制算法

本质上后续所有的垃圾回收算法,都是在上述两种算法的基础上优化而来。

1.4.2 垃圾回收算法评价标准

Java 垃圾回收过程会通过单独的 GC 线程来完成,但是不管使用哪一种 GC 算法,都会有部分阶段需要停止所有的用户线程。这个过程被称之为STW,如果 STW 时间过长则会影响用户的使用。

如下图,用户代码执行和垃圾回收执行让用户线程停止执行(STW)是交替执行的。

所以判断 GC 算法是否优秀,可以从三个方面来考虑:

1.吞吐量

吞吐量指的是 CPU 用于执行用户代码的时间与 CPU 总执行时间的比值,即吞吐量 = 执行用户代码时间 /(执行用户代码时间 + GC 时间)。吞吐量数值越高,垃圾回收的效率就越高。

2.最大暂停时间

最大暂停时间指的是所有在垃圾回收过程中的 STW 时间最大值。比如如下的图中,黄色部分的 STW 就是最大暂停时间,显而易见上面的图比下面的图拥有更少的最大暂停时间。最大暂停时间越短,用户使用系统时受到的影响就越短。

3.堆使用效率

不同垃圾回收算法,对堆内存的使用方式是不同的。比如标记清除算法,可以使用完整的堆内存。而复制算法会将堆内存一分为二,每次只能使用一半内存。从堆使用效率上来说,标记清除算法要优于复制算法。

上述三种评价标准:堆使用效率、吞吐量,以及最大暂停时间不可兼得。

一般来说,堆内存越大,最大暂停时间就越长。想要减少最大暂停时间,就会降低吞吐量。

没有一个垃圾回收算法能兼顾上述三点评价标准,所以不同的垃圾回收算法它的侧重点是不同的,适用于不同的应用场景。

2.4.3 标记清除算法

标记清除算法的核心思想分为两个阶段:

1.标记阶段:将所有存活的对象进行标记。Java 中使用可达性分析算法,从 GC Root 开始通过引用链遍历出所有存活对象。

2.清除阶段:从内存中删除没有被标记也就是非存活对象。

第一个阶段,从 GC Root 对象开始扫描 ,将对象 A、B、C 在引用链上的对象标记出来

第二个阶段,将没有标记的对象清理掉,所以对象 D 就被清理掉了。

优点:实现简单,只需要在第一阶段给每个对象维护标志位,第二阶段删除对象即可。

缺点

1. 碎片化问题

由于内存是连续的,所以在对象被删除之后,内存中会出现很多细小的可用内存单元。如果我们需要的是一个比较大的空间,很有可能这些内存单元的大小过小无法进行分配。

如下图,红色部分已经被清理掉了,总共回收了 9 个字节,但是每个都是一个小碎片,无法为 5 个字节的对象分配空间。

2. 分配速度慢

由于内存碎片的存在,需要维护一个空闲链表,极有可能发生每次需要遍历到链表的最后才能获得合适的内存空间。

我们需要用一个链表来维护,哪些空间可以分配对象,很有可能需要遍历这个链表到最后,才能发现这块空间足够我们去创建一个对象。如下图,遍历到最后才发现有足够的空间分配 3 个字节的对象了。如果链表很长,遍历也会花费较长的时间。

4.4.4 复制算法

复制算法的核心思想是:

  1. 准备**From 空间和To空间** ,每次在对象分配阶段,只能使用其中一块空间From空间)。

对象 A 首先分配在From空间:

2.在垃圾回收 GC 阶段,From中存活对象复制到To空间

在垃圾回收阶段,如果对象 A 存活,就将其复制到To空间。然后将**From空间直接清空**。

3.将两块空间的**FromTo名字互换**。

接下来将两块空间的名称互换,下次依然在From空间上创建对象。

完整的复制算法的例子:

  1. 将堆内存分割成两块From空间 To空间,对象分配阶段,创建对象。
  1. GC 阶段开始,将 GC Root 搬运到To空间
  1. 将 GC Root 关联的对象,搬运到To空间
  1. 清理From空间,并把名称互换

优点

1. 吞吐量高,复制算法只需要遍历一次存活对象复制到To空间即可,比标记 - 整理算法少了一次遍历的过程,因而性能较好,但是不如标记 - 清除算法,因为标记清除算法不需要进行对象的移动

2. 不会发生碎片化,复制算法在复制之后就会将对象按顺序放入To空间中,所以对象以外的区域都是可用空间,不存在碎片化内存空间。

缺点:内存使用效率低,每次只能让一半的内存空间来为创建对象使用。

1.4.5 标记整理算法

标记整理算法也叫标记压缩算法,是对标记清理算法中容易产生内存碎片问题的一种解决方案。

核心思想分为两个阶段:

1. 标记阶段:将所有存活的对象进行标记。Java 中使用可达性分析算法,从 GC Root 开始通过引用链遍历出所有存活对象。

2. 整理阶段:将存活对象移动到堆的一端。清理掉存活对象的内存空间。

优点

  1. 内存使用效率高,整个堆内存都可以使用,不会像复制算法只能使用半个堆内存

  2. 不会发生碎片化,在整理阶段可以将对象往内存的一侧进行移动,剩下的空间都是可以分配对象的有效空间

缺点:整理阶段的效率不高,整理算法有很多种,比如 Lisp2 整理算法需要对整个堆中的对象搜索 3 次,整体性能不佳。可以通过 Two-Finger、表格算法、ImmixGC 等高效的整理算法优化此阶段的性能。

1.4.6 分代垃圾回收算法

另外还可以选择的虚拟机参数如下:

代码

java 复制代码
package chapter04.gc;

import java.io.IOException;
import java.util.ArrayList;
import java.util.List;

/**
 * 垃圾回收器案例1
 */
//-XX:+UseSerialGC  -Xms60m -Xmn20m -Xmx60m -XX:SurvivorRatio=3  -XX:+PrintGCDetails
public class GcDemo0 {

    public static void main(String[] args) throws IOException {
        List<Object> list = new ArrayList<>();
        int count = 0;
        while (true){
            System.in.read();
            System.out.println(++count);
            //每次添加1m的数据
            list.add(new byte[1024 * 1024 * 1]);
        }
    }
}
  1. 分代回收时,创建出来的对象,首先会被放入Eden伊甸园区。
  1. 随着对象在Eden区越来越多,如果Eden区满,新创建的对象已经无法放入,就会触发年轻代的 GC,称为Minor GC或者Young GC

Minor GC会把需要eden中和From需要回收的对象回收,把没有回收的对象放入To区。

3.接下来,S0会变成To区,S1变成From区。当eden区满时再往里放入对象,依然会发生Minor GC

  1. 此时会回收eden区和S1(from)中的对象,并把edenfrom区中剩余的对象放入S0。 注意:每次Minor GC中都会为对象记录他的年龄,初始值为 0,每次 GC 完加 1。
  1. 如果Minor GC后对象的年龄达到阈值(最大 15,默认值和垃圾回收器有关),对象就会被晋升至老年代。
  1. 当老年代中空间不足,无法放入新的对象时,先尝试minor gc 如果还是不足,就会触发Full GC Full GC会对整个堆进行垃圾回收。

如果Full GC依然无法回收掉老年代的对象,那么当对象继续放入老年代时,就会抛出OutOfMemory异常。

1.5 垃圾回收器

为什么分代 GC 算法要把堆分成年轻代和老年代?首先我们要知道堆内存中对象的特性:

  • 系统中的大部分对象 ,都是创建出来之后很快就不再使用可以被回收,比如用户获取订单数据,订单数据返回给用户之后就可以释放了。

  • 老年代中会存放长期存活的对象,如Spring的bean对象,在程序启动之后就不会被回收了。

  • 在虚拟机的默认设置中,新生代大小要远小于老年代的大小

在虚拟机的默认设置中,新生代大小要远小于老年代的大小。

分代 GC 算法将堆分成年轻代和老年代主要原因有:

1.可以通过调整年轻代和老年代的比例来适应不同类型的应用程序,提高内存的利用率和性能。

2.新生代和老年代使用不同的垃圾回收算法,新生代一般选择复制算法,老年代可以选择标记 - 清除和标记 - 整理算法,由程序员来选择灵活度较高。

3.分代的设计中允许只回收新生代(minor gc),如果能满足对象分配的要求就不需要对整个堆进行回收(full gc),STW 时间就会减少。

垃圾回收器是垃圾回收算法的具体实现。

**由于垃圾回收器分为年轻代和老年代,除了 G1 之外其他垃圾回收器必须成对组合进行使用。**具体的关系图如下:

1.5.1 Serial 垃圾回收器

  • 回收年代和算法年轻代、复制算法

  • 优点单 CPU 处理器下吞吐量非常出色

  • 缺点:多 CPU 下吞吐量不如其他垃圾回收器,堆如果偏大会让用户线程处于长时间的等待

  • 适用场景:Java 编写的客户端程序或者硬件配置有限的场景

1.5.2 Serial Old 垃圾回收器

  • 回收年代和算法老年代、标记 - 整理算法

  • 优点单 CPU 处理器下吞吐量非常出色

  • 缺点:多 CPU 下吞吐量不如其他垃圾回收器,堆如果偏大会让用户线程处于长时间的等待

  • 适用场景:与 Serial 垃圾回收器搭配使用,或者在 CMS 特殊情况下使用

1.5.3 ParNew 垃圾回收器

  • 回收年代和算法年轻代、复制算法

  • 优点多 CPU 处理器下停顿时间较短

  • 缺点:吞吐量和停顿时间不如 G1,所以在 JDK9 之后不建议使用

  • 适用场景:JDK8 及之前的版本中,与 CMS 老年代垃圾回收器搭配使用

1.5.4 CMS 垃圾回收器

  • 回收年代和算法:老年代、标记清除算法

  • 优点:系统由于垃圾回收出现的停顿时间较短,用户体验好

  • 缺点:内存碎片问题,退化问题,浮动垃圾问题

  • 适用场景:大型的互联网系统中用户请求数据量大、频率高的场景,比如订单接口、商品接口等

CMS 执行步骤:

1.初始标记:用极短的时间标记出 GC Roots 能直接关联到的对象。

2.并发标记:标记所有的对象,用户线程不需要暂停。

3.重新标记:由于并发标记阶段有些对象会发生了变化,存在错标、漏标等情况,需要重新标记。

4.并发清理:清理死亡的对象,用户线程不需要暂停。

缺点:

1.CMS 使用了标记 - 清除算法,在垃圾收集结束之后会出现大量的内存碎片,CMS 会在 Full GC 时进行碎片的整理。这样会导致用户线程暂停,可以使用-XX:CMSFullGCsBeforeCompaction=N参数(默认 0)调整 N 次 Full GC 之后再整理。

2.无法处理在并发清理过程中产生的 "浮动垃圾",不能做到完全的垃圾回收。

3.如果老年代内存不足无法分配对象,CMS 就会退化成 Serial Old 单线程回收老年代。

1.5.5 年轻代 Parallel Scavenge 垃圾回收器

  • 回收年代和算法:年轻代、复制算法

  • 优点:吞吐量高,而且手动可控。为了提高吞吐量,虚拟机会动态调整堆的参数

  • 缺点:不能保证单次的停顿时间

  • 适用场景:后台任务,不需要与用户交互,并且容易产生大量的对象。比如:大数据的处理,大文件导出

常用参数:

Parallel Scavenge允许手动设置最大暂停时间和吞吐量。官方建议在使用这个组合时,不要设置堆内存的最大值,垃圾回收器会根据最大暂停时间和吞吐量自动调整内存大小。

  • 最大暂停时间,-XX:MaxGCPauseMillis=n 设置每次垃圾回收时的最大停顿毫秒数

  • 吞吐量,-XX:GCTimeRatio=n 设置吞吐量为n(用户线程执行时间 = n/n + 1)

  • 自动调整内存大小, -XX:+UseAdaptiveSizePolicy设置可以让垃圾回收器根据吞吐量和最大停顿的毫秒数自动调整内存大小

1.5.5 Parallel Old 垃圾回收器

回收年代和算法:老年代、标记 - 整理算法

  • 优点:并发收集,在多核 CPU 下效率较高

  • 缺点:暂停时间会比较长

  • 适用场景:与 Parallel Scavenge 配套使用

1.5.6 G1 垃圾回收器

JDK9 之后默认的垃圾回收器是 G1 (Garbage First) 垃圾回收器。Parallel Scavenge 关注吞吐量,允许用户设置最大暂停时间,但是会减少年轻代可用空间的大小。CMS 关注暂停时间,但是吞吐量方面会下降。

而 G1 设计目标就是将上述两种垃圾回收器的优点融合:

  1. 支持巨大的堆空间回收,并有较高的吞吐量。

  2. 支持多 CPU 并行垃圾回收。

  3. 允许用户设置最大暂停时间。

JDK9 之后强烈建议使用 G1 垃圾回收器。

**G1 出现之前的垃圾回收器,年轻代和老年代一般是连续的,**如下图:

G1垃圾回收器内存结构

年轻代回收

年轻代回收 (Young GC), 回收 Eden 区和 Survivor 区中不用的对象。会导致 STW,G1 中可以通过参数 - XX:MaxGCPauseMillis=n (默认 200) 设置每次垃圾回收时的最大暂停时间毫秒数,G1 垃圾回收器会尽可能地保证暂停时间。

1.新创建的对象会存放在 Eden 区。当 G1 判断年轻代区不足 (max 默认 60%), 无法分配对象时需要回收时会执行 Young GC。

  1. 标记出 Eden 和 Survivor 区域中的存活对象。
  1. 根据配置的最大暂停时间选择某些区域将存活对象复制到一个新的 Survivor 区中 (年龄 + 1),清空这些区域。

G1 在进行 Young GC 的过程中会去记录每次垃圾回收时每个 Eden 区和 Survivor 区的平均耗时,以作为下次回收时的参考依据。这样就可以根据配置的最大暂停时间计算出本次回收时最多能回收多少个 Region 区域了。

比如 -XX:MaxGCPauseMillis=n (默认 200), 每个 Region 回收耗时 40ms, 那么这次回收最多只能回收 4 个 Region。

4.后续 Young GC 时与之前相同,只不过 Survivor 区中存活对象会被搬运到另一个 Survivor 区。

5.当某个存活对象的年龄到达阈值 (默认 15),将被放入老年代。

  1. 部分对象如果大小超过 Region 的一半,会直接放入老年代,这类老年代被称为 Humongous 区。比如堆内存是 4G, 每个 Region 是 2M, 只要一个大对象超过了 1M 就被放入 Humongous 区,如果对象过大会横跨多个 Region。
  1. 多次回收之后,会出现很多 Old 老年代区,此时总堆占有率达到阈值时会触发混合回收 MixedGC。回收所有年轻代和部分老年代的对象以及大对象区。采用复制算法来完成。

混合回收

混合回收分为:初始标记 (initial mark)、并发标记 (concurrent mark)、最终标记 (remark 或者 Finalize Marking)、并发清理 (cleanup)

G1 对老年代的清理会选择存活度最低的区域来进行回收,这样可以保证回收效率最高,这也是 G1 (Garbage first) 名称的由来。

G1 对老年代的清理会选择存活度最低的区域来进行回收,这样可以保证回收效率最高,这也是 G1 (Garbage first) 名称的由来。最后清理阶段使用复制算法,不会产生内存碎片。

注意:如果清理过程中发现没有足够的空 Region 存放转移的对象,会出现 Full GC。单线程执行标记 - 整理算法,此时会导致用户线程的暂停。所以尽量保证应该用的堆内存有一定多余的空间。


大功告成!

相关推荐
ZHOUPUYU5 小时前
PHP 8.3网关优化:我用JIT将QPS提升300%的真实踩坑录
开发语言·php
小高不会迪斯科9 小时前
CMU 15445学习心得(二) 内存管理及数据移动--数据库系统如何玩转内存
数据库·oracle
寻寻觅觅☆9 小时前
东华OJ-基础题-106-大整数相加(C++)
开发语言·c++·算法
e***89010 小时前
MySQL 8.0版本JDBC驱动Jar包
数据库·mysql·jar
l1t10 小时前
在wsl的python 3.14.3容器中使用databend包
开发语言·数据库·python·databend
青云计划10 小时前
知光项目知文发布模块
java·后端·spring·mybatis
赶路人儿10 小时前
Jsoniter(java版本)使用介绍
java·开发语言
ceclar12311 小时前
C++使用format
开发语言·c++·算法
探路者继续奋斗11 小时前
IDD意图驱动开发之意图规格说明书
java·规格说明书·开发规范·意图驱动开发·idd
码说AI11 小时前
python快速绘制走势图对比曲线
开发语言·python