Java 虚拟机垃圾收集机制详解
点击上方「蓝字」关注我们
集发生的区域 垃圾收
之前我们介绍过 Java 内存运行时区域的各个部分,其中程序计数器、虚拟机栈、本地方法栈三个区域随线程共存亡。栈中的每一个栈帧分配多少内存基本上在类结构确定下来时就已知,因此这几个区域的内存分配和回收都具有确定性,不需要考虑如何回收的问题,当方法结束或线程结束,内存自然也跟着回收了
而 Java 堆和方法区这两个区域则有显著的不确定性,只有在程序运行时我们才能知道程序究竟创建了哪些对象,创建了多少对象,所以这部分内存的分配和回收是动态的,垃圾收集器所关注的正是这部分内存该如何管理
如何判定需要被回收的对象?
如果一个对象没有被其他对象引用,则证明这个对象可以被回收,因为它已经没有实际用途了。那我们怎么去判断一个对象是否可回收呢?业界主要有两种判断方式:
1. 引用计数法
在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值加一;当引用失效,计数器值减一;任何时刻计数器值都为零的对象就是不可能再被使用了。这种方法虽然会占用额外的内存空间用于计数,但它的原理简单,判定效率也高,大多数情况下它都是一个不错的算法。然而,这个看似简单的算法却需要考虑很多额外情况,否则将无法保证其正确工作,例如单纯的引用计数法就很难解决对象之间相互循环引用的问题
2. 可达性分析算法
该算法的基本思路是通过一系列称为 GC Roots 的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程走过的路径称为引用链。如果某个对象到 GC Roots 间没有任何引用链相连,则证明此对象是不可能再被使用,可以回收
在 Java 技术体系中,可以作为 GC Roots 的对象包括:
在虚拟机栈(栈帧中的本地变量表)中引用的对象
方法区中类静态属性引用的对象
方法区中常量引用的对象
本地方法栈中 JNI(即通常所说的 Native 方法)引用的对象
Java 虚拟机内部的引用,如基本数据类型对应的 Class 对象,一些常驻的异常对象(NullPointException、OutOfMemoryError)
所有被同步锁持有的对象
反映 Java 虚拟机内部情况的 JMXBean、JVMTI 中注册的回调、本地代码缓存等
除了这些固定的 GC Roots 集合外,根据用户所选用的垃圾收集器以及当前回收的内存区域的不同,还可以有其他对象临时加入,共同构成完整的 GC Roots 集合
四种引用类型
无论是通过引用计数法还是可达性分析算法,判断对象是否存活都和引用离不开关系。在 JDK1.2 以前,Java 里的引用是很传统的定义:如果 reference 类型的数据中存储的数值代表的是另外一块内存的起始地址,就称该 reference 数据是代表某块内存、某个对象的引用。这种定义当然没有什么不对,但现在看来显得太狭隘了,比如我们希望描述一类对象:当内存空间足够时,能保留在内存中,如果内存空间在进行了垃圾收集后仍然紧张,则可以抛弃这些对象,很多系统的缓存功能都符合这样的应用场景
JDK1.2 对引用的概念作了补充,将引用分为强引用(Strongly Reference)、软引用(SoftReference)、弱引用(Weak Reference)和虚引用(Phantom Reference),强度依次减弱
强引用
形如 Object obj = new Object() 这种引用关系就是我们常说的强引用。无论什么情况,只要强引用关系存在,对象就永远不会被回收
软引用
用来描述一些有用但非必须的对象。此类对象只有在进行一次垃圾收集仍然没有足够内存时,才会在第二次垃圾收集时被回收。JDK1.2 之后提供了 SoftReference 类来实现软引用
弱引用
也是用来描述那些非必须对象,但它的强度比软引用更弱一些。被软引用关联的对象只能生存到下一次垃圾收集发生为止,当垃圾收集器开始工作,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。JDK1.2 之后提供了 WeakReference 类来实现软引用
虚引用
最弱的一种引用关系,一个对象是否存在虚引用,丝毫不会对其生存时间造成任何影响,也无法通过虚引用来取得一个对象实例。设置虚引用关联的唯一目的就是让这个对象被回收时能收到一个系统通知。JDK1.2 之后提供了 PhantomReference 类来实现软引用
finalize() 方法
在可达性分析中被判定为不可达的对象,并不是立即赴死,至少要经历两次标记过程:如果对象在进行可达性分析后发现没有与 GC Root 相连接的引用链,那么它将被第一次标记,随后再进行一次筛选,筛选条件是对象是否有必要执行 finalize() 方法,如果对象没有覆盖 finalize() 方法或是 finalize() 方法已经被调用过,则都视为“没有必要执行”
如果对象被判定为有必要执行 finalize() 方法,那么该对象将会被放置在一个名为 F-Queue 的队列之中,并在稍后由一条由虚拟机自动创建的、低调度优先级的 Finalizer 线程去执行它们的 finalize() 方法。注意这里所说的执行是指虚拟机会触发这个方法开始运行,但并不承诺一定会等待它运行结束。这样做的原因是防止某个对象的 finalize() 方法执行缓慢,或者发生死循环,导致 F-Queue 队列中的其他对象永久处于等待状态
finalize() 方法是对象逃脱死亡命运的最后一次机会,稍后收集器将对 F-Queue 中的对象进行第二次小规模标记,如果对象希望在 finalize() 方法中成功拯救自己,只要重新与引用链上的任何一个对象建立关联即可,那么在第二次标记时它将被移出“即将回收”的集合;如果对象这时候还没有逃脱,那基本上就真的要被回收了
任何一个对象的 finalize() 方法都只会被系统自动调用一次,如果对象面临下一次回收,它的 finalize() 方法将不会再执行。finalize() 方法运行代价高,不确定性大,无法保证各个对象的调用顺序,因此已被官方明确声明为不推荐使用的语法
回收方法区
方法区的垃圾收集主要回收两部分:废弃的常量和不再使用的类型。判定一个常量是否废弃相对简单,与对象类似,只要某个常量不再被引用,就会被清理。而判定一个类型是否属于“不再被使用的类”的条件就比较苛刻了,需要同时满足下面三个条件:
该类的所有实例都已经被回收,即 Java 堆中不存在该类及其任何派生子类的实例
加载该类的类加载器已经被回收
该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法
Java 虚拟机允许对满足上述三个条件的无用类进行回收,但并不是说必然被回收,仅仅是允许而已。关于是否要对类型进行回收,HotSpot 虚拟机提供了 -Xnoclassgc 参数进行控制
分代收集理论
当前商业虚拟机的垃圾收集器大多数都遵循了“分代收集”的设计理论,分代收集理论其实是一套符合大多数程序运行实际情况的经验法则,主要建立在两个分代假说之上:
弱分代假说:绝大多数对象都是朝生夕灭的
强分代假说:熬过越多次垃圾收集过程的对象就越难以消亡
这两个分代假说共同奠定了多款常用垃圾收集器的一致设计原则:收集器应该将 Java 堆划分出不同的区域,将回收对象依据年龄(即对象熬过垃圾收集过程的次数)分配到不同的区域之中存储,把存活时间短的对象集中在一起,每次回收只关注如何保留少量存活的对象,即新生代(Young Generation);把难以消亡的对象集中在一起,虚拟机就可以使用较低的频率来回收这个区域,即老年代(Old Generation)
正因为划出了不同的区域,垃圾收集器才可以每次只回收其中一个或多个区域,因此才有了“Minor GC”、“Major GC”、“Full GC”这样的回收类型划分,也才能够针对不同的区域采用不同的垃圾收集算法,因而有了“标记-复制”算法、“标记-清除”算法、“标记-整理”算法
分代收集并非只是简单划分一下内存区域,它至少存在一个明显的困难:对象之间不是孤立的,对象之间会存在跨代引用。假如现在要进行只局限于新生代的垃圾收集,根据前面可达性分析的知识,与 GC Roots 之间不存在引用链即为可回收,但新生代的对象很有可能会被老年代所引用,那么老年代对象将临时加入 GC Roots 集合中,我们不得不再额外遍历整个老年代中的所有对象来确保可达性分析结果的正确性,这无疑为内存回收带来很大的性能负担。为了解决这个问题,就需要对分代收集理论添加第三条经验法则:
跨代引用假说:跨代引用相对于同代引用仅占少数
存在互相引用的两个对象,应该是倾向于同时生存或同时消亡的,举个例子,如果某个新生代对象存在跨代引用,由于老年代对象难以消亡,会使得新生代对象同样在收集时得以存活,进而年龄增长后晋升到老年代,那么跨代引用也随之消除了。既然跨代引用只是少数,那么就没必要去扫描整个老年代,也不必专门记录每一个对象是否存在哪些跨代引用,只需在新生代上建立一个全局的数据结构,称为记忆集(Remembered Set),这个结构把老年代划分为若干个小块,标识出老年代的哪一块内存会存在跨代引用。此后当发生 Minor GC 时,只有包含了跨代引用的小块内存里的对象才会被加入 GC Roots 进行扫描
标记 - 清除算法
如其名,算法分为标记和清除两个阶段:首先标记出所有需要回收的对象,在标记完成之后,统一回收所有被标记的对象,也可以反过来,标记存活的对象,统一回收所有未被标记的对象。标记过程就是对象是否属于垃圾的判定过程。标记 - 清除算法执行过程如图所示:
标记 - 清除算法是最基础的算法,后续的收集算法都是以标记 - 清除算法为基础,对其缺点进行改进,它的主要缺点有两个:
执行效率不稳定
如果 Java 堆中包含大量对象且大部分需要回收,则必须进行大量标记和清除的动作‘
内存空间碎片化问题
标记、清除之后会产生大量不连续的内存碎片,内存碎片太多会导致下次分配较大对象时无法找到足够的连续内存,从而不得不提前触发一次垃圾收集动作
8、标记 - 复制算法
为了解决标记 - 清除算法面对大量可回收对象时执行效率低的问题,复制算法将可用内存按容量划分为大小相等的两块,每次只使用其中一块,当这一块内存用完了,就将还存活着的对象复制到另外一内存上,再把已使用过的内存空间一次清理掉
如果内存中多数对象都是存活的,这种算法无疑会产生大量内存间复制的开销,但对于多数对象都是可回收的情况,算法需要复制的就是占少数的存活对象,而且每次都是针对整个半区进行内存回收,分配内存时也不用考虑空间碎片的问题,只要移动堆顶指针,按顺序分配即可。不过这种算法的缺陷也显而易见,可用内存被缩小为原来的一半
标记 - 复制算法大多用于新生代。实际上,新生代中的对象大多数都熬不过第一轮收集,因此不需要按 1:1 的比例来划分新生代的内存空间。具体做法是将新生代划分为一块较大的 Eden 区和两块较小的 Survivor 区,每次分配只使用 Eden 区和其中一块 Survivor 区。发生垃圾收集时,将 Eden 区和 Survivor 区中仍然存活的对象一次性复制到另一个 Survivor 区,然后直接清理掉 Eden 区和已经用过的 Survivor 区。HotSpot 虚拟机默认 Eden 和 Survivor 的大小比例是 8:1:1
当 Survivor 空间不足以容纳一次 Minor GC 之后存活的对象时,就需要依赖其他内存区域(大多是老年代)进行分配担保,上一次新生代存活下来的对象直接进入老年代
标记 - 整理算法
标记 - 复制算法不适合用在对象存活率高的区域,而且会浪费一半的空间,因此老年代一般不采用这种算法,取而代之的是有针对性的标记 - 整理算法。标记 - 整理算法的标记过程与标记 - 清除算法一样,但后续步骤不是直接清理可回收对象,而是让所有存活对象都向内存空间的一侧移动,然后直接清理掉边界以外的内存
是否移动回收后的存活对象是一项优缺点并存的风险决策,尤其是在老年代这种每次回收都有大量对象存活的区域,移动存活对象并更新其引用将会是一个极为繁重的操作,必须暂停用户应用程序线程才能进行,像这样的停顿行为被称为“Stop the World”。但如果不考虑移动存活对象,又会影响内存分配和访问的效率,为此使用者必须小心权衡其中的得失。一种和稀泥式的解决方案就是让虚拟机平时采用标记 - 清除算法,直到内存空间碎片化程度大到影响对象分配时,再采用标记 - 整理算法收集一次,已获得规整的内存空间
学习笔记来源《深入理解 Java 虚拟机》
扫码二维码
获取更多精彩
Java乐园