Java JVM——13. 垃圾回收算法
1.生存还是死亡?
在堆里存放着几乎所有的 Java 对象实例,在 GC 执行垃圾回收之前,首先需要区分出内存中哪些是存活对象,哪些是已经死亡的对象。只有被标记为己经死亡的对象,GC 才会在执行垃圾回收时,释放掉其所占用的内存空间,因此这个过程我们称为垃圾标记阶段。
那么在 JVM 中究竟是如何标记一个死亡对象呢?简单来说,当一个对象已经不再被任何的存活对象继续引用时,就可以宣判为已经死亡。
判断对象存活一般有两种方式:引用计数算法和可达性分析算法。
1.1 标记阶段:引用计数算法
引用计数算法(Reference Counting)比较简单,对每个对象保存一个整型的引用计数器属性,用于记录对象被引用的情况。
对于一个对象A,只要有任何一个对象引用了A,则A的引用计数器就加1;当引用失效时,引用计数器就减1。只要对象A的引用计数器的值为0,即表示对象A不可能再被使用,可进行回收。
★ 优点:实现简单,垃圾对象便于辨识;判定效率高,回收没有延迟性。
★ 缺点:它需要单独的字段存储计数器,这样的做法增加了存储空间的开销。
致命缺陷:
每次赋值都需要更新计数器,伴随着加法和减法操作,这增加了时间开销。 引用计数器有一个严重的问题,即无法处理循环引用的情况。这是一条致命缺陷,导致在 Java 的垃圾回收器中没有使用这类算法。
1.1.1 循环引用
1.1.2 小结
具体哪种最优是要看场景的,业界有大规模实践中仅保留引用计数机制,以提高吞吐量的尝试。
Java 并没有选择引用计数,是因为其存在一个基本的难题,也就是很难处理循环引用关系。
Python 是如何解决循环引用的?
手动解除。很好理解,就是在合适的时机,解除引用关系。使用弱引用 weakref,weakref 是 Python 提供的标准库,旨在解决循环引用。
1.2 标记阶段:可达性分析算法
1.2.1 概念
可达性分析算法,也可以称为:根搜索算法、追踪性垃圾收集。
相对于引用计数算法而言,可达性分析算法不仅同样具备实现简单和执行高效等特点,更重要的是该算法可以有效地解决在引用计数算法中循环引用的问题,防止内存泄漏的发生。
1.2.2 基本思路
可达性分析算法是以根对象集合(GC Roots)为起始点,所谓“GC Roots”根集合就是一组必须活跃的引用,按照从上至下的方式搜索被根对象集合所连接的目标对象是否可达。使用可达性分析算法后,内存中的存活对象都会被根对象集合直接或间接连接着,搜索所走过的路径称为引用链(Reference Chain)。
如果目标对象没有任何引用链相连,则是不可达的,就意味着该对象己经死亡,可以标记为垃圾对象。在可达性分析算法中,只有能够被根对象集合直接或者间接连接的对象才是存活对象。
1.2.3 GC Roots可以是哪些?
● 虚拟机栈中引用的对象
● 本地方法栈内JNI(通常说的本地方法)引用的对象
● 方法区中静态属性引用的对象
● 方法区中常量引用的对象
● 所有被同步锁(synchronized 关键字)持有的对象
● Java虚拟机内部的引用
● 反映java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等
总结:除了堆空间外的一些结构,比如:虚拟机栈、本地方法栈、方法区、字符串常量池等地方对堆空间进行引用的,都可以作为 GC Roots 进行可达性分析。
除了这些固定的 GC Roots 集合以外,根据用户所选用的垃圾收集器以及当前回收的内存区域不同,还可以有其他对象“临时性”地加入,共同构成完整 GC Roots 集合,比如:分代收集和局部回收(Partial GC)。
如果只针对 Java 堆中的某一块区域进行垃圾回收(比如:典型的只针对新生代),必须考虑到内存区域是虚拟机自己的实现细节,更不是孤立封闭的,这个区域的对象完全有可能被其他区域的对象所引用,这时候就需要一并将关联的区域对象也加入 GC Roots 集合中去考虑,才能保证可达性分析的准确性。
小技巧:由于 Root 采用栈方式存放变量和指针,所以,如果一个指针,它保存了堆内存里面的对象,但是自己又不存放在堆内存里面,那它就是一个 Root。
1.2.4 注意事项
如果要使用可达性分析算法来判断内存是否可回收,那么分析工作必须在一个能保障一致性的快照中进行,这点不满足的话,分析结果的准确性就无法保证,这点也是导致 GC 进行时必须“Stop The World”的一个重要原因。
即使是号称(几乎)不会发生停顿的 CMS 收集器中,枚举根节点时也是必须要停顿的。
2.垃圾收集算法
2.1 分代收集理论
分代收集理论,是基于这样一个事实:不同的对象的生命周期是不一样的。
当前商业虚拟机的垃圾收集器,大多数都遵循了“分代收集”(Generational Collection)的理论进行设计,分代收集名为理论,实质是一套符合大多数程序运行实际情况的经验法则,它建立在两个分代假说之上:
♣ 弱分代假说(Weak Generational Hypothesis):绝大多数对象都是朝生夕灭的。
♣ 强分代假说(Strong Generational Hypothesis):熬过越多次垃圾收集过程的对象就越难以消亡。
这两个分代假说共同奠定了多款常用的垃圾收集器的一致的设计原则:收集器应该将 Java 堆划分出不同的区域,然后将回收对象依据其年龄(年龄即对象熬过垃圾收集过程的次数)分配到不同的区域之中存储。显而易见,如果一个区域中大多数对象都是朝生夕灭,难以熬过垃圾收集过程的话,那么把它们集中放在一起,每次回收时只关注如何保留少量存活而不是去标记那些大量将要被回收的对象,就能以较低代价回收到大量的空间;如果剩下的都是难以消亡的对象,那把它们集中放在一块,虚拟机便可以使用较低的频率来回收这个区域,这就同时兼顾了垃圾收集的时间开销和内存的空间有效利用。
因此,不同生命周期的对象可以采取不同的收集方式,以便提高回收效率。一般是把 Java 堆分为新生代和老年代,这样就可以根据各个年代的特点使用不同的回收算法,以提高垃圾回收的效率。
在Java程序运行的过程中,会产生大量的对象,其中有些对象是与业务信息相关,比如 Http 请求中的 Session 对象、线程、Socket 连接等,这类对象跟业务直接挂钩,因此生命周期比较长。但是还有一些对象,主要是程序运行过程中生成的临时变量,这些对象生命周期会比较短,比如:String 对象,由于其不变类的特性,系统会产生大量的这些对象,有些对象甚至只用一次即可回收。
目前几乎所有的 GC 都采用分代收集算法执行垃圾回收的。
★ 年轻代(Young Gen)
➹ 特点:区域相对老年代较小,对象生命周期短、存活率低,回收频繁。
✎ 适用情况:复制算法的回收整理,速度是最快的。复制算法的效率只和当前存活对象大小有关,因此很适用于年轻代的回收。而复制算法内存利用率不高的问题,通过 HotSpot 中的两个 Survivor 的设计得到缓解。
★ 老年代(Tenured Gen)
➹ 特点:区域较大,对象生命周期长、存活率高,回收不及年轻代频繁。
✎ 适用情况:存在大量存活率高的对象,复制算法明显变得不合适。一般是由标记-清除或者是标记-清除与标记-整理的混合实现。
当成功区分出内存中存活对象和死亡对象后,GC接下来的任务就是执行垃圾回收,释放掉无用对象所占用的内存空间,以便有足够的可用内存空间为新对象分配内存。
目前在JVM中比较常见的三种垃圾收集算法是:
✁ 标记—清除算法(Mark-Sweep)
✁ 标记—复制算法(copying)
✁ 标记—整理算法(Mark-Compact)
2.2 清除阶段:标记—清除算法
最早出现也是最基础的垃圾收集算法是“标记-清除”(Mark-Sweep)算法,在1960年由Lisp之父John McCarthy所提出。如它的名字一样,算法分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回收所有未被标记的对象。标记过程就是对象是否属于垃圾的判定过程。
当堆中的有效内存空间(Available Memory)被耗尽的时候,就会停止整个程序(也被称为Stop The World),然后进行两项工作,第一项则是标记,第二项则是清除。
标记:Collector 从引用根节点开始遍历,标记所有被引用的对象。一般是在对象的 Header 中记录为可达对象。注意,标记的是引用的对象,不是垃圾!!
清除:Collector 对堆内存从头到尾进行线性的遍历,如果发现某个对象在其 Header 中没有标记为可达对象,则将其回收。
算法缺点:
1、执行效率不稳定。如果 Java 堆中包含大量对象,而且其中大部分是需要被回收的,这时必须进行大量标记和清除的动作,导致标记和清除两个过程的执行效率都随对象数量增长而降低。
2、内存空间的碎片化问题。标记、清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致当以后在程序运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。
为了解决标记—清除算法在垃圾收集效率方面的缺陷,M.L.Minsky 于1963年发表了著名的论文“使用双存储区的 Lisp 语言垃圾收集器(A LISP Garbage Collector Algorithm Using Serial Secondary Storage)”。M.L.Minsky 在该论文中描述的算法被人们称为复制(Copying)算法,它也被 M.L.Minsky 本人成功地引入到了 Lisp 语言的一个实现版本中。
算法过程:将活着的内存空间分为两块,每次只使用其中一块,在垃圾回收时将正在使用的内存中的存活对象复制到未被使用的内存块中,之后清除正在使用的内存块中的所有对象,交换两个内存的角色,最后完成垃圾回收。
其实新生代里面就用到了复制算法:把可达的对象,直接复制到另外一个区域中复制完成后,A区就没有用了,里面的对象可以直接清除掉。
算法优、缺点:
优点:
1、对于多数对象都是可回收的情况,算法需要复制的就是占少数的存活对象。
2、实现简单,运行高效。每次都是针对整个半区进行内存回收,分配内存时也就不用考虑有空间碎片的复杂情况,只要移动堆顶指针,按顺序分配即可。
缺点:
1、如果内存中多数对象都是存活的,这种算法将会产生大量的内存间复制的开销。
2、复制算法的代价是将可用内存缩小为了原来的一半,空间浪费太多了一点。
2.4 清除阶段:标记—整理算法
复制算法的高效性是建立在存活对象少、垃圾对象多的前提下的。这种情况在新生代经常发生,但是在老年代,更常见的情况是大部分对象都是存活对象。如果依然使用复制算法,由于存活对象较多,复制的成本也将很高。因此,基于老年代垃圾回收的特性,需要使用其他的算法。
标记—清除算法的确可以应用在老年代中,但是该算法不仅执行效率低下,而且在执行完内存回收后还会产生内存碎片,所以 JVM 的设计者需要在此基础之上进行改进,标记—整理(Mark-Compact)算法由此诞生。
1970年前后,G.L.Steele、C.J.Chene 和 D.s.Wise 等研究者发布标记—整理算法,在许多现代的垃圾收集器中,人们都使用了标记—整理算法或其改进版本。
算法过程:
第一阶段:和标记清除—算法一样,从根节点开始标记所有被引用对象。
第二阶段:将所有的存活对象压缩到内存的一端,按顺序排放。之后,清理边界外所有的空间。
优点:
1、消除了标记-清除算法当中,内存区域分散的缺点,我们需要给新对象分配内存时,JVM 只需要持有一个内存的起始地址即可。
2、消除了复制算法当中,内存减半的高额代价。
缺点:
1、从效率上来说,标记-整理算法要低于复制算法。
2、移动对象的同时,如果对象被其他对象引用,则还需要调整引用的地址。
3、移动过程中,需要全程暂停用户应用程序。即:STW。
3.总结一下
效率上来说,复制算法是当之无愧的老大,但是却浪费了太多内存。
而为了尽量兼顾上面提到的三个指标,标记—整理算法相对来说更平滑一些,但是效率上不尽如人意,它比复制算法多了一个标记的阶段,比标记—清除算法多了一个整理内存的阶段。
标记—清除算法 | 标记—整理算法 | 复制算法 | |
---|---|---|---|
速率 | 中等 | 最慢 | 最快 |
空间开销 | 少(但会堆积碎片) | 少(不堆积碎片) | 通常需要活对象的2倍空间(不堆积碎片) |
移动对象 | 否 | 是 | 是 |
综合来看,我们可以发现:没有最好的算法,只有最合适的算法。