垃圾回收需要完成的三件事情
在堆里面存放着 Java 世界中几乎所有的对象实例,垃圾收集器在对堆进行回收前,首先就要确定对象的存活状态
在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加一;当引用失效时,计数器值就减一;任何时刻计数器为零的对象就是不可能再被使用的
引用计数算法虽然占用了一些额外的内存空间来进行计数,但它的原理简单,判定效率也很高,在大多数情况下它都是一个不错的算法
这个看似简单的算法有很多例外情况要考虑,必须要配合大量额外处理才能保证正确地工作,譬如单纯的引用计数就很难解决对象之间相互循环引用的问题
public class ReferenceCountingGC {
public Object instance = null;
public static void testGC() {
ReferenceCountingGC objA = new ReferenceCountingGC();
ReferenceCountingGC objB = new ReferenceCountingGC();
objA.instance = objB;
objB.instance = objA;
objA = null;
objB = null;
}
}
除了两个对象互相引用外,这两个对象再无任何引用,实际上这两个对象已经不可能再被访问,但是它们因为互相引用着对方,导致它们的引用计数都不为零,引用计数算法也就无法回收它们
通过一系列称为 GC Roots 的根对象作为起点,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为引用链(Reference Chain),如果某个对象到 GC Roots 间没有任何引用链相连,则证明此对象是不可能再被使用的
深入理解Java虚拟机(第3版) - 图3-1 利用可达性分析算法判定对象是否可回收
除了这些固定的 GC Roots 集合以外,根据用户所选用的垃圾收集器以及当前回收的内存区域不同,还可以有其他对象临时性地加入,共同构成完整 GC Roots 集合
即使在可达性分析算法中判定为不可达的对象,也不是非死不可的,这时候它们暂时还处于缓刑阶段
要真正宣告一个对象死亡,至少要经历两次标记过程
对象逃脱死刑的最后机会,当垃圾收集器发现一个对象实例没有任何的引用与之关联,在准备执行垃圾回收之前该方法才会被调用,且 所有对象的 finalize 方法都只会被系统自动调用一次
public class Test {
private static Test test;
@Override
protected void finalize() throws Throwable {
super.finalize();
System.out.println("execute finalize");
test = this;
}
private static void print(Test t) {
if (t == null) {
System.out.println("dead!!!");
} else {
System.out.println("alive...");
}
}
public static void main(String[] args) throws InterruptedException {
test = new Test();
// 第一次成功自救
test = null;
System.gc();
Thread.sleep(500);
print(test);
// 第二次自救失败
test = null;
System.gc();
Thread.sleep(500);
print(test);
}
}
execute finalize
alive...
dead!!!
在 JDK1.2 版之前,Java 里面的引用是很传统的定义:如果 reference 类型的数据中存储的数值代表的是另外一块内存的起始地址,就称该 reference 数据是代表某块内存、某个对象的引用
为了实现对象在内存空间足够时,能保留在内存之中,如果内存空间在进行垃圾收集后仍然非常紧张,可以自动舍弃。在 JDK1.2 版之后,Java 对引用的概念进行了扩充
最传统的引用的定义,是指在程序代码之中普遍存在的引用赋值。无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象
Test test = new Test();
描述一些还有用,但非必须的对象。只被软引用关联着的对象,在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常
Reference
false
false
描述那些非必须对象,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生为止。当垃圾收集器开始工作,无论当前内存是否足够,都会回收掉只被弱引用关联的对象
Reference
false
true
虚引用也称为幽灵引用或者幻影引用,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知
ReferenceQueue queue = new ReferenceQueue();
Reference
true
true
当前商业虚拟机的垃圾收集器,大多数都遵循了分代收集的理论进行设计,它建立在两个分代假说之上
这两个分代假说共同奠定了多款常用的垃圾收集器的一致的设计原则:收集器应该将堆划分出不同的区域,然后将回收对象依据其年龄(即对象熬过垃圾收集过程的次数)分配到不同的区域之中存储
设计者一般至少会把堆划分为新生代(Young Generation)和老年代(Old Generation)两个区域。在新生代中,每次垃圾收集时都发现有大批对象死去,而每次回收后存活的少量对象,将会逐步晋升到老年代中存放
在堆中划分出不同的区域之后,垃圾收集器才可以每次只回收其中某一个或者某些部分的区域
分代收集并非只是简单划分一下内存区域那么容易,例如对象不是孤立的,对象之间会存在跨代引用
假如要现在进行一次 Minor GC,但新生代中的对象是完全有可能被老年代所引用的,为了找出该区域中的存活对象,不得不在固定的 GC Roots 之外,再额外遍历整个老年代中所有对象来确保可达性分析结果的正确性,反过来也是一样。遍历整个老年代所有对象的方案虽然理论上可行,但无疑会为内存回收带来很大的性能负担。为了解决这个问题,就需要对分代收集理论添加第三条经验法则
依据这条假说,我们就不应再为了少量的跨代引用去扫描整个老年代,也不必浪费空间专门记录每一个对象是否存在及存在哪些跨代引用,只需在新生代上建立一个全局的数据结构(记忆集,Remembered Set),这个结构把老年代划分成若干小块,标识出老年代的哪一块内存会存在跨代引用。此后当发生 Minor GC 时,只有包含了跨代引用的小块内存里的对象才会被加入到 GC Roots 进行扫描。虽然这种方法需要在对象改变引用关系(如将自己或者某个属性赋值)时维护记录数据的正确性,会增加一些运行时的开销,但比起收集时扫描整个老年代来说仍然是划算的
大多数情况下,对象在新生代 Eden 区中分配。当 Eden 区没有足够空间进行分配时,虚拟机将发起一次 Minor GC
据统计新生代中的对象有 98% 熬不过第一轮收集。可以把新生代分为一块较大的 Eden 空间和两块较小的 Survivor 空间,每次分配内存只使用 Eden 和其中一块 Survivor。发生垃圾收集时,将 Eden 和 Survivor 中仍然存活的对象一次性复制到另外一块 Survivor 空间上,然后直接清理掉 Eden 和已用过的那块 Survivor 空间
HotSpot 虚拟机默认 Eden 和 Survivor 的大小比例是 8∶1,即每次新生代中可用内存空间为整个新生代容量的 90%,只有一个 Survivor 空间,即 10% 的新生代是会被浪费的
大对象就是指需要 大量连续内存空间 的 Java 对象,比如很长的字符串,或者元素数量很庞大的数组
在分配空间时,大对象容易导致内存明明还有不少空间时就提前触发垃圾收集,以获取足够的连续空间才能安置好它们,而当复制对象时,大对象就意味着高额的内存复制开销
比一个大对象更糟糕的是一群短命的大对象
虚拟机给每个对象定义了一个对象年龄(Age)计数器。对象通常在 Eden 区里诞生,如果经过第一次 Minor GC 后仍然存活,并且能被 Survivor 容纳的话,该对象会被移动到 Survivor 空间中,并且将其对象年龄设为 1 岁。对象在 Survivor 区中每熬过一次 Minor GC,年龄就增加 1 岁,当它的年龄增加到一定程度(默认为 15),就会被晋升到老年代中
如果在 Survivor 空间中相同年龄所有对象大小的总和大于 Survivor 空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代
当 Survivor 空间不足以容纳一次 Minor GC 之后存活的对象时,就需要依赖其他内存区域(实际上大多就是老年代)进行分配担保(Handle Promotion)
在发生 Minor GC 之前,虚拟机必须先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那这一次 Minor GC 可以确保是安全的
如果不成立,则虚拟机会先检查是否允许担保失败。如果允许,那会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试进行一次 Minor GC,尽管这次 Minor GC 是有风险的;如果小于或者不允许,那这时就要改为进行一次 Full GC
新生代使用复制收集算法,但为了内存利用率,只使用其中一个 Survivor 空间来作为轮换备份,因此当出现大量对象在 Minor GC 后仍然存活的情况,且 Survivor 空间无法全部容纳,就需要老年代进行分配担保。当然前提是老年代本身还有容纳这些对象的剩余空间,但一共有多少对象会在这次回收中活下来在实际完成内存回收之前是无法明确知道的,所以只能取之前每一次回收晋升到老年代对象容量的平均大小作为经验值,与老年代的剩余空间进行比较,决定是否进行 Full GC 来让老年代腾出更多空间
最早出现也是最基础的垃圾收集算法,算法分为标记和清除两个阶段:首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回收所有未被标记的对象
深入理解Java虚拟机(第3版) - 图3-2 “标记-清除”算法示意图
后续的收集算法大多都是以标记-清除算法为基础,对其缺点进行改进而得到的。它的主要缺点有两个
为了解决标记-清除算法面对大量可回收对象时执行效率低的问题,复制算法将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉
深入理解Java虚拟机(第3版) - 图3-3 标记-复制算法示意图
如果内存中多数对象都是存活的,这种算法将会产生大量的内存间复制的开销,但对于多数对象都是可回收的情况,算法需要复制的就是占少数的存活对象,而且每次都是针对整个半区进行内存回收,分配内存时也就不用考虑有空间碎片的复杂情况,只要移动堆顶指针,按顺序分配即可。这样实现简单,运行高效,不过其缺陷也显而易见,可用内存缩小为了原来的一半,空间浪费未免太多了一点
现在的商用 Java 虚拟机大多都优先采用了这种收集算法去回收新生代,新生代中的对象有 98% 熬不过第一轮收集,因此并不需要按照 1∶1 的比例来划分新生代的内存空间
标记过程与标记-清除算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存
深入理解Java虚拟机(第3版) - 图3-4 “标记-整理”算法示意图
如果移动存活对象,尤其是在老年代这种每次回收都有大量对象存活区域,移动存活对象并更新所有引用这些对象的地方将会是一种极为负重的操作。但如果跟标记-清除算法那样完全不考虑移动和整理存活对象的话,弥散于堆中的存活对象导致的空间碎片化问题就只能依赖更为复杂的内存分配器和内存访问器来解决
是否移动对象都存在弊端,移动则内存回收时会更复杂,不移动则内存分配时会更复杂。从垃圾收集的停顿时间来看,不移动对象停顿时间会更短,甚至可以不需要停顿,但是从整个程序的吞吐量来看,移动对象会更划算
Serial 收集器是最基础、历史最悠久的收集器,是一个单线程工作的收集器,但它的单线程的意义并不仅仅是说明它只会使用一个处理器或一条收集线程去完成垃圾收集工作,更重要的是强调在它进行垃圾收集时,必须暂停其他所有工作线程(Stop The World),直到它收集结束
Serial 收集器依然是客户端模式下默认的新生代收集器,简单而高效(与其他收集器的单线程相比),对于内存资源受限的环境,它是所有收集器里额外内存消耗最小的;对于单核处理器或处理器核心数较少的环境来说,Serial 收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率
实质上是 Serial 收集器的多线程并行版本,在实现上这两种收集器也共用了相当多的代码
是不少运行在服务端模式下虚拟机的首选收集器,尤其是 JDK1.7 之前的遗留系统,其中有一个与功能、性能无关但其实很重要的原因是:除了 Serial 收集器外,目前只有它能与 CMS 收集器配合工作
基于标记-复制算法实现的收集器,是一款新生代收集器,也是能够并行收集的多线程收集器
它的关注点与其他收集器不同,CMS 等收集器的关注点是尽可能地缩短垃圾收集时用户线程的停顿时间,而 Parallel Scavenge 收集器的目标则是达到一个可控制的吞吐量(Throughput)。所谓吞吐量就是处理器用于运行用户代码的时间与处理器总消耗时间的比值 吞吐量 = 运行用户代码时间 / (运行用户代码时间 + 运行垃圾收集时间)
停顿时间越短就越适合需要与用户交互或需要保证服务响应质量的程序,良好的响应速度能提升用户体验;而高吞吐量则可以最高效率地利用处理器资源,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的分析任务
Serial Old 是 Serial 收集器的老年代版本,它同样是一个单线程收集器,使用标记-整理算法。这个收集器的主要意义也是供客户端模式下的虚拟机使用。如果在服务端模式下,它也可能有两种用途:一种是在 JDK1.5 以及之前的版本中与 Parallel Scavenge 收集器搭配使用,另外一种就是作为 CMS 收集器发生失败时的后备预案
Parallel Scavenge 收集器的老年代版本,支持多线程并发收集,基于标记-整理算法实现
直到 Parallel Old 收集器出现后,吞吐量优先收集器终于有了比较名副其实的搭配组合,在注重吞吐量或者处理器资源较为稀缺的场合,都可以优先考虑 Parallel Scavenge 加 Parallel Old 收集器这个组合
CMS(Concurrent Mark Sweep)收集器基于标记-清除算法,是一种以获取最短回收停顿时间为目标的收集器。目前很大一部分的 Java 应用集中在互联网网站或者基于浏览器的 B/S 系统的服务端上,这类应用通常都会较为关注服务的响应速度,希望系统停顿时间尽可能短,以给用户带来良好的交互体验。CMS 收集器就非常符合这类应用的需求
由于在整个过程中耗时最长的并发标记和并发清除阶段中,垃圾收集器线程都可以与用户线程一起工作,所以从总体上来说,CMS 收集器的内存回收过程是与用户线程一起并发执行的
事实上,面向并发设计的程序都对处理器资源比较敏感。在并发阶段,它虽然不会导致用户线程停顿,但却会因为占用了一部分线程(或者说处理器的计算能力)而导致应用程序变慢,降低总吞吐量
在 CMS 的并发标记和并发清理阶段,用户线程是还在继续运行的,程序在运行自然就还会伴随有新的垃圾对象不断产生,但这一部分垃圾对象是出现在标记过程结束以后,CMS 无法在当次收集中处理掉它们,只好留待下一次垃圾收集时再清理掉
基于标记-清除算法实现的收集器,收集结束时会有大量空间碎片产生
G1 是一款主要面向服务端应用的垃圾收集器。在 G1 收集器出现之前的所有其他收集器,包括 CMS 在内,垃圾收集的目标范围要么是整个新生代(Minor GC),要么就是整个老年代(Major GC),再要么就是整个堆(Full GC)。而 G1 可以面向堆内存任何部分来组成回收集(Collection Set,CSet)进行回收,衡量标准不再是它属于哪个分代,而是哪块内存中存放的垃圾数量最多,回收收益最大,这就是 G1 收集器的 Mixed GC 模式
G1 开创了基于 Region 的堆内存布局,不在按照分代区域划分,而是把堆划分为多个大小相等的独立区域(Region),每一个 Region 都可以根据需要,扮演新生代的 Eden 空间、Survivor 空间,或者老年代空间。收集器能够对扮演不同角色的 Region 采用不同的策略去处理
Region 中还有一类特殊的 Humongous 区域,专门用来存储大对象。G1 认为只要大小超过了一个 Region 容量一半的对象即可判定为大对象。而对于那些超过了整个 Region 容量的超级大对象,将会被存放在多个连续的 Humongous Region 之中,G1 会把 Humongous Region 作为老年代的一部分来进行看待
G1 收集器将 Region 作为单次回收的最小单元,即每次收集到的内存空间都是 Region 大小的整数倍,这样可以有计划地避免在整个堆中进行全区域的垃圾收集。更具体的处理思路是让 G1 收集器去跟踪各个 Region 里面的垃圾堆积的价值大小,价值即回收所获得的空间大小以及回收所需时间的经验值,然后在后台维护一个优先级列表,每次根据用户设定允许的收集停顿时间(默认值是 200 毫秒),优先处理回收价值收益最大的那些 Region,这也就是 Garbage First 名字的由来
G1 是 JDK9 的默认收集器
CMS 采用复制-清除算法,可能会产生的大量的内存空间碎片。而 G1 从整体来看是基于标记-整理算法,但从局部(两个 Region 之间)上看又是基于标记-复制算法,这两种算法都意味着 G1 在运行期间不会产生内存空间碎片
G1 需要记忆集来记录新生代和老年代之间的引用关系,这种数据结构在 G1 中需要占用大量的内存,可能达到整个堆内存容量的 20% 甚至更多。而且 G1 中维护记忆集的成本较高,带来了更高的执行负载,影响效率。相比较而言 CMS 的记忆集就简单得多内存占用也更少
CMS 在小内存应用上的表现要优于 G1,而大内存应用上 G1 更有优势,大小内存的界限是 6 ~ 8 GB