内存布局

线程私有区域
这些区域的生命周期与线程相同,每个线程都有自己独立的一份。
程序计数器
-
功能 :它可以看作是当前线程所执行的字节码的行号指示器。字节码解释器通过改变这个计数器的值来选取下一条需要执行的字节码指令。分支、循环、异常处理等核心功能都依赖它来完成。
- 因为代码是在线程中运行的,线程有可能被挂起。即 CPU 一会执行线程 A,线程 A 还没有执行完被挂起了,接着执行线程 B,最后又来执行线程 A 了,CPU 需要知道执行线程A的哪一部分指令,线程计数器会告诉 CPU。
-
特性 :它是线程私有的,从而确保多线程切换后能恢复到正确的执行位置 。如果线程正在执行 Native 方法,这个计数器的值则为空(Undefined)。此区域是唯一一个在 Java 虚拟机规范中没有规定任何
OutOfMemoryError情况的区域。
Java 虚拟机栈
-
功能 :它描述了 Java 方法执行的内存模型 。每个方法在执行时都会创建一个栈帧 ,用于存储局部变量表、操作数栈、动态链接、方法出口等信息。方法从调用到执行完成的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。
-
错误 :如果线程请求的栈深度大于虚拟机所允许的深度,将抛出
StackOverflowError异常;如果虚拟机栈可以动态扩展,但扩展时无法申请到足够内存,则会抛出OutOfMemoryError。
栈对应线程,栈帧对应方法
1. 局部变量表
局部变量表就是存放方法参数和方法内部定义的局部变量的区域。
如果局部变量是 Java 的 8 种基本基本数据类型,则存在局部变量表中,如果是引用类型。如 new 出来的 String,局部变量表中存的是引用,而实例在堆中。

2. 操作栈
操作数栈(Operand Stack)看名字可以知道是一个栈结构。Java 虚拟机的解释执行引擎称为"基于栈的执行引擎",其中所指的"栈"就是操作数栈。当 JVM 为方法创建栈帧的时候,在栈帧中为方法创建一个操作数栈,保证方法内指令可以完成工作。
3. 动态连接
每个栈帧中包含一个在常量池中对当前方法的引用, 目的是支持方法调用过程的动态连接。
4. 方法返回地址
方法执行时有两种退出情况:
- 正常退出,即正常执行到任何方法的返回字节码指令,如 RETURN、IRETURN、ARETURN 等
- 异常退出
无论何种退出情况,都将返回至方法当前被调用的位置。方法退出的过程相当于弹出当前栈帧,退出可能有三种方式:
- 返回值压入上层调用栈帧
- 异常信息抛给能够处理的栈帧
- PC 计数器指向方法调用后的下一条指令
本地方法栈
-
功能 :它与 Java 虚拟机栈作用非常类似,区别在于 它服务的对象是 Native 方法(通常由 C/C++ 编写)。
-
备注:在 HotSpot 等虚拟机中,本地方法栈与 Java 虚拟机栈是合二为一的。
线程共享区域
这些区域被所有线程共享,在虚拟机启动时创建,是内存管理和垃圾回收的重点区域。
堆
-
功能 :这是 JVM 所管理的内存中最大的一块 ,被所有线程共享。几乎所有的对象实例和数组 都在这里分配内存。它也是垃圾收集器管理的主要区域,因此常被称为 GC 堆。
-
分区 :为了更高效地管理内存和进行垃圾回收,堆空间又划分为新生代 和老年代 。新生代进一步分为一个 Eden 区和两个 Survivor 区(From, To),默认比例是 8:1:1。新创建的对象首先在 Eden 区分配,经过一次 Minor GC 后存活的对象会被移到 Survivor 区,年龄增加到一定程度(默认15)后会被晋升到老年代。
-
错误 :当堆中没有足够内存完成实例分配,并且堆也无法再扩展时,会抛出
OutOfMemoryError。

