我们正式来探讨垃圾回收这个话题。垃圾回收是性能调优的重中之重,也是高端程序员面试必备的领域,绝对值得我们深入研究。

 

垃圾回收相关的内容水非常的深,细节也非常的多。不过你不用担心。这块知识点其实单纯从难度上来讲,并不是那么难以接受。而且我已经把比较抽象难以理解的部分都用代码演示或者类比的方式给具象化了。所以理论上应该不会感到无所适从。这一点你应该对自己有一些信心。

 

那么,我个人建议你在学习这部分知识的时候,关键还是要能够保持耐心,多花时间勤记笔记。

如果学完一遍之后,没有轻车熟路的感觉,建议二遍学习,甚至是多遍的学习。另外也可以结合市面上的一些文章或者是博客学习这部分内容。只有多方印证,加深印象的方式在学习垃圾回收是很有必要的。再一个,当遇到问题的时候,千万不要羞涩,要敢于提问。

 

好,下面一起来跟啃垃圾回收这个硬骨头吧。我们知道在开发 Java 程序的时候,Java 程序员一般是不需要关注对象的回收的。而是由 Java 的垃圾回收机制帮助我们自动回收掉没用的对象,对吧?但是 JVM 为我们提供了多种垃圾回收算法以及多种垃圾回收策略,不同的回收算法以及策略有不同的适用场景。

 

如果你在项目中使用了不合适的垃圾回收算法或者策略,那么系统的性能将会很难达到最优。在某些业务场景下,不合适的垃圾口的算法或者策略,甚至可能会导致性能的大幅下降。所以说,垃圾回收的重要性是毋庸置疑的。

 

本课时我们主要探讨三个话题:

 

首先,什么场景下该使用什么样的垃圾回收策略?

 

第二,垃圾回收发生在哪些区域?

 

第三,对象在什么时候能够被回收?

 

什么场景下该使用什么样的垃圾回收策略?

 

现在来探讨一下什么场景下该使用什么样的垃圾回收策略。一般来说,总体原则大致上是这样的。

 

如果你的项目对内存要求非常苛刻,这个时候就是想办法提高对象的回收效率,再回收的时候要尽量多回收掉一些对象,从而腾出更多的内存。

 

如果你的项目 CPU 用率比较高,这个时候就去想办法降低高并发时垃圾回收的频率,从而让 CPU 更多的去执行你的业务,而不是垃圾回收。

 

垃圾回收发生在哪些区域?

 

下面我们来看看垃圾回收会发生在哪些区域,还是根据下图 JVM 内存结构。

 

image.png

 

我们知道虚拟机栈、本地方法栈以及程序计数器都是线程独享的,这三个区域随着线程的创建而创建,随着线程的销毁而销毁,而栈里面的栈帧又会随着方法的进入和退出,分别进行入栈和出栈的操作。所以这三块区域是不需要考虑垃圾回收的。

 

堆于和方法区是线程共享的,这两个区域才需要关注垃圾回收,那么堆是垃圾回收的主要区域,主要回收的是我们创建的对象,对吧?而方法区可以回收废弃的常量以及不需要使用的类。

 

对象在什么时候能够被回收?

 

对象在什么时候才能被回收呢?就目前来说,主要有两种算法去判断对象是不是可以回收。

 

第一种是引用计数法,引用计数法是通过对象的引用计数器来判断这个对象是不是被引用了,比如对于某一个对象 A,那么只要有一个对象引用了 A,A 的引用计数器就会 +1,当引用失效的时候,A上面的引用计数器就会 -1。如果 A 的引用计数器值变成零的话,就说明这个对象已经没有引用了。可以回收。比如下图:

 

image.png

 

假设图中有 A、B、C 三个对象,A 引用 B,B 引用 C,那么在 B上就可以找到 A 的引用,B 的引用计数是 1,B 引用 C,在 C 上就可以找到 B 的引用,C 的引用计数也是 1。那么不难发现,要想实现引用计数法还是比较简单的,而且使用引用计数法去判断对象是否可以回收,效率也是比较高的。但是如果对象之间存在循环引用的时候,使用引用计数法就无能为力了。比如下图所示:

 

