1、如何确认哪些对象“已死”
在上一篇文章中介绍到Java内存运行时的各个区域。其中程序计数器、虚拟机栈、本地方法栈3个区域随着线程而生,随线程而灭,栈中的栈帧随着方法的进入和退出而有条不紊的执行着入栈和出栈操作。每个栈帧中分配多少内存基本上在类确定下来时就是已知的。由于这几个区域有随线程而生,随线程而灭的特性,所以不需要考虑这三个区域的内存回收问题。所以需要回收的内存区域就只有堆和方法区两个区域。
那么如何确定哪些对象“已死”了呢?
有两种方式确定哪些对象对于Java虚拟机来说是可以进行回收的。一个是引用计数法算法,另一个是可达性分析算法。
1.1、引用计数算法
引用计数算法顾名思义就是在对象中添加一个引用计数器,当有一个地方引用到这个对象时,计数器的值就加一;当引用失效时,计数器的值就减一;任何时刻,当计数器的值为零时,表示没有地方引用到此对象,说明此对象时可以被JVM回收的。
从客观上来说,引用计数器算法虽然占用了一些额外的内存空间进行计数,但是它的原理简单,判定效率高,在大多数情况下它都是一个不错的算法。但是引用计数算法无法清理掉循环引用的对象。
1.2、可达性分析算法
在当前主流的商用程序语言的内存管理子系统都是通过可达性分析算法来判定对象是否存活。这个算法的基本思路就是通过一系列称为“GC Roots”的根对象作为起始节点集,从这个节点开始,根据引用关系向下搜索,搜索过程中所走的路径称为**“引用链”,如果一个对象到GC Roots没有引用链**,或者说是从GC Roots到对象不可达时,则证明此对象是可回收对象。
在Java体系里固定可作为GC Roots的对象包括以下几种:
- 在虚拟机栈(栈帧中的本地变量表)中引用的对象,比如,各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等。
- 在方法区中类静态属性引用的对象。比如Java类的引用类型静态变量
- 在方法区中常量引用的对象。比如字符串常量池里的引用。
- 在本地方法栈中JIN(即Native方法)引用的对象。
- Java虚拟机内部引用,如基本数据类型对应的Class对象,一些常驻的异常对象(比如NullPointExecption)等,还有系统类加载器
- 所有被同步锁(synchronized关键字)持有的对象
- 反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调。本地代码缓存等。
2、引用
无论通过引用计数算法还是可达性分析算法判断对象是否存活都和“引用”离不开关系。在java中对引用做了一下几种定义:
- 强引用:是指在代码中普遍存在的引用赋值,即类似“Object obj=new Object()”这种引用关系。任何情况下只要强引用关系还存在,垃圾收集器都不会回收掉被引用的对象。
- 软引用:是用来描述一些还有用,但非必须的对象。被软引用关联着的对象,在系统将要发生内存溢出异常前,会把这些对象列入可回收范围之中进行第二次回收,如果这次回收还没有足够的内存,则会抛出异常。在JDK中提供SoftReference来实现软引用。
- 弱引用:也用来描述那些非必须的对象。但是它的强度比软引用弱一些,被弱引用关联的对象,只能存活到下一次进行垃圾收集为止。当垃圾收集器开始工作,无论当前内存是否足够,都会回收掉被弱引用关联的对象。JDK中提供WeakReference来实现弱引用。
- 虚引用:也称为**“幽灵引用”或者“幻影引用”,它是最弱****的一种引用关系**。一个对象是否存在虚引用,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用的目的就是在这个对象内收集器回收时能收到一个通知。在JDK中通过实现PhantomReference来显示虚引用。
3、生存还是死亡
即使在可达性分析算法中判定对象时不可达的,对象也不是“非死不可”的,这时候他们暂时还处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历两次标记过程:如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那他将会被第一次标记,随后进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法。加入对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用,那么虚拟机将这两种情况视为**“没有必要执行”。
如果对象被判定为需要执行finalize()方法**,那么对象将被放置在一个名为F-Queue的队列中,并在稍后由一条虚拟机自动建立的、低调度优先级的Finalizer线程执行他们的finalize()方法。这里的“执行”是指虚拟机会触发这个方法开始运行,并不保证一定等待它运行结束。这样做得原因是,避免一个对象的finalize()方法执行缓慢,或者产生死循环,不会导致F-Queue对象中的其他对象一直等待或者内存回收子系统的崩溃。
finalize()方法是对象逃脱死亡的最后一次机会,稍后收集器会对F-Queue队列上的对象进行第二次小规模标记,如果对象想在finalize()中成功拯救自己——只要重新与引用链上的任何一个对象建立关联即可。比如把自己(this)赋值给某个类变量或者对象的成员变量,那在第二次标记时它将会被移出“即将回收”的集合;如果对象这时候还没有逃离,那么基本上就真的被回收。
值得注意的是,任何一个对象的finalize()方法只会被系统****自动调用一次,如果对象面临下一次回收,它的finalize()方法不会被再次执行。示例代码如下:
public class FinalizeEscapeGC {
private static FinalizeEscapeGC SAVE_HOKE=null;
private void alive(){
System.out.println("yes, i am still alice:");
}
@Override
protected void finalize() throws Throwable {
super.finalize();
System.out.println("finalize method executed!");
FinalizeEscapeGC.SAVE_HOKE=this;
}
public static void main(String[] args) throws InterruptedException {
SAVE_HOKE=new FinalizeEscapeGC();
//对象第一次成功拯救自己
SAVE_HOKE=null;
System.gc();
//由于finalize优先级较低,所以先暂定5s
Thread.sleep(500);
if (SAVE_HOKE!=null){
SAVE_HOKE.alive();
}else {
System.out.println("no, i am dead:");
}
//这段代码和上面一样,但是却拯救失败
SAVE_HOKE=null;
System.gc();
//由于finalize优先级较低,所以先暂定5s
Thread.sleep(500);
if (SAVE_HOKE!=null){
SAVE_HOKE.alive();
}else {
System.out.println("no, i am dead:");
}
}
}
可以看到两段相同的代码,一次对象自救成功,而一次失败。这也就能得出对象的finalize方法,只会被虚拟机自动执行一次。
4、方法区的垃圾回收
方法区中并不是一定存在垃圾收集行为,《Java虚拟机规范》中提到过可以不再方法区中实现垃圾收集。事实上也存在方法区没有垃圾收集的垃圾收集器,比如JDK11中的ZGC收集器。方法区中是否存在垃圾收集行为,取决于垃圾收集器的实现。
方法区的垃圾收集主要是两部分内容:废弃的常量和不再使用的类型。回收废弃的常量和回收Java堆中的对象非常类似。也是通过判断常量是否存在其他对象引用此常量来进行是否清除操作。
判断一个常量是否“废弃”比较简单,但是判断一个类型是否属于“不在被使用的类”的条件就比较苛刻。需要同时满足一下三个条件:
- 该类所有实例都被回收,也就是java堆中不存在该类于任何派生子类的实例。
- 加载该类的类加载器已被回收。
- 该类对应得java.lang.Class对象没有在任何地方被引用,即无法在任何地方通过反射访问该类的方法。
Java虚拟机允许满足上述三个条件的类型被回收,这里只是说的“允许”,而不是和对象一样没有了对象引用就一定被回收。关于是否被回收HotSpot虚拟机提供了**-Xnoclassgc**参数进行控制还可以通过-verbose:class以及-XX:+TraceClassLoading、-XX:+TraceClassUnLoading查看类的加载和卸载信息。
5、垃圾收集算法
当前商业垃圾收集器大都遵循“分代收集”的理论进行设计,分代收集名为理论实际上是一套大多数程序运行实际情况的经验法则,它建立在以下两个分代假说之上:
- 弱分代假说:绝大多数对象都是朝生夕灭的。
- 强分代假说:熬过越多次垃圾收集过程的对象就越难消灭。
以上两个分代假说奠定了多款常用垃圾收集器的一致设计原则:收集器应该讲Java堆分为不同的区域,然后将回收对象根据年龄(年龄即是对象熬过垃圾收集过程的次数)分配到不同的内存区域中存储。显而易见,如果一个区域中大多数对象都是朝生夕灭的,那么每次垃圾收集过程只需关注少量对象的保留问题即可。而不用关心那些大量将被回收的对象。
由此才有了“Minor GC”,“Major GC”,“Full GC”这样的回收类型划分。还有不同的垃圾回收算法:“标记-复制算法”,“标记-整理算法”,“标记-清除算法”。
java堆一般被分为“新生代”,“老年代”两个部分,顾名思义,在“新生代”中每次发生垃圾收集都会有大批对象死去。而每次回收存活的少量对象,将会逐步放到“老年代”中。
PS:分代收集存在一个明显的问题:即使对象并不是孤立的,对象之间会存在****跨代引用。
因此为了解决上述问题,对分代收集理论增加了第三条经验法则:
- 跨代引用假说:跨代引用相对于同代引用仅占少数。
这其实是可根据前两条假说推理出的隐含推论:存在相互引用关系的两个对象,是应该倾向于同时存在或者同时消亡的。比如,如果某个新生代的对象存在跨代引用,由于老年代对象难以消亡,代引用会使得新生代对象在垃圾收集过程中得以存活,进而在年龄增长之后晋升到“老年代”中,这样跨代引用就不存在了。
Java堆中垃圾收集类型可以分为两大类:
- 部分收集(Partial GC):指目标不是完整的收集整个java堆的垃圾收集器。其中又分为:
- 新生代收集(Minor GC/Young GC):指目标只是新生代的垃圾收集器
- 老年代收集(Major GC/Old GC):指目标只是老年代的收集器。目前只有CMS收集器会有单独收集“老年代”的行为。
- 混合收集(Mixed GC):指目标是收集整个新生代和部分老年代的垃圾收集器。目前只有G1收集器会有这种行为。
- 整堆收集(Full GC):收集整个Java堆和方法区的垃圾收集器。
5.1、标记-清除算法
最早出现也是最基础的垃圾收集算法就是标记-清除算法。其分**“标记”和“清除”两个阶段“:首先标记处哪些对象是要回收的,在标记完成后,统一回收掉所有被标记的对象。
虽然标记-清除算法是最基础的,但是其有两个缺点**:
- 执行效率不稳定:如果Java堆中有大量的对象需要回收,就需要进行大量的标记和清除操作,导致标记和清除的效率随着对象的增多而降低。
- 内存空间碎片化:标记、清除之后会产生大量不连续的内存碎片,空间碎片太多可能导致在分配较大对象时无法找到连续的内存空间而不得不提前执行另一次垃圾收集行为。
5.2、标记-复制算法
为了解决标记-清除算法中面对大量可回收对象执行效率低问题,有了标记-复制算法:它将可用内存分为大小相等的两块,每次只使用其中一块。这一块用完了,就将还存活的对象复制到另一块上,然后在将用过的内存空间一次清理掉。如果内存中有大量的对象存活,那么这种算法将产生大量的内存间复制开销。如果是少量对象存回的情况,算法需要复制的就是极少数的存活对象。而且每次都是针对整个半区的内存进行回收,分配内存也不用考虑空间碎片问题。只需要移动堆顶指针按需分配内存即可。
这样实现简单,运行高效,但是缺陷也明显:可用内存缩小为原来的一半。
IBM公司对新生代“朝生夕灭”的特点作了更量化的诠释:新生代中的对象有****98%**熬不过一轮垃圾收集。因此不需要按照1:1的比例来换分新生代的内存空间。
所以基于上述特点对标记-复制算法进行了改进:将新生代分为一块较大的Eden空间和两块较小的Survivor空间,每次分配内存只是用Eden和其中一块Survivor**。此类方式被称为**“Apple式回收”。发生垃圾收集时,将Eden和Survivor中任然存活的对象一次性复制到另一块Survivor空间上,然后直接清理掉用过的Eden和Survivor空间**。
HotSpot虚拟机默认Eden和Survivor的大小比例为8:1,也即每次新生代可用内存空间为整个新生代的90%(Eden的80%加上一个10%的Survivor),只有一个Survivor空间,10%的新生代会被浪费掉。
由于98%的对象被回收是“普通场景”下测得的数据。因此就会存在特殊情况下有超过10%的对象存活。因此“Apple 式回收”有一个“逃生门”的安全设计,就是当Survivor空间不足以容纳一次Minor GC之后存活的对象,就需要依赖其他内存区域(实际上大多都是老年代)进行分配担保。将对象直接存放到老年代。
5.3、标记-整理算法
标记-复制算法在对象存活率较高时就要进行比较多的复制操作,效率将会降低。更关键的是,如果不想浪费50%的空间,就需要有额外的空间进行分配担保。因此老年代不直接选用此种算法。
针对老年代对象的死亡特征,提出了一种针对性的标记-整理算法,其中标记和标记-清除算法中的标记操作一样,只是后续不是直接进行对象的清除,而是将存活的对象移动到一端,然后直接清理掉边界以外的内存。
标记-清除算法和标记-整理算法的区别就在于前者是非移动式的回收算法,后者是移动式的回收算法。是否移动存活对象是一项优缺点并存的风险决策:
1、如果移动存活对象,尤其是老年代对象每次回收都有大量对象存活的区域。有以下缺点:
- 移动存活对象并更新所有引用这些对象的地方将是一种极为负重的操作。
- 而且这种对象移动操作必须停止所有的用户应用程序才行。即**“Stop The World”**
2、如果和标记-清除算法那样完全不考虑移动和整理存活对象的话。那么为了解决空间碎片化问题就只能依赖更为复杂的内存分配和内存访问器来解决。比如**“分区空闲分配链表”。但是这样的话对系统的吞吐量有较大的影响。
3、还有一种“和稀泥”的做法就是让虚拟机平时多数时间都采用标记-清除算法**,暂时容忍内存碎片的的存在,直到内存空间的碎片化达到影响对象分配时,在采用标记-整理算法收集一次,以获得规整的内存空间。前面提到的基于标记-清除算法的CMS收集器面临空间碎片过多时采用的就是这种处理办法。