堆区的调整
根据 Java 虚拟机规范的规定,Java 堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可,就像我们的磁盘空间一样。在实现时,既可以实现成固定大小的,也可以在运行时动态地调整。
如何调整呢?
通过设置如下参数,可以设定堆区的初始值和最大值,比如 -Xms256M -Xmx 1024M,其中 -X 这个字母代表它是 JVM 运行时参数,ms 是 memory start 的简称,中文意思就是内存初始值 ,mx 是 memory max 的简称,意思就是最大内存。
值得注意的是,在通常情况下,服务器在运行过程中,堆空间不断地扩容与回缩,会形成不必要的系统压力,所以在线上生产环境中 JVM 的 Xms 和 Xmx 会设置成同样大小,避免在 GC 后调整堆大小时带来的额外压力。
创建一个新对象,内存分配流程?
1. 类加载检查
当JVM遇到一条 new指令时,首先会检查这个指令的参数能否在运行时常量池 中定位到一个类的符号引用。
-
检查内容 :会检查这个符号引用代表的类是否已被加载、解析和初始化过。
-
如果未加载 :如果检查发现这个类还没有被加载,那么JVM会先执行这个类的加载过程。只有类加载成功,后续步骤才能继续进行。这是对象创建的前提 。
2. 分配内存
类加载检查通过后,JVM将为新生对象在堆内存中分配空间。对象所需内存的大小在类加载完成后便可完全确定。这一步需要解决两个核心问题:"如何划分内存"和"如何解决并发问题" 。
划分内存的策略
JVM根据Java堆内存是否规整,采用两种策略:
-
指针碰撞 :如果堆内存是规整的(即所有已使用内存放在一边,空闲内存放在另一边),JVM只需将一个指针向空闲空间方向移动一段与对象大小相等的距离即可。Serial、ParNew等带压缩整理功能的收集器采用此方式 。
-
空闲列表 :如果堆内存不规整(已使用和空闲内存相互交错),JVM需要维护一个列表来记录哪些内存块是可用的。分配时从列表中找到一块足够大的空间划分给对象。CMS这类基于标记-清除算法的收集器采用此方式 。
解决并发问题的方法
为应对多线程同时创建对象的情况,JVM有两种保障原子性的方法:
-
CAS配合失败重试 :JVM采用CAS操作 来保证指针更新的原子性。如果某个线程在分配内存时失败,它会重试直到成功 。
-
本地线程分配缓冲 :JVM会为每个线程在堆中预先分配一小块称为 TLAB(线程本地分配缓冲区) 的私有内存。当线程需要创建对象时,可以先在自己的TLAB中分配,这样就避免了竞争。只有当TLAB用完并分配新的TLAB时,才需要进行同步控制。可以通过
-XX:+/-UseTLAB参数来设定 。
3. 初始化零值
内存分配完成后,JVM会将分配到的内存空间(不包括对象头)都初始化为零值(0, null, false等)。
-
作用 :这一步保证了对象的实例字段在Java代码中可以不赋初始值就直接使用,程序能访问到这些字段数据类型的默认值 。
-
如果使用了TLAB,这一步骤可以提前至TLAB分配时进行 。
4. 设置对象头
初始化零值后,JVM要对对象进行必要的设置,这些信息存储在对象头中。对象头主要包括两类信息 :
-
Mark Word :用于存储对象自身的运行时数据,如哈希码、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等。
-
类型指针 :即对象指向它的类元数据的指针,JVM通过这个指针来确定该对象是哪个类的实例。如果应用程序启用了指针压缩(
-XX:+UseCompressedOops,默认开启),类型指针占用4字节,否则占用8字节 。
5. 执行 <init>方法
至此,从JVM的视角看,一个新的对象已经产生了。但从Java程序的视角看,对象的创建才刚刚开始。<init>方法包含了程序员在构造函数中定义的代码以及对父类构造函数的调用。
- 执行顺序 :紧接着会执行
<init>方法(即构造方法),按照程序员的意愿对对象进行初始化,例如为成员变量赋予我们设定的初始值,并执行构造函数中的其他逻辑 。
元空间(Metaspace)
-
功能 :它也是所有线程共享的区域,用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。每当一个类初次被加载的时候,它的元数据都会放到永久代中。
-
演进 :在 JDK 8 及以后,HotSpot 虚拟机用元空间取代了永久代来实现方法区。元空间使用本地内存,减少了溢出风险,并不再像永久代那样受 JVM 最大内存参数的直接限制。
-
子区域 :运行时常量池是方法区的一部分,用于存放编译期生成的各种字面量 和符号引用。
Java 8 中 永久代(PermGen) 为什么被移出 HotSpot JVM 了?
- 永久代的大小由
-XX:MaxPermSize参数设定,默认值较小(如 82MB)。在动态类加载频繁的场景下(例如使用 Spring 等大量使用反射和字节码技术的框架,或 JSP 热编译),很容易耗尽空间,抛出经典的 java.lang.OutOfMemoryError: PermGen space错误 。调优变得困难,设置过小易溢出,设置过大则浪费宝贵的堆内存。- 自动扩容与本地内存 :元空间的最大区别是使用本地内存(Native Memory)而非 JVM 堆内存来存储类元数据。这意味着其容量默认只受操作系统可用内存的限制,可以根据应用需求动态扩展,从根本上减少了 PermGen Space 的 OOM
- 永久代是 HotSpot 虚拟机对 JVM 规范中"方法区"的特定实现,并非标准要求。它将类元数据这类与 Java 对象生命周期不同的数据放在堆内管理 ,逻辑上不够清晰。这阻碍了 HotSpot 与 Oracle 旗下的另一款 JRockit VM(没有永久代)的融合 ,为统一 JVM 平台带来了障碍。
- 由于元数据移到了本地内存,简化了 HotSpot VM 的内存管理模型,降低了复杂性,为未来的性能优化和创新奠定了基础。这也最终促成了 HotSpot 与 JRockit 的顺利融合。
垃圾回收
两种垃圾判定方法
1. 引用计数法
这是一种直观但 Java 并未采用的方法。
-
工作原理:每个对象关联一个引用计数器。当有一个地方引用它时,计数器加 1;引用失效时,计数器减 1。任何时刻计数器为 0 的对象即可被回收 。
-
致命缺陷 :它无法解决对象之间循环引用的问题 。例如,对象 A 和 B 互相引用,即使它们都已不被任何外部对象引用,它们的计数器也不为 0,导致无法回收,造成内存泄漏 。因此,主流的 Java 虚拟机均未采用此算法 。
2. 可达性分析算法
这是 JVM(特别是 HotSpot 虚拟机)实际采用的算法 。
-
核心思想 :通过一系列称为 "GC Roots" 的对象作为起始点,向下搜索。搜索走过的路径称为"引用链"。如果一个对象到 GC Roots 没有任何引用链相连,则此对象是不可达的,即可被回收 。
-
**哪些对象可以作为 GC Roots?** 它们都是程序运行中必须依赖的根基 :
-
虚拟机栈中的局部变量:当前正在执行的方法中的局部变量引用的对象。
-
类的静态属性:方法区中类静态属性引用的对象。
-
常量引用的对象:方法区中常量池引用的对象。
-
Native 方法引用的对象:本地方法栈中 JNI 引用的对象。
-
Java 提供了四种强度递减的引用类型,这直接影响垃圾回收器对它们的处理策略。
| 引用类型 | 被回收的时机 | 常见应用场景 |
|---|---|---|
| 强引用 | 只要强引用链存在,就永远不会被回收 。 | Object obj = new Object();最常见的引用方式。 |
| 软引用 | 在内存不足 时,垃圾回收器会在抛出 OutOfMemoryError 之前尝试回收它们 。 | 实现内存敏感的缓存,如图片缓存 。 |
| 弱引用 | 无论当前内存是否充足,只要发生垃圾回收,就会被回收 。 | 常用于 WeakHashMap或某些特定场景的缓存 。 |
| 虚引用 | 无法通过虚引用获取对象实例,其唯一目的是跟踪对象被垃圾回收的状态,在其被回收时收到一个系统通知 。 | 通常与 ReferenceQueue联合使用,用于在对象被回收后执行一些资源清理工作,如管理堆外内存(Direct ByteBuffer) 。 |
判定后的流程与最终命运
即使一个对象在可达性分析中被判为不可达,它也并非"非死不可"。它会经历一个最后的"审判"流程:
-
第一次标记与筛选 :虚拟机将会对不可达的对象进行第一次标记。随后会进行一次筛选,条件是此对象是否有必要执行
finalize()方法 。 -
执行 finalize() 方法 :如果对象覆盖了
finalize()方法且该方法未被虚拟机调用过,则这个对象会被放入一个名为F-Queue的队列中 。 -
最终判定 :虚拟机会触发一个低优先级的
Finalizer线程去执行队列中各个对象的finalize()方法。finalize()方法是对象逃脱死亡命运的最后一次机会 ------如果对象在finalize()方法中成功"自救"(例如,将this赋值给了某个类静态变量,重新与 GC Roots 建立了链接),它就会被移出"即将回收"的集合。否则,它将被第二次标记,随后被真正的回收 。
四种垃圾回收算法
标记-清除算法
这是最基础的垃圾回收算法,后续算法都是在其基础上改进而来。
-
回收流程:
-
标记阶段 :从GC Roots(如静态变量、活动线程栈上的局部变量等)开始,通过可达性分析 遍历对象图,将所有存活的对象进行标记 。通常采用深度优先搜索来遍历引用链,以节省内存占用 。
-
优点:
-
实现简单:逻辑清晰,是许多算法的基础 。
-
无对象移动 :在清除过程中不会移动存活对象,因此适合与保守式GC算法搭配使用 。
-
-
缺点:
-
内存碎片化:清除后会产生大量不连续的内存碎片,可能导致后续无法分配大对象而提前触发GC 。
-
分配速度慢:由于内存不连续,分配新对象时需要遍历空闲链表来寻找合适大小的内存块,效率较低 。
-
两次遍历,效率问题:需要扫描整个堆内存两次,在堆内存很大且存活对象较多时,效率会降低 。
-
-
复制算法
该算法通过"空间换时间"的策略,高效地解决了内存碎片问题。
回收流程:
-
内存划分 :将可用的堆内存平分为两块,每次只使用其中一块。
-
复制存活对象 :当正在使用的这块内存用尽时,暂停所有用户线程,将这块内存中所有存活的对象按顺序复制到另一块空闲的内存中。
-
清理与角色切换 :复制完成后,一次性清理掉刚才在使用的那块内存中的所有对象(包括垃圾和存活对象)。最后,将两块内存的角色进行切换,原先空闲的那块变为使用中,原先使用中的变为空闲 。
-
优点:
-
高效分配:复制完成后,空闲内存是连续的,新对象的分配可以通过简单的指针加法完成,速度极快 。
-
无内存碎片 :由于存活对象被紧凑地排列在一起,完全避免了内存碎片化问题 。
-
-
缺点:
-
内存利用率低 :在任何时刻,**总有一半的内存空间处于闲置状态,**内存利用率低 。
-
效率依赖存活率:当内存中存活对象比例很高时,复制的成本会非常大,效率显著下降 。
-
标记-整理算法
该算法结合了前两者的优点,尤其适合存活率高的老年代。
-
回收流程:
-
标记阶段:与"标记-清除"算法完全相同,从GC Roots开始遍历并标记所有存活对象。
-
整理阶段 :将所有被标记的存活对象 ,向内存空间的一端进行移动,从而在另一端形成连续的空闲内存。
-
清理边界:直接清理掉边界之外的所有内存(即垃圾对象所占空间) 。
-
-
优点:
-
无内存碎片:整理后产生连续的大块空闲内存,有利于大对象分配。
-
高内存利用率 :整个堆内存都可以被使用,不存在一半内存闲置的问题。
-
-
缺点:
-
移动开销大 :移动对象 需要更新所有指向这些对象的引用地址,操作繁重,是Stop-The-World时间最长的一种算法 。
-
算法复杂:需要考虑移动策略,如"滑动式"或"指针碰撞",实现比标记-清除复杂 。
-
分代收集算法
这是现代JVM的事实标准,它并非一种新算法,而是一种结合上述算法的工程实践方案。
核心思想与流程 :基于对象**"朝生夕死"** 的经验法则,将堆内存划分为新生代 和老年代。
-
新生代 :98%的新建对象生命周期极短。采用复制算法 的优化版。将新生代划分为一个较大的Eden区和两个较小的Survivor区。新对象在Eden区创建,发生Minor GC时,将Eden和一块Survivor中存活的对象复制到另一块空闲的Survivor中,然后清空Eden和已用的Survivor。对象在Survivor区间每熬过一次GC年龄就加1,达到阈值后进入老年代 。
-
老年代 :存放长时间存活的对象。采用标记-清除 或标记-整理 算法。因为老年代对象存活率高,不适合复制。CMS收集器采用标记-清除以降低停顿,而Parallel Old和G1等收集器采用标记-整理以减少碎片 。

