深入理解JVM《垃圾收集(GC)机制与算法 - 宇宙的清洁工》

简介: Java通过垃圾收集(GC)实现自动内存管理,避免手动释放内存导致的泄漏或崩溃。主流JVM采用可达性分析算法判断对象生死,结合分代收集理论,使用标记-清除、复制、标记-整理等算法回收内存。G1、ZGC等现代收集器进一步提升性能与停顿控制。

概述:为何要GC?

在C/C++中,程序员需要手动使用mallocfree来分配和释放内存。这是一项繁琐且极易出错的任务,如果忘记释放内存,就会导致内存泄漏;如果错误地释放了仍在使用的内存,就会导致程序崩溃

Java引入了自动内存管理,即垃圾收集(Garbage Collection, GC)机制。它的目标是自动识别并回收不再被任何引用的对象所占用的内存,从而将程序员从复杂的内存管理中解放出来,专注于业务逻辑。

垃圾:在GC的上下文中,垃圾就是指那些存在于堆内存中,但没有任何存活对象引用它的对象实例。这些对象已经“死亡”,它们占用的空间需要被回收以复用。

2.2 判断对象生死:垃圾定义的法则

垃圾收集器在对堆进行回收前,第一件事就是要确定哪些对象还“存活”着,哪些已经“死去”(即不可能再被任何途径使用)。判断对象生死有两种经典的算法。

引用计数法(Reference Counting)

  • 原理:在对象中添加一个引用计数器。每当有一个地方引用它时,计数器值就加一;当引用失效时,计数器值就减一。任何时刻计数器为零的对象就是不可能再被使用的。
  • 优点:原理简单,判定效率高。
  • 致命缺陷无法处理循环引用的问题。如下代码所示,对象A和B相互引用,除此之外再无任何引用。它们的引用计数器都不为零,但实际上它们已经无法被访问,应被回收。
public class ReferenceCountingGC {
    public Object instance = null;
    public static void main(String[] args) {
        ReferenceCountingGC objA = new ReferenceCountingGC();
        ReferenceCountingGC objB = new ReferenceCountingGC();
        objA.instance = objB; // objA引用了objB
        objB.instance = objA; // objB引用了objA -> 循环引用
        objA = null;
        objB = null;
        // 假设在这里发生GC,objA和objB能否被回收?引用计数法下:不能。
        System.gc();
    }
}

由于这个无法解决的硬伤,主流的Java虚拟机都没有选用引用计数法来管理内存

可达性分析算法(Reachability Analysis)

这是当前主流Java虚拟机采用的算法。它的基本思路是通过一系列称为 “GC Roots” 的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为 “引用链”(Reference Chain)。如果某个对象到GC Roots间没有任何引用链相连(用图论的话来说,就是从GC Roots到这个对象不可达),则证明此对象是不可能再被使用的。

  • 哪些对象可以作为GC Roots?
  • 在虚拟机栈(栈帧中的局部变量表)中引用的对象。(例如:当前正在运行的方法中的参数、局部变量、临时变量)。
  • 在方法区中类静态属性引用的对象。(例如:Java类的引用类型静态变量)。
  • 在方法区中常量引用的对象。(例如:字符串常量池里的引用)。
  • 在本地方法栈中JNI(即通常所说的Native方法)引用的对象。
  • Java虚拟机内部的引用(如基本数据类型对应的Class对象,一些常驻的异常对象等)。
  • 所有被同步锁(synchronized关键字)持有的对象。

对象引用的强度:从强到弱

在JDK 1.2之后,Java将引用概念拓宽,分为强引用(Strong Reference)软引用(Soft Reference)弱引用(Weak Reference)虚引用(Phantom Reference),引用强度依次逐渐减弱。

  • 强引用:类似Object obj = new Object()这种普遍的引用。只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象
  • 软引用:用来描述一些还有用但非必需的对象。在系统即将发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收。如果这次回收后还没有足够的内存,才会抛出内存溢出异常。使用SoftReference类实现。
  • 弱引用:用来描述非必需对象,但它的强度比软引用更弱。被弱引用关联的对象只能生存到下一次垃圾收集发生为止。当垃圾收集器开始工作,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。使用WeakReference类实现。
  • 虚引用:最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。设置虚引用的唯一目的,是为了能在这个对象被收集器回收时收到一个系统通知。使用PhantomReference类实现。
