深入理解JVM--垃圾收集算法
Posted
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了深入理解JVM--垃圾收集算法相关的知识,希望对你有一定的参考价值。
一. 概述
说起垃圾收集(Garbage Collection, GC), 大部分人都把这项技术当做Java语言的伴随生产物. 事实上, GC的历史远远比Java久远, 1960年 诞生于MIT的Lisp是第一门真正使用内存动态分配和垃圾收集技术的语言. 当Lisp还在胚胎时期时,人们就在思考GC需要完成的三件事情:
- 哪些内存需要回收?
- 什么时候回收?
- 如何回收?
现在内存的动态分配与内存回收技术已经相当成熟, 那为什么我们还要去了解GC和内存分配呢? 答案很简单: 当需要排查各种内存溢出, 内存泄漏问题时, 当垃圾收集称为系统达到更高并发量的瓶颈时, 我们就需要对这些"自动化"的技术实施必要的监控和调节.
二. 对象的生与死
堆中几乎存放着Java世界中所有的对象实例, 垃圾收集器在对堆进行回收前, 第一件事情就是要确定这些对象还有哪些还"存活", 哪些已经"死去"(即不可能再被任何途径适用的对象).
- 引用计数算法
概念: 给对象中添加一个引用计数器, 每当有一个地方引用它时, 计数器值+1; 当引用失效时, 计数器值-1; 任何时刻计数器都为0的对象就是不可能再被使用的.
客观地说, 引用计数算法(Reference Counting) 的实现简单, 判定效率也很高, 在大部分情况下它都是一个不错的算法, 也有一些比较著名的应用案例, 例如微软的COM(Component Object Model) 技术, 使用ActionScript 3的FlashPlayer, Python语言以及在游戏脚本领域被广泛引用的Squirrel中都使用了引用计数算法进行内存管理.但是, Java语言没有选用引用计数算法来管理内存, 其中最主要的原因是它很难解决对象之间的相互循环引用的问题.
2. 根搜索算法
概念: 通过一系列的名为"GC Roots" 的对象作为起始点, 从这些节点开始向下搜索, 搜索所走过的路径称为引用链(Reference Chain), 当一个对象到GC Roots 没有任何引用链想连(用图论的话来说就是从GC Roots到这个对象不可达)时, 则证明此对象是不可用的.
在Java和C#, 以及上面提到的古老的Lisp, 都是使用跟搜索算法(GC Roots Tracing) 判断对象是否存活的.
在Java语言里, 可作为GC Roots的对象包括下面几种:
- 虚拟机栈(栈帧中的本地变量表)中的引用的对象.
- 方法区中的类静态属性引用的对象.
- 方法区中的常量引用的对象.
- 本地方法栈中JNI(即一般说的Native方法)的引用的对象.
3. 生存还是死亡
在跟搜索算法中不可达的对象, 也并非是"非死不可"的, 这时候他们暂时处于"缓刑"阶段, 要真正宣告一个对象死亡, 至少要经历两次标记过程: 如果对象在进行根搜索后发现没有与GC Roots相连接的引用链, 那它将会第一次被标记并且进行一次筛选, 筛选的条件是此对象是否有必要进行finalize()方法, 当对象没有覆盖finalize() 方法, 或者finalize()方法已经被虚拟机调用郭, 虚拟机将这两种情况都视为"没有必要执行".
如果这个对象有必要执行finalize()方法, 那么这个对象将会被放置在一个名为F-Queue的队列之中, 并在稍后由一条由虚拟机自动建立的, 低优先级的Finalizer线程去执行. finalize()方法是对象逃脱死亡命运的最后一次机会, 稍后GC将对F-Queue中的对象进行第二次小规模标记, 如果对象要在finalize()中成功拯救自己---只要重新与引用链上的任何一个对象建立关联即可, 譬如把自己赋值给某个类变量或对象的成员变量, 那在第二次标记时它将被移除出"即将回收的集合", 如果对象这时候还没有逃脱, 那它就这的离死不远了.
代码: 一次对象自我拯救的演示
public class FinalizeEscapeGC { public static FinalizeEscapeGC SAVE_HOOK = null; public void isAlive() { System.out.println("Yes, I‘m still alive."); } @Override protected void finalize() throws Throwable { super.finalize(); System.out.println("Finalize method executed!"); FinalizeEscapeGC.SAVE_HOOK = this; } public static void main(String[] args) throws Throwable { SAVE_HOOK = new FinalizeEscapeGC(); SAVE_HOOK = null; System.gc(); Thread.sleep(500); if (SAVE_HOOK != null) { SAVE_HOOK.isAlive(); } else { System.out.println("No, I‘m dead."); } SAVE_HOOK = null; System.gc(); Thread.sleep(500); if (SAVE_HOOK != null) { SAVE_HOOK.isAlive(); } else { System.out.println("No, I‘m dead."); } } }
运行结果:
Finalize method executed!
Yes, I‘m still alive.
No, I‘m dead.
三. 垃圾收集算法
1. 标记-清除算法(Mark-Sweep)
如他的名字一样, 算法分为"标记"和"清除"两个阶段: 首先标记出所有需要回收的对象, 在标记完成后统一会受到所有被标记的对象,它的标记过程在上面讲述对象标记判定时已经基本介绍过了. 它是最基础的收集算法, 是因为后续的书记算法都是基于这种思路对其缺点进行改进而得到的.
它的主要缺点有两个: 一个是效率问题, 标记和清除过程的效率都不高, 另一个是空间问题, 标记清除之后会产生大量不连续的内存碎片, 空间碎片太多可能会导致, 当程序在以后的运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作.
2. 复制收集算法(Coping)
为了解决效率问题, 一种称为"复制"的收集算法出现了, 它将可用内存按容量划分为大小相等的两块, 每次只使用其中的一块. 当着一块的内存用完了, 就将还存活着的对象复制到另一块上面, 然后再把已使用过的内存空间一次清理掉, 这样使得每次都是对其中的一块进行内存回收, 内存分配时也不用考虑内存碎片等复杂情况, 只要移动堆顶指针, 按顺序分配内存即可, 实现简单, 运行高效. 只是这种算法的代价是将内存缩小为原来的一半, 未免太高了一点.
现在的商业虚拟机都采用这种收集算法来回收新生代, IBM的专门研究表明, 新生代中的对象98%都是朝生夕死, 所以并不需要按照1:1的比例来话费呢内存空间, 二十将内存分为一块较大的Eden空间和两块较小的Survivor空间, 每次使用Eden和其中的一块Survivor. 当回收时, 将Eden和Survivor中还存活着的对象一次性的拷贝到另一块Survivor空间上, 最后清理掉Eden和刚才用过的Survivor的空间, HotSpot虚拟机默认Eden和Survivor的大小比例是8:1, 也就是每次新生代中可用内存空间为整个新生代容量的90%(80%+ 10%), 只有10%的内存会被"浪费". 当然98%的对象可回收只是一般场景下的数据, 我们没有办法保证每次回收都只有不多余10%的对象存活, 当Survivor空间不够时, 需要依赖其他内存(这里指老年代)进行分配担保(Handle Promotion).
3. 标记-整理算法(Mark-Compact)
复制收集算法在对象存活率较高时就要执行较多的复制操作, 效率将会变低, 更关键的是, 如果不想浪费50%的空间, 就需要有额外的空间进行分配担保, 以应对被使用的内存中所有对象都100%存活的极端情况, 所以在老年代一般不能直接选用这种算法.
根据老年代的特点, 于是提出了另一种"标记-整理"(Mark-Compact)算法, 标记过程仍与"标记-清除"算法一样, 但后续步骤不是直接对可回收对象进行清理, 而是让所有存活的对象都向一端移动, 然后直接清理掉边界以外的的内存.
4. 分代收集算法
当前商业虚拟机的垃圾收集都采用"分代收集"(Generation Collection)算法, 这种算法并没有什么新思想, 只是根据对象的存活周期的不同将内存划分为几块. 一般是把Java堆氛围新生代和老年代, 这样就可以根据各个年代的特点采用最适当的收集算法, 在新生代中, 每次垃圾收集时都发现有大批对象死去, 只有少量存货, 那就选用复制算法, 只需要付出少量存活对象的复制成本就可以完成收集. 而老年代中因为对象存活率高, 没有额外的空间对它进行分配担保, 就必须使用"标记-清理"或"标记-整理"算法来进行回收.
以上是关于深入理解JVM--垃圾收集算法的主要内容,如果未能解决你的问题,请参考以下文章