新生代内存工作流程:
- 当一个对象刚被创建时会放到 Eden 区域,当 Eden 区域即将存满时做一次垃圾回收,将当前存活的对象复制到 SurvivorA ,随后将 Eden 清空
- 当Eden 下一次存满时,再做一次垃圾回收,先将存活对象复制到 SurvivorB ,再把 Eden 和 SurvivorA 所有对象进行回收,
- 当Eden 再一次存满时,再做一次垃圾回收,将存活对象复制到 SurvivorA ,再把 Eden 和 SurvivorB 对象进行回收。如此反复进行大概 15 次,将最终依旧存活的对象放入到老年代区域。
新生代工作流程与 复制算法 应用场景较为吻合,都是以复制为核心,所以会采用复制算法。
常用的垃圾回收器
垃圾回收器的设计主要围绕两个核心目标进行权衡,理解它们对后续理解不同回收器至关重要:
-
高吞吐量 :指应用程序运行时间 占系统总运行时间(应用程序运行时间 + GC时间)的比例 。高吞吐量意味着GC开销小,CPU资源更多用于业务逻辑,适合后台计算、批处理等不关心短暂停顿的场景。
-
低延迟 :**指每次垃圾回收时,导致应用线程暂停(Stop-The-World,STW)的时间长短。**低延迟对于需要快速响应的用户交互应用、Web服务等至关重要,追求极致的用户体验。
通常,这两个目标不可兼得,追求其中一个往往会牺牲另一个。不同的垃圾回收器正是针对不同目标而设计的。
Serial 收集器(单线程)
这是最古老、最基础的收集器,曾经是Client模式下JVM的默认选择。
-
工作原理 :
-
新生代 :采用复制算法 。当发生垃圾回收时,它会暂停所有应用线程(STW),然后使用单个GC线程进行垃圾回收。
-
老年代 :其搭档是Serial Old 收集器,采用标记-整理算法。同样是单线程且STW。
-
-
优点:由于是单线程,没有线程交互的开销,简单而高效。在内存资源受限、CPU核心数少的场景下(如嵌入式系统),其性能表现可能反而超过复杂的回收器。
-
缺点:在垃圾回收期间,整个应用会"卡死",停顿时间较长。
-
适用场景 :主要用于单核CPU或极小内存(几百MB)的客户端程序或嵌入式环境,现代服务器端应用已基本不适用。可通过JVM参数
-XX:+UseSerialGC启用。
Parallel Scavenge / Parallel Old 收集器(吞吐量)
这是JDK 8及之前版本的默认收集器,也常被称为"吞吐量优先"收集器。
工作原理:
-
新生代(Parallel Scavenge) :它是Serial收集器的多线程并行版本 ,使用复制算法 。在GC时,会启动多个GC线程并行清理垃圾,从而缩短了STW时间,提升了吞吐量。
-
老年代(Parallel Old) :对应的是Parallel Old收集器,同样是多线程并行,采用标记-整理算法。
-
核心特点 :与其关注低延迟,它更专注于达到一个可控制的吞吐量。它提供了两个关键参数来精确控制吞吐量行为:
-
-XX:MaxGCPauseMillis:设置GC的最大停顿时间目标(JVM会尽力实现,但不保证)。 -
-XX:GCTimeRatio:直接设置吞吐量目标。 -
此外,它还支持自适应调节策略 (
-XX:+UseAdaptiveSizePolicy),JVM会根据运行状况动态调整新生代大小、Eden与Survivor比例等参数,无需手动干预。
-
-
适用场景:适合那些主要进行运算、分析、处理任务,而对用户交互响应速度要求不高的后台应用,例如数据导出、报表生成、科学计算等。
CMS(Concurrent Mark-Sweep)收集器(低延迟)
CMS是第一款真正意义上追求低延迟的收集器,它的目标是尽量减少STW时间。它曾是 JVM 中一款里程碑式的垃圾收集器,核心目标是实现低延迟,通过与用户线程并发工作来最大限度地减少垃圾收集的停顿时间。
CMS 的工作原理与七个阶段
CMS 的收集过程比简单的"标记-清除"要复杂,它通过精细的阶段划分来实现并发。
-
初始标记 :这是第一次 STW 暂停。此阶段非常快速,它仅标记与 GC Roots 直接关联的对象 ,以及被新生代中存活对象所引用的老年代对象 。由于其扫描范围很小,所以停顿时间极短。为了进一步优化,可以使用参数
-XX:+CMSParallelInitialMarkEnabled开启多线程并行标记 。 -
并发标记 :从初始标记的对象开始,并发地遍历对象图,标记所有可达的存活对象 。此阶段与用户线程一起运行 ,耗时较长,但不会暂停应用。正因为是并发的,在标记期间对象的引用关系可能被用户线程改变 。
-
并发预清理 :在并发标记阶段,由于用户线程同时在运行,一些对象的引用关系会发生变化。JVM 通过写屏障 机制将发生引用变化的内存区域标记为"脏卡"。并发预清理阶段会重新扫描这些"脏卡"对应的对象 ,并标记被这些对象新引用的存活对象。这个阶段也是并发的,目的在于减轻后续重新标记阶段的工作量 。
-
可中止的预清理 :这个阶段是并发预清理的延伸。它会持续重复预清理的工作,直到满足某个中止条件 (如:持续5秒,或执行了一定次数,或 Eden 区使用率达到50%)。其核心目的是尽可能地多做一些预处理工作,并期待在进入重新标记阶段前能发生一次 Young GC ,从而减少需要扫描的新生代对象数量,降低下一次 STW 的停顿时间。可通过
-XX:CMSMaxAbortablePrecleanTime参数设置最大持续时间 。 -
重新标记 :这是第二次 STW 暂停,也是整个 CMS 周期中通常最耗时的停顿。它的任务是完成对老年代中所有存活对象的标记(在并发标记阶段由写屏障记录下来的、那些"变灰"的黑色对象) 。为了确保正确性,它需要扫描整个堆(包括新生代),因为新生代中的对象也可能引用老年代的对象。 可以使用
-XX:+CMSScavengeBeforeRemark参数,让 JVM 在重新标记前主动进行一次 Young GC,这能显著减少需要扫描的新生代对象,从而有效缩短此次 STW 的时间 -
并发清理 :此阶段再次与用户线程并发执行,负责清理、回收在标记阶段被判定为死亡的对象所占用的内存空间 。
-
并发重置:在并发清理完成后,CMS 会重置内部数据结构,为下一次垃圾收集周期做准备。此阶段也是并发进行的 。
优点:低延迟
最大的优势在于其并发性。耗时最长的标记和清理阶段都与用户线程一起工作,将 STW 时间切割成了两个非常短暂的停顿(初始标记和重新标记),这使得它非常适合对响应时间敏感的应用 。
缺点与挑战:
-
内存碎片 :CMS 采用"标记-清除"算法,不会移动存活对象。长期运行后,老年代会产生大量内存碎片。当需要分配大对象时,即使总内存剩余,也可能因找不到连续空间而触发昂贵的 Full GC 。
-
浮动垃圾 :在并发清理阶段,用户线程仍在运行,会产生新的垃圾对象。这部分"浮动垃圾"只能在下次 GC 时被清理。因此,CMS 不能等到老年代满了再收集,必须预留一部分空间。通过
-XX:CMSInitiatingOccupancyFraction参数可以设置触发 GC 的老年代使用阈值(如 70%)。 -
并发模式失败 :如果并发清理过程中,预留的内存不足以容纳新晋升到老年代的对象,JVM 会被迫中止并发收集,并启动备用的 Serial Old 收集器进行一次 Full GC。这会导致一次长时间的 STW 停顿,是需要避免的情况 。
-
CPU 资源敏感:并发阶段会占用一部分 CPU 资源,可能会对应用吞吐量造成影响 。
重要提示 :CMS 在 JDK 9 中已被标记为废弃,并在 JDK 14 中被移除。其设计上的固有问题(内存碎片、并发模式失败)使得它逐渐被 G1 、ZGC 和 Shenandoah 等更先进的支持全堆并发和压缩的收集器所取代。对于新项目,建议直接考虑 G1 或 ZGC;对于仍在 JDK 8 上运行且延迟敏感的老系统,理解 CMS 的原理和调优仍然具有重要价值 。
增量更新 - 漏标问题
CMS的增量更新策略是一种专为解决并发垃圾收集中"对象漏标"问题而设计的核心机制。要理解它,我们首先要从并发标记面临的挑战说起。
并发标记的挑战与三色标记法
并发垃圾收集器(如CMS)为了减少停顿时间,允许垃圾收集线程与应用线程同时工作。这引入了不确定性:在标记存活对象的过程中,应用线程可能正在修改对象间的引用关系。
为了解决这个问题,垃圾收集器普遍采用三色标记法来抽象和追踪对象的标记状态:
-
白色 :表示对象尚未被垃圾收集器访问过 。在标记结束时,仍为白色的对象被视为垃圾。
-
灰色 :表示对象本身已被垃圾收集器访问到,但它所引用的其他对象还没有被完全扫描。
-
黑色 :表示对象及其直接引用的对象都已被完全扫描,被认为是"已标记完成"的存活对象。
标记过程从GC Roots(如静态变量、活动线程栈上的引用)开始,初始时所有对象为白色。GC Roots直接引用的对象被置为灰色。垃圾收集器会不断从灰色集合中取出对象,将其引用的白色对象变为灰色,并将自身变为黑色。当灰色集合为空时,标记完成。
在并发标记阶段,如果应用线程修改了引用关系,就可能引发"对象消失"问题,即一个本应存活的对象被错误地标记为垃圾。这通常需要同时满足两个条件:
-
赋值器插入了一条或者多条从黑色对象到白色对象的新引用。
-
赋值器删除了全部从灰色对象到该白色对象的直接或间接引用。
增量更新策略的核心目标就是打破上述第一个条件。
增量更新策略的工作原理
增量更新的思路是:当黑色对象被赋予对白色对象的新引用时,打破其"黑色"的稳定状态,将其"变回"灰色 。这样,垃圾收集器在后续阶段会重新扫描这个黑色对象,从而发现其新引用的白色对象,避免其被漏标。
这一机制主要通过写屏障 技术实现。写屏障可以看作是JVM在对象引用赋值操作(如 objA.field = objB)前后插入的一段"钩子"代码。
当发生 a.field = b这样的赋值操作时,写屏障会介入检查:
java
// 写屏障逻辑示意
if (gc_phase == CONCURRENT_MARK && // 处于并发标记阶段
a != null && b != null &&
isMarkedBlack(a) && // a是黑色对象
isMarkedWhite(b)) { // b是白色对象
remarkSet.add(a); // 将黑色对象a记录到待重新扫描的集合中
// 或者直接将a的颜色从黑色变为灰色
}
G1垃圾回收器(可预测的停顿时间、高吞吐量)
G1垃圾收集器是JDK 9及以后版本的默认收集器 ,G1的设计目标是在大内存场景下,实现可预测的停顿时间,同时兼顾高吞吐量。
| 核心模块 | 键细节 |
|---|---|
| 基础设计与目标 | 设计目标(可预测停顿、高吞吐)、Region分区模型(Eden, Survivor, Old, Humongous)、弱化分代概念 。 |
| 核心数据结构 | 记忆集(RSet) :如何记录跨Region引用,避免全堆扫描 。 收集集(CSet):如何在Young GC和Mixed GC中动态选择要回收的Region 。 |
| 关键算法与机制 | 写屏障(Write Barrier) :如何拦截引用变化,维护RSet和SATB的准确性 。 SATB(Snapshot-At-The-Beginning):如何在并发标记阶段保证标记正确性,防止漏标 。 |
| 完整回收流程 | 年轻代GC(Young GC) :触发条件、STW过程、对象复制与晋升 。 并发标记周期(Concurrent Marking Cycle) :初始标记、根区域扫描、并发标记、最终标记、清理等阶段 。 混合回收(Mixed GC) :如何同时回收年轻代和部分选定的老年代Region 。 Full GC:触发条件(如晋升失败)及其作为失败保护机制的角色 。 |
Garbage-First
G1的核心设计思想是"Garbage-First ",即优先回收垃圾比例最高(最"脏")的Region,从而在有限的时间内尽可能多地释放内存。这与之前收集器固定划分新生代、老年代的模式有本质区别。
-
核心目标 :为用户提供可预测的停顿时间模型 。你可以通过
-XX:MaxGCPauseMillis参数设置一个期望的最大停顿时间目标(如200ms) ,G1会尽力达成这个目标,但它是一个软目标,并非绝对保证。 -
实现方式 :G1通过维护一个Region的优先级列表,并根据"回收所能获得的空间大小"以及"回收所需时间"的经验值来计算每个Region的"回收价值",每次收集都会优先选择价值最高的Region。
内存布局:化整为零的Region
G1不再采用连续物理内存的新生代、老年代划分,而是将整个堆划分为多个大小固定、物理上不连续 的Region。每个Region的大小可以通过 -XX:G1HeapRegionSize设置,范围通常在1MB到32MB之间,必须是2的幂。

