JVM 垃圾收集的原理与过程分析
JVM(Java Virtual Machine)垃圾收集是 Java 内存自动管理的核心机制,其目标是自动回收不再使用的对象所占用的堆内存,避免内存泄漏和手动管理内存的复杂性。
JVM 内存模型
JVM主要分为5个核心区域(6个子区域):
程序计数器(线程私有)
Java虚拟机栈(线程私有)
本地方法栈(线程私有)
Java堆(线程共享,大量对象实例的创建区域,JVM 重点关注的主战场)
方法区(线程共享,存储类信息、常量、静态变量等,可回收无用类)
运行时常量池 (属于“方法区”的一部分)
JVM 垃圾收集主要针对 Java 堆区的内存管理。对于线程私有区域,随线程结束而自动释放,只会占用栈空间。
| 区域 | 线程共享 | 作用 | 异常 | 备注 |
|---|---|---|---|---|
| 程序计数器 | 线程私有 | 记录当前线程执行的字节码行号指示器。 确保线程切换能恢复到正确位置 |
Java虚拟机规范中唯一一个没有规定OutOfMemoryError(内存不足错误)的区域。 | 它保存的是程序将要执行的指令地址。 JVM是多线程的,每一个线程都有一个独立的程序计数器(为了线程切换后能恢复到正确的执行位置),是一块较小的内存空间,它与线程共存亡。 JVM中的程序计数器指向的是正在执行的字节码地址,可以看作是当前线程所执行的字节码的行号指示器。 (如果线程执行的是非native方法,则程序计数器中保存的是当前需要执行的指令的地址;如果线程执行的是native方法,则程序计数器中的值是undefined) |
| Java虚拟机栈 | 线程私有 | Java方法执行的内存模型 存放局部变量表、操作数据栈、动态链接、方法出口等信息。 |
栈深大于允许的最大深度,抛出StackOverflowError(栈溢出错误)。 内存不足时,抛出OutOfMemoryError(内存不足错误)。 |
常说的“栈”说的就是Java虚拟机栈,或者是Java虚拟机栈中的局部变量表。 一个线程一个栈,并且生命周期与线程相同。它的内部由一个个栈帧构成,一个栈帧代表一个调用的方法,线程在每次方法调用执行时创建一个栈帧然后压栈,栈帧用于存放局部变量、操作数、动态链接、方法出口等信息。方法执行完成后对应的栈帧出栈。 **实例方法中第一个位置存放的是它所属对象的引用,而静态方法则没有对象的引用。**另外静态方法里所操作的静态变量存放在方法区。 局部变量没有默认初始值,使用必须赋值 |
| 本地方法栈 | 线程私有 | 和Java虚拟机栈类似,不过是为JVM用到的Native方法服务。 | 同上 | 通过java本地接口JNI(Java Native Interface)来调用其它语言编写(如C)的程序 本地方法栈就是虚拟机线程调用Native方法执行时的栈 |
| Java堆 | 线程共享 | 存放实例化数据。 | 内存不足时,抛出OutOfMemoryError(内存不足错误)。 | 通过-Xmx和-Xms控制大小。 GC的主要管理对象。 放置所有对象实例以及数组 不过在JIT(Just-in-time)情况下有些时候也有可能在栈上分配对象实例 GC基本都是采用的分代收集算法,所以堆内存结构还分块成:新生代和老年代;Eden空间、From Survivor、To Survivor等 |
| 方法区 | 线程共享 | 存放类信息(版本、字段、方法、接口等)、常量、静态变量、即时编译后的代码等数据。 | 内存不足时,抛出OutOfMemoryError(内存不足错误)。 | 每个类的全限定名 每个类的直接超类的全限定名(可约束类型转换) 该类是类还是接口 该类型的访问修饰符 直接超接口的全限定名的有序列表 已装载类的详细信息 运行时常量池 |
| 运行时常量池 | 线程共享 | 存放编译期生成的各种字面量和符号引用。 | 内存不足时,抛出OutOfMemoryError(内存不足错误)。 | 属于“方法区”的一部分。 |
| 直接内存 | – | 如NIO可以使用Native函数库直接分配堆外内存,该内存受计算机内存限制。 | 内存不足时,抛出OutOfMemoryError(内存不足错误)。 | 不是JVM运行时数据区的一部分,也不是JVM虚拟机规范中定义的内存区域。但这部分内存也被频繁的使用。所以放到一起。 |