image.png

 

假设图中有 A、B、C、D 四个对象,A 引用 B,B 引用 C,C 引用 D,D 又引用 B。 在这个情况下,B 上可以找到 A 的引用以及 D 的引用,B 的引用计数是 2,某一天将我们引用改了,A 不引用 B。那么理论上应该 B、C、D 都要被回收,但是我们发现,D 上可以找到 B 的引用,C

上可以找的 B 的引用,D 上可以找到 C 的引用,B、C、D 的引用计数都是 1。那么这种情况下使用引用计数法的话,就会导致 B、C、D 都不能回收。

 

那么由于引用计数法处理循环引用存在问题,所以就目前而言,Java 并没有使用引用计数法,Java 采用的是第二种算法,即可达性分析。

 

可达性分析的思路是以根对象(GC Roots)作为起点向下搜索,走过的路径被称为是引用链(Reference Chain)。如果某一个对象到根对象没有引用链的话,就认为这个对象是不可达的,可以回收。

 

下图就是可达性分析示例图:

 
image.png

 

从根对象向下搜索,搜索到的引用就被称为是引用链。如果对象没有到根对象的引用链的话,就认为这个对象是不可达的。所以对象 1、2、3、4都是可达的,不能被回收。而对象 5、6、7没有到根对象的引用链存在,所以都可以回收。

 

那么对象可以作为根对象的?根对象主要包括以下几类:

 

第一,虚拟机栈里面引用的对象,一般是一些局部变量。

 

第二,方法区里面的静态属性所引用的变量。

 

第三,方法区里面的常量作用的对象。

 

第四,本地方法栈里面引用的对象。

 

知道根对象是什么之后,我们还需要知道什么是引用。那么从 JDK 1.2 开始,Java 里面设计了四种引用:

 

第一种是强引用,强应用就是我们平时所使用的引用,例如:Object objec = new Object()。只要强引用存在,垃圾回收器是永远不会回收被引用的对象,哪怕是出现内存溢出,都不会回收这些对象。

 

第二种是软引用,使用这种方式就可以创建一个软引用。软引用是描述一些有用,但是不是必须的对象。对于软引用关联的对象,就在内存不足的时候,JVM 才会回收掉这些对象。利用这个特性,软引用就比较适合用来实现缓存,比如网页缓存、图片缓存等等。

 

第三种是弱引用(Weak Reference),如果需要创建弱引用,那么就可以这么写。例如:

 

WeakReference<String> weakReference = new WeakReference<>("hello")。

 

弱引用也是用来描述非必需对象的。当前 JVM 进行垃圾回收的时候,不管内存是不是充足,都会回收掉弱引用关联的对象。

 

第四种是虚引用(Phantom Reference),要想创建虚引用,那么就可以这样写。示例:

 

ReferenceQueue<String> rq = new) ReferenceQueue<>();
PhantomReference<String> pr = new PhantomReference<("hello", queue);

 

 

虚引用和前面的软引用以及弱音用是不太一样的,虚引用不会影响对象的生命周期。如果一个对象只有虚引应用的话,那么它就和没有任何引用一样,在任何时候都可能会被垃圾回收器回收。虚引用主要是用来跟踪对象被垃圾回收器回收的活动。虚引用和软引用以及弱引用的一个区别在于虚引用必须要和 ReferenceQueue 配合使用,但垃圾回收器准备回收一个对象的时候。如果发现他还有虚引用的话,就在回收对象的内存之前,把这个虚引用加入到和它关联的 ReferenceQueue 里面去,这样程序就会通过判断 ReferenceQueue 里面。是不是已经加入了虚引用,从而去了解被用的对象是不是即将要被回收了。如果程序发现某一个虚引用已经加入了 ReferenceQueue。那么就可以在所引用的对象的内存被回收之前采取必要的行动。

 

 