如上面的图所示,G1垃圾回收器采用了内存分区(Region)思路,将整个堆空间分成若干个大小相等的内存区域,这些内存区域的大小一般大小在1MB~32MB之间,可以通过设置启动参数 -XX:G1HeapRegionSize=n 来指定分区的大小。
Region的类型包括:
-
Eden Region:存放新创建的对象。
-
Survivor Region:存放年轻代垃圾收集后存活的对象。
-
Old Region:存放长期存活的对象。
-
Humongous Region :专门用于存放大对象 (大小超过Region容量一半的对象)。如果一个Humongous Region放不下,会分配连续的多个。大对象的管理是G1的一个重点和潜在性能瓶颈。
-
Free Region:空闲区域,等待分配。
在上图中,我们注意到还有一些Region标明了H,它代表Humongous,这表示这些Region存储的是巨大对象(humongous object,H-obj),即大小大于等于region一半的对象。
H-obj有如下几个特征:
- * H-obj直接分配到了old gen,防止了反复拷贝移动。
- * H-obj在global concurrent marking阶段的cleanup 和 full GC阶段回收。
- * 在分配H-obj之前先检查是否超过 initiating heap occupancy percent和the marking threshold, 如果超过的话,就启动global concurrent marking,为的是提早回收,防止 evacuation failures 和 full GC。
为了减少连续H-objs分配对GC的影响,需要把大对象变为普通的对象,建议增大Region size。
G1 卡片(Card)标记机制

如上图所示,在G1垃圾回收器中,每个分区内部进一步被划分为若干大小为512 Byte的卡片(Card)。这些卡片用于标识堆内存的最小可用粒度。所有分区的卡片记录在全局卡片表中(Global Card Table),而分配的对象将占用物理上连续的若干个卡片。
- 卡片(Card): G1将每个分区(Region)进一步划分为大小为512 Byte的卡片,这是最小的标记单位,对象的分配和回收都以卡片为基本单位进行,这个卡片的大小是可以进行设置和调整的。
- 全局卡表( Global Card Table**): 所有分区的卡片状态会被记录在全局卡片表中,这个表维护了对每个卡片的引用状态,**用于在垃圾回收过程中准确地追踪对象引用关系。
- 对象分配物理连续性: 分配的对象会占用物理上连续的若干个卡片,这种设计有助于提高内存的利用率,并简化对内存的管理。
- 引用查找: 对分区(Region)内对象引用的查找通过记录卡片(Card)的方式实现 ,G1维护了一个叫做RSet(Remembered Set)的数据结构,用于记录在分区内对象的引用关系。
- 回收处理: 每次对内存的回收都涉及对指定分区的卡片进行处理。通过检查RSet中的信息,G1能够确定哪些对象是不再被引用的,从而进行相应的回收操作。
如何通过卡表解决跨代引用问题?

如图所示,OLD区里引用了EDEN区中的对象,那么我们直接回收EDEN区是可能出现问题的,这就是跨代引用。
G1收集器通过引入记忆集(Remembered Set,RSet)和Card Table来解决跨代引用问题。
记忆集(Remembered Set,RSet)
在G1中,堆被划分为多个大小相等的 Region 。传统的"跨代引用"在这里变成了 "跨Region引用" 。记忆集就是为每个Region配备的一个"账本",专门记录"谁引用了本Region中的对象"。
-
核心作用 :避免全堆扫描。当需要回收某个Region(如Region A)时,为了找出其中所有存活对象,GC需要知道哪些对象从GC Roots出发是可达的。如果没有RSet,就必须扫描堆中所有其他Region,开销巨大。有了RSet,GC只需要扫描Region A自身的RSet ,就能找到所有来自外部的引用,将这些引用作为GC Roots的扩展,从而快速准确地标记出Region A中的存活对象。
-
实现粒度 :RSet通常采用卡表 来实现。它将堆内存划分为多个512字节的卡页,并用一个字节数组(卡表)来对应这些卡页。如果一个卡页内存在指向本Region的引用,卡表中对应的字节就被标记为"脏"。这样,RSet无需记录精确到单个对象的引用,大大节省了空间。对于G1,每个Region的RSet本质上是一个哈希表,其键是其他Region的地址,值是一个卡表,记录该Region中哪些卡页存在指向本Region的引用。

