分代收集理论
分代收集(Generational Collection)理论建立在两个分代假说之上:
- 弱分代假说:绝大多数对象都是朝生夕灭的。
- 强分代假说:熬过越多次垃圾收集过程的对象就越难以消亡。
这两个分代假说共同奠定了多款常用的垃圾收集器的一致的设计原则:收集器应该将 Java 堆划分出不同的区域,然后将回收对象依据其年龄(年龄即对象熬过垃圾收集过程的次数)分配到不同的区域之中存储。
显而易见,如果一个区域中大多数对象都是朝生夕灭,难以熬过垃圾收集过程的话,那么把它们集中放在一起,每次回收时只关注如何保留少量存活而不是去标记那些大量将要被回收的对象,就能以较低代价回收到大量的空间;如果剩下的都是难以消亡的对象,那把它们集中放在一块,虚拟机便可以使用较低的频率来回收这个区域,这就同时兼顾了垃圾收集的时间开销和内存的空间有效利用。
1. 分代收集的区域划分
在 Java 堆划分出不同的区域之后,垃圾收集器才可以每次只回收其中某一个或者某些部分的区域——因而才有了 Minor GC、Major GC、Full GC 这样的回收类型的划分;也才能够针对不同的区域安排与里面存储对象存亡特征相匹配的垃圾收集算法——因而发展出了 标记-复制算法、标记-清除算法、标记-整理算法 等针对性的垃圾收集算法。
把分代收集理论具体放到现在的商用 Java 虚拟机里,设计者一般至少会把 Java 堆划分为新生代和老年代两个区域。顾名思义,在新生代中,每次垃圾收集时都发现有大批对象死去,而每次回收后存活的少量对象,将会逐步晋升到老年代中存放。但分代收集并非只是简单划分一下内存区域那么容易,它至少存在一个明显的困难:对象不是孤立的,对象之间会存在跨代引用。
2. 记忆集
假如要进行一次只局限于新生代区域内的收集,但新生代中的对象是完全有可能被老年代所引用的,为了找出该区域中的存活对象,不得不在固定的 GC Roots 之外,再额外遍历整个老年代中所有对象来确保可达性分析结果的正确性。虽然理论上可行,但无疑会为内存回收带来很大的性能负担。为了解决这个问题,就需要对分代收集理论添加第三条经验法则:
- 跨代引用假说:跨代引用相对于同代引用来说仅占极少数。
依据这条假说,我们就不应再为了少量的跨代引用去扫描整个老年代,也不必浪费空间专门记录每一个对象是否存在及存在哪些跨代引用,只需在新生代上建立一个全局的数据结构——记忆集(Remembered Set),这个结构把老年代划分成若干小块,标识出老年代的哪一块内存会存在跨代引用。
此后当发生 Minor GC 时,只有包含了跨代引用的小块内存里的对象才会被加入到 GC Roots 进行扫描。虽然这种方法需要在对象改变引用关系时维护记录数据的正确性,会增加一些运行时的开销,但比起收集时扫描整个老年代来说仍然是划算的。
部分收集(Partial GC):指目标不是完整收集整个 Java 堆的垃圾收集,其中又分为:
新生代收集「Minor GC 或 Young GC」指目标只是新生代的垃圾收集。
老年代收集「Major GC 或 Old GC」指目标只是老年代的垃圾收集,目前只有 CMS 收集器会有单独收集老年代的行为。
混合收集「Mixed GC」指目标是收集整个新生代以及部分老年代的垃圾收集,目前只有 G1 收集器会有这种行为。
整堆收集「Full GC」指收集整个 Java 堆和方法区的垃圾收集。
垃圾收集算法
1. 标记-清除算法
标记-清除(Mark-Sweep)算法是最早出现也是最基础的垃圾收集算法,它分为标记和清除两个阶段:首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象。标记过程就是对象是否属于垃圾的判定过程。之所以说它是最基础的收集算法,是因为后续的收集算法大多都是以标记-清除算法为基础,对其缺点进行改进而得到的。它的主要缺点有两个:
- 一是执行效率不稳定,如果 Java 堆中包含大量对象,而且其中大部分是需要被回收的,这时必须进行大量标记和清除的动作,导致标记和清除两个过程的执行效率都随对象数量增长而降低。
- 二是内存空间的碎片化问题,标记、清除后会产生大量不连续的内存碎片,空间碎片太多可能会导致当以后在程序运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。
2. 标记-复制算法
为了解决标记-清除算法面对大量可回收对象时执行效率低的问题,人们提出了一种称为半区复制的垃圾收集算法,它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。
如果内存中多数对象都是存活的,这种算法将会产生大量的内存间复制的开销,但对于多数对象都是可回收的情况,算法需要复制的就是占少数的存活对象,而且每次都是针对整个半区进行内存回收,分配内存时也就不用考虑有空间碎片的复杂情况,只要移动堆顶指针,按顺序分配即可。这样实现简单,运行高效,不过其缺陷也显而易见,这种复制回收算法的代价是将可用内存缩小为了原来的一半,空间浪费未免太多了一 点。
但是经研究发现:新生代中的对象有 98% 熬不过第一轮收集,因此并不需要按照 1:1 的比例来划分新生代的内存空间。而是把新生代分为一块较大的 Eden 空间和两块较小的 Survivor 空间,每次分配内存只使用 Eden 和其中一块 Survivor。当 Eden 区的空间耗尽时 Java 虚拟机便会触发一次 Minor GC 来收集新生代的垃圾。
Eden 区和 from 指向的 Survivor 区中的存活对象会被复制到 to 指向的 Survivor 区中,然后交换 from 和 to 的指针,以保证下一次 Minor GC 时 to 指向的 Survivor 区还是空的。HotSpot 虚拟机默认 Eden 和 Survivor 的大小比例是 8:1,也即每次新生代中可用内存空间为整个新生代容量的 90%,只有一个 Survivor 空间,即 10% 的新生代是会被浪费的。
Java 虚拟机会记录 Survivor 区中的对象复制次数,如果超过了 15(通过 -XX:+MaxTenuringThreshold 参数配置)则该对象将被晋升至老年代。另外当 Survivor 空间不足以容纳一次 Minor GC 之后存活的对象时,就需要依赖其他内存区域(实际上大多就是老年代)进行分配担保。
3. 标记-整理算法
标记-复制算法在对象存活率较高时就要进行较多的复制操作,效率将会降低。更关键的是,如果不想浪费 50% 的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都 100% 存活的极端情况,所以在老年代一般不能直接选用这种算法。
针对老年代对象的存亡特征,人们提出了另一种有针对性的标记-整理(Mark-Compact)算法,其中的标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存。
标记-清除算法与标记-整理算法的本质差异在于前者是一种非移动式的回收算法,而后者是移动式的。是否移动回收后的存活对象是一项优缺点并存的风险决策:
标记-清除算法的优缺点:
如果移动存活对象,尤其是在老年代这种每次回收都有大量对象存活区域,移动存活对象并更新所有引用这些对象的地方将会是一种极为负重的操作,而且这种对象移动操作必须全程暂停用户应用程序才能进行。
但如果跟标记-清除算法那样完全不考虑移动和整理存活对象的话,弥散于堆中的存活对象导致的空间碎片化问题就只能依赖更为复杂的内存分配器和内存访问器来解决。内存的访问是用户程序最频繁的操作,假如在这个环节上增加了额外的负担,势必会直接影响应用程序的吞吐量。
基于以上两点,是否移动对象都存在弊端,移动则内存回收时会更复杂,不移动则内存分配时会更复杂。从垃圾收集的停顿时间来看,不移动对象停顿时间会更短,甚至不需要停顿,但从整个程序的吞吐量来看,移动对象会更划算。因为内存分配和访问相比垃圾收集频率要高得多。HotSpot 虚拟机里关注吞吐量的 Parallel Scavenge 收集器是基于标记-整理算法的,而关注延迟的 CMS 收集器则是基于标记-清除算法的,这也从侧面印证这点。
另外,还有一种“和稀泥式”的解决方案是让虚拟机多数时间都采用标记-清除算法,暂时容忍内存碎片的存在,直到内存空间的碎片化程度已经影响对象分配时,再采用标记-整理算法收集一次,以获得规整的内存空间。基于标记-清除算法的 CMS 收集器面临空间碎片过多时采用的就是这种处理办法。
垃圾收集过程
Java 应用在运行过程中会不断创建对象,通常分配在 Eden 区域,当其空间占用达到一定阈值时,触发 Minor GC。仍然被引用的对象(绿色方块)存活下来,被复制到 JVM 选择的 Survivor 区域,而没有被引用的对象(黄色方块)则被回收。注意,我给存活对象标记了数字 1 是为了表明对象的存活时间。
然后,经过一次 Minor GC,Eden 就会空闲下来,直到再次达到 Minor GC 触发条件,此时另一个 Survivor 区域则会成为 to 区域,Eden 区域的存活对象和 From 区域对象,都会被复制到 to 区域,并且存活的年龄计数会被加 1。
然后,类似第二步的过程会发生很多次,直到有对象年龄计数达到阈值,这时候就会发生所谓的老年代晋升(Promotion)过程,如下图所示,超过阈值的对象会被晋升到老年代。
后面就是老年代 GC,具体取决于选择的 GC 选项,对应不同的算法。下面是一个简单标记 - 整理算法过程示意图,老年代中的无用对象被清除后,GC 会将对象进行整理,以防止内存碎片化。
通常我们把老年代 GC 叫作 Major GC,将对整个堆进行的清理叫作 Full GC。
涉及的 GC 参数如下:
# 最大堆内存
-Xmx
# 初始堆内存
-Xms
# 新生代中 Eden 区域与 Survivor 区域的容量比值,默认为 8
-XX:SurvivorRatio
# 老年代和新生代比例,默认为2,即老年代是新生代的2倍大;也就是新生代是堆大小的1/3
-XX:NewRatio
# 设置新生代和老年代的比例
-XX:NewRatio
# 直接晋升到老年代的对象大小,设置这个参数后,大于这个参数的对象将直接在老年代分配
-XX:PretenureSizeThreshold
# 晋升到老年代的对象年龄。每个对象在坚持过一次 Minor GC 之后,年龄就加 1,当超过这个参数值时就进入老年代,该参数默认为15
-XX:MaxTenuringThreshold