生存还是死亡 —— Java虚拟机如何判断对象是否需要回收

时间:2022-12-27 18:27:17

带着问题阅读

  • Java的内存区域中,有哪些区域是垃圾收集器所关注的
  • 怎么判断一个对象是不是需要回收?


导语

前面两讲,给大家讲解了Java的内存区域和常见的内存溢出异常,从这一讲开始,我们来学习Java如何进行垃圾回收。首先,让我们来看看虚拟机是如何判断一个对象是否需要回收的。

本文是Effective Java专栏Java虚拟机专题的第四讲,如果你觉得看完之后对你有所帮助,欢迎订阅本专栏,也欢迎您将本专栏分享给你身边的工程师同学。

在学习本节课程之前,建议您了解一下以下知识点:


当我们在讨论内存分配回收时  我们在讨论什么

之前我们了解了Java虚拟机的内存区域,对于程序计数器、虚拟机栈、本地方法栈这三个区域的数据,随线程生而生,随线程灭而灭,每个栈帧什么是栈帧?分配多少内存,也基本是在类结构确定(编译期)的时候就已知了,因此这几个区域的内存分配和回收,都具有确定性,也就不需要考虑太多的回收问题。

我们常说的垃圾回收,主要指的是Java堆方法区的垃圾回收。一个接口的多个实现类需要的内存可能不一样,而编译期只知道对象的静态类型;一个方法中需要创建多少对象,也只有在运行期才知道,因此,这些部分的内存分配和回收都是动态的,垃圾收集器关注的是这部分的内存。

故而课程里讨论的内存分配和回收,也仅是针对Java堆和方法区的内存。


对象生死的判断策略

垃圾收集器在对堆进行回收之前,第一件事就是要确定哪些对象已经“死去”,需要回收。判断对象生死的算法,主要有以下两种。


引用计数算法

这种算法,给每个对象设置一个引用计数器,每当有一个地方引用它时,计数器加1;引用失效时,计数器减1;计数器为0,意味着对象独自漂泊在堆中,没人认识它,不可能再被使用,这时就是一个“废柴”,可以回收了。

这种算法,实现简单,判定效率也高,但是有一个致命的缺陷——很难解决对象之间相互引用的问题。

什么是对象相互引用,看下面这个例子:

/**
* testGC()方法执行后,objA和objB会不会被GC呢?
*/
public class ReferenceCountingGC {

public Object instance = null;

private static final int _1MB = 1024 * 1024;

/**
* 这个成员属性的唯一意义就是占点内存,以便在能在GC日志中看清楚是否有回收过
*/
private byte[] bigSize = new byte[2 * _1MB];

public static void testGC() {
ReferenceCountingGC objA = new ReferenceCountingGC();
ReferenceCountingGC objB = new ReferenceCountingGC();
objA.instance = objB;
objB.instance = objA;

objA = null;
objB = null;

// 假设在这行发生GC,objA和objB是否能被回收?
System.gc();
}
}


testGC()方法的前四行执行之后,objA对象被objA和objB.instance引用着,objB也类似;执行objA=null和objB=null之后,objA对象的objA引用失效,但是objB.instance引用仍然存在,因此如果采用单纯的引用计数法,objA并不会被回收,除非在执行objB=null时,遍历objB对象的属性,将里面的引用全部置为无效。


可达性分析算法