如上图所示:
-
Region 1, 2, 3 :代表G1堆内存中划分出来的三个独立区域。每个Region大小固定,可以是Eden、Survivor或Old类型。
-
RSet for Region X :即 "记忆集" 。这是每个Region私有的数据结构
-
蓝色箭头 :表示对象之间的引用关系 。箭头的方向是从引用者指向被引用者。
以图中最典型的 Region 2 为例:
-
观察引用 :图中有一个蓝色箭头从 Region 1 指向 Region 2。这意味着,位于Region 1中的某个对象A,其某个字段引用着位于Region 2中的对象B。
-
记录在册 :由于这是一个跨Region的引用 ,G1的写屏障 会捕捉到这个操作。它不会去记录复杂的对象信息,而是会找到对象A所在的内存卡页 ,并将这个卡页的地址,作为一条记录,登记到被引用对象B所在Region(即Region 2)的RSet中。
-
RSet的内容 :因此,
RSet for Region 2里面的一个小方块,就代表着类似这样的一条记录:"Region 1 的某个卡页内存有指向我的对象"。RSet中可能有多条记录,说明可能有来自多个不同Region的引用。
为什么需要RSet?------ 解决跨Region引用扫描难题
这是G1设计的精妙之处。想象一下,如果G1要回收 Region 2 ,它必须准确找出Region 2中所有存活的对象。存活的对象包括:
-
被GC Roots(如线程栈上的局部变量)直接引用的对象。
-
被堆内其他存活对象引用的对象。
如果没有RSet,为了找到第2类对象,G1就不得不扫描整个堆(除Region 2以外的所有Region),来寻找谁引用了Region 2。这在堆内存很大时,代价是无法承受的。
有了RSet之后,整个过程变得极其高效:
当G1需要回收 Region 2 时:
-
它会以GC Roots作为起点开始标记。
-
同时 ,它会查看
RSet for Region 2。 -
RSet直接告诉G1:"嗨,Region 1的某个卡页里可能有你的存活对象的线索。"
-
G1于是只需精确地扫描RSet指向的那一个(或几个)特定的卡页,就能找到所有来自外部的引用,并将这些引用也作为标记的起点。这避免了扫描整个Region 1和Region 3。
写屏障(Write Barrier)
屏障(Barrier)是指在原生代码片段中,当某些语句被执行时,屏障代码也会被执行。而G1主要在赋值语句中,使用写前屏障(Pre-Write Barrier)和写后屏障(Post-Write Barrier)。
G1 垃圾收集器中的写屏障(Write Barrier) 是其实现高效并发回收的核心机制之一。它就像一位在每次对象引用关系变更时 都会被触发的"监察员",主要职责是记录下这些变更的踪迹,从而帮助 G1 在后续的垃圾回收中,无需扫描整个堆就能准确地找到所有存活对象。
写屏障的双重职责
G1 的写屏障实际上包含两种屏障,它们在不同阶段发挥作用,共同维护垃圾回收的正确性:
-
写前屏障 - 维护并发标记的正确性(漏标问题)
在引用被更新之前 ,写前屏障会被触发。它的核心任务是配合 G1 的 SATB(Snapshot-At-The-Beginning) 算法。当某个对象的引用被断开时(例如,设置
obj.field = null),写前屏障会将被断开的旧引用(即原来的那个对象)记录下来。这样,垃圾回收器在并发标记阶段就会基于标记开始时对象图的快照进行扫描,即使后来引用断开了,快照中的对象也不会被错误地当作垃圾回收,从而解决了"漏标"问题 。 -
写后屏障 - 维护记忆集(RSet)的准确性(跨代引用)
在引用被更新之后 ,写后屏障会被触发。这是解决跨Region引用 问题的关键。当一次引用更新导致一个 Region 中的对象引用了另一个 Region 中的对象时,写后屏障会进行记录 。需要注意的是,为了效率,G1 的写后屏障并不直接更新 RSet,而是通过一个间接的机制------**卡表(Card Table)** 。
-
判断与过滤:当引用更新后,写屏障首先会进行一系列高效的判断 。
-
判断是否跨Region:只有当引用者和被引用者不在同一个 Region 时,才需要记录。
-
过滤空引用 :如果新引用是
null,则无需记录。 -
过滤重复标记:如果该引用所在的内存区域(卡页)已经被标记为"脏",则跳过,避免重复工作。
-
-
标记卡表(Card Table) :如果确定需要记录,写屏障会找到存储该引用变量的内存地址,并根据地址计算出其对应的 卡页(Card Page)。卡表是堆内存的一个位图,每个位代表一个卡页(通常是512字节)。写屏障会将这个卡页在卡表中对应的位置标记为"脏"(Dirty)。
-
异步更新RSet :有一个或多个卡页被标记为"脏",并不意味着 RSet 会立即更新。G1 使用 脏卡队列(Dirty Card Queue, DCQ) 来批量处理这些信息 。有专门的 Refine 线程 会异步地从队列中取出脏卡信息,然后扫描这个脏卡对应的内存区域,找出其中所有的跨 Region 引用,最后再去更新被引用者所在 Region 的 RSet 。这种"生产者-消费者"模式将繁重的 RSet 更新工作与应用程序线程解耦,减少了对程序性能的影响。
-
始快照算法(Snapshot at the Beginning,SATB)- 漏标问题
在G1垃圾收集器中,SATB(Snapshot-At-The-Beginning)算法 最核心的用处是 在并发标记阶段维持堆内存对象关系的"逻辑快照",从而解决因应用程序与垃圾收集器同时运行而可能导致的"对象漏标"问题,确保垃圾回收的正确性。
对象漏标:垃圾回收线程 和 应用程序线程 同时运行,回收器在标记存活对象的同时,程序却在不停地修改对象之间的引用关系,这会导致回收器看到的对象关系"快照"已经过时了。漏标的发生必须同时满足以下两个条件:
-
赋值器插入了一条或一条以上从黑色对象到白色对象的新引用:一个已经被标记为黑色(表示它及其引用的所有对象都已完成扫描)的对象,突然又引用了一个尚未被扫描的白色对象。
-
赋值器删除了全部从灰色对象到该白色对象的直接或间接引用:同时,原来可能引用这个白色对象的灰色对象(表示自身已扫描,但引用的对象还未扫描)断开了对它的引用。
当以上两个条件同时满足时,这个白色对象就"消失"了:因为引用它的黑色对象不会被重新扫描,而原本可能引用它的灰色对象又断开了链接。垃圾回收器最终会认为这个对象是不可达的垃圾,从而将其错误回收。
SATB算法通过创建一个标记开始时的堆内存快照 来解决这个问题。它的基本思路是:只要在标记开始时是存活的对象,就会被视为在整个标记周期中始终存活 。
G1通过写屏障 技术来实现SATB。具体来说,当程序要删除一个引用时(例如 objB.field = null),写屏障会介入,将被删除的旧引用(即原来指向的那个对象) 记录下来。在后续的重新标记阶段,G1会把这些记录下来的旧引用作为根,重新扫描一遍,从而确保在快照时被引用的对象不会被遗漏。
| 特性 | CMS的增量更新 | G1的SATB |
|---|---|---|
| 核心思想 | 关注"新增"的引用。破坏漏标第一个条件,确保新建立的连接不被遗漏。 | 关注"删除"的引用。破坏漏标第二个条件,基于标记开始时对象图的快照进行保守标记。 |
| 写屏障记录内容 | 记录新增引用的发起方(黑色对象)。 | 记录被覆盖的旧引用所指的对象。 |
| 主要代价 | 重新标记阶段需要重新扫描变更的黑色对象,可能增加停顿。 | 会产生更多的浮动垃圾,因为快照时存活的对象都会被保留。 |
| 设计考量 | 适用于传统的连续分代内存布局。 | 更适合G1的Region分区模型,能与记忆集高效协作。 |
CSet(收集集合,Collection Set)
CSet可以理解为每次垃圾回收(GC)事件中,被划定并准备进行清理的特定内存区域(Region)的集合 。
G1不再像传统收集器那样一次性地回收整个新生代或老年代,而是根据用户设定的停顿时间目标(-XX:MaxGCPauseMillis),智能地选择一组回收价值最高(即垃圾最多)的Region组成CSet。每次GC只处理CSet中的Region,从而将一次长时间的回收,拆分成多次短暂的、可控的回收 。
基本原理 :在任意一次收集暂停中,CSet内的所有Region都会被彻底处理:其中的存活对象会被复制到新的空闲Region中,而后整个原Region将被完全清空并回收。无论处理的是年轻代还是老年代Region,其回收机制是统一的(都是采用复制的算法)
CSet 的两种类型与触发条件
CSet的具体内容取决于触发的GC类型,主要分为两种:
1. 年轻代收集的CSet
-
触发条件 :当 Eden区被填满 时触发 。
-
CSet组成 :仅包含所有的年轻代Region ,即所有的Eden区和Survivor区。其目标是回收大部分"朝生夕死"的对象。
-
工作流程 :将Eden区中的存活对象复制到Survivor区,而Survivor区中存活年龄达到阈值(
MaxTenuringThreshold)的对象则会晋升到老年代。之后,整个原有的Eden和Survivor区被整体回收 。
2. 混合收集的CSet
-
触发条件 :当老年代占用整个堆的空间比例超过IHOP阈值 (默认45%,通过
-XX:InitiatingHeapOccupancyPercent设置)时,在完成一个并发标记周期 后触发 。 -
CSet组成 :这是G1的精华所在。它包含两部分 :
-
所有的年轻代Region:和Young GC一样,这部分是必选的。
-
一部分被筛选出的老年代Region :G1会根据回收收益 ,从老年代中筛选出**垃圾比例最高(即存活对象最少)**的Region加入CSet。筛选并非全部回收,而是增量进行。
-
-
筛选策略:G1通过启发式算法来保证效率和安全性 :
-
收益门槛 :通过
-XX:G1MixedGCLiveThresholdPercent(默认85%)设置。如果一个老年代Region中的存活对象比例高于此值,则认为回收它的成本太高,本次不予考虑 。 -
数量限制 :通过
-XX:G1OldCSetRegionThresholdPercent(默认10%)设置一次Mixed GC最多能回收的老年代Region数量,防止一次回收过多Region导致停顿时间过长 。 -
增量完成 :混合收集会分多次进行(默认最多8次,通过
-XX:G1MixedGCCountTarget调节),直至将并发标记周期中识别出的所有可回收的老年代Region处理完毕 。
-
CSet的高效运作离不开另一个关键组件------记忆集(RSet, Remembered Set)。可以这样理解它们的关系 :
-
CSet 回答"回收哪里":它确定了本次GC要清理的目标区域。
-
RSet 回答"如何安全回收" :每个Region都有一个RSet,它像一个"外来人口登记簿",记录了其他Region中的对象对本Region内对象的引用。
当回收CSet中的一个Region时,为了找出其中所有存活对象,GC线程需要从GC Roots出发,还要扫描所有可能引用了该Region内对象的外部引用。如果没有RSet,就不得不扫描整个堆,开销巨大。有了RSet,GC线程只需扫描CSet中每个Region的RSet,就能快速、精确地找到所有这些外部引用,并将其作为GC Roots的扩展,从而在极小时空开销下完成回收 。
垃圾回收流程
阶段一:年轻代回收(Young GC)
年轻代回收是G1中最频繁发生的回收类型,专注于回收生命周期短暂的对象。
-
触发条件 :当Eden区被对象填满时,G1会触发一次Young GC。
-
执行特点 :这是一个 Stop-The-World(STW) 事件,意味着所有应用线程会被暂停,但G1会使用多个GC线程并行地进行垃圾回收,因此停顿时间通常很短。
核心过程:
-
根扫描 :从GC Roots(如静态变量、线程栈局部变量)开始,结合**记忆集(RSet)** 中记录的老年代对年轻代的引用,作为扫描入口。
-
更新RSet:处理由写屏障(Write Barrier)记录的引用变更卡片(Dirty Card),更新RSet,以准确反映老年代对年轻代的跨代引用。
-
对象复制 :将Eden区和Survivior区(From)中的存活对象 复制到新的Survivor区(To)中。每个对象经历一次Young GC后,年龄会增加。当年龄超过阈值(默认15),这些对象会被**晋升(Promote)** 到老年代。
-
区域回收:清空原有的Eden区和From区,它们变为可分配的空闲Region。
阶段二:并发标记周期(Concurrent Marking Cycle)
并发标记周期是G1区别于传统收集器的关键,它为后续的混合回收(Mixed GC) 做准备,其目标是找出老年代中哪些Region最具回收价值。这个过程涉及多次STW和并发执行。
1. 初始标记:
-
特点:短暂的STW暂停。
-
任务 :标记从GC Roots直接可达 的对象。这个阶段通常会借机执行一次Young GC,并利用其STW期来完成,因此开销很小。
2. 根区域扫描:
-
特点 :与应用线程并发执行。
-
任务 :扫描Survivor区(在初始标记后存活下来的对象)中直接引用的老年代对象。此阶段必须在下一次Young GC开始前完成,否则会等待。
3. 并发标记:
-
特点 :耗时最长,但与应用线程完全并发执行,对应用影响最小。
-
任务 :从已标记的对象开始,遍历整个堆 ,识别所有存活对象。G1使用**SATB(Snapshot-At-The-Beginning)** 算法来保证在标记过程中引用关系发生变化时的正确性。
4. 最终标记:
-
特点:STW暂停。
-
任务 :处理在并发标记期间由于应用运行而产生的引用变更记录,最终完成所有存活对象的标记。G1的SATB算法使得此阶段比CMS的重新标记更快。
5. 清理:
-
特点:主要是STW的,包含部分并发工作。
-
任务:
-
统计每个Region中存活对象的比例,并依据**回收价值(回收所能获得的空间)和成本(复制存活对象的时间)** 进行排序。
-
识别出完全空闲的Region并立即回收。
-
此阶段并不实际进行垃圾回收,只是为Mixed GC筛选出目标Region。
-
阶段三:混合回收(Mixed GC)
在并发标记周期结束后,G1并不会立即回收所有老年代垃圾,而是启动混合回收。
-
触发条件 :当老年代占用率超过阈值 (默认45%,通过
-XX:InitiatingHeapOccupancyPercent设置)时,在并发标记周期完成后触发。 -
执行特点:STW暂停,其停顿时间目标与Young GC类似。
-
核心过程 :Mixed GC的回收算法与Young GC完全相同,都是采用复制算法。但其回收集(Collection Set) 不仅包括所有的年轻代Region ,还会加入一部分在并发标记周期中筛选出的垃圾比例最高、最具回收价值的老年代Region。G1会通过多次Mixed GC(默认最多8次)来逐步回收这些老年代Region,从而将一次大规模的回收拆分成多次可控的短停顿。
阶段四:完全垃圾回收(Full GC) - 备份方案
Full GC是G1竭力避免的最后手段。
-
触发条件 :当垃圾产生速度过快,Mixed GC的回收速度跟不上对象分配的速度 ,导致没有足够的内存容纳新对象时,G1会**退化(Fall Back)** 到Full GC。
-
执行特点 :这是一次长时间的、单线程的STW暂停 。G1会启用Serial Old收集器来对整个堆进行回收和压缩,整个过程会非常缓慢,对应用性能影响巨大。
-
调优目标 :G1的设计和调优的终极目标之一就是避免Full GC的发生。
| 回收类型 | 触发条件 | 回收范围 | STW情况 | 核心目标 |
|---|---|---|---|---|
| Young GC | Eden区满 | 年轻代 (Eden + Survivor) | 短暂停,并行 | 快速回收短期对象,防止晋升过快 |
| 并发标记 | 老年代占用超阈值 | 全堆(仅标记,不回收) | 两次短暂STW,其余并发 | 为Mixed GC找出高价值回收目标 |
| Mixed GC | 并发标记周期结束 | 所有年轻代 + 部分高价值老年代 | 短暂停,并行 | 增量、可控地回收老年代,避免Full GC |
| Full GC | 内存分配/回收速度失衡 | 整个堆(年轻代+老年代) | 长时间STW,单线程 | 内存耗尽时的保底方案,应极力避免 |
ZGC垃圾回收器
ZGC(Z Garbage Collector)是Java 11中引入的一款以极低停顿时间 和支持超大堆内存 为核心目标的垃圾回收器。它通过多项创新技术,即使堆容量达到TB级别,也能将垃圾回收的停顿时间控制在10毫秒以内,并且停顿时间不会随堆容量增大而增加。
内存布局
ZGC采用了 基于 Region 的分区模型,但与传统分代收集器(如 G1)不同,ZGC 目前暂未分代(即没有物理上隔离的新生代、老年代)。它将整个堆空间划分为三类不同大小的 Region (也称为 Page),以适应不同大小的对象。