现在相信你应该能够比较清楚的知道可达性分析是怎么玩的了。那么有关可达性分析还有一个注意点,就是即使一个对象不可达,那么它也不一定会被回收,对象不可能只是给这个对象探入了死缓,要想猝死这个对象完整的流程,大致流程示例图:

 

image.png

 

 

在做垃圾回收的时候,首先会判断对象有没有根对象的引用链。如果存在引用链,那么就不会收。如果没有引用链,那么会给它判死缓。

 

那么怎么样给这个对象判死刑呢?JVM 首先会判断有没有必要去执行这个对象的 finalize() 方法。如果没有必要执行对象的 finalize() 方法,那么就直接会收。那么如果你的对象没有重写过 finalize() 方法或者是 finalize() 方法已经被调用过了,会认为没有必要再去执行 finalize() 方法了。

 

如果判断出来有必要执行 finalize() 方法,那么就会把这个对象放到一个名为 F-Queue 的队列里面去,然后会由虚拟机自动建立一个低优先级的 finalize 线程去执行这个对象的 finalize() 方法,那么如果你在 finalize() 方法里面重新和引用链上的任意一个对象建立连接。

那么 这个对象就从 F-Queue 里面移除,不去回收。

 

因为你已经重新建立连接了,这个对象又变成可达的了,如果调用过finalize() 方法之后,这个对象还是没有到根对象的引用链,那么这个对象也会被回收。

 

好,下面来开单代码,帮助我们理解这个流程。

这个代码呢我们重写了finalize 方法,然后呢写了一个漫方法在里面做了一堆的处理。

运行下看看是什么结果。

可以看到先打印泛滥是被调用了,然后呢,第一次回收。

说对象是可用的,第二次回收对象才变成了。now 来分析一下是为什么。

 

 

在这个类里面,我们重写了finalize 方法,并且 finalize() 方法里面把 this 赋给了 object。然后,在 main 方法里面,我们先 new object,object 设置为 null 去调了对象的引用。然后使用 System.gc() 强制回收这个对象。

 

代码执行到这里,按照刚刚的讲解,首先会调用 finalize() 方法,然后把对象扔的 F-Queue 里面去。因为这里我们重写了finalize() 方法,所以不会走无必要这个分支。

 

但是 finalize() 方法里面,我们把 this 付给 object,也就是说 finalize 方法里面我们又重新创建了对象的引用。于是就会导致这个对象会从F-Queue 里面移除,没有被回收。因此输出 object 可用。

 

紧接着,我们再次把 object 对象设置为 null,并且再次触发了 gc。这一次因为之前已经执行过三次了,所以会走无必要分支,于是这一次对象才被回收了。

 

那么经过分析不难发现,这段代码其实存在一个严重的问题。那就是如果第二次,我们没有人工的把 object设置为 null,就会导致他们这个的对象永远无法回收。

 

所以,实际项目里面一般建议:

 

第一,尽量的去避免使用 finalize 方法,因为操作不当的话,可能会导致问题。

 

第二, finalize() 方法优先级是比较低的,所以什么时候会被调用不太容易确定。而且什么时候发现垃圾回收也是不确定的。当然了,在我们的例子里面使用 System.gc 强制触发垃圾回收的。

 

第三,实际项目里面应该尽量使用try … catch … finally 去代替 finalize() 方法。

 

总结

 

简单总结一下本课时的内容,希望经本课时的讲解,你应该至少能够了解以下几点:

 

第一,什么场景下该使用什么垃圾回收策略,在内存要求苛刻的情况,以及 CPU 使用率高的情况下,分别该使用什么样的策略。

 

第二,要知道垃圾回收主要发生在哪些区域,堆和方法区。

 

第三,要知道对象在什么时候能够被回收。这个话题下,主要要知道引用计数法和可达性分析分别是什么。而且要知道在实际项目中,应该尽量不要去使用 finalize() 方法。

 

那么至于 Java 里面的四种引用方式以及特性,如果同志们基础不好或者难以掌握的话,有一个印象就可以了。