Java虚拟机栈
一个线程一个栈,并且生命周期与线程相同。它的内部由一个个栈帧构成,一个栈帧代表一个调用的方法,线程在每次方法调用执行时创建一个栈帧然后压栈,栈帧用于存放局部变量、操作数、动态链接、方法出口等信息。方法执行完成后对应的栈帧出栈。
一个线程中的方法可能还会调用其他方法,这样就会构成方法调用链,而且这个链可能会很长,而且每个线程都有方法处于执行状态。对于执行引擎来说,只有活动线程栈顶的栈帧才是有效的,称为当前栈帧(Current Stack Frame),这个栈帧关联的方法称为当前方法(Current Method)。
每一个栈帧的结构都包括了局部变量表、操作数栈、方法返回地址和一些额外的附加信息。某个方法的栈帧需要多大的局部变量表、多深的操作数栈都在编译程序时完全确定了,并且写入到类方法表的相应属性中了,因此某个方法的栈帧需要分配多少内存,不会受到程序运行期变量数据变化的影响,而仅仅取决于具体虚拟机的实现。
局部变量
-
存储方法的参数和局部变量(包括基本类型和对象引用)。
-
是方法内部数据的主要存储区域。
特点:
大小在编译期确定:由方法的字节码中的
max_locals决定。以“槽(Slot)”为单位:
- 每个 Slot 可存放一个
boolean、byte、char、short、int、float、引用类型(reference)。long和double占用 2 个连续 Slot(称为“宽类型”)。索引从 0 开始:
- 对于实例方法:
Slot[0]存放隐式参数this(当前对象引用);Slot[1]开始存放方法参数;- 之后是局部变量。
- 对于静态方法:
Slot[0]直接存放第一个参数。
示例:
1 | public void add(int a, int b) { |
对应的局部变量表(假设无优化):
| SLOT | 内容 |
|---|---|
| 0 | this |
| 1 | a (int) |
| 2 | b (int) |
| 3 | c (int) |
| 4 | msg (引用) |
注意:局部变量表中的“引用”指向堆中的对象,对象本身不在栈上。
操作数栈
-
作为 JVM 执行字节码指令的工作台。
-
字节码指令(如
iadd,invokevirtual)从操作数栈中弹出操作数,计算后再压入结果。
特点:
- 后进先出(LIFO) 的栈结构。
- 大小在编译期确定:由
max_stack字段指定。- 不直接命名变量,而是通过“压栈-弹栈”传递数据。
- 与局部变量表配合完成计算。
示例:
int c = a + b;对应的字节码:
1
2
3
4 iload_1 // 将局部变量表 Slot[1] (a) 压入操作数栈
iload_2 // 将 Slot[2] (b) 压入栈
iadd // 弹出两个 int,相加,将结果压入栈
istore_3 // 弹出结果,存入 Slot[3] (c)
动态链接
-
支持方法调用时的符号引用解析。
-
每个栈帧都包含一个指向运行时常量池(Runtime Constant Pool) 中该方法引用的指针。
作用:
- 在方法执行过程中,若需调用其他方法(如
invokevirtual),JVM 通过动态链接找到目标方法的实际入口地址。- 支持多态(Polymorphism):例如,
obj.toString()在运行时根据obj的实际类型决定调用哪个toString()。实现机制:
字节码中的方法调用指令(如
invokevirtual)使用的是符号引用(类名、方法名、描述符)。第一次调用时,JVM 将符号引用解析(Resolve) 为直接引用(内存地址),并可能进行内联缓存(Inline Caching) 优化。
方法出口
保存方法正常或异常返回后,下一条应执行的字节码指令地址。也称为“方法出口信息”,方法返回地址(Method Return Address)。
正常返回(Normal Return):
- 执行到方法末尾或遇到
return指令。- JVM 从栈帧中取出返回地址,跳转到调用者方法的下一条指令。
异常返回(Abrupt Return):
- 方法内抛出异常且未被捕获。
- JVM 通过异常表(Exception Table)查找处理代码,不依赖返回地址。
Java堆内存结构
Java 堆(Heap)是 JVM 中最大的一块内存区域,也是所有线程共享的运行时数据区,几乎所有的对象实例和数组都在堆上分配。
堆是垃圾回收(GC)的主要战场,其内部结构在不同 JVM 实现和不同垃圾收集器下略有差异,但核心思想一致:分代设计 + 区域划分。
新生代(Young)
-
存放新创建的对象。
-
大多数对象“朝生夕死”,在此区域经历多次 GC 后仍存活的对象会被晋升到老年代。
Eden 区:对象首次分配的地方。绝大多数新对象在此创建。
Survivor 区(S0 和 S1):两个大小相等的区域,始终只有一个在使用(From/To)。用于存放 Young GC 后存活的对象。
其中 Eden : Survivor0 : Survivor1 = 8 : 1 : 1,通过参数
-XX:SurvivorRatio=8控制(表示 Eden 与单个 Survivor 的比值)举例:若新生代总大小为 1GB,则 Eden ≈ 800MB,S0 = S1 ≈ 100MB。
对象分配流程:
新对象优先在 Eden 分配;
Eden 空间不足 → 触发 Minor GC(Young GC);
GC 过程:
- 扫描 Eden + From Survivor(如 S0);
- 将存活对象复制到 To Survivor(如 S1);
- 清空 Eden 和 From Survivor;
- S0 与 S1 角色互换(下次 GC 时 S1 为 From,S0 为 To)。
使用 复制算法(Copying),避免内存碎片,效率高。
老年代(Old)
-
存放长期存活的对象和大对象(可配置)。
-
对象从新生代晋升而来,或直接分配(如大对象)。
-
老年代 GC(Major GC / Full GC)通常停顿时间长,应尽量避免。
对象晋升条件:
年龄阈值:对象在 Survivor 中经历
MaxTenuringThreshold次 GC 后仍存活(默认 15,-XX:MaxTenuringThreshold=15);动态年龄判定:若 Survivor 中相同年龄的对象总和 > Survivor 空间一半,则大于等于该年龄的对象直接晋升;
Survivor 空间不足:存活对象无法全部放入 To Survivor,多余对象直接进入老年代;
大对象直接分配:通过
-XX:PretenureSizeThreshold=N(单位字节),超过阈值的对象直接进入老年代(避免在 Survivor 间复制)。
回收算法:
标记-清除(Mark-Sweep):如 CMS;
标记-整理(Mark-Compact):如 Serial Old、Parallel Old;
分区回收:如 G1(逻辑上属于老年代 Region)。
特殊区域
在 G1 垃圾收集器 中,堆被划分为多个 固定大小的 Region(默认 1~32MB),不再显式区分 Eden/Survivor/Old,而是逻辑分代:
Humongous 对象:大小 ≥ Region 一半的对象。
直接分配在 连续的 Humongous Region 中。
回收效率低,应尽量避免频繁创建大对象(如大数组、大字符串)。
Java堆内存分配
为对象分配空间的任务等同于把一块确定大小的内存从Java堆中划分出来。
对象在堆内分配内存的两种方法:
指针碰撞 (Serial、ParNew等带压缩的收集器)
假设Java堆中内存是绝对规整的,所有用过的内存都放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间那边挪动一段与对象大小相等的距离,这种分配方式称为“指针碰撞”(Bump the Pointer)
空闲列表 (CMS这种基于Mark-Sweep算法的收集器)
如果Java堆中的内存并不是规整的,已使用的内存和空闲的内存相互交错,那就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式称为“空闲列表”(Free List)。
选择哪种分配方式由Java堆是否规整决定,而Java堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。因此,在使用Serial、ParNew等带Compact过程的收集器时,系统采用的分配算法是指针碰撞,而使用CMS这种基于Mark-Sweep算法的收集器时,通常采用空闲列表。
堆内存分配的优化:
为提升多线程分配效率,JVM 引入 TLAB(Thread Local Allocation Buffer):
每个线程在 Eden 中预先分配一小块私有缓冲区(TLAB);
线程创建对象时,优先在 TLAB 中分配,无需加锁;
TLAB 用尽时,才同步分配新的 TLAB 或直接在 Eden 公共区域分配。
通过
-XX:+UseTLAB启用(默认开启),通过-XX:TLABSize调整大小
判定对象是否存活
要真正宣告一个对象死亡,至少要经历两次标记过程:
如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那它将会被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法。
当对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,虚拟机将这两种情况都视为“对象可回收”。
程序中可以通过覆盖finapze()来一场自我拯救。
引用计数法
给对象中添加一个引用计数器,每当有一个地方引用它时,计数器值加1;当引用失效时,计数器值减1.
任何时刻计数器值为0的对象就是不会再被使用的。
当一个对象被创建时,就将该对象实例分配给一个变量,该变量计数设置为1
当一个对象实例被垃圾收集时,它引用的任何对象实例的引用计数器减1。
缺点:无法解决循环引用问题(如 A → B,B → A,但无外部引用)。
优点:执行较快,可以和用户线程并发执行(不存在STW)。
主流的Java虚拟机里面都没有选用引用计数算法来管理内存(Python、PHP 等使用)。
可达性分析
通过一系列“GCRoots”对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(reference chain),当一个对象到GC Roots没有任何引用链相连时,则证明对象是不可用的。
缺点:时间较长,会遍历所有的对象,且会导致STW
优点:可以解决循环引用问题
常见的 GC Roots 包括:
虚拟机栈(栈帧中的本地变量)中引用的对象。
方法区中静态属性、常量引用的对象
本地方法栈中JNI(Native方法)引用的对象。
Java虚拟机内部的引用,如基本数据类型对应的Class对象,常驻的异常对象等
**被同步锁(synchronized)**持有的对象
是否被引用?
通过以上算法,JVM如何判断对象是否被引用的。从底层数据结构分析:
对象头中的标记位(Mark Word)
每个Java对象在堆中都有一个对象头
包含用于GC的标记位(是否被访问过、是否可达等)
使用位图(Bitmap)技术高效记录对象状态
记忆集(Remembered Set)
用于记录跨代引用(如老年代对象引用新生代对象)
通常采用卡表(Card Table)实现
将堆划分为512字节的卡(Card),脏卡表示可能包含跨代引用
可达性分析的核心过程:
通过**安全点(Safepoint)**暂停所有用户线程
扫描查找GC Roots
遍历对象图
递归标记(Recursive Marking),在对象头设置标记位
三色标记算法(Tri-color Marking)
- 白色:未访问对象(初始状态)
- 灰色:已访问但引用未完全处理
- 黑色:已访问且所有引用已处理
处理特殊引用
- 处理软引用
- 清除所有弱引用
- 处理虚引用并加入引用队列
引用级别
如果reference类型的数据中存储的数值代表的是另外一块内存的起始地址,就称这块内存代表着一个引用(java.lang.ref)。
Java对引用的概念进行了扩充,将引用分为强引用、软引用、弱引用、虚引用4种,这4种引用强度依次逐渐减弱。
强引用(Strong Reference)
类似
Object obj = new Object()这类引用,只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象。软引用(Soft Reference)
用来描述一些还有用但并非必须的对象。对于软引用关联着的对象,在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行第二次回收。如果这次回收后还没有足够的内存,才会抛出内存溢出异常。
弱引用(Weak Reference)
也是用来描述非必需对象的,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集之前。当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象(ThreadLocal.ThreadLocalMap 中有实现)
虚引用(Phantom Reference)
也叫幽灵引用或幻影引用,是最弱的一种引用 关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。它的作用是能在对象被收集器回收时收到一个系统通知。
垃圾回收算法
程序计数器、虚拟机栈、本地方法栈3个区域随线程而生、随线程而灭,因此这几个区域的内存分配和回收都具备确定性。
只有线程共享区域需要通过算法标记特殊处理。
标记-清除(Mark-Sweep)
分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记对象。
-
优点:实现简单。
-
缺点:产生大量不连续的内存碎片;效率不高(需遍历两次)。
标记-整理(Mark-Compact)
标记过程和“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清除,而是让所有存活对象都向一端移动,然后直接清理掉端边界以外的内存。
-
优点:无碎片。
-
缺点:整理过程耗时。
-
适用场景:老年代(Old Generation)。
复制算法(Copying)
它将可用内存按照容量划分为大小相等的两块(From/To),每次只使用其中一块。当这一块的内存用完了,则就将还存活的对象复制到另一块上面,然后再把已经使用过的内存空间一次清理掉。使得每次都是对整个半区进行内存回收。
-
优点:无碎片、效率高(只需移动指针)。
-
缺点:内存利用率仅 50%;较多复制操作,效率较低。
-
适用场景:新生代(Young Generation),因对象“朝生夕死”。
三色标记算法
-
黑色: 根对象,或者该对象与它的子对象都被扫描
-
灰色: 对象本身被扫描,但还没扫描完该对象中的子对象
-
白色: 未被扫描对象,扫描完成所有对象之后,最终为白色的为不可达对象,即垃圾对象
当GC开始扫描对象时,按照如下图步骤进行对象的扫描:
根对象被置为黑色,子对象被置为灰色。继续由灰色遍历,将已扫描了子对象的对象置为黑色。
遍历了所有可达的对象后,所有可达的对象都变成了黑色。不可达的对象即为白色,需要被清理。
分代收集思想
-
基于 “弱代假说”:
- 大多数对象朝生夕死;
- 熬过多次 GC 的对象越难死亡。
-
将堆分为:
- 新生代(Young):使用 复制算法(如 Eden + Survivor)。
- 老年代(Old):使用 标记-清除 或 标记-整理。
- 永久代 / 元空间(Metaspace):存储类元数据(JDK 8+ 用 Metaspace 替代 PermGen)。
垃圾收集器
-
串行收集器(Serial和Serial Old)
只能有一个垃圾回收线程执行,用户线程暂停。
适用于内存比较小的嵌入式设备。
1
2-XX:+UseSerialGC
-XX:+UseSerialOldGC -
并行收集器[吞吐量优先]( Parallel Scanvenge、Parallel Old)
多条垃圾收集线程并行工作,但此时用户线程仍然处于等待状态。
适用于科学计算、后台处理等若交互场景。
1
2-XX:+UseParallelGC
-XX:+UseParallelOldGC -
并发收集器[停顿时间优先](CMS、G1)
用户线程和垃圾收集线程同时执行(但并不一定是并行的,可能是交替执行的),垃圾收集线程在执行的时候不会停顿用户线程的运行。
适用于相对时间有要求的场景,比如Web。
1
2-XX:+UseConcMarkSweepGC
-XX:+UseG1GC
通过命令查看JDK使用的默认收集器:
查看JDK8使用的默认收集器:
java -XX:+PrintFlagsFinal -version | grep .\*Use.\*GC.\*Parallel(并行)和 Parallel Old(并行)
![]()
JDK9及之后使用的收集器:G1
![]()

Parallel 收集器(并行)
JDK8 的默认收集器,需要配合 Parallel Old。吞吐量优先的垃圾收集器,年轻代收集器
Parallel Scavenge 收集器是一个新生代收集器,采用复制算法,并且是多线程收集器
- CMS等收集器是尽可能缩短垃圾收集时用户线程的停顿时间
- Parallel 收集器则是达到一个可控制的吞吐量(Throughput)
这里所谓的吞吐量是指CPU用于运行用户代码的时间与CPU总消耗时间的比值,既
吞吐量 = 运行用户代码时间 / (运行用户代码时间 + 垃圾收集时间),虚拟机总共运行了100分钟,其中垃圾收集花掉了1分钟,那么吞吐量就是99%。Parallel Scavenge 收集器提供了两个参数用于精确控制吞吐量,分别是控制最大收集停顿时间的
-XX:MaxGCPauseMillis参数以及直接设置吞吐量大小的-XX:GCTimeRatio参数。
Parallel Old收集器(并行)
JDK8 的默认收集器,需要配合 Parallel。是老年代的并行收集器,使用多线程和“标记-整理”算法。
**MaxGCPauseMillis:**参数允许的值是一个大于0的毫秒数,收集器将尽可能地保证内存回收花费的时间不超过设定值。
GC停顿时间缩短是以牺牲吞吐量和新生代空间换取来的:新生代调小导致收集更频繁,吞吐量也下降。
**GCTimeRatio:**参数的值是一个大于0且小于100的整数,也就是垃圾收集时间占总时间的比率,相当于是吞吐量的倒数。
如果把此参数设置为19,那允许的最大GC时间就占总时间的5%(既
1 /(1 + 19)),默认值为99,允许最大1%(既1 /(1 + 99))的垃圾收集时间。
在注重吞吐量以及CPU资源敏感的场合,可以优先考虑Parallel Scavenge 加 Parallel Old 收集器。
ParNew收集器(并发)
新生代收集器,CMS默认搭配,Serial的多线程版本。
-
-XX:UseParNewGC:启用ParNew收集器。
-
-XX:ParalletGCThreads:设定并行垃圾收集的线程数量。
默认开启的线程数等于cpu数。
多核环境较Serial效率高。
并行收集(非并发)。
复制算法。
CMS收集器(并发)
以获取最短回收停顿时间为目标。垃圾收集的线程和用户执行的线程是可以同时执行的。
基于标记-清除算法:只将标记为不存活的对象删除,并不会移动对象整理内存空间,会造成内存碎片参数:-XX:CMSFullGCsBeforeCompaction=n
只会回收老年代和永久代(元数据区),不会收集年轻代(年轻带只能配合Parallel New或Serial回收器)
一种预处理垃圾回收器(在内存用尽前,完成回收操作,触发阈值,默认是老年代或永久带达到92%)
并发收集、低停顿
处理过程:
初始标记(STW) - > 并发标记 ->重新标记(STW) ->并发清除.
初始标记(CMS-initial-mark) , 只标记老年代中与GC ROOT对象关联的对象,速度较快(STW)。
并发标记(CMS-concurrent-mark),与用户线程同时运行;从GC Roots开始找到它能引用的所有其它对象。
在运行期间会发生新生代的对象晋升到老年代、或者是直接在老年代分配对象、或者更新老年代对象的引用关系等等,对于这些对象,都是需要进行重新标记的,否则有些对象就会被遗漏。为了提高重新标记的效率,该阶段会把上述对象所在的Card标识为Dirty,后续只需扫描这些Dirty Card的对象,避免扫描整个老年代;并发标记阶段只负责将引用发生改变的Card标记为Dirty状态,不负责处理;
预清理(CMS-concurrent-preclean),与用户线程同时运行;
可被终止的预清理(CMS-concurrent-abortable-preclean) 与用户线程同时运行;
重新标记(CMS-remark) ; 修正并发标记期间因用户程序继续动作而导致标记产生变动的那一部分对象的标记记录,完成标记整个年老代的所有的存活对象(整个堆,STW)。
并发清除(CMS-concurrent-sweep),与用户线程同时运行;清除标记未被引用的对象。
重置状态:等待下次CMS的触发(CMS-concurrent-reset),与用户线程同时运行;
缺点
内存碎片(空间碎片过多时,会出现老年代虽然还有很大空间,但是无法找到足够大的连续空间来分配,不得不进行Full GC)
-XX:+UseCMSCompactAtFullCollection 执行Full GC时开启内存碎片的合并整理(默认开启),停顿时间变长浮动垃圾(标记整理时、用户线程执行的新对象),只能等待下一次GC再将该对象回收
-XX:CMSInitiatingOccupancyFraction 触发Full GC的百分比,可以降低内存回收次数要是CMS运行期间预留的内存无法满足程序需要时,虚拟机将启动后备预案: 临时启用Serial Old收集器来重新进行老年代的垃圾收集,停顿时间就很长了。对CPU资源非常敏感
G1收集器(并发)
JDK 7开始使用,JDK 8非常成熟,JDK 9 之后默认使用的垃圾收集器,适用于新老生代。
工作在堆内不同分区上的并发收集器。分区既可以归属于老年代,也可以归属新生代,同一个代的分区不需要保持连续
G1收集器专注于垃圾最多的分区,最终的效果是花费较少的时间就能回收这些分区的垃圾
特点
并行与并发
分代收集(仍然保留了分代的概念)
空间整合(整体上属于“标记-整理”算法,不会导致空间碎片)
可预测的停顿(比CMS更先进的地方在于能让使用者明确指定一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒)
处理过程:
初始标记(Initial Marking) 标记GC Roots能够关联的对象,并且修改TAMS的值(STW)
并发标记(Concurrent Marking) 从GC Roots进行可达性分析,找出存活的对象,与用户线程并发执行
最终标记(Final Marking) 修正在并发标记阶段因为用户程序的并发执行导致变动的数据(STW)
筛选回收(Live Data Counting and Evacuation) 对各个Region的回收价值和成本进行排序,根据用户所期望的GC停顿时间制定回收计划
GC 收集过程:
G1 将堆划分为多个 Region(大小 1~32MB),逻辑上仍分代。
-
Young GC:
- 回收 Eden + Survivor Region;
- STW,复制存活对象到 Survivor 或 Old Region。
-
Concurrent Marking(并发标记):
- 与应用线程并发执行,标记整个堆的存活对象。
-
Mixed GC:
- 回收部分 Old Region(选择垃圾最多的 Region);
- STW,但可控制停顿时间(
-XX:MaxGCPauseMillis)。
G1 的核心思想:“Garbage First” —— 优先回收垃圾最多的 Region。
各收集器特点
不同 GC 器适用于不同场景,JDK 版本演进中不断优化。
| GC 收集器 | 适用代 | 所用算法 | 特点 | 适用场景 |
|---|---|---|---|---|
| Serial | 新生代 | 复制 | 单线程,STW | 客户端模式、小内存 |
| ParNew | 新生代 | 复制(并行) | 多线程版 Serial | 配合 CMS 使用 |
| Parallel Scavenge | 新生代 | 复制(并行) | 高吞吐量 | 后台计算、批处理 |
| Serial Old | 老年代 | 标记-整理 | 单线程 | 与 Serial 配套 |
| Parallel Old | 老年代 | 标记-整理(并行) | 高吞吐 | 与 Parallel Scavenge 配套 |
| CMS(Concurrent Mark Sweep) | 老年代 | 标记-清除(并发) | 低延迟,但有碎片 | Web 应用、响应敏感 |
| G1(Garbage First) | 全堆 | 分区 + 复制/整理 | 可预测停顿,兼顾吞吐与延迟 | 大内存(>4G)、JDK 9+ 默认 |
| ZGC / Shenandoah | 全堆 | 并发、Region | 超低延迟(<10ms) | JDK 11+(ZGC),JDK 12+(Shenandoah) |
小内存应用 → Serial
后台计算 → Parallel Scavenge
Web 服务 → G1(JDK 8u40+)或 ZGC(JDK 11+)
实时系统 → ZGC / Shenandoah
GC 日志分析
开启 GC 日志(JDK 8):
1 | -XX:+PrintGCDetails |
典型日志片段:
1 | [GC (Allocation Failure) [PSYoungGen: 512M->128M(512M)] 1024M->600M(2048M), 0.025 secs] |
PSYoungGen:新生代使用 Parallel Scavenge;
512M->128M:GC 前后 Eden+Survivor 使用量;
1024M->600M:堆总使用量;
0.025 secs:STW 时间。
关注指标:
-
GC 频率:是否过于频繁?
-
停顿时间(Pause Time):是否影响响应?
-
老年代增长趋势:是否内存泄漏?
GC 日志记录:
1 | 63.971: [GC (Allocation Failure) [PSYoungGen: 31073K->4210K(38400K)] |
63.971:gc发生时,虚拟机运行了多少秒。
GC (Allocation Failure) : 发生了一次垃圾回收,这是一次Minor GC 。注意它不表示只GC新生代,括号里的内容是gc发生的原因,这里的Allocation Failure的原因是年轻代中没有足够区域能够存放需要分配的数据而失败。如果是System.gc(),说明这是一次成功的垃圾回收。
PSYoungGen: 使用的垃圾收集器的名字。
31073K->4210K(38400K)指的是垃圾收集前->垃圾收集后(年轻代堆总大小)
31073K->4234K(125952K),指的是垃圾收集前后,Java堆的大小(总堆125952K,堆大小包括新生代和年老代), 因此可以计算出年老代占用空间为125952k-38400k = 87552k
0.0049946 secs:整个GC过程持续时间
[Times: user=0.05 sys=0.02, real=0.01 secs]分别表示用户态耗时,内核态耗时和总耗时。也是对gc耗时的一个记录。
Full GC 日志记录:
1 | 83.783: [Full GC (System.gc()) [PSYoungGen: 15361K->0K(372224K)] [ParOldGen: 83468K->98200K(172032K)] 98829K->98200K(544256K), [Metaspace: 9989K->9989K(1058816K)], 0.3036213 secs] [Times: user=1.03 sys=0.00, real=0.30 secs] |
1 [PSYoungGen: 15361K->0K(372224K)] :年轻代:垃圾收集前->垃圾收集后(年轻代堆总大小)
2 [ParOldGen: 83468K->98200K(172032K)] :年老代:垃圾收集前->垃圾收集后(年老代堆总大小)
3 98829K->98200K(544256K), :垃圾收集前->垃圾收集后(总堆大小)
4 [[Metaspace: 9989K->9989K(1058816K)], Metaspace空间信息,同上
5 0.3036213 secs:整个GC过程持续时间
6 [Times: user=1.03 sys=0.00, real=0.30 secs] 分别表示用户态耗时,内核态耗时和总耗时。也是对gc耗时的一个记录。
GC 问题与调优分析
JVM 垃圾回收(GC)调优是 Java 应用性能优化的核心环节,目标是在有限资源下,平衡吞吐量、延迟(停顿时间)和内存使用,避免频繁 GC、长时间停顿或内存溢出(OOM)。
以下三者不可兼得:通常需在“吞吐 vs 延迟”之间权衡。
| 目标 | 说明 | 使用场景 |
|---|---|---|
| 高吞吐量(Throughput) | 最大化应用运行时间,最小化 GC 时间占比 | 后台计算、批处理、离线任务 |
| 低延迟(Low Latency) | 控制单次 GC 停顿时间(STW)在毫秒级 | Web 服务、实时交易、用户交互系统 |
| 内存效率 | 避免内存浪费,合理控制堆大小 | 容器化部署、资源受限环境 |
GC 调优分析
-
明确应用特征
- 是 CPU 密集型还是 I/O 密集型?
- 对响应时间敏感吗?(如 API 接口要求 <100ms)
- 对象生命周期分布?(短命对象多?大对象多?)
-
收集基线数据
- 当前堆大小(
-Xms,-Xmx) - GC 类型(默认?手动指定?)
- GC 日志(必须开启!)
- 应用负载(QPS、并发数、请求类型)
- 当前堆大小(
-
开启 GC 日志
1
2
3
4
5
6
7
8
9
10
11
12# JDK 8 示例:
-XX:+UseG1GC \
-XX:+PrintGCDetails \
-XX:+PrintGCDateStamps \
-XX:+PrintGCTimeStamps \
-Xloggc:/data/logs/gc.log \
-XX:+UseGCLogFileRotation \
-XX:NumberOfGCLogFiles=5 \
-XX:GCLogFileSize=100M
# JDK 9+ 使用统一日志语法:
-Xlog:gc*:file=gc.log:time,tags -
GC 指标与分析维度
指标 说明 健康阈值(参考) Young GC 频率 每秒/分钟发生次数 <1 次/秒(视 Eden 大小而定) Young GC 停顿时间 STW 时间 <50ms(Web 应用) Full GC / Mixed GC 频率 应尽量避免 Full GC 0 次/天(理想) 老年代增长速率 每次 Young GC 后老年代增量 稳定或缓慢增长 GC 吞吐量 1 - (GC总时间 / 运行总时间) >95%(吞吐型应用) Metaspace 使用率 是否持续增长? 稳定,无 OOM -
Young GC 后老年代突增 → 对象过早晋升
-
Full GC 频繁触发 → 内存泄漏或堆太小
-
GC 停顿毛刺大 → 大对象、CMS 并发失败、G1 回收效率低
-
GC 调优步骤
一、选择合适的 GC 收集器
| 应用类型 | GC 收集器 | JDK版本 |
|---|---|---|
| 吞吐优先(批处理) | Parallel GC | JDK 8+ |
| 延迟敏感(Web/API) | G1 GC | JDK 8u40+ |
| 超低延迟(<10ms) | ZGC / Shenandoah | JDK 11+(ZGC),JDK 12+(Shenandoah) |
| 小内存(<1GB) | Serial GC | 客户端应用 |
JDK 8 默认是 Parallel GC,但 Web 应用建议手动切换为 G1。
二、合理设置堆大小
-
原则:
-Xms = -Xmx(避免动态扩容带来的性能抖动) -
经验值:
- 小型应用:1~2GB
- 中型 Web 服务:4~8GB
- 大型服务:8~32GB(G1 更适合大堆)
-
容器环境:务必限制容器内存,并设置
-XX:MaxRAMPercentage=75.0(JDK 8u191+)
三、调整新生代大小
新生代越大,Young GC 频率越低,但单次停顿可能变长。
-
Parallel GC:通过
-Xmn或-XX:NewRatio(老年代:新生代,默认 2:1) -
G1 GC:不要手动设置
-Xmn!G1 自动管理 Region,可通过-XX:G1NewSizePercent(默认 5%)和-XX:G1MaxNewSizePercent(默认 60%)微调。
四、控制对象晋升行为
-
调整晋升阈值:默认15,可尝试降低(如6)减少老年代压力
-XX:MaxTenuringThreshold=15 -
大对象直接进老年代(避免 Survivor 拷贝):
-XX:PretenureSizeThreshold=1M单位字节(仅 Serial/ParNew 有效)
五、针对特定 GC 器调优
G1 GC
关键参数调整:G1 会根据 MaxGCPauseMillis 动态调整 Young Region 数量和 Mixed GC 策略。
| 参数 | 说明 | 建议值 |
|---|---|---|
-XX:MaxGCPauseMillis=200 |
目标最大停顿时间 | 50~200ms(Web 应用) |
-XX:G1HeapRegionSize |
Region 大小(1~32MB) | 自动(除非堆 >32GB) |
-XX:G1MixedGCCountTarget=8 |
Mixed GC 次数目标 | 默认8,可增加以降低单次压力 |
-XX:G1HeapWastePercent=5 |
允许的堆浪费比例 | 默认5,可适当提高 |
Parallel GC(吞吐优先):
1 | -XX:ParallelGCThreads=N # GC 线程数(默认 CPU 核数) |
ZGC(JDK 11+):
几乎无参数可调,停顿时间稳定在 1~10ms,适合大堆(TB 级)。
1 | -XX:+UseZGC |
GC 问题与解决方案
Young GC频繁
每秒多次 Young GC,CPU 高,应用卡顿。
Eden 区太小、对象创建过快、存在内存泄漏(短命对象实际长期存活)。
- 调优:
- 增大新生代(
-Xmn),或调整 Eden/Survivor 比例。- 减少临时对象创建(如避免在循环中 new)
- 使用对象池或克隆技术
Full GC 频繁
日志中出现 Full GC,停顿几百毫秒到几秒。
可能原因:
- 老年代空间不足;
- Metaspace 不足(类加载过多);
- 显式调用
System.gc()。- CMS 并发模式失败
调优:
- 增大堆或老年代(
-Xmx);- 关闭显式 GC(
-XX:+DisableExplicitGC);- 监控 Metaspace(
-XX:MetaspaceSize=256m -XX:MaxMetaspaceSize=512m)。- CMS 场景:增大老年代、提前触发并发标记(
-XX:CMSInitiatingOccupancyFraction=70)
G1 Mixed GC 效率低
Mixed GC 持续多轮,老年代回收慢。
原因:G1 选择的 Region 垃圾比例不高。
解决:
- 降低
G1MixedGCLiveThresholdPercent(默认 85%)→ 更激进回收;- 增加
G1MixedGCCountTarget→ 分摊回收压力。
长时间 STW
改用低延迟 GC(如 G1、ZGC)。
GC 调优工具使用
| 工具 | 用途 |
|---|---|
| jstat | 实时监控 GC 统计(jstat -gcutil <pid> 1s) |
| jmap | 生成堆转储(jmap -dump:live,format=b,file=heap.hprof <pid>) |
| jconsole / VisualVM | 图形化监控 GC、内存、线程 |
| GCViewer / GCEasy | 可视化分析 GC 日志 |
| MAT (Memory Analyzer) | 分析内存泄漏、对象引用链 |
| Arthas | 线上诊断(heapdump、vmtool) |
1 | -Xms4g # 初始堆大小,在启动时即分配较大内存,减少运行时动态扩展带来的性能损耗。 |
由于设置XX:MetaspaceSize=80M,当内存用超过80M时,触发Full GC,同时Metadata内存以设置的幅度增长。
1 | 2021-03-23T17:17:25.874+0800: [Full GC (Metadata GC Threshold) [PSYoungGen: 37341K->0K(387072K)] [ParOldGen: 31360K->61147K(94208K)] 68701K->61147K(481280K), [Metaspace: 76992K->76991K(81920K)], 0.1373842 secs] [Times: user=0.63 sys=0.00, real=0.14 secs] |