如上图所示,ZGC的Region分类包括:
- Small Region(小区域): 大小为2MB,用于存放小于256KB的小对象。这有助于提高小对象的分配和回收效率。
- Medium Region(中等区域): 大小为32MB,用于存放大于等于256KB但小于4MB的对象。中等区域的引入有助于处理中等大小的对象。
- Large Region(大区域): 大区域的大小是可变的,最小为4MB。每个大区域只用于存放一个大对象,且不会被重新分配,这有助于处理大对象,减少内存碎片化。
染色指针(ZGC的垃圾标记算法)
在ZGC出现之前,GC信息被保存在对象头的Mark Word当中,如64位的JVM,对象头的Mark Word中保存的信息如下:

64位的MarkWord中保存了GC和对象相关的信息,包括:对象的哈希码、分代年龄、锁记录等。由于这些信息存在于对象头的MarkWord中,而对象实体一般存储在堆内存中,虚拟机栈帧中的局部变量表中存储的则是堆中对象的引用(指针)。
因此,当访问对象的时候获取这些信息是很方便的,但是当我们在希望不直接访问对象,仅仅通过对象的指针就得到这些信息 的话,这种实现方式就做不到。例如,在ZGC之前的垃圾回收器中,使用三色标记法,颜色标记都是被打在对象头的MarkWord当中的,这样判断是否是垃圾需要去实际访问堆中对象。
染色指针技术放弃了传统的思路,它直接将GC所需的元数据(如标记状态、重定位状态)嵌入到64位内存指针的高几位中 。染色指针可以将颜色标记直接记录在指针上,这样就省去了访问实际对象的过程。这样一来,垃圾标记阶段遍历的不再是对象图来标记垃圾,而是通过遍历"引用图"来标记垃圾,引用的对象便是堆中的对象。
染色指针的结构
染色指针简单来说就是一种将额外少量的垃圾标记信息(颜色信息)存储在对象指针上 的技术。它重新定义了 64 位指针的含义,将垃圾回收所需的元数据直接嵌入指针的高位,而非存储在对象头中。在64 位操作系统中,对象指针的长度也是64位,染色指针的结构图如下:

1. 地址位 (低42位)
这42位用于存储对象的实际内存地址 。理论上,2^42 字节 = 4TB,这意味着 ZGC 在不进行额外配置时,最大可管理 4TB 的堆内存 。这个设计充分考虑了现代操作系统的实际寻址能力(如 Linux x86_64 系统通常使用 48 位虚拟地址空间),确保了地址转换的效率 。
2. 元数据位 (中间的4位)
这是染色指针的精髓所在。ZGC 固定使用指针的第 42 至 45 位(即紧接在42位地址空间之后的4个高位)来存储四个关键的GC状态标志 。这四个位是:
-
MARKED0和MARKED1(标记位0和1) :用于并发标记阶段 。ZGC 会交替使用这两个位(例如,一个GC周期用MARKED0,下一个周期就用MARKED1),这是为了防止"ABA问题",确保在并发标记过程中能正确识别对象的标记状态变化。-
第一次GC周期 :使用
MARKED0作为活跃视图。对象A和B被标记为存活(它们的MARKED0位被置1)。在转移阶段,只有对象A被移动到新位置,其标记位被置为Remapped;对象B未被移动,仍保留MARKED0标记。 -
第二次GC周期开始 :此时堆中的对象状态是混合的 。如果本次周期仍然使用
MARKED0作为活跃视图,ZGC将无法区分一个带有MARKED0标记的对象(比如对象B)到底是上一次周期标记过的存活对象 ,还是本次周期新产生的存活对象。这会导致标记混乱。
-
-
REMAPPED(重映射位) :用于并发重定位(转移)阶段 。如果此位被设置,表示该对象已经被移动到了新的内存地址。读屏障在访问指针时会检查此位,如果发现对象已被转移,就会通过"转发表"自动将访问重定向到新地址,这个过程称为指针的"自愈 " 。 -
FINALIZABLE(可终结位) :这是一个相对次要的位,用于标记该对象有需要被执行的finalize()方法,以便GC进行特殊处理 。
3. 未使用位 (高18位)
在 64 位指针中,最高的18位在当前设计中通常未被使用。这是由于硬件和操作系统的实际限制,例如 x86-64 架构目前主要使用 48 位进行虚拟地址寻址 。这些位为未来的扩展预留了空间。
内存多重映射
上面的染色指针的实现方案中存在着一个问题------如何映射到真实的内存物理地址。因为JVM作为一个普通的进程,这样随意的定义指针中的某几位,操作系统是不认可得。实际上Java程序最终都会被转换成机器指令交给具体的平台去执行,CPU可不会认可你Java自己定义的指针结构,只会把整个指针都看作普通的内存地址去映射到物理内存地址。
内存多重映射的核心目的是解决 "染色指针"带来的寻址问题 。由于ZGC将GC状态信息(如Marked0, Marked1, Remapped)直接存储在对象指针的高几位上,这导致同一个对象的指针值在GC的不同阶段是不同的。操作系统和CPU会将这些带有不同标记位的指针(比如一个带着MARKED0标签的地址和一个普通的地址)视为完全不同的虚拟地址。 如果没有多重映射,当ZGC修改指针的标记位后,应用程序将无法通过这个"新"指针访问到原来的对象。
内存多重映射的核心:ZGC通过操作系统的功能,将三个不同的虚拟内存地址范围(Marked0, Marked1, Remapped),都映射到了同一块实际的物理内存上。所有指向MARKED0区的地址、所有指向MARKED1区的地址、还有所有指向REMAPPPED区的地址,都转到同一块物理内存上去。

