上篇文章我们讲解了对象的内存布局,本文将介绍 JVM 如何找出已经无用的对象。
不难回忆 Java 的宏观内存布局。
其中程序计数器、虚拟机栈、本地方法栈 3 个区域是 Thread Local 的,随线程而生,随线程而灭,自然也不用过多关心如何回收的问题。
然而 Heap 和方法区有显著的不确定性,这部分内存更是实际中用得最多的内存,也因此诞生了许多分配与回收算法。
引用记数
引用记数(Reference
Counting)是最简单的一种收集方法,许多编程语言使用该方法管理内存(Objective-C、Swift
等),或提供了对应的封装(C++、Rust 等)。它在对象中添加一个引用记数器
i
。每当有一个地方引用该对象 i++
,引用失效时
i--
。当 i == 0
时,则自动释放该对象。
它的缺点和优势都很明显。它的判定效率高,占用低。然而仍然需要程序员在编码时额外配合,必须手动将对象置空,以 Objective-C 为例:
* obj = [[NSObject alloc] init]; // counter: 1
NSObject= [obj1 retain]; // counter: 2
obj [obj release]; // counter: 1
[obj release]; // counter: 0, obj released
除此之外,它无法解决循环引用问题。
比如,obj1
引用了 obj2
为成员变量,obj2
引用了 obj3
为成员变量,obj3
又引用了 obj1
为成员变量:
可以用以下代码测试一下 JVM 对循环引用的表现:
public class RecursiveReference {
public Object instance = null;
private static final int _10MB = 10 * 1024 * 1024;
private final byte[] bigField = new byte[_10MB];
public static void main(String[] args) {
var obj1 = new RecursiveReference();
var obj2 = new RecursiveReference();
.instance = obj2;
obj1.instance = obj1;
obj2
= null;
obj1 = null;
obj2
System.gc(); // force gc
}
}
在手动 Run GC 之后,内存明显下降:
这也说明了,JVM 并不使用引用记数算法判断对象生死。
当然,也可以对引用记数做出改进,比如在 Swift 中新增了
unowned
weak
等关键字来解决这一问题。虽然,JVM
实现中也有强弱引用等概念,但不体现在语法层面,完全走的是另一条分支,就不再过多赘述了……
可达性分析
多数 GC 语言会使用可达性分析(Reachability Analysis)来判定对象生死。
如图所示,该算法的主要思路是把一系列被称作「GC Roots」的根对象作为起始节点,从这些节点开始往下搜索,搜索过的路径称作「引用链」(Reference Chain)。如果某个对象和 GC Roots 间没有引用链连接。就说明该对象不可达,也就不可能再被使用。
图中 Object 5、6、7,虽然互相引用,但它们和 GC Roots 不可达。因此会被判定为可回收对象。
在 JVM 中,可作为 GC Roots 的对象有以下几种:
- VM Stack 中引用的对象,如方法参数、局部变量、临时变量等。
- 在方法区内的静态属性对象
- 在方法去内常量引用的对象,如字符串常量池(String Table)中的引用
- 本地方法栈中 JNI 引用的对象
- JVM 内部引用,如基本数据类型对应的 Class 对象,一些长驻的异常对象(NPE OOM 等),以及类加载器。
- 所有被同步锁(synchronized 关键字)持有的对象
- JMXBean、JVMTI 中的回调、本地代码缓存等
除这些长驻 GC Roots 之外,还可能根据 GC 不同和当前回收的内存区域不同,而有其他对象「临时性」加入,共同构成完整 GC Roots 集合。
实际上,最新的几款垃圾收集器(OpenJDK 中的 G1、Shenandoah、ZGC,Azul 的 PGC、C4 等)都具备了局部回收特征。在细节上做了许多优化。
强弱引用
无论是引用计数算法,还是可达性分析,都是通过「引用」来判断对象是否存活的。在 JDK 1.2 之前,JVM 的引用是很传统的定义,reference 存储着另一块内存的起始地址,只有「被引用」或「未被引用」两种状态。这个定义是完全正确的,然而缺乏细分,在实际场景中可能缺乏灵活性。可能有些对象,我们希望在内存充裕时保留,而紧张时就丢弃它们——很多系统中的缓存都符合该描述。
在 JDK 1.2 之后,JVM 对引用的概念做了扩充和细分:
- 强引用(Strongly Reference),最传统的引用定义,即普遍存在的引用赋值。在任何情况下只要强引用关系存在,GC 就不会回收这些对象。
- 软引用(Soft
Reference),描述一些还有用但非必要的对象。在系统接近 OOM 之前,JVM
会把这些对象收集起来回收第二次。JDK 提供了
SoftReference
来实现软引用。 - 弱引用(Weak
Reference)是比软引用更弱。无论内存是否足够,它都只能生存到下一次垃圾收集发生为止。可通过
WeakReference
实现。 - 虚引用(Phantom
Reference)是最弱的一种引用关系。虚引用关联的唯一目的,就是为了在被回收时收到通知。可通过
PhantomReference
实现。
Finalize 方法
即使被 GC 判定为可回收对象,也不是非死不可的。还会进行一次
finalize
的判断。如果对象未覆盖 finalize()
方法,或者已经被调用过一次,JVM 都会直接收集该对象。
否则,JVM 中将它加入 F-Queue
中,并在一条低优先级的
Finalizer
线程中顺序执行。当然,如果某个
finalize()
方法执行缓慢,甚至是死循环。虚拟机会直接清理该对象。
在 finalize()
中,对象可以尝试重新与 GC Roots
连接。从而避免被回收。
以下代码展示了一个对象的自救过程:
public class Finalize {
public static Finalize SAVE_HOOK = null;
public void isAlive() {
System.out.println("Yes, I'm alive! :)");
}
@Override
protected void finalize() throws Throwable {
super.finalize();
System.out.println("Finalize method executed!");
.SAVE_HOOK = this;
Finalize}
private static void test() throws InterruptedException {
= null;
SAVE_HOOK System.gc();
Thread.sleep(500);
if (SAVE_HOOK != null) {
.isAlive();
SAVE_HOOK} else {
System.out.println("No, I'm dead :(");
}
}
public static void main(String[] args) throws InterruptedException {
= new Finalize();
SAVE_HOOK test();
test();
}
}
输出如下:
Finalize method executed!
Yes, I'm alive! :)
No, I'm dead :(
可以看到,第一次尝试自救成功了,而第二次却失败了。原因之前提到过,finalize()
对于一个对象,最多只能被执行一次。
然而,finalize()
已经在 JDK 9 及以后被标记为弃用,在 JDK
18 中被标记为
for removal
,无法通过编译,但运行时仍然保留,相信离正式删除不远了。它并不等同于
C、C++
中的析构函数。反而运行代价高昂,不确定性大,容易出现意想不到的问题。
finalize()
能做的工作,建议都使用
try-finally
块或其他方式来完成。避免使用该方法。
方法区回收
在日常开发中,不用太关心方法区(比如 HotSpot VM 中的 Metaspace 或永久代)中的回收行为。然而如果大量使用反射、动态代理、CGLib 等字节码框架,就需要注意这一点了。
JVM 规范中不强制要求虚拟机在方法区中实现垃圾收集,实质上也有未完整实现类卸载的收集器存在(如 JDK 11 时的 ZGC)。因为回收方法区确实性价比较低,条件也极为苛刻。但不代表方法区不可能存在垃圾收集。
可收集的内容主要有:
- 废弃的常量,如一个字符串
"JVM"
曾进入过常量池,但当前系统中没有任何一个字符串对象为"JVM"
。如果此时发生垃圾回收,并且收集器判断确实有必要的话,"JVM"
就会被清理出常量池。类、接口、方法、字段的符号引用回收方法与此类似。 - 不再使用的类型,这种情况比较严苛,需要同时满足 3 大条件:
- 该类所有实例都被回收,Heap 中不存在任何该类及其派生子类的实例。
- 加载该类的类加载器已被回收。通常到这一步,已经没有类可以被回收了。然而在 OSGi、JSP 的可替换类加载器场景下,还是存在的。
- 该类的的
java.lang.Class
对象没有被任何地方引用,无法在任何地方通过反射访问该类。
JVM 对类的回收提供了 -Xnoclassgc
参数控制,还可通过
-verbose:class
-XX:TraceClassLoading
查看详细的加载、卸载日志。
本篇文章的内容就到这里,下篇文章会介绍 JVM 是如何清理内存的。