上一篇文章,我们分析了JVM运行时数据区,大致知道了JVM各个内存区域分别存储哪些数据,以及Java堆中对象的创建、布局与如何访问,具体可查看《【JVM系列一】深入理解JVM内存模型,看完这篇足以!》。本文我们主要分析JVM的垃圾回收相关内容。那么,我们在进入正文前,可以先思考以下几个问题:
- 哪些内存需要回收?
- 什么时候回收?
- 如何回收?
我们来先看一下,JVM体系结构概览图:
其中,程序计数器、虚拟机栈、本地方法栈3块区域都是线程私有的,因此生命周期与线程相同。栈中的栈帧随着方法的进入和退出而有条不紊地执行着出栈和入栈操作。每一个栈帧中分配多少内存基本上是在类结构确定下来时就已知的,因此,这几个区域的内存分配和回收都具备确定性,不需要过多考虑垃圾回收,因为方法结束或者线程结束时,内存自然跟着回收了。
而 Java 堆和方法区则不一样,一个接口中的多个实现类需要的内存可能不一样,一个方法中的多个分支需要的内存也可能不一样,我们只有在程序处于运行期才知道那些对象会创建,这部分内存的分配和回收都是动态的,垃圾回收期所关注的正是这部分内存,如下图:
在进行垃圾内存回收前,我们要先思考一个问题:如何判断哪些对象是还存活的,哪些对象是已死的?明白了这个问题,我们就知道哪些内存是可以回收的。因此,下面我们将介绍判断对象存活的方法。
给对象添加一个引用计数器,每当有一个地方引用它时,计数器值加1;当引用失效时,计数器值减1;当计数器值为0时表示对象不被使用,可以进行回收。
但是,主流的JVM里并没有选用引用计数算法来管理内存,主要原因是它难以解决互相循环引用问题。
假如,我们有一个A类对象和B类对象,都有一个instance字段,赋值:
objectA.instance=objectB;
objectB.instance=objectA;
就算它们不被使用了,但它们互相引用着对方,导致它们的引用计数值都不为0,因此引用计数法无法通知GC收集器回收它们。
如下图,就算直接把 objectA 和 objectA都置 null,但在 Java 堆中的这两对象内存依然保持着互相引用而无法回收。
在主流的商用程序语言的主流实现中(Java、C#),都是使用可达性分析法来判断对象是否存活。
设计思路:通过一系列的 “GC Roots”的对象作为起始点,从这些节点出发所走过的路径称为引用链(Reference Chain)。当一个对象到 GC Roots 没有任何引用链相连时,表示该对象不可用。
如下图,对象object5、object6、object7虽然互相关联,但它们到GC Roots 不可达,所以它们会被判定为可回收的对象。
在Java中, 可作为GC Roots的对象包括:
为何这些对象可以作为GC Roots?
因为这些对象肯定不会被回收的。比如,虚拟机栈中国正在执行的方法,所以里面引用的对象不会被回收。
如何确保新生代对象被老年代对象引用的时候不被gc?(查询老年代对象来确认对新生代对象的引用避免误回收)
当老年代存活对象多时,每次minor gc查询老年代所有对象影响gc效率(因为gc stop-the-world),所以在老年代有一个write barrier(写屏障)来管理的card table(卡表),card table存放了所有老年代对象对新生代对象的引用。 所以每次minor gc通过查询card table来避免查询整个老年代,以此来提高gc性能。
finalize()方法最终判定对象是否存活
(引用:https://blog.csdn.net/wuzhiwei549/article/details/80561208 )
即使在可达性分析算法中不可达的对象,也并非是“非死不可”的,这时候它们暂时处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历再次标记过程。
标记的前提是对象在进行可达性分析后发现没有与GC Roots相连接的引用链。
1).第一次标记并进行一次筛选。
筛选的条件是此对象是否有必要执行finalize()方法。
当对象没有覆盖finalize方法,或者finzlize方法已经被虚拟机调用过,虚拟机将这两种情况都视为“没有必要执行”,对象被回收。2).第二次标记
如果这个对象被判定为有必要执行finalize()方法,那么这个对象将会被放置在一个名为:F-Queue的队列之中,并在稍后由一条虚拟机自动建立的、低优先级的Finalizer线程去执行。这里所谓的“执行”是指虚拟机会触发这个方法,但并不承诺会等待它运行结束。这样做的原因是,如果一个对象finalize()方法中执行缓慢,或者发生死循环(更极端的情况),将很可能会导致F-Queue队列中的其他对象永久处于等待状态,甚至导致整个内存回收系统崩溃。
Finalize()方法是对象脱逃死亡命运的最后一次机会,稍后GC将对F-Queue中的对象进行第二次小规模标记,如果对象要在finalize()中成功拯救自己----只要重新与引用链上的任何的一个对象建立关联即可,譬如把自己赋值给某个类变量或对象的成员变量,那在第二次标记时它将移除出“即将回收”的集合。如果对象这时候还没逃脱,那基本上它就真的被回收了。
流程图如下:
引用计数法与可达性分析法都与“引用”相关,在JDK1.2之前,Java引用的定义:如果reference类型的数据中存储的数值代表的是另外一块内存的起始地址,就称这块内存代表着一个引用。这种定义太过狭隘,只存在“被引用”和“没有被引用”两种状态,但是经常会有一些“食之无味,弃之可惜”的对象:当内存空间足够时,可以保留这些对象;当内存空间在进行垃圾回收后仍然不足,则可以抛弃这些对象。
因此,在 JDK 1.2 之后,引用概念进行了扩充,分为:强引用、软引用、弱引用、虚引用,强度依次减弱。
强引用
程序中普遍存在,类似“
Object obj = new Object()
” 所创建的引用,只要强引用还存在,GC就不会回收被引用的对象。
软引用
软引用用来描述一些还有用但并非必需的对象,在系统进行垃圾回收后,内存仍然不足,将要发生内存溢出异常前,将会把这些对象列进回收范围以便进行第二次回收。
JDK1.2 后,提供了SoftReference 类实现软引用。
弱引用
弱引用也是用来描述非必需对象,但强度比软引用更弱,被弱引用关联的对象只能生存到下一次垃圾回收前。当垃圾收集器工作时,无论内存是否足够,都会回收掉只被弱引用关联的对象。
JDK1.2 后,提供了WeakReference 类实现软引用。
虚引用
虚引用也称为幽灵引用或幻影引用,最弱。
无法通过虚引用获取一个对象的实例,为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。
JDK1.2后,提供了PhantomReference 类实现虚引用。
即使在可达性分析算法中不可达的对象,也并非是“非死不可”的,这时候它们暂时出于“缓刑”阶段,一个对象的真正死亡至少要经历两次标记过程:如果对象在进行可达性分析后发现没有与 GC Roots 相连接的引用链,那他将会被第一次标记并且进行一次筛选,筛选条件是此对象是否有必要执行 finalize() 方法。当对象没有覆盖 finalize() 方法,或者 finalize() 方法已经被虚拟机调用过,虚拟机将这两种情况都视为“没有必要执行”。
如果这个对象被判定为有必要执行 finalize() 方法,那么这个对象将会放置在一个叫做 F-Queue 的队列中,并在稍后由一个由虚拟机自动建立的、低优先级的 Finalizer 线程去执行它。这里所谓的“执行”是指虚拟机会出发这个方法,并不承诺或等待他运行结束。finalize() 方法是对象逃脱死亡命运的最后一次机会,稍后 GC 将对 F-Queue 中的对象进行第二次小规模的标记,如果对象要在 finalize() 中成功拯救自己 —— 只要重新与引用链上的任何一个对象建立关联即可。
注:任何一个对象的finalize() 方法只会被系统自动调用一次。
在堆中,尤其是在新生代中,一次垃圾回收一般可以回收 70% ~ 95% 的空间,而永久代的垃圾收集效率远低于此。
永久代垃圾回收主要两部分内容:废弃的常量和无用的类(方法区的内存回收主要是针对常量池的回收和对类型的卸载)。注:永久代并不等价于方法区,只是JVM将GC分代收集扩展至方法区,或者说使用永久代实现方法区而已。
判断废弃常量:回收废弃常量与回收Java堆中的对象类似,一般是判断该常量没有任何地方被引用。
判断无用的类,需要同时满足以下3个条件:
在大量使用反射、动态代理 、CGLib等ByteCode框架、动态生成JSP以及OSGi这类频繁自定义ClassLoader的场景,都需要虚拟机具备类卸载的功能,以保证永久代不会溢出。
主要分为“标记”与“清除”两个阶段,而对象标记的判定在上面已经介绍过(引用计数法、可达性分析法)。
原理:
标记-清除算法示意图,如下:
优点:不需要额外的空间。
缺点:
为了解决效率问题,出现“复制”算法。
它可将可用内存空间分成大小相等的两块,每次只使用其中一块。当这块内存使用完时,在触发GC时,就会将还存活的对象复制到另一块上面,并将已使用过的内存清除掉。
如下图,采用复制算法GC,内存空间被划分为大小相等的两块区域,回收前,左半部分有5个对象仍然存活,回收后被复制到右半部分,并清理掉左半部分:
但是,因为大多数新生代对象都是“朝生夕死”,熬不过第一次 GC。所以没必要 1 : 1 划分空间。可以分一块较大的 Eden 空间和两块较小的 Survivor 空间(Survivor From、Survivor To),每次使用 Eden 空间和其中一块 Survivor。当回收时,将 Eden 和 Survivor 中还存活的对象一次性复制到另一块 Survivor 上,最后清理 Eden 和 刚才用过的Survivor 空间。它们在HotSpot VM 中默认的大小比例是 8 : 1 : 1,每次浪费 10% 的 Survivor 空间。但是这里有一个问题就是如果存活的大于 10% 怎么办?这里采用一种分配担保策略:多出来的对象直接进入老年代。
原理:
优缺点:解决了标记-清除算法的效率低问题,但是会造成空间利用率低;如果不想浪费50%的内存空间,则还需要额外的内存空间作为分配担保。
复制算法针对对象存活率高的情况就需要较多的复制操作,反而效率变低。更关键的是,如果不想浪费50%的空间,则需要额外的空间进行分配担保,以应对被使用的内存中所有对象100%存活的极端情况,所以老年代一般不采用该算法。
不同于针对新生代的复制算法,针对老年代的特点,创建该算法。主要是把所有存活的对象都移到内存的同一端,并清理掉边界以外的内存。
原理:
优缺点:没有内存碎片产生,无需分配担保的额外空间,节省内存空间;但需要移动对象的成本,效率低。
如下,是采用标记-整理算法GC的示意图:
目前主流的商业虚拟机都是采用“分代收集”的垃圾算法,它主要是根据对象存活周期的不同将堆内存划分为几块。一般是将Java堆分为新生代与老年代,根据不同那个年龄代对象的特点,采用不同的回收算法。在新生代中,每次GC时都会有大部分对象死去,只有少量存活,因此选用复制算法,只需要付出少量存活对象的复制成本就可以完成回收。而老年代中,因为对象存活率高、也没有额外的空间对它进行分配担保,所以选用“标记-清除”或“标记-整理”算法进行GC。
什么是分配担保机制?
在发生Minor GC之前,虚拟机会检查老年代最大可用的连续空间是否大于新生代所有对象的总空间(分配担保是老年代为新生代作担保),
如果大于,则此次Minor GC是安全的。
如果小于,则虚拟机会查看HandlePromotionFailure设置值是否允许担保失败。如果HandlePromotionFailure=true,那么会继续检查老年代最大可用连续空间是否大于历次晋升到老年代的对象的平均大小,如果大于,则尝试进行一次Minor GC,但这次Minor GC依然是有风险的;如果小于或者HandlePromotionFailure=false,则改为进行一次Full GC。
为什么要进行空间担保?
是因为新生代采用复制收集算法,假如大量对象在Minor GC后仍然存活(最极端情况为内存回收后新生代中所有对象均存活),而Survivor空间是比较小的(8:1:1),这时就需要老年代进行分配担保,把Survivor无法容纳的对象放到老年代。老年代要进行空间分配担保,前提是老年代得有足够空间来容纳这些对象,但一共有多少对象在内存回收后存活下来是不可预知的,因此只好取之前每次垃圾回收后晋升到老年代的对象大小的平均值作为参考。使用这个平均值与老年代剩余空间进行比较,来决定是否进行Full GC来让老年代腾出更多空间。
由于篇幅原因,本文先介绍JVM常用的垃圾回收算法,下一篇再详细介绍HotSpot VM中分代收集的算法实现。
《【JVM系列三】HotSpot JVM的垃圾回收算法实现-JVM垃圾回收器》
参考:《深入理解Java虚拟机》
●史上最强Tomcat8性能优化
●阿里巴巴为什么能抗住90秒100亿?--服务端高并发分布式架构演进之路
●B2B电商平台--ChinaPay银联电子支付功能
●学会Zookeeper分布式锁,让面试官对你刮目相看
●SpringCloud电商秒杀微服务-Redisson分布式锁方案
查看更多好文,进入公众号--撩我--往期精彩
一只 有深度 有灵魂 的公众号0.0