为了解决上述问题,ZGC 使用了内存多重映射 技术 。这是在 ZGC 初始化堆内存时完成的一次性设置,其过程如下:
- 分配物理内存:ZGC 先向操作系统申请一大块连续的物理内存(或者一系列物理页)作为 Java 堆 。
- 创建三个虚拟视图 :接着,ZGC 通过
mmap等系统调用,将同一块物理内存 分别映射到进程虚拟地址空间中的三个不同地址范围 。这三个视图通常被称为:- Remapped视图:对应染色位 Remapped有效的地址空间。
- Marked0视图:对应染色位 Marked0有效的地址空间。
- Marked1视图:对应染色位 Marked1有效的地址空间。
- 建立映射关系 :操作系统内核的内存管理子系统会为这三个虚拟地址范围创建页表项,但它们最终都指向相同的物理页帧 。
自愈指针
自愈指针使引用(即指针)在应用程序毫无察觉的情况下,自动从指向旧对象地址"愈合"为指向新对象地址。
在没有自愈能力的回收器中,当对象被移动后,垃圾回收器必须暂停所有应用线程,然后遍历整个堆,找出并更新所有指向旧对象的引用。这个"更新引用"的阶段会导致长时间的STW停顿,并且停顿时间会随着堆大小和存活对象数量的增加而线性增长。
而ZGC通过自愈指针,将这项繁重的任务分摊到了应用程序线程的运行过程中 。修复引用的工作不再需要一次性的、漫长的STW暂停,而是由多个线程在程序正常执行时零敲碎打地完成。这使得ZGC的停顿时间与堆大小解耦,即使处理TB级别的堆,也能保持亚毫秒级的停顿。
自愈指针机制的本质是 "拦截-修正-更新"。
-
触发条件:对象移动与指针"过时"
ZGC在并发重分配阶段,会将存活对象从旧的Region复制到新的Region。对象被移动后,堆中可能还存在大量指向旧对象地址的"过时"指针。如果应用程序此时去访问这个过时指针,就会触发自愈机制。
-
关键执行者:读屏障
自愈行为是由读屏障 实现的。读屏障是JVM在应用程序从堆内存中读取对象引用时 自动插入的一小段检查代码。当你写
Object obj = someField;时,读取someField这个引用的操作就会触发读屏障。 -
自愈过程
读屏障会检查指针上的元数据标记位
REMAPPED(这是染色指针技术的一部分)。如果发现该指针指向的对象正处于重分配集内(即已经被移动),读屏障会立即执行以下操作:-
查询转发表:根据旧地址,在一个称为"转发表"的数据结构中查找该对象的新地址。
-
执行修正 :将当前正在被读取的引用值(即那个过时的指针)直接更新为新的地址。
-
转发访问:让程序最终访问到新地址上的对象。
-
读屏障
简单来说,读屏障是JVM在应用代码中自动插入的一小段检查指令。
- 触发时机 :当应用线程从堆内存中读取一个对象引用时 ,这段指令就会被执行。需要注意的是,它只针对引用类型的字段读取有效。
- 会触发读屏障的示例 :
Object o = obj.fieldA;(从obj对象中读取fieldA这个引用字段)。
- 会触发读屏障的示例 :
- 核心目标 :解决在并发转移阶段,应用线程可能访问到一个正在被GC线程移动的对象的旧地址的问题。读屏障能"感知"到这种并发操作,并确保应用线程总能拿到正确的、最新的对象地址 。
读屏障的实现包含两条关键路径:
-
快速路径
读屏障首先会检查目标指针的染色位 。如果指针的颜色是当前GC周期认为的"好"颜色(例如
Remapped),说明该对象不在本次回收的重分配集中,或者其引用已经是更新后的新地址。此时,读屏障会立即返回该指针,不做任何额外操作。这条路径开销极低,仅需几条CPU指令 。 -
慢速路径与"指针自愈"
如果指针的颜色是"坏"的(例如,是旧的标记位
Marked0),读屏障就会进入慢速路径。这里是其魔法的核心:-
查询转发表 :读屏障会查询一个由GC维护的转发表,这个表记录了对象旧地址到新地址的映射关系 。
-
"治愈"指针 :找到新地址后,读屏障不仅会返回新地址,还会直接更新当前应用程序正在读取的那个堆中的引用字段 ,将其值从旧地址改为新地址。这个过程被称为 **"指针自愈"** 。
-
一次性开销 :这个"治愈"操作的关键在于,每个不正确的引用只需被修复一次。之后,所有线程再访问这个字段时,读屏障检查会发现它已经是"好"颜色了,从而走快速路径。这避免了像某些回收器那样每次访问都可能产生的性能开销 。
-
垃圾回收流程
第一阶段:标记
这个阶段的目标是找出堆中所有存活的對象。
-
初始标记
-
状态 :STW。这是整个周期的第一次短暂停顿。
-
工作 :从GC Roots(如线程栈、静态变量)开始,仅标记与GC Roots直接关联的第一层对象,速度极快。
-
视图切换 :在此阶段,ZGC会将全局的地址视图 从
Remapped切换为Marked0或Marked1(下图以M0为例)。这是一个重要的并发基础 。
-
-
并发标记
-
状态 :并发。GC线程与应用程序线程同时工作。
-
工作 :从初始标记标记的对象出发,递归遍历整个对象图,标记所有可达的存活对象。在这个过程中,无论是GC线程还是应用程序线程(通过读屏障)访问对象,都会将其"染色"为当前周期的有效视图(例如M0)。
-
-
再标记
-
状态 :STW。第二次短暂停顿。
-
工作:处理在并发标记期间,因应用程序运行而可能产生的少量新增引用或变动的引用关系,确保标记的准确性。由于需要处理的对象很少,停顿时间极短 。
-
第二阶段:转移
这个阶段的目标是移动存活对象,以释放内存碎片。
-
并发转移准备
-
状态 :并发。
-
工作 :ZGC会根据标记结果,统计分析各个Region中存活对象的比例,筛选出垃圾最多、回收收益最高 的Region,组成"重分配集"。并为这些Region创建转发表 ,用于记录对象旧地址到新地址的映射关系 。
-
-
初始转移
-
状态 :STW。第三次也是最后一次短暂的停顿。
-
工作 :转移所有被GC Roots直接引用的、且位于重分配集中的对象。
-
视图切换 :在此阶段,全局地址视图 会从
Marked0切换回Remapped。
-
-
并发转移
-
状态 :并发。
-
工作 :GC线程并发地将重分配集中剩余的存活对象复制到新的Region中。新对象的地址会记录在转发表里。在此期间,如果应用程序线程试图访问一个已被转移的对象,会触发读屏障 。读屏障会查询转发表,自动将引用"自愈"到新地址,并更新堆中的指针值。这个"指针自愈"能力是ZGC实现高并发的关键 。
-
类的加载流程
当程序使用某个类时,如果该类还未被加载到内存中,则JVM会通过加载、链接、初始化三个步骤对该类进行类加载。
阶段一:加载
加载是类加载过程的入口。此阶段主要完成三件事:
- 获取二进制字节流 :JVM通过类的全限定名 (如
java.lang.String)来获取定义此类的二进制字节流。这个流可以来自多种来源,如.class文件、JAR/WAR包、网络、甚至由JSP文件或动态代理技术运行时生成。 - 转换静态结构 :将字节流所代表的静态存储结构(基于《Java虚拟机规范》的特定格式)转换为方法区(Metaspace) 的运行时数据结构
- 生成Class对象 :在内存中(堆上)生成一个代表该类的
java.lang.Class对象,作为程序访问方法区中该类各种数据(类型信息、字段、方法等)的入口。
阶段二:验证
验证是连接阶段的第一步,目的是确保Class文件的字节流是安全、合规的,不会危害虚拟机自身的安全。主要包括:
-
文件格式验证:验证字节流是否符合Class文件格式规范,例如魔数(Magic Number)是否正确。
-
元数据验证:对类的元数据信息进行语义校验,如检查类是否有父类、是否继承了不允许被继承的类(如final类)等。
-
字节码验证:通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的,例如保证方法体中的类型转换是有效的。
-
符号引用验证:发生在后续的解析阶段,确保符号引用能够被正常解析为直接引用。
阶段三:准备
在准备阶段,JVM会在方法区中为类变量(被static修饰的变量)分配内存 ,并将其初始化为默认的零值。
-
例如,对于
public static int value = 123;,在准备阶段过后,value的初始值是0,而非123。 -
但如果是常量(被
static final修饰),如public static final int value = 123;,在编译时就会为value生成ConstantValue属性,在准备阶段就会直接将其赋值为123。
阶段四:解析
解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。
-
符号引用:是一组用来描述所引用目标(如类、字段、方法)的符号,与虚拟机实现的内存布局无关。
-
直接引用:可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。直接引用和虚拟机实现的内存布局直接相关。
阶段五:初始化
初始化是类加载过程的最后一步,此时才开始真正执行类中定义的Java程序代码。在此阶段,JVM会执行类构造器 <clinit>()方法 ,该方法是由编译器自动收集类中的所有类变量的赋值动作 和静态语句块中的语句合并产生的。
-
JVM保证在子类的
<clinit>()方法执行前,父类的<clinit>()方法已经执行完毕。 -
<clinit>()方法对于类或接口并不是必须的,如果一个类中没有静态语句块,也没有对类变量的赋值操作,那么编译器可以不为这个类生成<clinit>()方法。 -
虚拟机会保证一个类的
<clinit>()方法在多线程环境中被正确地加锁和同步。如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的<clinit>()方法,其他线程都需要阻塞等待。
双亲委派机制
三种类加载器
启动类加载器 (Bootstrap ClassLoader)
启动类加载器是JVM层级结构中最顶层的类加载器,是所有类加载器的始祖。
-
实现方式 :由C/C++语言实现 ,是JVM自身的一部分,因此在Java代码中无法直接获取其引用 (通过
getClassLoader()方法会返回null)。 -
核心职责 :负责加载Java运行时的最核心的类库。
-
加载范围 :主要加载**
<JAVA_HOME>/jre/lib目录下的核心JAR包** (如rt.jar、resources.jar等)以及被-Xbootclasspath参数指定的路径中被JVM识别的类库。它加载的是Java标准库的基础,例如java.lang.*,java.util.*等包中的类
扩展类加载器 (Extension ClassLoader)
扩展类加载器是层级中的第二层,由Java语言实现,是Launcher$ExtClassLoader类的实例。
-
核心职责 :负责加载Java的扩展库。
-
加载范围 :主要加载**
<JAVA_HOME>/jre/lib/ext目录下的所有JAR包** ,或者由系统属性java.ext.dirs指定的路径中的所有类库。这个目录用于存放JDK的扩展功能包,例如JDBC的早期实现、XML处理器等就曾放在这里。 -
父加载器:它的父加载器是Bootstrap ClassLoader。
应用程序类加载器 (Application ClassLoader)
应用程序类加载器,也称为系统类加载器 ,是层级中最接近开发者的一层,由Launcher$AppClassLoader类实现。
-
核心职责 :负责加载用户类路径下指定的类库。
-
加载范围 :加载环境变量
CLASSPATH、系统属性java.class.path或者通过命令行参数-cp/-classpath所指定的JAR包和类目录。我们自己编写的Java类以及项目引入的第三方JAR包,基本上都是由它加载的。 -
默认加载器 :在大多数情况下,如果没有自定义类加载器,应用程序类加载器就是程序中默认的类加载器 。可以通过
ClassLoader.getSystemClassLoader()方法直接获取到它。 -
父加载器:它的父加载器是Extension ClassLoader。
这三种类加载器之间的协作遵循双亲委派模型。当一个类加载器收到类加载请求时,它不会立即自己去加载,而是:
-
向上委托 :将这个请求委托给自己的父类加载器去完成。
-
逐级尝试:父类加载器又会委托给它的父类加载器,直至顶层的Bootstrap ClassLoader。
-
向下反馈 :只有当所有父类加载器 在自己的加载范围内都无法完成加载(找不到指定的类)时,子加载器才会尝试自己来加载。
这种模型的核心优势是保证了Java核心库的类型安全和唯一性 。例如,无论哪个加载器试图加载java.lang.Object类,最终都会被委派给Bootstrap ClassLoader,从而确保在整个JVM中,Object类都是同一个,避免了用户自定义一个java.lang.Object类导致的核心基础被破坏的风险。
打破双亲委派模型的场景有哪些?
SPI 服务发现机制
在 Java 开发中,SPI(Service Provider Interface) 是一种非常常见的模式,它允许框架定义接口,而后由第三方厂商提供实现。最典型的例子就是 JDBC。
-
核心矛盾 :
java.sql.DriverManager这样的 SPI 接口由启动类加载器 加载,属于核心库。而数据库厂商提供的具体驱动实现(如com.mysql.cj.jdbc.Driver)则位于 ClassPath 下,由应用类加载器 加载。根据双亲委派规则,父加载器无法"向下"委托子加载器,因此DriverManager无法直接访问和加载由子加载器提供的实现类 。 -
打破方式 :Java 引入了线程上下文类加载器 机制。
DriverManager在加载驱动时,会获取当前线程的上下文类加载器(默认设置为应用类加载器),然后使用这个类加载器去加载并实例化在META-INF/services配置文件中发现的驱动实现 。这相当于让父加载器(启动类加载器)主动向子加载器(应用类加载器)"借"了一把钥匙,来打开自己无法访问的资源,完美地解决了这个"上下级"访问的难题
JVM调优
定义清晰的目标:没有统一的最优配置,最优解取决于应用场景。
-
高吞吐量型 :如后台数据批处理系统,目标是在固定时间内处理尽可能多的数据。关注点是总体的 GC 时间占比(GC Time Ratio)。
-
低延迟型 :如在线交易处理(OLTP)系统或 Web 服务,目标是降低用户请求的响应时间,减少卡顿。关注点是 GC 的停顿时间(Pause Time)。
1. 开启并分析 GC 日志:这是最重要的诊断信息。
通过 GC 日志,你可以分析:
-
Young GC 是否过于频繁?可能因为 Eden 区太小。
-
Young GC 耗时是否太长?可能因为存活对象过多或复制开销大。
-
是否有过早晋升?年轻代对象过快进入老年代,可能导致 Full GC。
-
Full GC 是否频繁且耗时很长?这是最需要解决的问题,通常意味着老年代已满或存在内存泄漏。
- 使用专业工具进行剖析:
- 命令行工具:jps(查看进程)、jstat -gcutil <pid>(实时 GC 统计)、jmap -heap <pid>(堆摘要)、jstack <pid>(线程快照,查死锁)。
- 图形化工具:JConsole、VisualVM 适合简单监控。Java Mission Control (JMC) 开销低,适合生产环境采样。
- 高级 Profiler:Arthas(在线诊断)、JProfiler、Async-Profiler 可以深入定位热点方法、内存泄漏点和锁竞争问题。
JVM问题故障排查
内存泄漏
在深入细节之前,首先需要通过一些宏观迹象判断是否存在内存泄漏的可能。
-
监控内存使用趋势 :最直接的迹象是程序的内存占用率 (如RES 物理内存或JVM堆内存使用量)随着时间的推移而持续稳定增长,即使在没有大量用户请求或业务处理的平静期也是如此 。你可以使用操作系统的任务管理器、
top命令(Linux)或htop命令来观察进程的整体内存消耗。 -
关注垃圾回收(GC)行为 :对于Java应用程序,垃圾回收情况是重要的风向标。如果你观察到 Full GC 变得越来越频繁,但每次回收后老年代(Old Generation)的内存释放得很少 ,甚至使用率还在攀升,这就是一个强烈的泄漏信号。可以使用
jstat -gcutil <pid>命令来监控GC统计信息。bash# 每隔1秒输出一次GC情况,共输出5次 jstat -gc <Java进程PID> 1000 5 -
警惕系统告警与错误 :在严重的情况下,系统会抛出
OutOfMemoryError(Java)等错误,或者在Linux系统中,内核的OOM Killer可能会终止你的进程,可以在系统日志(使用dmesg | grep -i kill命令查看)中发现线索。
生成堆转储(Heap Dump)
确认嫌疑后,就需要"现场取证",即生成堆内存的快照。
-
使用 jmap 生成堆转储:这也是 JDK 自带工具 。
bashjmap -dump:format=b,file=heapdump.hprof <Java进程PID>
分析堆转储定位根源
这是最核心的一步,你需要使用专业工具分析 .hprof文件。
-
推荐工具 :**Eclipse Memory Analyzer (MAT)** 是首选,功能强大且免费 。
-
分析步骤:
-
打开泄漏疑点报告 :MAT 启动后会自动生成一个 "Leak Suspects Report",它能快速给出最可能发生泄漏的点,比如一个特定对象实例占用了 90% 的内存 。
-
查看支配树:在支配树中,对象按"保留内存"排序,可以一目了然地找到是哪个类的哪个实例占用了大量内存 。
-
追溯引用链 :找到可疑的大对象后,右键选择 "Path To GC Roots" 或 "Merge Shortest Paths to GC Roots" ,然后排除所有软/弱引用。剩下的那条引用链,就是**阻止这个对象被垃圾回收的"罪魁祸首"** 。需要顺着这条链找到在代码中持有此引用的源头。
-
| 泄漏场景 | 问题根源 | 修复方案 |
|---|---|---|
| 静态集合 | 静态集合(如 static Map)的生命周期与应用相同,若不手动移除,其中的对象永远不会被回收 。 |
① 为集合添加清理逻辑(如 remove)。 ② 改用 WeakHashMap或专业的带过期策略的缓存库(如 Caffeine)。 |
| 未关闭资源 | 数据库连接、文件流、网络连接等未调用 close()方法 。 |
使用 try-with-resources 语句,确保资源无论是否异常都能被关闭 。 |
| Listener/Callback | 向观察者模式的事件源注册后,在对象销毁时没有注销 。 | 在对象生命周期结束时(如 destroy()方法中)主动调用移除方法 (如 removeListener)。 |
| ThreadLocal | 线程池中的线程会复用,如果使用 ThreadLocal后不调用 remove(),那么线程下次被使用时可能还持有上个任务的脏数据 。 |
在 finally块中**显式调用 threadLocal.remove()** 。 |