JVM之垃圾回收机制
前言
本文章只是一个对《深入理解Java虚拟机(第3版 周志明著)》的一个知识整理和个人思考,如有错误麻烦指出,不尽感激!
如何判断对象是否存活
引用计数法
- 概念:在一个对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加一;当引用失效时,计数器值就减一;任何计数器为零的对象都是不可能再被使用的。
- 优点:原理简单、效率高、在大多数情况下都是一个不错的算法
- 缺点:有很多例外情况需要考虑,要配合大量的额外处理才能保证正确工作。经典的一个例子是:对象
objA
引用了objB
,同时objB
又引用了objA
。如果不配合额外处理,它们的计数器就永远不会为0,也就不会被回收,这样就容易造成资源的浪费。
使用强弱引用解决循环引用问题
下面是实际解决方式:
- 加一个引用计数器,使其有一个强引用计数器还有一个弱引用计数器
- 当一个对象强引用了另一个对象,那么这个被强引用的对象的强引用计数器就加一,当这个强引用失效时,强引用计数器进行减一
- 当一个对象弱引用另一个对象,那么这个被弱引用对象的弱引用计数器就加一,当引用失效时,弱引用计数器就减一
- 只有强引用计数器才参与是否存活的判断
- 其中如果A对象强引用了B,那么B对象引用A时需要使用弱引用
下面举一个简单的例子:
A对象强引用了B,那么B对象的强引用计数器就进行+1,此时对象B想引用对象A,由于A已经强引用了B,那么B只能弱引用对象A,A的弱引用计数器+1,当进行垃圾清除时,发现A的强引用计数器为0,那么说明没有A可以被清除。
上面的例子看上去挺美好,但是还有一个问题就是:B弱引用了A,此时A消失了,这个弱指针就变成了一个野指针。不好意思串台了,在Java中没有指针这个概念,A消失后,如果B想引用A,虚拟机是会禁止我们使用的,但是却不会产生内存泄漏的问题。
可达性分析算法
-
概念:通过一系列称为
GC Roots
的根对象作为起始节点集,从这些节点开始根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”,如果一个对象到GC Roots
之间没有任何引用链,那么这个对象就是不可能再被使用的 -
优点:只需要确定
GC Roots
就可以找到所有存活的对象 -
缺点:
GC Roots
可能包含过多对象而过度膨胀(实际上最新的几款垃圾收集器都有进行优化,优化方式是提供了局部回收的方式) -
GC Roots
对象一般包括:- 在虚拟机栈(栈帧中的本地变量表)中引用的对象,如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等等
- 在方法区中类静态属性引用的对象,如Java类的引用类型静态变量
- 在方法区中常量引用的对象,如字符串常量池里的引用
- 在本地方法栈中
JNI
(也就是平时所说的Native
方法)引用的对象 - Java虚拟机内部的引用,如基本数据类型对应的Class对象、一些常驻的异常对象、系统类加载器
- 所有被同步锁(
synchronized
关键字)持有的对象 - 反映JVM内部情况的
JMXBean
、JVMTI
中注册的回调、本地代码缓存等
再谈引用
无论通过引用计数法还是可达性分析法,判定存活都跟“引用”离不开关系,在JDK1.2之后,Java对引用的概念进行了扩充。将引用分为强引用、弱引用、软引用、虚引用
-
强引用:
最传统的引用定义,是指在程序代码中普遍存在的引用赋值,类似“Object obj = new Object()”这种引用关系,无论任何情况下,只有强引用关系还在,垃圾处理器就永远不会回收被引用的对象
-
软引用:
用于描述一些还有用但不是必须的对象。在系统即将要发生内存溢出异常前,才会将这些对象放进回收范围进行第二次回收,如果回收之后仍没有足够内存,才会抛出内存溢出异常
-
弱引用:
也用于描述哪些非必须对象,但它的强度比软引用还要弱一些,被弱引用关联的对象只能生存到下一次垃圾回收发生为止。在垃圾收集器开始工作后,无论内存是否足够都只会回收掉被弱引用关联的对象
-
虚引用
是最弱的一种引用关系,为一个对象设置弱引用关联的唯一目的是在这个对象被回收时收到一个系统通知
生存还是死亡
在可达性分析算法中判定为不可达的对象,也并非是“非死不可”的,要宣判一个对象死亡,至少要经历两次标记过程。过程如下:
- 当一个对象经过可达性分析后发现没有与
GC Roots
有相连接的引用链,那么它将会被第一次标记 - 标记后进行一次筛选,判断这个对象是否有必要执行
finalize()
方法,执行的条件是重写了finalize()方法和该方法没有被虚拟机调用过
,如果没有必要,宣判死刑 - 如果有必要,将这个对象放入到一个名为
F-Queue
的队列中,并稍后由一个低优先级的Finalizer
线程去执行该方法,执行这个方法后,如果重新建立了关联,那就“免于一死”,如果没有则宣判死刑。
回收方法区
方法区的垃圾收集主要回收两部分内容:废弃的常量和不再使用的类型
-
一个常量如果曾经进入常量池中,但是当前系统没有一个对象的值是这个常量,如果垃圾收集器判断有回收的必要的话,这个常量就会被回收
-
判断一个类型是否为“不再被使用的类”:
- 该类中所有的实例都已经被回收,也就是说Java堆中不存在该类及其任何派生子类的实例
- 加载该类的类加载器已经被回收(除非精心设计,否则这个条件一般不会达成)
- 该类对应的
java.lang.Class
对象没有在任何地方被引用,无法在任何地方通过反射的方法访问该类的方法
而且就算这三个条件满足,也不一定会被回收,只是“被允许回收”
垃圾回收算法
三个分代假说
- 弱分代假说:绝大多数对象都是朝生夕灭的。
- 强分代假说:熬过越多次垃圾收集过程的对象就越难以消亡
- 跨代引用假说:跨代引用相对于同代引用来说仅占极少数
基于上面三个假说,我们可以得出这样一个思路:
- 收集器应该把Java堆划分出不同的区域,然后将回收对象按年龄分配到不同的区域中
- 如果一个区域中大多数对象都是朝生夕灭,难以熬过垃圾收集过程的话,那么将他们集中放在一起,每次回收时只关注如何保留少量存活而不是去标记那些大量将要被回收的对象,就能以较低代价获得大量的空间
- 如果剩下的都是难以消亡的对象,将他们集中到一块,虚拟机便可以使用较低的频率来回收这个区域,这就同时兼顾了垃圾收集的时间开销和内存空间的有效利用
- 每次回收后存活的少量对象,将会逐步晋升到老年区进行存放
- 存在互相引用关系的两个对象,应该是倾向于同时生存或同时消亡的
- 在新生代建立一个全局数据结构“记忆集”,这个数据结构将会把老年代分成若干小块,标志出老年代的那一块内存会存在跨代引用,在进行垃圾回收时,将这些对象也加入到
GC Roots
之中
标记——清除算法
首先标记出所有需要回收的对象,在标记完成后,统一回收所有被标记的对象;或者标记出所有存活的对象,将未标记的对象进行回收
缺陷:
- 执行效率不稳定,标记和清除两个过程的效率随对象数量增长而降低
- 内存空间碎片化问题,会导致内存分配问题,需要依赖更为复杂的内存分配器和内存访问器解决,由于计算机最频繁的操作也就是内存的访问,在这个环节上若增加了额外的负担,势必会影响应用程序的吞吐量
标记——复制算法
同样先标记出所有要进行回收的对象,在标记完成之后,将所有未被标记的对象存放到另一块空间,然后对这块空间一次清理掉。
优点:实现简单、运行高效
缺陷:将可用内存减少了,造成空间的浪费。特别是“半区复制”,但后面根据三个假说,进行了优化
标记——整理算法
同样标记出所有要进行回收的对象,在标记完成后,将所有存活对象往内存空间一端进行移动,然后直接清理掉边界以外的内存。
缺陷:这种方法在移动存储对象并更新所有引用对象的地方将会是一种极为负重的操作,而且这种移动操作必须全程暂停用户应用程序才能进行。
和稀泥的解决方式
一开始使用标记——清除算法,当碎片化程度到一定程度后,使用标记——整理算法收集一次。
使用记忆集和卡表解决跨代引用如何添加GC Roots
问题
记忆集是一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构,下面列举三种记录粒度来节省记忆集的存储和维护成本:
- 字段精度:每一个记录精确到一个机器字长(就是处理器的寻址位数,如常见的32位和64位),该自包含跨代指针
- 对象精度:每一个记录精确到一个对象,该对象里有字段含有跨代指针
- 卡精度:每一个记录都精确到一块内存区域,该区域内有对象,对象中含有跨代指针
其中第三种是指用一种被称为“卡表”的方式去实现记忆集,也是目前最常用的一种记忆集实现形式。卡表最简单的形式可以只是一个字节数组。HotSpot虚拟机也是这样做的:
字节数组CARD_TABLE
的每一个元素都对应着其标识的内存区域中一块特定大小的内存块,这个内存块被称作“卡页”。一个卡也得内存中通常包含不止一个对象,只要当卡页中有一个或更多个对象的字段存在着跨代指针,那么就将对应卡表的数组元素的值标识为1,称为这个元素变脏,没有则标识为0,在垃圾回收发生时,只需要筛选出卡表中变脏的元素,就可以轻易知道哪些内存块中含有跨代指针,将其放进GC Roots
中