垃圾收集算法理论和思想
Posted aaron-cell
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了垃圾收集算法理论和思想相关的知识,希望对你有一定的参考价值。
垃圾收集算法的实现涉及大量的细节,且各个平台的虚拟机操作内存的方法各有差异,本文主要讲分代收集的理论和几种算法回收的思想。
从如何判断对象的消亡角度出发,垃圾收集算法可以划分为两类:“引用计数式垃圾收集”(Reference Counting GC)和“追踪式垃圾收集”
(Tracing GC)两大类,通常被称为直接垃圾回收和间接垃圾回收。由于引用计数式垃圾收集在主流Java虚拟机中均未涉及,所以本节主要介绍追踪式垃圾收集。
1.分代收集理论
目前主流的虚拟机的垃圾收集器,基本上都遵循了“分代收集”的理论进行设计,所谓分代收集理论实质上是一种符合大多数程序运行实际情况的经验法则,建立在两个分代理论之上:
1)弱分代假说:绝大多数的对象都是朝生夕灭。
2)强分代假说:熬过也多次垃圾收集的对象,越难消融。
这两个分代假说表明了垃圾收集器一致设计的原则:应该将Java堆划分为多个不同的区域,然后根据对象的年龄(年龄指的是熬过垃圾收集的次数)分配到不同的区域,比如:如果一个区域中的对象绝大多数都是朝生夕灭,应该将这些对象集中放置在一块区域,每次垃圾收集时只需要关注如何去保存少量存活的对象,而不是去标记大量将要回收的对象,以较低的代价回收这块区域;如果 剩下的都是难以消亡的对象,便可以把这些对象集中放置另一块区域,在垃圾回收时只关注少量将要回收的对象。这就同时兼顾了内存的空间和时间的效率。
把分代收集理论体现到Java虚拟机中,设计者一般至少会把Java堆划分为两个区域:新生代和老年代。顾明思议,新生代的对象在每次进行垃圾收集后都有大部分的对象死亡,而剩下来的对象会随着年龄增长逐步晋升到老年代中存放。
即便对Java堆进行了区域划分,垃圾收集器想要进行垃圾收集也并非易事,它至少存在一个明显的困难:对象的引用不是孤立的,也会存在跨代引用。所以引出了第三条经验法则:
3)跨分代引用假说:跨分代引用相对于同代引用占据极少数。
跨分代引用假说其实是依据前两条分代假说推理出来:存在相互引用关系的对象,应该是趋向同时生存或者同时消亡的。比如:如果一个新生代对象存在跨代引用,由于老年代难以消亡,最终会使新生代的被引用对象难以消亡,并逐步晋升到老年代。这时也就不存在跨代引用的说法了,根据这条假说,我们可以认为不在为了少量的跨代引用去扫描整个老年代,只需要在新生代上建立一个全局数据结构(“记忆集”),这个结构会把老年代划分多个区域,标记处哪一块区域存在跨代引用。当发生Minor GC时,将标记出来的区域中的对象加入到GC Roots集合中去进行可达性分析。这个方法虽然需要在对象改变引用关系(比如:自己或者某个属性赋值)时维护“记忆集”中数据的正确性,增加运行时开销,但是同比扫描整个老年代是十分跨算的。
刚才提到“Minor GC”,为了避免读者迷惑,这里统一定义:
.整堆收集(Full GC):收集整个Java堆和方法区的垃圾收集。
.部分收集(Partial GC):指目标不是完整收集整个Java堆中垃圾收集,其中分为以下几类:
- 新生代收集(Minor GC/Young GC):指目标是新生代的垃圾收集。
- 老年代收集(Major GC/Old GC):指目标是老年代垃圾收集。目前只有CMS收集器会存在单独收集老年代的行为。
- 混合收集(Mixed GC):指目标是收集整个新生代和部分老年代的垃圾收集。目前只有GI收集器会有这种行为。
2.垃圾收集算法
2.1标记—清除算法
标记—清除算法是最早的也是最基础的算法。算法分为“标记”和“清除”两个阶段:首先标记出需要回收的对象,然后在标记完成后对所标记的对象进行垃圾回收。当然,也可以反过来,标记不需要回收的对象,然后收集未标记的对象。标记过程就是判定对象是否属于垃圾的过程。
后续的大多数算法都是以标记—清除算法进行改进得到的。它主要有两个缺点:第一个效率执行不稳定,如果Java堆中存放的对象绝大多数都是要回收的,那么该算法需要进行大量的标记和清除动作,导致标记和清除两个过程随着对象数量的增长效率持续降低;第二个是空间碎片化的问题,在进行标记—清除算法后,产生大量不连续的内存碎片,如果内存碎片过多,导致下一次为对象分配内存时找不到合适的内存大小存储对象,导致不得不提前触发一次垃圾收集动作。或者说对象分配内存时,找不到合适的对象造成大量内存浪费。标记—清除算法如图:
2.2标记—复制算法
标记—复制算法,主要解决了标记—清除算法之后内存过于碎片化的问题,它是将可用的内存区域划分为大小相等的两块内存(当然也不一定非要相等),在进行垃圾收集时,现将存活的对象标记,然后将标记的存活对象复制到另一半空闲的内存区域,最后将之前的一半区域中所有对象完全清除用来存放之后新生的对象。这样就解决了使用标记—回收算法之后内存碎片化的问题,而且在对象内存分配时也极为简单,只需要移动堆顶指针,按顺序分配即可,简单高效。
但是缺点也极为明显:1.如果某块内存区域中在进行垃圾回收时,有大量的存活的对象,那么就会产生大量的对象复制的开销。2.本来一块完整的可使用的内存区域被分为两份区域,一次只能使用其中一份区域,无疑极大的浪费了内存。
标记—复制算法如下图:
考虑到标记—复制算法的优缺点,现在大多数Java虚拟机都优先采用这种收集算法去回收新生代(朝生夕灭的特点),而且并不需要按照1:1的比例去划分新生代,Andrew Appel针对具备“朝生夕灭”的特点的对象,提出了一种更优化的半区复制分代策略,称为“Appel式回收”。HotSpot虚拟机的Serial、ParNew等新生代收集器均采用这种策略来设计新生代内存布局。Appel式回收具体做法是把新生代分为一块较大的Eden空间和两块较小的Survivor空间,每次分配内存只是用Eden和其中一块Survivor区域内存,当发生垃圾收集时,将一块Eden和一块Survivor区域中的存活对象复制到另一块Survivor区域中去,最后清理掉之前的Eden和Survivor中空间。
HotSpot虚拟机中默认划分比例为Eden:Survivor:Survivor=8:1:1,也就是说每次新生代对象可使用内存为整个新生代区域的90%,只有10%(一个Survivor空间)是浪费的。当然,不能保证所有情况下一块Survivor区域都能足够,所以Appel式回收还有一个安全设计,就是当一块Survivor不足以存放一次Minor GC后存活对象时,需要依赖其他区域的分配担保(大多数是老年代),即将一块Survivor中不足以存放的存活对象直接分配进老年代。
2.3标记—整理算法
标记—整理算法在标记存活的对象后,然后将存活的对象移动到内存空间的一端,然后清除边界以外的内存区域。如图所示:
标记—清除算法与标记—整理算法的本质差异在于前者是一种非移动式回收算法,后者是移动式的。是否移动存活对象是一种优缺点并存的决策,如果移动存活对象,尤其是在老年代这种每次都有大量的存活对象的区域来说,移动存活对象无疑是一种很大的负担,而且像这种移动对象操作必须暂停用户应用程序才能进行。
但是像标记—清除算法那样不考虑移动和整理存活对象又不可避免的造成空间碎片化,只能依赖更复杂的内存分配器和内存访问器来解决。比如:通过“分区空闲分配链表”来解决内存分配问题(计算机硬盘存储大文件就不会要求物理连续的磁盘空间,能够在碎片化的硬盘上存储和访问就是通过硬盘分区表实现)。内存的访问是用户程序最频繁的操作,甚至没有之一,如果在这个环节额外增加负担,势必会直接影响应用程序的吞吐量。
基于以上两点,是否移动对象都存在弊端,移动则内存回收时更复杂,不移动则内存分配上和访问更为复杂。从垃圾收集的停顿时间来看,不移动对象停顿时间更短,甚至不需要停顿,但是从整个应用程序的吞吐量来看,移动对象更划算。
另外还有一种“和稀泥式”方案可以不再内存分配和访问上增加太大额外负担同时有不会频繁移动对象造成额外消耗,做法就是虚拟机平时大多数时间采用标记—清除算法,暂时容忍内存碎片的存在,直到内存空间碎片化程度已经大到影响对象分配时,采用一次标记—整理算法,获得规整的内存空间。
3.HotSpot算法细节实现
根据《深入理解Java虚拟机》一书这节以及后面关于这一部分的内容暂时跳过,待以后继续学习。
以上是关于垃圾收集算法理论和思想的主要内容,如果未能解决你的问题,请参考以下文章