💡 摘要:你是否曾遭遇过OutOfMemoryError的困扰?是否好奇JVM如何自动管理内存?是否想了解不同垃圾回收器的选择策略?
别担心,垃圾回收是Java最重要的特性之一,它让开发者从繁琐的内存管理中解放出来。
本文将带你从JVM内存模型讲起,理解堆、栈、方法区等内存区域的作用。然后深入垃圾回收算法,学习标记-清除、复制、标记-整理等核心原理。
接着探索各种垃圾回收器的特点和适用场景,从Serial到G1再到ZGC。最后通过实战案例展示内存问题排查和性能优化。从内存分配到垃圾回收,从算法原理到调优实践,让你全面掌握Java内存管理的精髓。文末附常见内存问题和解法,助你写出高性能的Java应用。
一、JVM内存模型:运行时数据区
1. 内存区域概述
JVM内存结构图:
text
JVM内存区域
├── 线程私有区域
│ ├── 程序计数器 (PC Register)
│ ├── Java虚拟机栈 (Java Stack)
│ └── 本地方法栈 (Native Method Stack)
│
├── 线程共享区域
│ ├── 堆 (Heap) // 对象实例存储
│ │ ├── 新生代 (Young Generation)
│ │ │ ├── Eden区
│ │ │ ├── Survivor0区
│ │ │ └── Survivor1区
│ │ └── 老年代 (Old Generation)
│ │
│ └── 方法区 (Method Area) // 类信息、常量、静态变量
│ └── 运行时常量池
│
└── 直接内存 (Direct Memory) // NIO使用的堆外内存
2. 各区域详细说明
堆(Heap):对象实例存储区域
java
// 所有对象实例和数组都在堆上分配内存
public class MemoryExample {
public static void main(String[] args) {
// 在堆上分配对象内存
Object obj1 = new Object(); // 对象在Eden区分配
Object obj2 = new Object(); // 另一个对象
// 数组也在堆上分配
int[] array = new int[1024]; // 数组对象
// 大对象可能直接进入老年代
byte[] largeObject = new byte[10 * 1024 * 1024]; // 10MB大对象
}
}
虚拟机栈(Java Stack):线程私有的方法调用栈
java
public class StackExample {
public static void main(String[] args) {
int localVar = 42; // 局部变量在栈帧中
String text = "hello"; // 引用在栈中,对象在堆中
method1(); // 方法调用创建新的栈帧
}
static void method1() {
double value = 3.14; // 局部变量
method2(); // 嵌套调用
}
static void method2() {
// 每个方法调用对应一个栈帧
// 栈帧包含局部变量表、操作数栈、动态链接、方法返回地址
}
}
方法区(Method Area):类元数据存储
java
public class ClassMetaExample {
// 类信息存储在方法区
private static final String CONSTANT = "constant"; // 常量在方法区
private static int staticVar = 100; // 静态变量在方法区
public void method() {
// 方法字节码存储在方法区
}
}
二、垃圾回收算法:理论基础
1. 引用计数法(Reference Counting)
基本原理:
java
// 简化的引用计数示例
public class ReferenceCounting {
private int count = 0;
private Object data;
public void setData(Object data) {
if (this.data != null) {
this.data.decrementCount(); // 减少旧引用计数
}
this.data = data;
if (this.data != null) {
this.data.incrementCount(); // 增加新引用计数
}
}
// 问题:循环引用无法回收
}
class A { B ref; }
class B { A ref; }
// 创建循环引用
A a = new A();
B b = new B();
a.ref = b;
b.ref = a;
// 即使a和b不再使用,引用计数也不为0,无法回收
2. 可达性分析算法(Root Searching)
GC Roots对象包括:
- 虚拟机栈中引用的对象
- 方法区中静态属性引用的对象
- 方法区中常量引用的对象
- 本地方法栈中JNI引用的对象
java
public class ReachabilityExample {
private static Object staticObj; // GC Root
private final Object finalObj = new Object(); // GC Root
public void method() {
Object localObj = new Object(); // 局部变量,GC Root
// 这些对象都是可达的
Object obj1 = new Object();
Object obj2 = new Object();
obj1 = null; // obj1不可达,可回收
System.gc(); // 建议GC,但不保证立即执行
}
}
3. 标记-清除算法(Mark-Sweep)
过程描述:
java
// 标记阶段:从GC Roots开始遍历,标记所有可达对象
标记对象(Object obj) {
if (obj != null && !obj.isMarked()) {
obj.mark = true;
for (Object ref : obj.references) {
标记对象(ref); // 递归标记
}
}
}
// 清除阶段:遍历堆,回收未标记对象
清除() {
for (Object obj : heap) {
if (!obj.isMarked()) {
free(obj); // 释放内存
} else {
obj.unmark(); // 清除标记位
}
}
}
优缺点:
- ✅ 简单直接
- 🔴 内存碎片问题
- 🔴 效率较低
4. 复制算法(Copying)
新生代回收:
java
// 新生代通常采用复制算法
public class CopyingAlgorithm {
// 新生代分为Eden、Survivor0、Survivor1
private Space eden = new Space("Eden", 80); // 80%
private Space survivor0 = new Space("Survivor0", 10); // 10%
private Space survivor1 = new Space("Survivor1", 10); // 10%
private Space from = survivor0;
private Space to = survivor1;
public void minorGC() {
// 1. 标记Eden和From Survivor中的存活对象
List<Object> liveObjects = markLiveObjects(eden, from);
// 2. 将存活对象复制到To Survivor
copyToSurvivor(liveObjects, to);
// 3. 清空Eden和From Survivor
eden.clear();
from.clear();
// 4. 交换From和To
Space temp = from;
from = to;
to = temp;
// 5. 对象年龄增加,达到阈值(默认15)则晋升老年代
for (Object obj : liveObjects) {
obj.age++;
if (obj.age > MAX_AGE) {
promoteToOldGen(obj);
}
}
}
}
5. 标记-整理算法(Mark-Compact)
老年代回收:
java
public class MarkCompactAlgorithm {
public void fullGC() {
// 1. 标记阶段:标记所有存活对象
markPhase();
// 2. 整理阶段:移动存活对象,消除碎片
compactPhase();
// 3. 更新引用指针
updateReferences();
}
private void compactPhase() {
int freePointer = 0;
for (Object obj : heap) {
if (obj.isMarked()) {
// 移动对象到空闲位置
moveObject(obj, freePointer);
freePointer += obj.size;
}
}
}
}
三、垃圾回收器:实战选择
1. 串行收集器(Serial Collector)
特点:单线程,Stop-The-World
bash
# 启用串行收集器
java -XX:+UseSerialGC -Xms512m -Xmx512m -jar application.jar
# 适用场景:客户端应用,小堆内存
2. 并行收集器(Parallel Collector)
吞吐量优先:
bash
# 启用并行收集器
java -XX:+UseParallelGC -XX:+UseParallelOldGC -Xms2g -Xmx2g -jar application.jar
# 调优参数
-XX:ParallelGCThreads=4 # GC线程数
-XX:MaxGCPauseMillis=200 # 最大暂停时间目标
-XX:GCTimeRatio=99 # GC时间与应用时间比率
3. CMS收集器(Concurrent Mark Sweep)
低延迟收集器:
bash
# 启用CMS收集器
java -XX:+UseConcMarkSweepGC -Xms4g -Xmx4g -jar application.jar
# CMS调优参数
-XX:CMSInitiatingOccupancyFraction=75 # 老年代使用率触发阈值
-XX:+UseCMSInitiatingOccupancyOnly # 仅使用占用率作为触发条件
-XX:+CMSScavengeBeforeRemark # Remark前先做Young GC
CMS执行过程:
- 初始标记(Initial Mark):Stop-The-World
- 并发标记(Concurrent Mark)
- 重新标记(Remark):Stop-The-World
- 并发清除(Concurrent Sweep)
4. G1收集器(Garbage First)
分区收集器:
bash
# 启用G1收集器
java -XX:+UseG1GC -Xms8g -Xmx8g -jar application.jar
# G1调优参数
-XX:MaxGCPauseMillis=200 # 目标暂停时间
-XX:InitiatingHeapOccupancyPercent=45 # 堆使用率触发阈值
-XX:G1HeapRegionSize=4m # 分区大小
G1内存布局:
text
G1堆结构:
├── 多个等大小区域(Region)
│ ├── Eden Regions
│ ├── Survivor Regions
│ ├── Old Regions
│ └── Humongous Regions(大对象)
│
└── 收集集合(CSet):本次GC要处理的区域
5. ZGC和Shenandoah
新一代低延迟收集器:
bash
# 启用ZGC(JDK 15+)
java -XX:+UseZGC -Xms16g -Xmx16g -jar application.jar
# ZGC调优参数
-XX:ConcGCThreads=4 # 并发GC线程数
-XX:ParallelGCThreads=8 # 并行GC线程数
# 启用Shenandoah
java -XX:+UseShenandoahGC -Xms16g -Xmx16g -jar application.jar
四、内存分配与回收策略
1. 对象分配规则
对象分配过程:
java
public class ObjectAllocation {
public void createObjects() {
// 1. 优先在Eden区分配
Object obj1 = new Object(); // 在Eden分配
// 2. 大对象直接进入老年代
byte[] largeArray = new byte[1024 * 1024]; // 1MB以上可能直接进入老年代
// 3. 长期存活的对象进入老年代
for (int i = 0; i < 20; i++) {
Object obj = new Object();
// 经历多次GC后,存活对象会晋升老年代
}
// 4. 动态对象年龄判定
// 如果Survivor中相同年龄对象大小超过Survivor空间一半,年龄≥该年龄的对象直接晋升
}
}
2. 空间分配担保
GC前后的空间保证:
java
public class SpaceGuarantee {
public void beforeGC() {
// Minor GC前检查:
// 老年代最大可用空间 > 新生代所有对象总大小?
// 是:确保Minor GC安全
// 否:检查HandlePromotionFailure设置
// 允许担保失败:继续Minor GC
// 不允许担保失败:直接Full GC
}
public void afterGC() {
// Minor GC后检查:
// 如果Survivor空间不足以存放存活对象
// 需要通过分配担保机制进入老年代
}
}
五、实战:内存问题排查
1. 内存泄漏检测
常见内存泄漏场景:
java
// 1. 静态集合引用
public class MemoryLeakExample {
private static final List<Object> STATIC_LIST = new ArrayList<>();
public void addToStaticList(Object obj) {
STATIC_LIST.add(obj); // 对象永远无法回收
}
}
// 2. 监听器未移除
public class ListenerLeak {
private List<Listener> listeners = new ArrayList<>();
public void addListener(Listener listener) {
listeners.add(listener);
// 但忘记提供remove方法
}
}
// 3. 内部类引用外部类
public class Outer {
private byte[] data = new byte[1024 * 1024];
class Inner {
// 隐式持有Outer.this引用
void method() {
System.out.println(data.length); // 访问外部类数据
}
}
public Inner getInner() {
return new Inner(); // 即使Outer不再使用,由于Inner引用也无法GC
}
}
2. 内存分析工具使用
MAT内存分析:
bash
# 生成堆转储文件
jmap -dump:live,format=b,file=heapdump.hprof <pid>
# 或者运行时输出堆转储
java -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=./heapdump.hprof -jar app.jar
# 使用MAT分析hprof文件
# 1. 查找最大对象
# 2. 分析支配树
# 3. 检查GC Roots引用链
jstat监控GC:
bash
# 监控GC情况
jstat -gc <pid> 1s 10
# 输出说明:
# S0C/S1C: Survivor区容量
# S0U/S1U: Survivor区使用量
# EC/EU: Eden区容量/使用量
# OC/OU: 老年代容量/使用量
# YGC/YGCT: Young GC次数/时间
# FGC/FGCT: Full GC次数/时间
六、性能优化实践
1. 堆大小调优
合理的堆配置:
bash
# 生产环境推荐配置
java -Xms4g -Xmx4g # 堆大小固定,避免动态调整
-XX:NewRatio=2 # 新生代:老年代=1:2
-XX:SurvivorRatio=8 # Eden:Survivor=8:1:1
-XX:MaxTenuringThreshold=15 # 晋升年龄阈值
# 根据应用特点调整
-XX:+UseAdaptiveSizePolicy # 开启自适应大小策略(默认开启)
2. GC调优策略
根据应用类型选择GC:
bash
# 吞吐量优先应用(后台计算)
-XX:+UseParallelGC -XX:+UseParallelOldGC
-XX:ParallelGCThreads=8
-XX:GCTimeRatio=99
# 低延迟应用(Web服务)
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200
-XX:InitiatingHeapOccupancyPercent=45
# 大堆应用(16G+)
-XX:+UseZGC
-XX:ConcGCThreads=4
七、常见问题与解决方案
1. OutOfMemoryError排查
各种OOM及处理:
java
// 1. Java heap space
// 原因:堆内存不足
// 解决:增大堆大小,检查内存泄漏
// 2. GC overhead limit exceeded
// 原因:GC效率太低,98%时间在做GC
// 解决:检查代码中的内存问题,调整GC参数
// 3. PermGen space / Metaspace
// 原因:类元数据空间不足
// 解决:增大Metaspace大小,检查类加载器泄漏
// 4. Unable to create new native thread
// 原因:线程数超出系统限制
// 解决:减少线程数,调整系统参数
2. GC性能问题
常见GC问题现象:
- 🔴 Full GC频繁:老年代空间不足,内存泄漏
- 🔴 Young GC时间过长:Eden区过大,存活对象过多
- 🔴 GC停顿时间过长:选择合适的低延迟收集器
- 🔴 吞吐量下降:GC线程占用过多CPU时间
八、总结:GC最佳实践
1. 监控与调优流程
GC优化步骤:
- 监控:使用jstat、GC日志监控GC情况
- 分析:识别问题(延迟高、吞吐量低、OOM等)
- 调优:调整堆大小、代比例、收集器参数
- 验证:对比调优前后效果
- 重复:持续监控和优化
2. 参数配置建议
生产环境推荐:
bash
# 基本配置
-Xms4g -Xmx4g # 固定堆大小
-XX:+HeapDumpOnOutOfMemoryError # OOM时输出堆转储
-XX:HeapDumpPath=./logs # 堆转储文件路径
# GC日志配置
-Xlog:gc*=info:file=gc.log:time,uptime,level,tags:filecount=10,filesize=10M
# 根据应用选择收集器
# 中小应用:G1GC
# 大堆低延迟:ZGC
# 吞吐量优先:ParallelGC
九、面试高频问题
❓1. JVM内存结构是怎样的?
答:分为线程私有的程序计数器、虚拟机栈、本地方法栈,线程共享的堆、方法区。堆分为新生代和老年代,新生代又分为Eden和Survivor区。
❓2. 对象什么时候会进入老年代?
答:年龄达到阈值(默认15)、大对象直接分配、Survivor区相同年龄对象超过一半空间。
❓3. G1和CMS的区别是什么?
答:G1采用分区算法,可预测停顿时间,适合大堆;CMS采用标记-清除,追求低延迟,但容易产生碎片。
❓4. 如何排查内存泄漏?
答:使用jmap生成堆转储,用MAT分析支配树和GC Roots引用链,查找无法回收的对象。
❓5. ZGC有什么特点?
答:新一代低延迟收集器,停顿时间不超过10ms,支持TB级堆内存,基于Region和指针染色技术。