在主流的商业程序语言(Java、C#),都是通过可达性分析来判断对象是否存活的。这个算法的基本思路是:通过一系列的称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,当GC Roots到一个对象不可达时,则证明这个对象是不可用的,可以将其回收。

这个算法很好的解决了引用计数法在处理相互引用时遇到的难题,如下图,object5和object6虽然相互引用,但是由于他们到GC Roots都不可达,因此会被判定为可回收的对象。

生存还是死亡 —— Java虚拟机如何判断对象是否需要回收

在Java中,可作为GC Roots的对象主要有两种:

  • 全局性的对象,如常量或者类的静态属性,如果一个对象被全局对象所引用,那就不能被回收;
  • 执行上下文,如栈帧中的局部变量,如果方法上下文中有局部变量引用了这个对象,那就不能被回收;


可达不一定就安全了

默认情况下,到GC Roots可达的对象都不会被回收,这种对象,我们成为“强引用”。

然而,实际开发中,并不是所有强引用的对象,我们都认为是不能回收的,比如一个从缓存获取的很占用内存的对象,我希望他可以在下一次垃圾收集时被回收,如果下一次需要使用,再从缓存重新获取。

针对这种“食之无味,弃之可惜”的对象,从JDK 1.2开始,Java对引用的概念进行了扩充,将引用分为强引用(StrongReference)、软引用(SoftReference)、弱引用(WeakReference)和虚引用(PhantomReference)4种:

  • 强引用:也就是默认的引用,只要到GC Roots可达,就不会被回收;
  • 软引用:对象在将要发生内存溢出之前,会被回收;
  • 弱引用:对象在下一次GC时被回收;
  • 虚引用:形同虚设,虚引用的对象,可以视为GC Roots不可达的对象;

这里以弱引用为例,演示一下如何使用引用类型:

public class WeakReferenceTest {
public static void main(String[] args) throws InterruptedException {
WeakReference<WeakReferenceTest> weakReference = new WeakReference<WeakReferenceTest>(new WeakReferenceTest());
// 第一次打印弱引用所引用的对象
System.out.println(weakReference.get());
// 进行一次GC
System.gc();
// 由于GC进行需要时间,这里等一秒钟
Thread.sleep(1000);
// 再次打印弱引用所引用的对象
System.out.println(weakReference.get());
}
}

运行结果:

com.hzy.jvm.chp03.WeakReferenceTest@6f92c766
null


起死回生

即使在可达性分析算法中不可达的对象,也不是“非死不可”的。

对象在被标记为不可达之后,如果对象覆盖了finalize()方法并且该对象还没有调用过finalize(),那么这个对象会被放入F-Queue队列中,并在稍后一个由虚拟机建立的、低优先级Finalize线程中去执行对象的finalize()方法。稍后GC会对F-Queue的对象进行再一次的标记,如果对象的finalize方法中,将对象重新和GC Roots建立了关联,那么在第二次标记中就会被移除出“即将回收”的集合。

但是,finalize线程的优先级很低,GC并不保证会等待对象执行完finalize方法之后再去回收,因而想通过finalize方法区拯救对象的做法,并不靠谱。鉴于finalize()方法这种执行的不确定性,大家其实可以忘记finalize方法在Java中的存在了,无论什么时候,都不要使用finalize方法。


总结

这一讲讲解了Java虚拟机如何判断对象是否需要回收,重点介绍了可达性分析算法。下一讲,让我们来了解一下HotSpot是如何实现可达性分析算法的。


课后思考

GC开始后,是否需要暂停除GC线程以外的其他线程?为什么?欢迎在评论区写下您的答案,O(∩_∩)O谢谢。


上一节课后思考题的答案

上一讲的问题是——“除了本文所讲的异常,你还见过什么其他的内存溢出异常

就我个人而言,遇到过一个比较少见的异常:

java.lang.OutOfMemoryError: GC overhead limit exceeded

查询了一下Oracle上的文献

The parallel collector will throw an OutOfMemoryError if too much time is being spent in garbage collection: if more than 98% of the total time is spent in garbage collection and less than 2% of the heap is recovered, an OutOfMemoryError will be thrown. This feature is designed to prevent applications from running for an extended period of time while making little or no progress because the heap is too small. If necessary, this feature can be disabled by adding the option-XX:-UseGCOverheadLimit to the command line.

大致意思就是:

并发垃圾收集器如果花费太多的时间在垃圾收集上就会抛出此异常。具体标准是:虚拟机将98%以上的时间花在了回收少于2%的垃圾上,就会抛出这个异常。虚拟机的这个特性可以防止应用程序由于堆设置太小的缘故而花费太多时间在垃圾收集上做无用功。如果想放弃这个特性,可以加入这个参数-XX:-UseGCOverheadLimit.

*上也有如下的描述:

This message means that for some reason the garbage collector is taking an excessive amount of time (by default 98% of all CPU time of the process) and recovers very little memory in each run (by default 2% of the heap).
This effectively means that your program stops doing any progress and is busy running only the garbage collection at all time.
To prevent your application from soaking up CPU time without getting anything done, the JVM throws this Error so that you have a chance of diagnosing the problem.
The rare cases where I've seen this happen is where some code was creating tons of temporary objects and tons of weakly-referenced objects in an already very memory-constrained environment.

我将在讲完垃圾收集器之后,尝试给大家重现这个异常。


参考资料

  • 《深入理解Java虚拟机》 周志明