// 软引用和弱引用示例
public class ReferenceTypeDemo {
    public static void main(String[] args) {
        // 强引用
        Object strongRef = new Object();
        // 软引用
        SoftReference<Object> softRef = new SoftReference<>(new Object());
        System.out.println("Before GC (Soft): " + softRef.get());
        System.gc();
        System.out.println("After GC (Soft, if memory not low): " + softRef.get()); // 可能还存在
        // 弱引用
        WeakReference<Object> weakRef = new WeakReference<>(new Object());
        System.out.println("Before GC (Weak): " + weakRef.get());
        System.gc(); // 显式触发GC,弱引用对象大概率被回收
        System.out.println("After GC (Weak): " + weakRef.get()); // 大概率输出 null
    }
}

2.3 垃圾收集算法:清洁工的方法论

从如何回收的角度,衍生出了几种不同的算法。

标记-清除算法(Mark-Sweep)

最基础的收集算法,分为“标记”和“清除”两个阶段。

  1. 标记:首先标记出所有需要回收的对象(使用可达性分析)。
  2. 清除:统一回收所有被标记的对象。
  • 优点:是最基础的算法,后续很多算法都是以其为思路改进的。
  • 缺点
  • 执行效率不稳定:如果堆中包含大量需要回收的对象,标记和清除两个过程的效率都会随之降低。
  • 内存碎片化问题:标记、清除之后会产生大量不连续的内存碎片。空间碎片太多可能会导致以后在分配大对象时无法找到足够的连续内存,从而不得不提前触发另一次垃圾收集动作。

复制算法(Copying)

为了解决效率问题,“复制”算法出现了。它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。

  • 优点
  • 高效:每次都是针对整个半区进行内存回收,分配内存时也无需考虑有空间碎片的复杂情况,只要移动堆顶指针,按顺序分配即可。
  • 无碎片:复制过去的过程中自然就完成了整理,不会产生内存碎片。
  • 缺点
  • 空间浪费:将可用内存缩小为了原来的一半,空间浪费太多。
  • 当对象存活率较高时,复制的开销会很大。

应用场景:现代商用Java虚拟机都优先采用了这种收集算法去回收新生代。IBM的研究表明,新生代中的对象有98%是“朝生夕死”的,所以并不需要按照1:1的比例来划分内存空间。HotSpot虚拟机将新生代内存分为一个较大的Eden空间和两个较小的Survivor空间(通常称为From和To),每次分配内存只使用Eden和其中一块Survivor。发生垃圾收集时,将Eden和Survivor中仍然存活的对象一次性复制到另一块Survivor空间上,然后直接清理掉Eden和已用过的那块Survivor空间。HotSpot默认的Eden和Survivor大小比例是8:1:1,即每次新生代中可用内存空间为整个新生代容量的90%,只有10%的内存会被“浪费”。

标记-整理算法(Mark-Compact)

复制算法在对象存活率较高时要进行较多的复制操作,效率会降低。更关键的是,如果不想浪费50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况,所以在老年代一般不能直接选用这种算法。

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

  • 优点
  • 无内存碎片
  • 消除了复制算法浪费一半空间的代价。
  • 缺点
  • 效率问题:移动存活对象并更新所有引用这些对象的地方,需要全程暂停用户应用程序(Stop The World),并且移动操作在对象多、存活率高时,开销更为可观。

分代收集理论(Generational Collection)

当前商业虚拟机的垃圾收集器,大多都遵循了 “分代收集” 的理论。它建立在两个分代假说之上:

  1. 弱分代假说(Weak Generational Hypothesis):绝大多数对象都是朝生夕死的。
  2. 强分代假说(Strong Generational Hypothesis):熬过越多次垃圾收集过程的对象就越难以消亡。

