本章内容概述:哪些内存区域需要回收?需要回收的内存区域中怎么判断哪些数据应该被回收?怎么回收?
各个内存区域垃圾回收的迫切性和必要性
Java虚拟机将内存进行了区域划分,从线程角度可以划分为两大区(JVM内存区域划分详细描述可以参考)
- 线程私有区域:Java虚拟机栈、本地方法栈、程序计数器
- 线程共享区域:堆区、方法区
线程私有区域不需要过多考虑垃圾回收的问题
因为这些区域中的数据与线程的生命周期是一致的,线程启动时开辟空间,线程结束时释放空间。
栈中的栈帧随着方法的进入和退出而有条不紊地执行着出栈和入栈操作。每一个栈帧中分配多少内存基本上是在类结构确定下来时就已知的(尽管在运行期会由即时编译器进行一些优化,但在基于概念模型的讨论里,大体上可以认为是编译期可知的),而程序计数器再线程结束时也就不需要再为线程服务了,因此这几个区域的内存分配和回收都具备确定性,在这几个区域内就不需要过多考虑如何回收的问题,当方法结束或者线程结束时,内存自然就跟随着回收了。
线程共享区域需要关注内存该如何管理
因为这部分区域中的内存分配和使用具有不确定性,只有在正真运行的过程中才能知道具体需加载哪些类,创建哪些实例(例如同一个接口可能由多个实现类,只有在运行时才知道装载哪些类信息到方法区;例如一个方法所执行的不同条件分支所需要的内存也可能不一样,只有处于运行期间,才能知道程序究竟会创建哪些对象,创建多少个对象),所以这部分内存的分配和回收都是动态的,是垃圾回收器关注的重点。、
方法区的内存回收
- 方法区的垃圾收集主要回收两部分内容:废弃的常量和不再使用的类型
- 《Java虚拟机规范》中提到过可以不要求虚拟机在方法区中实现垃圾收集
因为方法区垃圾收集的“性价比”通常也是比较低的
- 方法区内存回收条件苛刻
堆区的内存回收
堆区垃圾收集的“性价比”通常很高,每次可以回收70%至99%的内存空间,所以JVM虚拟机堆这一块内存中的管理做了很多优化,以提高程序的执行效率。这很好理解,因为管理好这一块儿内存带来的收益更大,自然更受关注。
对象是否存活的判断方法
判断哪些对象不可能再被任何途径使用
引用计数算法 —— 对象的引用数量
- 原理:在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就(+1);当引用失效时,计数器值就(-1);任何时刻计数器为 0 的对象就是不可能再被使用的。
- 优势:原理简单,判定效率也很高
- 缺点:
- 需要占用额外的内存空间来进行计数
- 虽然算法原理很简单,但是想要它正确的工作需要做很多额外的处理(就像补丁)。
例如:两个对象相互应用(A的一个字段指向B实例,B的一个字段指向A实例),但是这两个对象都不可能在被使用了,这种情况引用技术器的值都不为 0 .无法正确回收内存空间,对于这种情况就需要做额外的处理了。
可达性分析算法 —— 引用链可达性
-
基本思想:
通过一系列称为“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”(Reference Chain),如果某个对象到GC Roots间没有任何引用链相连,或者用图论的话来说就是从GC Roots到这个对象不可达时,则证明此对象是不可能再被使用的。
-
可作为GC Roots的对象
- 在虚拟机栈(栈帧中的本地变量表)中引用的对象
理解:我对栈帧的理解就是线程调用的方法的上下文环境,栈帧中本地变量表中的对象就是方法执行过程中还要用的对象,既然还要用那自然是不能回收的了,在它的引用链上的对象也不能回收,所以它可以作为GC ROOT。换言之,当方法结束,栈帧出栈后,方法中使用的很多变量就会被判断为可回收对象。 - 在方法区中类静态属性引用的对象
理解:就是类中用static修饰的静态全局对象。这类对象可以被全局访问,存储在线程共享的方法区中。既然是全局可用,那自然不能回收。例如,Java类的引用类型静态变量(类中 static 修饰的引用类型)。 - 在方法区中常量引用的对象
理解:例如字符串常量池里的引用。final 关键字修饰,由于这种引用初始化之后不会修改,所以方法区常量池里的引用的对象也应该作为GC Roots。 - 在本地方法栈中JNI(即通常所说的Native方法)引用的对象
理解:在使用JNI技术时,有时候单纯的Java代码并不能满足需求,可能需要在Java程序中调用C或C++的代码,因此会使用native方法,而JVM内存中专门有一块本地方法栈,用来保存这些对象的引用,所以本地方法栈中引用的对象也会被作为GC Roots - Java虚拟机内部的引用
理解:基本类型对应的class对象,常驻的异常类对象,系统类加载器等。个人认为这些对象是java程序其他用户自定义实现的基础,比如,自己实现一个类加载器,需要依赖JVM内部的类加载器,所以这些对象应该也做为GC ROOT - 所有被同步锁(synchronized关键字)持有的对象
理解:同步锁持有的对象在多线程场景下是可以用来线程键传递信息的,所以应当作为GC ROOT - 反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等
理解:笔者目前还不了解…
- 在虚拟机栈(栈帧中的本地变量表)中引用的对象
对象回收前的自救 —— finalize() 方法
finalize() 方法时对象逃脱死亡命运的最后一次机会。
真正宣告一个对象死亡,至少要经过两次标记:
- 第一次:可达性分析判定对象不可达、不会再被使用时,标记一次
- 第二次:检测对象是否有必要执行 finalize() 方法。如果对象再 finalize() 方法中将自己与GCROOT的任意一个引用链链接起来(比如将自己赋值到一个类的成员变量),那它将不会被回收,反之则会被回收。
如果对象没有覆盖 finalize() 方法 或者 已经执行过一次 finalize() 方法,那么久不会执行 finalize() 方法。
package org.jvm.example;
/**
* @Program: JvmStudy
* @ClassName FinalizeEscapeGC
* @Description: 显示垃圾回收时对象的自救
* 只有一次自救机会,应为finalize()方法之后被执行一次
**/
public class FinalizeEscapeGC {
public static FinalizeEscapeGC SAVE_HOOK = null;
public void isAlive(){
System.out.println("yes,i am still alive!");
}
@Override
protected void finalize() throws Throwable {
super.finalize();
System.out.println("finalize method executed!");
//拯救一下自己,将自己复制给一个类的静态成员
FinalizeEscapeGC.SAVE_HOOK = this;
}
public static void main(String[] args) throws InterruptedException {
SAVE_HOOK = new FinalizeEscapeGC();
//第一次会成功拯救自己
SAVE_HOOK = null;
System.gc();
Thread.sleep(500);
if (SAVE_HOOK != null){
SAVE_HOOK.isAlive();
}else {
System.out.println("no, i am not alive! I am dead!");
}
//第二次不会执行finalize()方法,拯救自己失败
SAVE_HOOK = null;
System.gc();
Thread.sleep(500);
if (SAVE_HOOK != null){
SAVE_HOOK.isAlive();
}else {
System.out.println("no, i am not alive! I am dead!");
}
}
}
引用知识补充
什么是引用:代表另一块内存的起始地址。
什么是引用类型:类型中存储的数值代表的是另一块内存的起始地址,那么就称之为引用类型。
上面的说发在jdk1.2之前是完全适用的,但是在jdk1.2之后,为了适应更丰富的场景(比如,希望在内存空间紧张时,能够回收这块儿内存),Java对引用的概念做了扩充,将应用细分为:
强引用(String Reference),软引用(Soft Reference),弱引用(Weak Reference)和虚引用(Phantom Reference)。
- 强引用:Java默认都是强引用,这是最传统的“引用”,最普遍存在的引用赋值,即
Object obj = new Object();
这种应用关系。无论在任何情况下,只要强应用关系存在,就不会回收这块内存空间。
- 软引用:用来描述一些还有用,但是非必要的对象。Java提供了SoftReference类来实现软引用。如果一个对象之被软引用引用,那么在系统抛出内存益处异常之前(也就是JVM内存空间不足时),会将这个对象列入到回收范围之中,进行第二次回收,如果二次回收还是空间内存不做,那么才会抛出异常。基于这个特性,软引用很适合用来做缓存,在JVM空间充足时可以提高程序的查询效率,在JVM内存空间紧张时可以回收掉这块内存空间。
- 弱引用:若一个对象只被弱引用所引用,那么它将在下一次GC中被回收掉。如ThreadLocal和WeakHashMap中都使用了弱引用,防止内存泄漏。
- 虚引用:虚引用是四种引用中最弱的一种引用。我们永远无法从虚引用中拿到对象,被虚引用引用的对象就跟不存在一样。虚引用一般用来跟踪垃圾回收情况,或者可以完成垃圾收集器之外的一些定制化操作。Java NIO中的堆外内存(DirectByteBuffer)因为不受GC的管理,这些内存的清理就是通过虚引用来完成的。
回收收集的算法(怎么回收)
分代收集理论
- 弱分代假说(Weak Generational Hypothesis):绝大多数对象都是朝生夕灭的。
- 强分代假说(Strong Generational Hypothesis):熬过越多次垃圾收集过程的对象就越难以消亡。
- 跨代引用假说(Intergenerational Reference Hypothesis):跨代引用相对于同代引用来说仅占极少数。
这三条假说字面上很好理解,解释一下就是:
- 第一条说明,在堆内存的数据大多数都会在下一次垃圾回收之前灭亡(例如,在一个方法执行时用到了很多的临时对象实例,在方法执行完其实这些实例对象就没用了)
- 第二条说明,堆内存中大多数对象实例会很快消亡,但是有一小部分会存活下来,这些存活下来的对象都是难以消亡的对象(换句话说会更容易理解一些:这些存活下来的对象是更有用的、作用域更大的对象)。
- 基于前两条,JVM垃圾收集器大多都将内存划分为两个区域:新生代区域和老年代区域。对象的年代与对象熬过垃圾回收的次数成正比。这样的好处就是,立即回收可以更专注于消亡对象更多的空间,这样回收效率更高。
- 第三条说明,由于老年代中的对象可能引用了新生代中的对象,这样会怎加回收的困难,于是,JVM垃圾收集器大多将这些老年代跨代引用的对象也规划到老年代。这也很好理解,老年代中大多都是不容易被回收的对象,那么它们引用的对象也不容易被回收。
标记-清楚算法
是最基础的收集算法,算法分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回收所有未被标记的对象。
优点:简单
缺点:执行效率不稳定,内存空间的碎片化
- 执行效率不稳定:Java堆中包含了大量需要回收的对象,这样一来,每次 GC 时都要进行大量标记和清除的动作,导致垃圾回收时标记和清除操作的效率与对象的多少成反比。对象越多,标记清除的效率越低,压力越大。
- 内存空间的碎片化:看图就很容易理解。这样依赖,如果有一个比较大的对象要存储,很大概率会找不到一块儿可以容纳的空间。
标记-复制算法
算法优化点:解决标记-清除算法面对大量可回收对象时执行效率低的问题
算法主要思想:半区复制
半区复制:即每次只使用内存的一半区域,在区域内存用完时,将存活对象复制到另一半,原来区域全部清空。
下图是这个算法的示意图:
这个算法多用于新生代,因为新生代区域的对象多数会无法存活,这样可以尽可能的减少复制操作。
优点:解决了标记-清除算法面对大量可回收对象时执行效率低的问题
缺点:在存活对象较多时,需要进行较多的复制操作,回收效率将会降低。需要额外的内存担保空间(如果存活对象超过半区)。
半区复制的优化
IBM公司曾有一项专门研究对新生代“朝生夕灭”的特点做了更量化的诠释——新生代中的对象有98%熬不过第一轮收集。
由于新生代存活下来的对象一般不超过10%,所以聪明的JVM垃圾回收器研发者们想出来一个优化版的“标记-复制”模型:
将内存按照8:2划分为Eden区和Survivor区,Survivor区有细分为Survivor0和Survivor1两个一样大的区域。
- Survivor区:上一次垃圾收集后存活对象的存放位置
- Eden区: 新对象实例的存放位置
工作逻辑:Eden区和其中一个Survivor区配合,存储垃圾回收之前的对象实例,当这两块内存紧张时,将触发垃圾回收工作,将存活的对象复制到另一块Survivor区,如下图:
标记-整理算法
标记,然后让所有存活的对象都向内存空间一端移动。适合用于老年代。
优点:整理空间,100%利用空间,不需要额外的内存担保空间,适用于存活率高(移动少)的内存区域。
缺点:移动大对象会带来很大负担