Appearance
垃圾回收机制
前置知识
在阅读本章前,你需要了解:Java 内存模型基础,面向对象编程,以及基本的Java代码结构。
为什么需要垃圾回收?
想象一下:你写了个 Java 程序,需要频繁创建对象。每次对象不再使用,这部分内存就得释放,要不然程序最终会因为内存不足崩溃。手动管理内存非常容易出错,尤其在复杂系统中,内存泄露和悬挂指针问题屡见不鲜。Java 的垃圾回收(Garbage Collection, GC)机制就是为了解决这个烦恼——它自动帮你清理不再需要的对象,让你专注于业务逻辑,而不用操心“啥时候释放内存”。
但GC究竟怎么知道哪些对象不再需要了呢?它是如何执行这项“隐形保洁工”的工作的?这正是本章要带你深入理解的内容。
什么是垃圾回收?
简单说,垃圾回收就是:自动识别不再被程序引用的对象,并把它们占用的内存释放回系统的过程。
为什么需要它
- 避免内存泄露:防止无用对象一直占据内存。
- 提升程序健壮性:减少内存错误导致的程序崩溃。
- 开发效率:程序员不用写复杂的内存管理代码。
基本概念
- 引用(Reference):代码中指向对象的“指针”。
- 可达性(Reachability):一个对象如果还能通过引用链从根(如栈中的引用)找到,它就是“活动”的。
- 垃圾对象:不再有任何引用指向的对象,准备被回收。
分代回收的思路
Java 的垃圾回收器并不会把堆当作一个整体来回收,而是根据对象的生命周期将堆划分为几个“代”(Generations):
- 年轻代(Young Generation):新创建的对象先在这里,回收频率高。
- 老年代(Old Generation):长时间存活的对象会被“晋升”到这里,回收频率低。
- 永久代(PermGen)/元空间(Metaspace):存放类的元数据(Java 8之后为元空间)。
这个分代设计基于一个经验法则:“大多数对象很快成为垃圾,少数对象寿命很长”。因此,年轻代的回收很频繁且成本要低。
常见垃圾回收算法
Java 中经典的 GC 算法主要有三种,我们一个个拆开讲。
1. 标记-清除(Mark-Sweep)
这是最原始也最直观的垃圾回收算法。
原理:
- 标记阶段:扫描从根开始的所有可达对象,给它们做标记。
- 清除阶段:没有标记的对象就是垃圾,直接清除内存。
优缺点:
- 优点:实现简单。
- 缺点:会产生“内存碎片”,释放的空间不连续,影响后续内存分配效率。
2. 复制算法(Copying)
复制算法先将内存空间分为两块(如from区和to区)。只使用其中一块,另一块空着。
原理:
- 把存活的对象从一块内存复制到另一块,复制完清空“from”区。
- 碎片问题得以解决,分配空间简单。
优点: 解决了碎片问题,分配快。
缺点: 需要一半内存作为备份空间,内存利用率较低。
复制算法常用在年轻代,因为年轻代对象回收率高且对象生命周期短。
3. 标记-整理(Mark-Compact)
标记-整理算法是标记-清除的升级版本,先做标记,然后对存活对象进行“整理”,移动它们,使对象连续存放。
优点:
- 解决内存碎片问题。
- 不浪费像复制算法那样需要半区内存。
缺点:
- 对象移动的开销较大。
代码示例 1:触发GC的简单对象创建
我们先写个程序看看触发GC的简单场景。
java
public class SimpleGcExample {
public static void main(String[] args) {
// 不断创建对象,让垃圾回收器有机会工作
for (int i = 0; i < 100000; i++) {
String temp = new String("Object_" + i);
// 这里 temp 变量每次循环都会被新对象覆盖,之前的对象变成垃圾
}
System.gc(); // 建议JVM执行垃圾回收(不保证立即执行)
System.out.println("请求垃圾回收完成");
}
}这段代码做了什么:
- 循环中不停创建很多临时字符串对象。
- 每次循环旧对象没有引用后,会变成垃圾。
System.gc()告诉JVM可以考虑运行垃圾回收。
这个例子演示了如何产生大量垃圾对象,供GC回收。
代码示例 2:模拟对象晋升(分代回收)
这段代码用较大对象来演示年轻代到老年代的对象晋升。
java
public class ObjectPromotionDemo {
public static void main(String[] args) throws InterruptedException {
final int _1MB = 1024 * 1024;
// 创建几个大对象,这些大对象会绕过eden区,直接进入老年代
byte[] largeObject1 = new byte[4 * _1MB];
byte[] largeObject2 = new byte[4 * _1MB];
// 多次创建大对象数组,触发年轻代垃圾回收
for (int i = 0; i < 20; i++) {
byte[] temp = new byte[1 * _1MB];
}
// 让程序暂停,方便观察内存变化
Thread.sleep(1000);
System.out.println("演示对象晋升结束");
}
}这段代码做了什么:
- 创建几个4MB的大对象,通常它们不在Eden区,而是直接进入老年代。
- 循环创建1MB的小对象,模拟年轻代频繁回收。
- 通过这种行为,部分小对象会在多次GC后“晋升”到老年代。
这个例子可以帮我们理解年轻代与老年代的交互。
代码示例 3:自定义标记-清除的简化示范(伪代码)
真正的垃圾回收算法实现非常复杂,不过我们可以写一个简化版的标记-清除逻辑,帮助理解。
java
import java.util.*;
public class SimpleMarkSweep {
static class ObjectNode {
boolean marked = false;
List<ObjectNode> references = new ArrayList<>();
String name;
ObjectNode(String name) { this.name = name; }
}
public static void main(String[] args) {
// 构造对象图
ObjectNode root = new ObjectNode("root");
ObjectNode child1 = new ObjectNode("child1");
ObjectNode child2 = new ObjectNode("child2");
ObjectNode orphan = new ObjectNode("orphan");
root.references.add(child1);
child1.references.add(child2);
// orphan没有任何引用,模拟垃圾对象
// 标记阶段:以root为起点,标记所有可达对象
mark(root);
// 清除阶段:遍历所有对象,收集没标记的
List<ObjectNode> heap = Arrays.asList(root, child1, child2, orphan);
List<ObjectNode> garbage = new ArrayList<>();
for (ObjectNode obj : heap) {
if (!obj.marked) {
garbage.add(obj);
}
}
System.out.println("垃圾对象:");
for (ObjectNode obj : garbage) {
System.out.println(obj.name);
}
}
// 递归标记所有可达对象
static void mark(ObjectNode obj) {
if (obj == null || obj.marked) return;
obj.marked = true;
for (ObjectNode ref : obj.references) {
mark(ref);
}
}
}这段代码做了什么:
- 手动构造了“对象图”,其中
orphan是一个无引用的垃圾对象。 - 用
mark方法从根节点递归标记所有可达对象。 - 遍历所有对象,清除没有标记过的,模拟清除阶段。
- 输出垃圾对象名单。
这虽然远不能和JVM的GC媲美,但相当直观地演示了标记-清除的核心思想。
对比总结
| 算法 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 标记-清除 | 实现简单,可处理复杂对象图 | 产生碎片,内存利用不均匀 | 老年代,堆内存大时使用 |
| 复制算法 | 无碎片,分配速度快 | 需要半区空间,内存使用率低 | 年轻代(Eden区) |
| 标记-整理 | 无碎片,内存整理好 | 移动对象开销较大 | 老年代,代替标记清除算法 |
实战建议
💡 实战建议
- 合理划分堆内存大小:年轻代和老年代的比例设定影响GC效率。一般年轻代占堆的1/3到1/4。
- 监控GC日志:在生产环境开启GC日志,观察频率和耗时,调整垃圾收集器参数。
- 避免过大对象频繁创建:大对象直接进入老年代,回收成本高,避免无意义地创建。
- 使用合适的GC策略:JVM提供多种GC收集器(如G1,ZGC,Shenandoah),针对不同场景选择。
- 减少长生命周期对象的引用链:避免不必要的对象存活,减少老年代压力。
理解决策的本质,将帮助我们写出既高效又稳定的代码。
常见陷阱
⚠️ 常见陷阱
- 误以为
System.gc()会立即回收:这个调用只是建议JVM执行GC;何时执行不确定。 - 频繁创建大对象导致老年代频繁GC:会严重影响性能,甚至产生Full GC。
- 不释放资源产生内存泄漏:虽然对象被GC,但资源如文件句柄、数据库连接未关闭会造成泄露。
- 忽视内存碎片导致分配失败:即使内存总量够,但碎片过多,引发内存溢出。
- 错误的对象引用保持:比如静态集合或单例持有大量对象引用,导致对象无法被回收。
小结
- 垃圾回收是Java自动管理内存的“隐形保姆”,解放程序员双手。
- 分代回收基于对象生命周期的不同,将堆划分年轻代和老年代,提高回收效率。
- 标记-清除、复制、标记-整理三种算法各有利弊,配合分代使用。
- 通过代码示例加深理解GC的核心原理和过程。
- 真实项目中合理调整内存设置和GC参数,避免常见内存使用陷阱。
延伸思考
- 除了堆内存,Java还有方法区、栈等其他内存区域。不同区域的管理机制有何异同?
- 现代JVM引入了哪些更加先进的垃圾回收技术,比如并行GC、增量GC、ZGC、Shenandoah?
- 如何通过代码设计降低内存压力?有什么模式或技巧能减少GC停顿时间?
欢迎你慢慢消化这些知识,如果你能把GC的工作细节弄清楚,在Java领域的底层优化和故障排查中绝对能如虎添翼。我们下一章继续深入探讨高级GC调优与工具使用吧!