根据这两个假说,收集器将Java堆划分出不同的区域(新生代和老年代),然后根据各个区域的特点,“因地制宜”地采用不同的垃圾收集算法

  • 新生代(Young Generation):区域小,对象存活率低。
  • 回收频繁
  • 采用复制算法,只需付出少量存活对象的复制成本就可以完成收集,效率高。
  • 老年代(Tenured/Old Generation):区域大,对象存活率高。
  • 回收频率较低
  • 采用标记-清除标记-整理算法。

2.4 经典垃圾收集器:清洁工战队

垃圾收集算法是方法论,而垃圾收集器是具体的实现。HotSpot虚拟机提供了多种不同的收集器,下图展示了JDK 7/8时期HotSpot虚拟机的垃圾收集器及其组合关系:

  • Serial收集器:最古老、最基础的收集器。它是一个单线程工作的收集器,在进行垃圾收集时,必须暂停所有其他工作线程("Stop The World")。它是Client模式下虚拟机的默认新生代收集器,简单而高效。
  • ParNew收集器:实质上是Serial收集器的多线程并行版本,除了使用多条线程进行垃圾收集外,其余行为与Serial完全一样。它是许多运行在Server模式下的HotSpot虚拟机中首选的新生代收集器,因为除了Serial外,目前只有它能与CMS收集器配合工作。
  • Parallel Scavenge收集器:也是一个并行的多线程新生代收集器,使用复制算法。它的关注点与其他收集器不同:CMS等收集器的目标是尽可能缩短垃圾收集时用户线程的停顿时间,而Parallel Scavenge的目标是达到一个可控制的吞吐量(Throughput)
  • 吞吐量 = 运行用户代码时间 / (运行用户代码时间 + 垃圾收集时间)
  • 高吞吐量可以最高效率地利用CPU时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。
  • JDK 8的默认收集器组合:Parallel Scavenge(新生代) + Parallel Old(老年代)。
  • Serial Old收集器:是Serial收集器的老年代版本,同样是一个单线程收集器,使用标记-整理算法。主要用于Client模式下的老年代,或在Server模式下作为CMS收集器失败后的后备预案。
  • Parallel Old收集器:是Parallel Scavenge收集器的老年代版本,支持多线程并发收集,基于标记-整理算法。在注重吞吐量或者处理器资源较为稀缺的场合,都可以优先考虑Parallel Scavenge加Parallel Old收集器这个组合。
  • CMS收集器(Concurrent Mark Sweep):一种以获取最短回收停顿时间为目标的收集器。基于标记-清除算法,其运作过程相对于前面几种收集器来说更复杂,分为四个步骤:
  • 初始标记(Initial Mark):仅仅标记一下GC Roots能直接关联到的对象,速度很快。需要“Stop The World”
  • 并发标记(Concurrent Mark):从GC Roots的直接关联对象开始遍历整个对象图的过程,耗时较长,但不需要停顿用户线程
  • 重新标记(Remark):为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录。需要“Stop The World”
  • 并发清除(Concurrent Sweep):清理掉标记阶段判断的已经死亡的对象,不需要停顿用户线程
  • 优点:并发收集、低停顿。
  • 缺点
  • 对处理器资源非常敏感(会和服务线程抢CPU)。
  • 无法处理“浮动垃圾”,在并发清理阶段用户线程产生的垃圾,只能留到下一次GC再清理。
  • 基于标记-清除算法,会产生内存碎片
  • G1收集器(Garbage-First):面向服务端应用的垃圾收集器,是JDK 9及之后的默认垃圾收集器。它的使命是未来可以替换掉CMS收集器。
  • 革命性变化:G1将堆划分为多个大小相等的独立区域(Region),它同时兼顾新生代老年代。G1跟踪各个Region里面的垃圾堆积的“价值”大小(回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,优先回收价值最大的Region,这就是“Garbage-First”名字的由来。
  • 运作步骤:虽然也遵循分代收集,但步骤与CMS类似:初始标记 -> 并发标记 -> 最终标记 -> 筛选回收。其中,最终标记和筛选回收阶段需要停顿用户线程。
  • 优势
  • 并行与并发:能充分利用多核环境优势。
  • 分代收集:依然区分分代概念。
  • 空间整合:从整体看是基于标记-整理算法,从局部(两个Region之间)看是基于复制算法。这意味着G1运作期间不会产生内存空间碎片
  • 可预测的停顿:能建立可预测的停顿时间模型,让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒。
  • ZGC与Shenandoah:JDK 11及之后引入的超低延迟收集器(实验或生产可用),目标是在任意堆内存大小下(如TB级)都能把垃圾收集的停顿时间控制在10毫秒以内。它们都采用了着色指针(Colored Pointers)读屏障(Read Barrier) 等革命性技术,实现并发标记和并发整理,几乎在整个GC过程中都不需要Stop The World。

2.5 实战:GC日志解读与参数配置

开启与解读GC日志

GC日志是理解GC行为、进行性能调优的最重要工具。使用以下JVM参数开启详细的GC日志:


-XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:./gc.log

一段典型的Parallel Scavenge收集器的GC日志:


2024-05-20T10:23:45.732+0800: [GC (Allocation Failure) [PSYoungGen: 65536K->10720K(76288K)] 65536K->15024K(251392K), 0.0085989 secs] [Times: user=0.02 sys=0.00, real=0.01 secs]
  • 2024-05-20T10:23:45.732+0800:GC发生的时间戳。
  • GC / Full GC:区分是Minor GC还是Full GC。
  • Allocation Failure:触发GC的原因(分配失败)。
  • PSYoungGen:收集器名称(这里是Parallel Scavenge)。
  • 65536K->10720K(76288K)GC前该区域已使用容量 -> GC后该区域已使用容量 (该区域总容量)
  • 65536K->15024K(251392K)GC前Java堆已使用容量 -> GC后Java堆已使用容量 (Java堆总容量)
  • 0.0085989 secs:GC耗时。
  • [Times: user=0.02 sys=0.00, real=0.01 secs]:用户态消耗的CPU时间、内核态消耗的CPU时间和操作从开始到结束经过的墙钟时间(Wall Clock Time)。

关键参数速查表

参数

描述

示例

-Xms

设置初始堆大小

-Xms2g

-Xmx

设置最大堆大小

-Xmx2g (通常设为与-Xms相同以避免内存震荡)

-Xmn

设置新生代大小(Eden + 2*Survivor)

-Xmn512m

-XX:SurvivorRatio

设置新生代中Eden区与一个Survivor区的比例

-XX:SurvivorRatio=8 (Eden:Survivor=8:1)

-XX:NewRatio

设置老年代与新生代的比例

-XX:NewRatio=2 (老年代:新生代=2:1)

-XX:+UseConcMarkSweepGC

指定使用CMS收集器(老年代)


-XX:+UseG1GC

指定使用G1收集器


-XX:MaxGCPauseMillis

设置期望的最大GC停顿时间目标(G1等收集器适用)

-XX:MaxGCPauseMillis=200

-XX:MaxMetaspaceSize

设置元空间最大值

-XX:MaxMetaspaceSize=256m

在下一部分,我们将进入JVM监控与性能工具实战,学习如何使用这些工具来捕获GC日志、分析JVM状态,并将本章的理论知识应用于实际问题的定位中。

相关文章
|
25天前
|
存储 算法 安全
《Java集合核心HashMap:深入剖析其原理、陷阱与性能优化》
HashMap是Java中最常用的Map实现,基于哈希表提供近乎O(1)的存取效率。其核心为“数组+链表+红黑树”结构,通过扰动哈希、&运算索引、扩容机制等实现高效操作。但线程不安全,需注意Key的不可变性与合理初始化容量。深入理解其原理,有助于写出高性能代码,避免常见陷阱。
|
25天前
|
Arthas 监控 数据可视化
深入理解JVM《JVM监控与性能工具实战 - 系统的诊断工具》
掌握JVM监控与诊断工具是Java性能调优的关键。本文系统介绍jps、jstat、jmap、jstack等命令行工具,以及jconsole、VisualVM、JMC、Arthas、async-profiler等可视化与高级诊断工具,涵盖GC分析、内存泄漏定位、线程死锁检测及CPU热点追踪,助力开发者全面提升线上问题排查能力。(238字)
|
25天前
|
监控 算法 Java
深入理解JVM《G1垃圾收集器:面向局部收集与停顿模型的里程碑》
G1收集器是JDK 9+默认的高性能垃圾回收器,采用Region分区模型,实现可预测停顿时间。它通过RSet跟踪跨区引用,结合SATB算法确保并发标记准确性,兼顾低延迟与高吞吐,适用于大内存多核场景。
|
25天前
|
存储 安全 Java
JUC系列之《深入理解synchronized:Java并发编程的基石 》
本文深入解析Java中synchronized关键字的使用与原理,涵盖其三种用法、底层Monitor机制、锁升级过程及JVM优化,并对比Lock差异,结合volatile应用场景,全面掌握线程安全核心知识。
|
15天前
|
人工智能 运维 自然语言处理
别再靠“救火”过日子了:智能运维,正在重塑IT服务的未来
别再靠“救火”过日子了:智能运维,正在重塑IT服务的未来
163 15
|
28天前
|
文字识别 自然语言处理 数据处理
《大模型赋能文化遗产数字化:古籍修复与知识挖掘的技术实践》
本文记录大模型赋能文化遗产数字化的实践,针对古籍异体字识别难、残缺文本补全不准、隐性知识难挖掘、多模态数据割裂、中小机构部署难、知识难更新等痛点,提出对应方案:搭建古籍文字与语境知识库提升识别理解率,以多源史料关联与历史逻辑约束实现文本精准补全,构建多层级框架挖掘隐性知识,设计多模态语义对齐整合多元信息,通过轻量化优化与混合部署降低使用门槛,建立动态机制保障知识迭代。优化后多项关键指标显著提升,为古籍数字化提供有效路径。
112 9
|
24天前
|
SQL 人工智能 搜索推荐
Dataphin功能Tips系列(71)X-数据管家:数据资产运营的「AI外挂」
企业数据资产繁多,手动管理效率低易出错。Dataphin「X-数据管家」基于大模型智能生成标签、描述、字段类型等信息,支持批量处理与一键上架,大幅提升资产运营效率,实现高效数据治理。
86 15
|
23天前
|
前端开发 JavaScript API
JSAR 交互式菜单开发实战:打造沉浸式 3D 导航体验
本文介绍如何使用JSAR框架在Rokid智能眼镜上开发3D交互式菜单系统。通过Babylon.js创建按钮、动态纹理与动画,结合空间计算实现沉浸式导航体验,涵盖多视图切换、信息面板与手势适配,助力打造直观高效的AR界面。
|
28天前
|
人工智能 文字识别 自然语言处理
有了AI叠buff,低代码行业在沉寂了一段时间后,好似又活过来了?
曾被质疑“难堪大用”的低代码平台,在AI驱动下正焕发新生。借助大模型,AI可理解自然语言、自动生成应用、智能补全数据、解析文档图表,大幅提升开发效率与业务响应速度。从“拖拉拽”到“你说我做”,低代码已迈入智能化时代,加速企业数字化转型。
|
25天前
|
存储 算法 搜索推荐
《数据之美》:Java数据结构与算法精要
本系列深入探讨数据结构与算法的核心原理及Java实现,涵盖线性与非线性结构、常用算法分类、复杂度分析及集合框架应用,助你提升程序效率,掌握编程底层逻辑。