在文章的开头,引用《哈姆雷特》中的一句话「TO BE OR NOT TO BE - 生存还是毁灭」
在文章 运行时数据区域 中介绍过:「程序计数器、虚拟机栈、本地方法栈」三个区域是线程私有滴,随线程而生,随线程而灭;而「堆、方法区」是线程共享滴!
栈中的栈帧随着方法的进入和退出而有条不紊地执行着入栈和出栈的操作,每一个栈帧中分配多少内存基本上是在类结构确定下来时就已经确定
所以「程序计数器、虚拟机栈、本地方法栈」三个区域的内存分配和回收具备确定性,当方法结束或者线程结束时,内存自然就跟着回收了
而「堆、方法区」两个区域则有着很显著的不确定性:一个接口的多个实现类需要的内存可能会不太一样,一个方法所执行的不同条件分支所需要的内存也可能不一样,只有处于运行期间才能知道程序究竟需要哪些对象,创建多少个对象,这部分内存的分配和回收是动态的
垃圾收集是 Java 中不可或缺的一部分,它所关注的正是「堆、方法区」部分内存的管理问题!!!总的来说,它需要完成三件事情:
哪些内存需要回收?what
什么时候回收?when
如何回收?how
而本篇文章主要来解决第一件事情:哪些内存需要回收?
堆里面存放着 Java 世界中几乎所有的对象实例,垃圾收集器在对堆进行回收前,第一件事情就是要确定这些对象之中哪些还「存活」着,哪些已经「死去」了
这里说的是几乎,是为了和《深入理解 Java 虚拟机》作者的观点保持一致,但我们更应该用辩证的态度去看待这个观点,关于这个观点更多分析可见 逃逸分析
在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值 +1;当引用失效时,计数器值 -1;任何时刻计数器值为 0 的对象就是不可能再被使用的
上面所说的是利用「引用计数」来判断对象是否存活的算法核心思想,它有一些好处:原理简单,判断效率高
但 Java 领域并没有选择使用它来管理内存,原因:存在对象间相互循环引用的问题
举个简单的例子,如下图所示:
对于上图的右边,实际上这两个对象已经不可能再被访问,但由于它们俩互相引用着对方,导致它们的引用计数器值都不为 0,引用计数算法也就无法回收它们!!
假设我们已经知道了一定不可能被回收的对象集合,那么与这个集合中有关联的对象也一定不能被回收,与这个集合中无关联的对象就一定可以被回收,就算它们之间存在相互循环引用
按照这个思路,给出更加规范的定义:通过一系列称为「GC Roots」的根对象作为起始节点集,从这些节点开始根据引用关系向下搜索,搜索过程所走过的路径称为「引用链」,如果某个对象到 GC Roots 间没有任何引用链相连 (或者用图论的话来说就是从 GC Roots 到这个对象不可达时),则证明此对象是不可能再被使用的
所以总体来说可达性分析算法主要过程包括:枚举根节点 & 查找引用链。更严格的来说,「GC Roots」中应该存放的是一组必须活跃的引用
注意:该算法的本质是通过找出所有活对象来把其余空间认定为「无用」,而不是找出所有死掉的对象并回收它们占用的空间
那什么样的对象引用可以放到「GC Roots」集合中呢??
在 Java 技术体系里面,固定可作为 GC Roots 的对象包括以下几种:
在虚拟机栈 (栈帧中的局部变量表) 中引用的对象,如:当前正在运行的方法所使用到的参数、局部变量、临时变量等
在方法区中类静态属性 (类变量) 引用的对象,如:Java 类中的引用类型静态变量
在方法区中常量引用的对象,如:字符串常量池 (String Table) 里的引用
在本地方法栈中 JNI (Native 方法) 引用的对象
Java 虚拟机内部的引用,如:基本数据类型对应的 Class 对象;一些常驻的异常对象 (NullPointException、OutOfMemoryError) 等;系统类加载器 (应用程序类加载器)
所有被同步锁 (synchronized 关键字) 持有的对象
反映 Java 虚拟机内部情况的 JMXBean、JVMTI 中注册的回调、本地代码缓存等
对于上述第一种可作为 GC Roots 的对象来举一个例子,具体代码如下所示:
public class Test {
public static Scanner scanner = new Scanner(System.in);
public static void f() {
User user = new User();
System.out.println("f() 方法中...");
// 第一次 dump 时间点...
scanner.nextInt();
}
public static void main(String[] args) {
f();
System.out.println("main() 方法中...");
// 第二次 dump 时间点...
scanner.nextInt();
}
}
首先当执行到f()
方法中时,进行一次 heap dump;然后当回到main()
方法中时,进行第二次 heap dump
利用 MAT 软件分析两次的 dump 文件,如下图所示:
可以看到,第一次 dump 时,user
在 GC Roots 集合中;第二次 dump 时,user
不在 GC Roots 集合中。从 GC Roots 的数量上也可以看出来,两次 dump 只相差一个user
所以可以得出结论,当执行f()
方法时,user
会加入到 GC Roots 集合中;当方法执行完,栈帧出栈后user
会从 GC Roots 集合中去除
除了这些固定的 GC Roots 集合之外,根据用户所选用的垃圾收集器以及当前回收的内存区域不同,还可以有其他对象「临时性」地加入,共同构成完整的 GC Roots 集合
更多内容可见 垃圾收集算法 中第三点假说!!
例如:在局部回收中,仅仅对 Java 堆中某一块区域发起垃圾收集时 (最典型的是只对「新生代」的垃圾收集),如果该区域的对象被堆中其他区域的对象所引用,这个时候就需要将关联区域的对象也一并加入 GC Roots 集合中去,才能保证可达性分析的正确性。换句话说:在执行局部回收时,从 GC 堆的非回收部分指向回收部分的引用,也必须作为 GC Roots 的一部分
如上图所示,如果此时对新生代进行垃圾收集,那么Object5
也必须作为 GC Roots 的一部分!
注:当前主流的商用程序语言 (Java,C#,Lisp) 的内存管理子系统都是通过「可达性分析算法」来判定对象是否存活的!!
无论是「引用计数算法」,还是「可达性分析算法」,它们判断对象的存活都离不开「引用」
在 JDK 1.2 之前,Java 里面的引用是很传统的定义:如果 reference 类型的数据中存储的数值代表的是另外一块内存的地址,就称该 reference 数据是代表某块内存、某个对象的引用
这个定义在现在看来过于狭隘,一个对象在这种定义下只有两种状态:「被引用」or「未被引用」
我们希望对于一个对象:当内存空间还足够时,能保留在内存之中;如果内存空间在进行垃圾收集后仍然非常紧张,那就可以抛弃这个对象
基于上述所描述的应用场景,在 JDK 1.2 之后,Java 对「引用」的概念进行了扩充,分为:
强引用 (Strongly Reference):指程序中普遍存在的引用赋值,和传统意义下的引用一致;无论任何情况下,只要强引用关系存在,垃圾收集器就永远不会回收掉被引用的对象
软引用 (Soft Reference):指一些还有用,但非必须的对象;只要被软引用关联的对象,在系统将要发生 OOM 前,会对这些对象列进回收范围之中进行第二次回收,如果这次回收后依旧内存不足,才抛出 OOM 异常
弱引用 (Weak Reference):指一些非必须的对象,但它比软引用强度更弱,被弱引用关联的对象只能生存到下一次垃圾收集发生为止
虚引用 (Phantom Reference):它是一种最弱的引用关系,虚引用是否存在完全不影响对象的生存时间,也无法通过虚引用获取一个对象的实例,它的作用只是对象在被回收时收到一个系统通知
关于上述四种引用更详细的内容可见「强/软/弱/虚」引用
终于回归这篇文章的初心了,本来只是想简简单单总结一下finalize()
方法的作用,但是如果仅仅介绍这个方法,又感觉前不着村,后不着店,没头没尾的
所以秉着系统化,完整化,结构化的原则,还是稍微总结了一下与之相关的基础知识,不说废话了,现在开始!!
即使在可达性分析算法中判定为不可达的对象,也不是「非死不可」的,这个时候它们暂时还处于「缓刑」阶段,真正要宣告一个对象死亡,最多会经历两次标记过程,如下图所示:
根据上面的流程可以看出一些finalize()
方法的特点:当没有任何引用指向一个对象时,按理来说它应该被回收,但垃圾回收器会给它一次自救的机会,在回收前会执行该对象的finalize()
方法!!
正是因为该机制使得一个对象并非只有两种状态:存活 or 死亡;还有一种中间状态:可复活态 (最多只有一次复活机会)
因为finalize()
方法的特点:在回收前会被执行一次,导致被用来「关闭外部资源」之类的清理工作,但并不建议使用!原因:
运行代价高昂,不确定性大,无法保证各个对象的调用顺序
虽然垃圾回收器会调用该方法,但若极端情况下没有发生 GC,那么该方法就不会被执行
若一个对象的finalize()
方法执行慢或发生死循环,直接会导致 F-Queue 队列中其它对象处于等待状态,甚至导致整个内存回收子系统崩溃
下面给出一段演示对象复活的代码:
public class FinalizeEscapeGC {
public static FinalizeEscapeGC SAVA_HOOK = null;
public void isAlive() {
System.out.println("yes, i am still alive :)");
}
protected void finalize() throws Throwable {
super.finalize();
System.out.println("finalize method executed!");
FinalizeEscapeGC.SAVA_HOOK = this;
}
public static void main(String[] args) throws InterruptedException {
SAVA_HOOK = new FinalizeEscapeGC();
// 对象第一次成功拯救自己
System.out.println("--- 第 1 次自救 ---");
SAVA_HOOK = null;
System.out.println("before gc ....");
System.gc();
// 因为 finalize() 优先级很低,暂停 0.5 秒,以等待它
Thread.sleep(500);
System.out.println("after gc ....");
if (SAVA_HOOK != null) {
SAVA_HOOK.isAlive();
} else {
System.out.println("no, i am dead :(");
}
// 下面代码与上面完全相同,但是这次自救却失败了
System.out.println("--- 第 2 次自救 ---");
SAVA_HOOK = null;
System.out.println("before gc ....");
System.gc();
// 因为 finalize() 优先级很低,暂停 0.5 秒,以等待它
Thread.sleep(500);
System.out.println("after gc ....");
if (SAVA_HOOK != null) {
SAVA_HOOK.isAlive();
} else {
System.out.println("no, i am dead :(");
}
}
}
// result
--- 第 1 次自救 ---
before gc ....
finalize method executed! // GC 第一次标记后,筛选是否有必要执行 finalize() 方法,此时有必要执行,所以会放入 F-Queue 队列中,Finalizer 线程会去执行 finalize() 方法
after gc ....
yes, i am still alive :)
--- 第 2 次自救 ---
before gc ....
after gc ....
no, i am dead :(
《Java 虚拟机规范》中提到可以不要求虚拟机实现对方法区的垃圾收集,也确实有虚拟机未实现
相比于「堆」中的垃圾收集来说,「方法区」的垃圾收集性价比较低,尤其是在新生代区域中,一次垃圾收集通常可以回收 70% - 99% 的内存空间
方法区的垃圾收集主要回收两部分内容:「废弃的常量」和「不再使用的类型」
回收「废弃的常量」和回收 Java 堆中的对象非常类似!
对于一个字符串常量"java"
来说,假设曾经进入过常量池,但当前系统没有任何一个字符串对象的值是"java"
换句话说,已经没有任何字符串对象引用常量池中的"java"
常量,且虚拟机中也没有其他地方引用这个字面量
如果这个时候发生内存回收,而且垃圾收集器判断确有必要的话,这个"java"
常量就会被系统清理出常量池
常量池中其它类 (接口)、方法、字段的符号引用也与此类似
判断一个常量是否「废弃」还是相对简单,而要判断一个类型是否属于「不再被使用的类」的条件就比较苛刻,需要同时满足以下三个条件:
该类所有的实例都已经被回收,也就是 Java 堆中不存在该类及其任何派生子类的实例
加载该类的类加载器已经被回收 (类和类加载器相互记录着对方:类记录着被谁加载,类加载器记录着加载过谁!所以它俩同生共死),这个条件除非是经过精心设计的可替换类加载器的场景,如:OSGi、JSP 的重加载等,否则通常很难达成的
该类对应的java.lang.Class
对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法
Java 虚拟机「被允许」对满足上述三个条件的无用类进行回收,这里说的仅仅是「被允许」,而不是和对象一样,没有引用了就必然会回收。关于是否要对类型进行回收,可以通过参数控制
在大量使用反射、动态代理、CGLib 等字节码框架,动态生成 JSP 以及 OSGi 这类频繁自定义类加载器的场景中,通常都需要 Java 虚拟机具备「类型卸载能力」,以保证不会对方法区造成过大的内存压力
深入理解 Java 虚拟机