垃圾收集器主要面对内存动态分配的语言。比如 java 语言,java 方法区的内存更多的是线程生命周期相关的,在线程结束或者方法结束的时候,相关的方法区内存也会也就跟着一起回收了。
而 java 堆相比方法区有个很显著的不确认性:一个接口可能有多个实现类,不同实现类所需的内存大小不一样、不同的代码分支可能需要的内存大小可能也不一样。只有在程序运行期间,我们才能知道程序会创建那些对象,创建了多少对象,这部分的创建和内存分配是动态的。垃圾回收器更关心的应该是这一部分的内存管理。
垃圾收集器需要面对三个问题:
关于什么是垃圾:一般认为占有内存的无用对象也就是垃圾。也就是我们常说的对象已死,那怎么确定对象已经死亡呢?如何保证判定的准确性?判定失败可能会出现什么问题?
引用计数器是用来判断一个对象是否有其他对象引用的方法,通过这种方法来判断对象是否在使用。如果一个对象的引用计数器记录为 0。则说明这个对象没有其他对象持有他的引用,也就是这个对象没有在使用,可以认定为垃圾。比如下面这段代码:
class A { public object b = new B(); } class B { public object a = new A(); } 复制代码
这段代码中。A 对象持有一个 B 对象的引用,B 的引用计数器为 1 。认为 b 对象在使用中,不会被回收。
但是引用计数器也有一个问题,还是上面这段代码,A 对象持有 B 对象的一个引用,所以 b 不会被回收。B 对象也持有一个 A 对象的引用,a 也不会被回收。这两个对象其实都没有使用,可以被回收,因为两个对象的互相引用,两个对象都被错误的认为不是垃圾。
这就是引用计数器的特点:计算方法简单,但是可能会因为循环引用导致判定错误。
可达性分析算法是从一个名为 GC Root Set 集合中的元素出发,根据引用构建出一个图。在图中的对象被称为可达对象,不在图中的对象被称为不可达对象。
可达性分析算法相比引用计数器拥有更高的判断准确率,比如上面那个例子,A 对象与 B 对象循环依赖了,但是并没有其他对象引用 A 与 B 对象。所以从 GC Root Set 出发是找不到 A 与 B 的,a,b 对象就可以被当作垃圾并进行回收。
而 x,y 对象,从 GC Root 出发,整个引用关系构建成了一个图,可以从 GC Root 到达对象节点,x,y 对象就被认为是应该存活的对象。
那什么是 GC Root Set ,GC Root Set 是一些不会被回收或者不易回收的对象引用组成的一个数组,比如常量以及一些初始化的对象。
从这里看好像 GC Root 非常好用,是不是没有什么缺点呢?
GC Root 在单线程(用户程序线程与 GC 线程只有一个在执行)情况下表现是非常好的,但是在多线程(应用程序和 GC 线程)并发情况下就会存在一些问题。在了解这个问题前,我们先来看看可达性分析算法的详细步骤。
可达性分析算法(Hotspot)将对象的引用分为三种类型:
可达性分析算法将分为两个阶段对引用打染色标记:
通过这种方法就可以做到上图一样,分析出那些节点是根节点可达。哪些节点虽然相互引用,但是根节点不可达,是可以回收的节点了。但是也有一些复杂的情况:
如上图这种情况,在可达性分析并未完全分析完成时,应用线程修改了引用关系。C -> D 的引用关系被删除了,增加了 B -> D 的引用关系,最终分析的结果,C 是黑色节点不会被回收,但是由于黑色节点不会重复分析,所以 D 和 E 对象被认为是分析完成的。而这两个白色的对象最终会被认为是垃圾回收掉。
在多线程并发的可达性分析中,一般会有两种情况,一种是已标记生存的对象被应用线程将对应的应用删除了,这种情况影响也不是很大,就是有一些垃圾没有被及时清理,下一次 GC 触发时清理就好了。另一种就是上图那种情况,待回收的对象(D,E) 被修改了引用,其实应该是存活对象,这种情况下可能将需要的对象错误的认为是垃圾并回收,可能会导致应用程序产生错误。
其实这里有个比较简单的方法,就是让应用线程进入等待,只有 GC 线程在做可达性分析。这就是虚拟机的“Stop The World”,在可达性分析的第一个阶段 "根节点枚举”就是采用这样的方式,根节点枚举需要在一个可以保证根节点一致性的场景下开始枚举,如果这个时候应用线程对常量或者其他类型的根节点做修改,会导致根节点枚举出来的节点有缺失或不一致。
“Stop The World”会暂停所有的应用线程,会对应用的 qps,响应时间,吞吐量等有一定的影响。根节点枚举阶段还好,根节点的数量一般不是很多,可以很快处理然后恢复应用线程。但是第二阶段耗时较长,如果使用"Stop The World" 对应用的影响就会比较大,就会考虑是否可以采用并发的方式来做后续的可达性分析。但是并发可达性分析需要解决两个问题:
这两个问题必须全部满足,才会出现对象消失的问题。那么我们只需要破坏其中一个条件,就可以防止对象消失问题的产生。这样就产生了两种解决方案:
增量更新破坏的是第一个条件,当黑色对象插入了新的白色对象的引用,就将这个已加入的引用记录下来,待并发标记完成后,重新对这些新增的引用记录进行扫描,简单的说,黑色对象一旦插入了一个白色对象的引用,那么他就会变成灰色对象。
原始快照破坏的是第二个条件,当灰色对象要删除白色对象的引用关系时,也是将这个引用记录下来,并发标记完成后,对其重新扫描。
可达性分析算法是为了判断对象是否是垃圾,主要是根据根节点到对应节点的引用是否可达。根据是否可达会对对象做三色标记,黑色是可达对象,白色的垃圾对象,灰色是一种中间态最终也会转换为黑色。可达性分析需要枚举根节点和从根节点出发遍历整个图,根节点枚举需要保证根节点一致性,所以会使用“Stop The World”暂停应用线程,而在遍历图的阶段“Stop The World”对应用性能影响较大,会考虑并发可达性分析。并发可达性分析可能造成的对象消失问题,通过增量更新和原始快照解决。
对象被标记为不可达的垃圾对象后,这个对象就一定会被回收吗?其实也不一定,在对象被回收前还有一次拯救自己的机会,在 GC 执行时,会判断是否需要执行 finalize() 方法,如果对象覆盖了该方法且未被虚拟机调用过,那么这个方法就会被放置到 F-queue 中,虚拟机会在另一个低优先级的 Finalizer 线程执行出发 finalizer() 方法,但不承诺等待其执行结束。
如果在该方法中显示修改该对象的引用关系,让该对象与 GC Root Set 可达,在执行 GC 时就不会回收该对象。不过 finalize() 方法运行代价高昂,不确定性大,且无法保证各个对象的调用顺序,一般不建议使用,更多情况下可以使用 try-finally 作为替代。
当前的一些虚拟机垃圾收集器,大多都遵循了“分代收集”的理论进行设计。分代收集其实是一套符合大多数程序实际运行情况的经验法则,它建立在两个分代假说之上:
这两个分代假说共同形成了多款常用垃圾收集器的一致的设计原则:收集器应该将 Java 堆划分出不同的区域,然后将回收对象按照年龄分配到不同的区域进行存储。
由此将虚拟机堆内存划分两个分代,新生代及老年代。其中新生代又分为一个 Eden 区域和两个 survivor 区域。在经典的垃圾收集器算法中,Eden:Survivor1:Survivor2 的比例是 8:1:1,每次回收会回收掉 Eden 区域的所有内存和其中一个 survivor ,而存活对象在另一个 survivor 中。而根据不同的分代,也采用了不同的垃圾回收算法。
标记-清除是比较简单的垃圾收集算法,在经过可达性分析标记出了垃圾后,GC 回收线程会将标记过的对象直接回收掉。保持内存有空间可用。但是这样直接清除可能会产生一些问题,主要是会存在大量的内存碎片,而且在 GC 次数越来越多后会产生更多的内存碎片。内存碎片会导致在分配大对象的时候,没有连续可用的大内存,会再触发一次 GC ,如果 GC 后仍然没有可用的连续内存,这时候会抛出 OutOfMemoryError 异常,程序中断。
标记-复制算法与清除不同,复制算法是思想是每次仅使用一般的内存,另一半的内存闲置。当发生垃圾回收后,将存活的对象复制到闲置的内存区域中,再将另一块全是垃圾内存区域清空并保持闲置,等待下一次垃圾回收时复制存活对象。复制算法可以解决内存碎片的问题,不会因为内存碎片而导致无法分配大对象,不过带来的是内存利用率不高,每次分配对象只有一半的内存可以使用,比较浪费内存空间。不过已经基于分代原理将新生代分为 Eden 区域和两个 Survivor 区域,一个 Survivor 区域仅占 10% 的内存大小,也就是在新生代使用复制算法,仅浪费 10% 的内存空间。
标记-整理与标记-清除算法类似,在可达性算法分析标记出垃圾后,会将垃圾清除,并将存活对象整理到内存的一端。通过整理这个操作,可以避免内存碎片的产生,相应的,整理算法带来了一定的性能开销。与复制算法主要使用在新生代不同,整理算法主要在老年代使用,因为老年代都是存活较久不易回收的对象。所以在老年代回收整理带来的性能开销也比较可控。
根节点枚举是找到一些固定可作为 GC Roots 的节点,主要在全局性的应用及执行上下文。这个步骤是必须暂停用户线程的,如果根节点集合的对象引用关系还在不断变化的话,无法保证分析结果的准确性的。
在 HotSpot 的解决方案中,使用一组 OopMap 的数据结构,一旦类加载动作完成后,HotSpot 就会把对象内什么位置是什么类型的数据计算出来,在即时编辑过程中,也会在特定的位置记录下栈里和寄存器里那些位置是引用。
这样收集器在扫描时就可以直接获取到这些信息,并不需要真正的一个不漏的从方法区等 GC Roots 开始查找。
在 OopMap 的协助下,HotSpot 可以快速准确地完成 GC Roots 的枚举,但是一个很现实的问题也随之而来:导致 OopMap 内容变化的指令非常多,如果要为每一条指令都生成一条对应的 OopMap,拿会需要大量的额外存储空间。
实际上 HotSpot 也没有为每条指令都生成 OopMap 指令,只是在特定的位置记录了这些信息。这里特定的就被称为安全点。简单点理解,安全点就是程序代码中不会引起引用变更的代码行。有了安全点的设定,也就决定了用户线程并非在程序代码的任意位置都可以暂停等待垃圾收集,而是强制要求到达安全点以后才能暂停。
因此,安全点的选定既不能太少,让垃圾收集器等待时间过长,也不能过于频繁以至于增大运行时的内存负担。只有在方法调用、循环跳转、异常跳转等指令序列复用的功能指令时才会产生安全点。
安全点机制保证了程序执行时,在不太长的时间内就会遇到课进入垃圾收集过程的安全点。但是在程序不执行的时候呢?程序没有分配处理器时间,无法到达安全点。
这个时候就必须引入安全区域来解决。安全区域是指可以确保在某一段代码片段中,引用关系不会发生变化,因此在这个区域中任意一个位置开始垃圾收集都是安全的。安全区域也可以看作是扩展拉伸了的安全点。也可以理解为连续的安全点集合,安全点的安全在于引用关系不会发生变化,如果发生变化会导致 GC Roots 分析枚举有问题,在后续的垃圾回收中可能回收到错误的对象,这样的行为对程序来说是不安全的。
基于分代理论的垃圾收集器都会遇到对象跨代引用的问题,比如老年代的对象引用了新生代的对象,在对新生代的对象做可达性分析时,需要将老年代的对象也一起加入到对象图中来分析。但是如果因为跨代引用的原因就将老年代的对象全部分析一遍会产生更长时间的停顿,对程序应用不够友好。为了解决跨代引用的问题,垃圾收集器在新生代建立了名为记忆集的数据结构,用来避免把整个老年代加入到 GC Roots 扫描范围。
记忆集是一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构。
目前最常用的一种实现是卡表的实现,卡表最简单的形式可以是一个字节数组。数组中的每一个元素都对应其内存区域的一块特定大小的内存块,这个内存块被称为卡页。可以理解为,将老年代划分为不同的内存块(卡页),每个内存块有一个索引存储在卡表中。可以通过这个索引找到对应的内存块。
一个卡页的内存中通常有多个对象,只要卡页中有一个对象或多个对象存在跨代指针,就将对应卡表中数组元素(索引)标识为1,称为这个元素变脏,没有则标识为 0。在垃圾收集过程中,只要将这个脏元素对应的卡页,把她们的对象都加入到 GC Roots 中一起扫描就行了。
前面解决了使用记忆集来缩减 GC Roots 扫描范围的问题,引入了卡表,但是卡表元素怎么维护,它们是何时变脏,谁来把它们变脏的?何时变脏的答案是比较明确的--有其他分代区域中的对象引用了本区域对象时,其对应的卡表元素就应该变脏。变脏时间点应发生在引用类型字段赋值的那一刻。
在 Hotspot 中是使用写屏障技术来维护卡表状态的。写屏障可以看做是虚拟机层面对“引用类型字段赋值”这个动作的 AOP 切面,在引用对象赋值时会有一个环形的通知,赋值动作前的部分屏障叫做写前屏障,赋值动作后的部分屏障叫做写后屏障。Hotspot 大多是用写后屏障,也就是在执行完“引用类型字段赋值”这个动作后,都会执行一次写后屏障来完成卡表状态的更新。
单线程垃圾收集器,这里所谓的单线程并不是实际意义上的单线程,而是垃圾回收期间,需要进行 Stop The World 暂停应用线程,仅能运行 GC 线程,直到它收集结束,这是单线程的由来。Serial 收集器是面向新生代的,主要使用的垃圾回收算法是标记-复制。
“Stop The World”是比较难以让人接受的,假设我们在使用电脑时,每一个小时会停止五分钟,我们也会难以接受。从 JDK1.3 开始,一直在现在最新的 JDK13 都在尽量降低用户线程因垃圾收集而导致停顿的时间。
Serial 也有它的优点,那就是简单且高效。在资源受限的环境中,它是所有收集器里面额外内存消耗最小的;对于单核或者少处理器核心的环境中,因为少了线程交互的开销,专心做垃圾收集可以获得最高的单线程收集效率。
而在现在微服务应用场景中,分配给虚拟机新生代的内存一般都不会很大,收集几十兆或者几百兆的新生代,垃圾收集的停顿时间可以控制在一百毫秒以内,只要不是频繁的发生垃圾收集停顿,这点停顿时间对很多应用来说都是可以接受的。
Serial 收集器的并行多线程版本,除了并行特性。收集算法、“Stop The World”、对象分配规则、回收策略等都与 Serial 收集器完全一致。
在单核处理核心的场景中,ParNew 处理器由于存在线程交互的开销,不能保证百分百超越 Serial 收集器。当然,随着处理器核心的增加,ParNew 对于垃圾收集时系统资源的高效利用还是有很多好处的。
ParNew 在 jdk7 之前作为系统中首选的垃圾收集器,除了 serial 收集器,目前只有它可以与 CMS 收集器配合工作。而在 G1 收集器取代了 CMS 后,jdk9 官方已经不推荐使用 ParNew 与 CMS 的收集器解决方案了。
ParNew 可以说是 HotSpot 虚拟机中第一款退出的历史舞台的垃圾收集器。
可并行收集的多线程收集器,新生代收集器,也是基于标记—复制算法实现的。与 ParNew 不同的是,Parallel Scavenge 的关注点是尽可能达到一个可控制的吞吐量。
Parallel Scavenge 有两个精确控制吞吐量的参数,分别是 -XX:MaxGCPauseMills 用于控制 GC 最大停顿时间,参数是一个大于 0 的毫秒数,垃圾收集器会尽力保证内存回收花费是时间不超过这个值,但是也不能异想天开的认为,将这个值设置的越小,垃圾收集就越快,垃圾收集停顿时间的缩短是以吞吐量和新生代大小为代价的:更小的新生代意味着更小的停顿时间,但也会导致垃圾收集发生得更加频繁。
-XX:GCTimeRatio 参数的值是一个正整数,表示用户期望虚拟机消耗在 GC 上的时间不超过程序运行时间的 1/
(1+N)。默认值是 99 ,也就是程序运行时间是 GC 收集消耗时间的 99 倍。也就是收集时间不能超过程序运行时间的 1%。
Parallel Scavenge 也常被称为“吞吐量优先收集器”,除了上述两个精确控制的参数外,还有一个 -XX:UseAdaptiveSizePolicy ,这是一个开关参数,打开这个开关,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整 GC 参数以提供最合适的停顿时间或最大的吞吐量。如:新生代大小比例,晋升老年代对象大小等参数。这种自动调节 gc 参数的方式称为垃圾收集器的自适应调节策略。
与 Serial 收集器一致,都是单线程且使用标记-整理算法。不同点是 Serial Old 是面向老年代的算法,而 Serial 收集器是面向新生代的,Serial Old 在服务端模式下,可能有两个用途,一个是与 Parallel Scavenge 配合使用。一个是作为 CMS 失败的后备预案——CMS 是标记-清理算法,可能会产生垃圾碎片,清理失败后,使用 Serial Old 整理内存碎片。
Parallel Old 收集器是 Parallel Scavenge 的老年代版本,支持多线程并发收集,基于标记-整理算法实现。JDK 6 才开始提供,在此之前 Parallel Scavenge 一直是与 Serial Old 配合使用,由于 Serial Old 在性能上的拖累,使用 Paralle Scavenge 也未必能在整体获得很好的吞吐量提升效果。这种组合的吞吐量不一定比 ParNew 加 CMS 的组合来的优秀。
直到 Parallel Old 的出现,“吞吐量优先”的收集器终于有了名副其实的组合。在注重吞吐量或者资源比较稀缺的场景,都可以优先考虑 Parallel Scavenge 加 Parallel Old 的组合。
CMS 收集器是一种以获取最短停顿时间为目标的收集器。基于标记-清除算法,它的运作过程相对前面几种来说要复杂一些,整个过程大概可以分为四个步骤:
初始标记(CMS initial mack)
并发标记(CMS concurrent mack)
重新标记(CMS remack)
并发清除(CMS concurrent swaap)
由于其中耗时最长的并发标记与并发清除都可以与用户线程一起工作,所以总体上来说,CMS 收集器的内存回收过程是可以和用户线程一起工作的。
CMS 是一款优秀的垃圾收集器,主要是优点是:并发收集、低停顿。一些官方文档也称之为“并发低停顿收集器”,CMS 是 hotSpot 追求低停顿的第一次成功尝试,但是还达不到完美,也有一些明显的缺点:
CMS 收集器对处理器资源非常敏感。
CMS 收集器无法处理“浮动垃圾”
内存碎片
CMS 比较适合更关注服务响应速度,希望系统停顿时间尽可能断,关注用户体验的系统。
Garbage First(简称 G1)收集器是垃圾收集器技术发展历史上的里程碑式的成果,开创了收集器面向局部收集的设计思路和基于 Region 的内存布局形式。
面向局部收集设计:之前的垃圾收集器目标范围都是整个分代,要么是新生代,要么是老年代,甚至是整个 Java 堆,G1 可以面向堆内存的任何部分来组成回收集,衡量标准不再是它属于那个分代,而是那块内存中的垃圾数量最多,回收收益最大。
Region 的内存布局:G1 虽然也有内存分代,但是不在坚持固定大小以及固定数量的分代区域划分了,而是将连续的 Java 堆划分为多个大小相等的独立区域(Region)。每个 region 都可以根据需要,扮演分代上的角色,如 Eden,Survivor和老年代空间。新生代和老年代不再固定,它们都只是一系列 Region(不需要连续)的动态集合。G1 将 Region 作为单次回收的最小单元,可以有计划的避免在整个 Java 堆中进行全区域的垃圾收集。
G1 收集器会跟踪各个 Region 里面的垃圾堆积的“价值”大小,价值即回收所获得的空间大小以及回收所需时间的经验值,回收时间越短回收的垃圾越多则价值越高,然后在后台维护一个优先级列表,每次优先回收价值收益最大的那个 Region,这也是“Garbage First”的由来。使用 Region 划分内存的方式,以及具有优先级的区域回收策略,保证了 G1 收集器在有限时间内获取尽可能高的收集效率。
G1 收集器可以允许用户自定义收集的停顿时间,使用参数 -XX:MaxGCPauseMills 指定,默认值是 200。可以由客户指定期望停顿时间是 G1 收集器是一项强大能力,设置不同的期望停顿时间,可使得 G1 在不同应用场景中取得关注吞吐量和关注延迟之间的最佳平衡。
G1 收集器关键细节问题:
跨 Region 引用如何避免全堆扫描
并发干扰对对象图的干扰
如果建立可靠的停顿预测模型
G1 收集器的运作过程可以大致分为四个步骤:
并发标记
最终标记
筛选回收
G1 收集器除了并发标记外,其余阶段也是需要完全暂停用户线程的,并非纯碎的追求低延迟,官方给它设定的目标是:延迟可控的情况下尽可能高的吞吐量。G1 从整体来看是基于“标记-整理”算法实现的收集器,从 Region 局部来看,是基于“标记-复制”的,这两种算法都不会产生内存碎片,垃圾回收完后可以提供规整的可用内存,这种特效有利于程序的长时间运行。
G1 因为每个 region 都需要维护一个记忆集,相对 CMS 来说需要占用更大的内存,这个内存可能会占用整个堆容量的 20% 或以上。