1. 对象的"生死判决":从可触及到不可触及
在JVM的眼中,一个对象从"创建"到"消亡"并不是一瞬间的。如何判定一个对象是垃圾?从**根节点(GC Roots)**出发,如果无法找到一条引用链到达该对象,该对象就被宣告"死亡"。
但是,对象真的会乖乖等死吗?有时候,它还有一次"苟延残喘"的机会------对象复活。
1.1 对象的三种"可触及性"状态
根据根节点能否访问到对象,JVM将对象分为以下三种状态:
-
可触及的 (Reachable) :从根节点出发,可以直达该对象。在正常情况下,我们
new出来的对象就处于这个状态,不会被回收。 -
可复活的 (Resurrectable) :对象的所有引用都被释放,即将被回收 。但在回收之前,如果该对象重写了
finalize()方法,它就有可能在finalize()方法中重新建立引用,从而"复活"自己。 -
不可触及的 (Unreachable) :对象的
finalize()方法已经被JVM调用过,并且对象没有复活。此时对象彻底变为"不可触及"状态,只能等待被回收。
💡 核心知识点 :finalize()方法只会被JVM调用一次 。如果一个对象在finalize()中自救成功,下一次再被判定为垃圾时,JVM不会再调用其finalize(),它将直接进入"不可触及"状态并死亡。
1.2 实战:利用 finalize() 方法复活对象
看一段经典的"对象复活"代码:
java
public class CanReliveObj {
public static CanReliveObj obj;
@Override
protected void finalize() throws Throwable {
super.finalize();
System.out.println("CanReliveObj finalize called");
obj = this; // 关键:重新建立引用,自己救自己!
}
public static void main(String[] args) throws InterruptedException {
obj = new CanReliveObj();
obj = null; // 1. 断开强引用
System.gc(); // 2. 第一次GC
Thread.sleep(1000); // 给GC线程一点时间执行finalize
if (obj == null) {
System.out.println("obj是null");
} else {
System.out.println("obj可用"); // 3. 对象成功复活
System.out.println("第2次gc");
obj = null;
System.gc(); // 4. 第二次GC
Thread.sleep(1000);
if (obj == null) {
System.out.println("obj是null"); // 5. 这次真的没了
} else {
System.out.println("obj可用");
}
}
}
}
运行结果分析:
-
第一次GC :
System.gc()触发回收,JVM检测到对象重写了finalize(),将其放入一个低优先级的队列中执行。在finalize()执行时,this指针将自身赋值给了静态变量obj,导致该对象再次被GC Roots引用,成功"复活"。 -
第二次GC :再次置空并触发GC。此时JVM不再调用
finalize()方法,对象没有机会自救,被彻底回收。
⚠️ 重要警告 :极其不推荐在实际开发中依赖finalize()来释放资源! 它不仅调用时机不确定,还会导致对象复活、内存泄漏等难以排查的诡异Bug。Java 9之后该方法已被标记为弃用(Deprecated)。
2. 引用的强度:强、软、弱、虚
既然对象是否被回收完全取决于"引用"的状态,Java在java.lang.ref包中提供了4种引用级别。除了我们最熟悉的强引用外,其他三种旨在帮助开发者更好地控制GC时机,常见于缓存、内存敏感型应用等场景。
2.1 强引用 (Strong Reference) ------ "死也不放手"
-
定义 :我们在代码中直接
new出来的对象引用。例如StringBuffer str = new StringBuffer("hello");。 -
特点 :这是最"强势"的引用。只要强引用还存在,垃圾回收器永远不会 回收该对象。即使内存耗尽抛出
OOM,JVM也不会回收强引用对象。 -
隐患:容易造成内存泄漏。如长生命周期对象持有短生命周期对象的强引用。
2.2 软引用 (SoftReference) ------ "内存告急时的救星"
-
定义 :使用
java.lang.ref.SoftReference实现。 -
特点 :软引用比强引用"弱"。如果内存空间充足 ,垃圾回收器不会 回收它;如果内存空间不足 ,在抛出OOM之前,JVM会强制回收软引用所指向的对象。
-
适用场景 :非常适合实现内存敏感的高速缓存。比如图片缓存、网页缓存等。内存够时就留着加速系统,不够时自动清除防止内存溢出。
示例代码:
java
User u = new User(1, "geym");
SoftReference<User> userSoftRef = new SoftReference<>(u);
u = null; // 断开强引用,此时对象只有软引用
// 即使调用GC,对象通常依然存在
System.gc();
System.out.println(userSoftRef.get()); // 依然能输出对象
// 强制分配大量内存,触发内存紧张
byte[] b = new byte[1024 * 935 * 7];
System.gc();
System.out.println(userSoftRef.get()); // 输出 null,因为内存紧张被回收了
2.3 弱引用 (WeakReference) ------ "每一次GC都是生死劫"
-
定义 :使用
java.lang.ref.WeakReference实现。 -
特点 :比软引用更弱。只要发生垃圾回收 (无论内存是否紧张),弱引用的对象一定会被回收。不过由于GC线程优先级较低,弱引用对象可能存活一小段时间。
-
适用场景 :
WeakHashMap、以及ThreadLocal中的ThreadLocalMap正是利用了弱引用来防止内存泄漏(当线程销毁时,ThreadLocal能够被及时回收)。
2.4 虚引用 (PhantomReference) ------ "影子般的跟踪者"
-
定义 :使用
java.lang.ref.PhantomReference实现。这是最弱的一种引用。 -
特点:
-
无法获取对象 :永远无法通过虚引用的
get()方法获取真实的对象,调用get()永远返回null。 -
必须搭配引用队列 :虚引用必须与
ReferenceQueue联合使用。 -
用于追踪回收:当垃圾回收器准备回收对象时,如果发现该对象还有虚引用,回收后会将该虚引用放入关联的引用队列中。应用程序可以通过监控这个队列,得知对象被回收的确切时机。
-
-
适用场景 :主要用于**直接内存(Direct Memory)**的回收跟踪,以及某些框架中实现细粒度的对象回收后清理操作(比如NIO中的回收清理)。
3. 垃圾回收的代价:Stop-The-World (STW)
理解了引用的原理,我们知道GC会回收垃圾。但GC是"免费"的吗?绝对不是。为了能够安全地标记和清除垃圾,JVM必须暂停所有用户线程,这就是著名的 Stop-The-World (STW) 现象。
3.1 为什么会有 STW?
就像我们在打扫一间有人的房间一样,人如果不停止走动(产生新的垃圾),我们是永远扫不干净的。GC也是如此,为了保证系统状态的一致性和垃圾的准确性,GC执行时必须暂停所有应用线程。STW期间,整个应用程序"卡死",没有任何响应,这是Java程序性能调优中最需要关注的点。
3.2 实战演示:压力下暴露的 GC 停顿
我们来模拟一段大量消耗内存并打印时间的代码:
java
public class StopWorldTest {
// 消费内存的线程
public static class MyThread extends Thread {
HashMap<Long, byte[]> map = new HashMap<>();
@Override
public void run() {
try {
while (true) {
// 内存占满时清理
if (map.size() * 512 / 1024 / 1024 >= 900) {
map.clear();
System.out.println("clean map");
}
for (int i = 0; i < 100; i++) {
map.put(System.nanoTime(), new byte[512]);
}
}
} catch (Exception e) {}
}
}
// 定时打印时间的线程
public static class PrintThread extends Thread {
public static final long starttime = System.currentTimeMillis();
@Override
public void run() {
try {
while (true) {
long t = System.currentTimeMillis() - starttime;
System.out.println(t / 1000 + "." + t % 1000); // 打印时间戳
Thread.sleep(100); // 每100ms打印一次
}
} catch (Exception e) {}
}
}
}
3.3 GC 日志中的真相
我们使用 -Xmx1g -Xms1g -Xmn900m -XX:+UseSerialGC -Xloggc:gc.log -XX:+PrintGCDetails 参数启动程序。
假设我们在控制台看到的时间输出出现了"断层":
java
15.690
15.791
15.992
16.397
16.498 <-- 这里出现了停顿,下一行是 17.123 (跨度达600多毫秒)
17.123
17.226
这就说明在这个时间段内,JVM进行了 Full GC。查看 gc.log 日志:
java
16.643: [GC (Allocation Failure) 16.650: [DefNew (promotion failed) : 478630K->468622K(614400K), 0.3180471 secs] 16.968: [Tenured: 126975K->126975K(126976K), 0.2966344 secs] 478630K->330798K(741376K), 0.6221686 secs]
日志解析:
-
16.643:这是JVM启动到发生GC的耗时。
-
0.6221686 secs :GC总共耗时 622毫秒!
-
正是这622毫秒的GC停顿,导致了
PrintThread的输出在16.498和17.123之间出现了断裂。
3.4 如何缓解 STW 带来的影响?
GC停顿是不可能消除的,我们只能通过调优降低它的影响程度:
-
调整新生代与老年代的比例 :从日志中发现,新生代GC极其频繁。调大新生代空间(如
-Xmn900m),可以减少GC触发频率,虽然单次GC时间略微增加,但整体累计停顿时间会大幅减少。 -
选择垃圾收集器 :对于低延迟(低STW时间)要求的系统,应使用 CMS 或 G1 垃圾收集器,尽量将GC的停顿控制在可控的毫秒级别。
-
合理设置堆大小:不要使用过小的堆,否则频繁触发Full GC会导致严重的性能雪崩。
总结:梳理 JVM 垃圾回收知识点
-
对象生命周期 :经历过可触及 -> 可复活 (
finalize())-> 不可触及 三个阶段。finalize()仅调用一次且不推荐使用。 -
引用强度层级:
-
强引用:绝不回收,内存泄漏之源。
-
软引用:内存不足时回收,适合做缓存。
-
弱引用:每次GC时回收,避免ThreadLocal等内存泄漏。
-
虚引用:无法获取对象,仅用于追踪GC过程。
-
-
GC的影响 :垃圾回收会触发 Stop-The-World 暂停所有用户线程。调优GC的核心就是平衡吞吐量 和低停顿时间,通过分析GC日志可以精确定位性能瓶颈。