一、引子
事实上,垃圾收集的历史远远比Java久远,在1960年诞生于麻省理工学院的Lisp是第一门开始使用内存动态分配和垃圾收集技术的语言。当Lisp还在胚胎时期时,其作者John McCarthy就思考过垃圾收集需要完成的三件事情:
- 哪些内存需要回收
- 什么时候回收?
- 如何回收?
这篇文章,我们就带着这三个问题梳理jvm垃圾回收。每个栈帧分配多少内存基本在类结构确定时就已知,这个区域的内存分配和回收都具有确定性,不是考虑的重点。然而java堆和方法区往往有很强的不确定性:一个接口的多个实现类需要的内存可能会不一样,一个方法所执行的不同条件分支所需要的内存也可能不一样,只有处于运行期间,我们才能知道程序究竞会创建哪些对象,创建多少个对象,这部分内存的分配和回收是动态的。垃圾收集器所关注的正是这部分内存该如何管理。
二、哪些内存需要回收?
目前主要靠两种算法区进行这一判断,分别是引用计数算法和可达性分析算法,下面我们一起来讨论这两种算法。
2.1 引用计数算法
算法是这样的:在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加一;当引用失效时,计数器值就减一;任何时刻计数器为零的对象就是不可能再被使用的。
然而这个算法有一个致命的缺陷:循环引用!
所以java虚拟机实际上并不是通过这种算法进行判断的。
2.2 可达性分析算法(根搜索算法)
这个算法的基本思路就是通这系列称为“GC Roots"的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索程所走过的路径称为“引用链”(Reference Chain),如果某个对象到GC Roots间没有任何引用链相连或者用图论的话来说就是从GC Roots到这个对象不可达时,则证明此对象是不可能再被使用的。如图:
值得一提的是,GC Roots是一个集合,如何确定这个集合也需要一定的考虑。在Java技术体系里面,固定可作为GC Roots的对象包括以下几种:
- java栈中引用的对象,例如参数、局部变量、临时变量。
- 方法区中类的静态属性引用的对象。
- 方法区中常量引用的对象,如字符串常量池的引用。
- 本地方法栈中JNI(即通常所说的Native方法)引用的对象。
- Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(比如NullPointExcepiton、OutOfMemoryError)等,还有系统类加载器。
- 所有被同步锁(synchronized关键字)持有的对象。
- 反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。
当然还可以有其他对象”临时性“加入这个集合,而且根据具体的垃圾收集器参与的算法和实现的细节,这个集合也并不是每次都要彻底扫描。目前最新的垃圾收集器都采用了局部回收的设计,为了避免GC Roots过度膨胀也做了各种优化处理。
2.3 引用发生了改变
java传统的引用可类比为地址,然而当垃圾回收开始发展后,引用发生了变化,因为我们希望能描述一类对象:当内存空间还足够时,能保留在内存之中,如果内存空间在进行垃圾收集后仍然非常紧张,那就可以抛弃这些对象。
于是JDK 1.2,引用的概念进行了扩充,将引用分为四种
- 强引用——strongly
- 软引用——soft
- 弱引用——weak
- 虚引用——phantom(虚幻的)
上面四种引用的强度依次减弱。为了不使博客文字乏味,加入一张导图来进行描述:
值得注意的是,用户定义的finalize()方法仅仅会执行一次,也就是拯救对象一次,因此应该避免使用finalize()方法。
2.4 java对象在虚拟机中的生命周期
Java对象被类加载器加载到虚拟机中后,Java对象在java虚拟机中有7个阶段:创建阶段、应用阶段、不可见阶段、不可达阶段、收集阶段、终结阶段、对象空间重新分配阶段。这七个阶段可以简要概括如下:
三、什么时候回收?——分代
当前商业虚拟机的垃圾收集器,大多数都遵循了“分代收集”(Generational Collection)的理论进行设计,分代收集名为理论,实质是一套符合大多数程序运行实际情况的经验法则,它建立在两个分代假说之上:
弱分代假说(Weak Generational Hypothesis):绝大多数对象都是朝生夕灭的。
强分代假说(Strong Generational Hypothesis):熬过越多次垃圾收集过程的对象就越难以消
效利用。
这两个分代假说共同奠定了多款常用的垃圾收集器的一致的设计原则:收集器应该将Java堆划分出不同的区域,然后将回收对象依据其年龄(年龄即对象熬过垃圾收集过程的次数)分配到不同的区域之中存储。
如果一个区域中大多数对象都是朝生夕灭,难以熬过垃圾收集过程的话,那么把它们集中放在一起,每次回收时只关注如何保留少量存活而不是去标记那些大量将要被回收的对象,就能以较低代价回收到大量的空间;如果剩下的都是难以消亡的对象,那把它们集中放在一块,虚拟机便可以使用较低的频率来回收这个区域,这就同时兼顾了垃圾收集的时间开销和内存的空间有效利用。
把分代收集理论具体放到现在的商用Java虚拟机里,设计者一般至少会把Java堆划分为新生代(Young Generation)和老年代(Old Generation〉两个区域顾名思义,在新生代中,每次垃圾收集时都发现有大批对象死去,而每次回收后存活的少量对象,将会逐步晋升到老年代中存放。
假如要现在进行一次只局限于新生代区域内的收集(Minor GC),但新生代中的对象是完全有可能被老年代所引用的,为了找出该区域中的存活对象,不得不在固定的GC Roots之外,再额外遍历整个老年代中所有对象来确保可达性分析结果的正确性,反过来也是一样。遍历整个老年代所有对象的方案虽然理论上可行,但无疑会为内存回收带来很大的性能负担。为了解决这个问题,就需要对分代收集理论添加第三条经验法则:
- 跨代引用假说(Intergenerational Reference Hypothesis):跨代引用相对于同代引用来说仅占极少数。
依据这条假说,我们就不应再为了少量的跨代引用去扫描整个老年代,也不必浪费空间专门记录每一个对象是否存在及存在哪些跨代引用,只需在新生代上建立一个全局的数据结构(该结构被称为“记忆集”,Remembered Set),这个结构把老年代划分成若干小块,标识出老年代的哪一块内存会存在跨代引用。此后当发生Minor GC时,只有包含了跨代引用的小块内存里的对象才会被加入到GC Roots进行扫描。虽然这种方法需要在对象改变引用关系(如将自己或者某个属性赋值)时维护记录数据的正确性,会增加一些运行时的开销,但比起收集时扫描整个老年代来说仍然是划算的。
分代理论可以整理如下:
当执行一次Minor Collection时,Eden空间的存活对象会被复制到To Survivor空间,并且之前经过一次Minor Collection并在From Survivor空间存活的仍年轻的对象也会复制到To Survivor空间。
有两种情况Eden空间和From Survivor室间存活的对象不会复制到To Survivor空间,而是晋升到老年代。一种是存活的对象的分代年龄超过参数所指定的阈值。另一种是To Survivor空间容量达到阈值。当所有存活的对象被复制到To Survivor空间,或者晋升到老年代,也就意味着Eden空间和From Survivor空间剩下的都是可回收对象,如下图所示。
这个时候,Eden空间和From Survivor空间都会被清空,新生代中存活的对象都存放在To Survivor空间。接下来将From Survivor空间和To Survivor空间互换位置,也就是此前的From Survivor空间成为了现在的To Survivor空间,每次Survivor空间互换都要保证To Survivor空间是空的,这就是复制算法在新生代中的应用。在老年代则会采用标记一压缩算法或者标记—清除算法。
四、如何回收?
刚才将分代回收理论的时候,提到了标记-清除、标记-复制、标记-整理(压缩)算法,这几个算法解决的问题,就是如何回收的问题。针对不同的分代,采用不同的算法是因为不同算法的特点绝地的。我们先来大致看一下这三种算法:
4.1 标记-清除算法
标记-清除算法是最简单最早的垃圾回收算法,如它的名字一样,算法分为“标记”和"清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回收所有未被标记的对象。标记过程就是对象是否属于垃圾的判定过程,采用的算法就是之前描述的可达性算法。这个算法可用下图描述:
最早的算法存在的问题也很明显,主要有两个
- 执行效率不稳定,如果对象很多,就需要执行大量的标记和清除,执行效率随对象增长而降低。
2.内存空间的碎片化,反复标记=清除,会产生大量的内存碎片,导致无法为大对象分配连续内存。
4.2 标记-复制算法
标记-复制算法简称复制算法。它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把己使用过的内存空间一次清理掉。如果内存中多数对象都是存活的,这种算法将会产生大量的内存间复制的开销,但对于多数对象都是可回收的情况,算法需要复制的就是占少数的存活对象,而且每次都是针对整个半区进行内存回收,分配内存时也就不用考虑有空间碎片的复杂情况,只要移动堆顶指针,按顺序分配即可。算法过程如图:
这样实现简单,运行高效,不过其缺陷也显而易见,这种复制回收算法的代价是将可用内存缩小为了原来的一半,空间浪费未免太多了一点。而且不适合会有大量对象存活的老生代,这也就是为什么新生代会采用复制算法,而老生代通常不采用复制算法的原因。
而之前提到过的新生代分为Eden、Survivor的做法,也是为了更好的使用复制算法,降低50%的空间损失。经过优化后的算法,空间损失会大大降低。HotSpot虚拟机默认Eden和Survivor的大小比例是8∶1,也即每次新生代中可用内存空间为整个新生代容量的90%(Eden的80%加上一个Survivor的10%),只有一个Survivor空间,即10%的新生代是会被“浪费”的。
4.3 标记-整理(压缩)算法
标记-复制算法在对象存活率较高时就要进行较多的复制操作,效率将会降低。更关键的是,如果不想浪费50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况,所以在老年代一般不能直接选用这种算法。
标记-整理算法,其中的标记过程仍然与“标记-清除"算法一样,但后续步骤不是直接按对可回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存。整个过程如图:
标记-清除算法与标记-整理算法的本质差异在于前者是一种非移动式的回收算法,而后者是移动式的。是否移动回收后的存活对象是一项优缺点并存的风险决策:
- 如果移动存活对象,尤其是在老年代这种每次回收都有大量对象存活区域,移动存活对象并更新所有引用这些对象的地方将会是一种极为负重的操作,而且这种对象移动操作必须全程暂停用户应用程序才能进行。
- 但如果跟标记-清除算法那样完全不考虑移动和整理存活对象的话,弥散于堆中的存活对象导致的空间碎片化问题就只能依赖更为复杂的内存分配器和内存访问器来解决。譬如通过“分区空闲分配链表”来解决内存分配问题。
基于以上两点,移动则内存回收时会更复杂,不移动则内存分配时会更复杂。从垃圾收集的停顿时间来看,不移动对象停顿时间会更短,甚至可以不需要停顿,但是从整个程序的吞吐量来看,移动对象会更划算。
即使不移动对象会使得收集器的效率提升一些,但因内存分配和访问相比垃圾收集频率要高得多,这部分的耗时增加,总吞吐量仍然是下降的。HotSpot虚拟机里面关注吞吐量的Parallel Scavenge收集器是基于标记-整理算法的,而关注延迟的CMS收集器则是基于标记-清除算法的,这也从侧面印证这点。
另外,还有一种和稀泥式”解决方案可以不在内存分配和访问上增加太大额外负担,做法是让虚拟机平时多数时间都采用标记-清除算法,暂时容忍内存碎片的存在,直到内存空间的碎片化程度已经大到影响对象分配时,再采用标记-整理算法收集一次,以获得规整的内存空间。前面提到的基于标记-清除算法的CMS收集器面临空间碎片过多时采用的就是这种处理办法。
五、经典的垃圾收集器
经典的垃圾收集器有很多,针对新生代、老生代,它们在并发性和并行性上有所不同,彼此搭配使用的方案也不相同,适合的场景也不相同,因此,没有最好的垃圾收集器,只有最适合的垃圾收集器,本文此处不再赘述各种垃圾收集器,再次强烈推荐阅读《深入理解java虚拟机》。
六、小结
这两篇文章大致梳理了JDK的发展、JVM的算法等基础知识,下面的文章将更加贴近安卓——Dalvik和ART。