1. 概述
垃圾收集主要考虑一下三件事情:
- 哪些内存需要回收?
- 什么时候回收?
- 如何回收?
程序计数器、虚拟机栈、本地方法栈不需要垃圾回收,因为它们的内存分配和回收都具备确定性。同时因为这几个区域是线程私有的,当方法或线程结束时,内存自然就跟着回收了。
堆内存和方法区这两个区域的内存分配和回收是动态的,具有不确定性,所以需要垃圾收集器来管理内存。
2. 对象已死
2.1 引用计数法
定义:在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加一;当引用失效时,计数器值就减一;任何时刻计数器为零的对象就是不可能再被使用的。
- 优点:原理简单,判定效率高
缺点:
- 占用额外的内存空间
- 无法解决对象相互循环引用的问题
如上图这样两个对象相互引用,他们已经无法通过其他对象访问到了,但此时引用计数器并不为零,所以无法被垃圾收集器回收,就会造成内存泄漏。
2.2 可达性分析算法
通过一系列 “GC Roots” 对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”。如果某个对象到 GC Roots 间没有任何引用链相连,或者用图论的话来说就是从 GC Roots 到这个对象不可达时,则证明此对象是不可能再被使用的。
在 Java 中,固定可作为 GC Roots 的对象有以下七种:
- 在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等
- 在方法区中类静态属性引用的对象,譬如 Java 类的引用类型静态变量
- 在方法区中常量引用的对象,譬如字符串常量池(String Table)里的引用
- 在本地方法栈中 JNI(即通常所说的 Native 方法)引用的对象
- Java 虚拟机内部的引用,如基本数据类型对应的 Class 对象,一些常驻的异常对象(比如 NullPointerException、OutOfMempryError)等,还有系统类加载器
- 所有被同步锁( synchronized 关键字)持有的对象
- 反映 Java 虚拟机内部情况的 JMXBean、JVMTI 中注册的回调、本地代码缓存等
固定为 GC Roots 的节点主要在全局性的引用(例如常量或类静态属性)与执行上下文(例如栈帧中的本地变量表)中
2.3 再谈引用
2.3.1 强引用
TestClass a = new TestClass();
这种普通的引用就是强引用,强引用的对象不会被 GC 回收,只有在引用断开时,对象才会被 GC 回收。
2.3.2 软引用
软引用用来描述这个对象还有用,但非必需的情况。在 JVM 将要发生 OOM 时,GC 会对这些对象进行回收,如果回收之后内存还是不足,才会发生 OOM。
使用SoftReference
类,可以建立一个软引用:
SoftReference<List> softReference = new SoftReference<>(new ArrayList());
softReference 强引用指向 SoftReference 对象,SoftReference 对象软引用指向 ArrayList 对象。
2.3.3 弱引用
弱引用也是用来描述那些非必须的对象,但它的强度比软引用更弱,被弱引用关联的对象只能生存到下一次垃圾收集发生为止。当 GC 开始工作时,无论当前内存是否足够,弱引用的对象都会被回收。
使用WeakReference
类,可以建立一个弱引用:
WeakReference<List> weakReference = new WeakReference<>(new ArrayList());
2.3.4 虚引用
虚引用是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的只是为了在这个对象被 GC 回收时收到一个系统通知。
使用PhantomReference
类,可以建立一个弱引用:
PhantomReference<List> phantomReference = new PhantomReference<>(new ArrayList(), referenceQueue);
NIO、Netty 中会用到虚引用
2.4 生存还是死亡
即使在可达性分析算法中判定为不可达的对象,也不是“非死不可”的,这时候它们暂时还处于“缓刑”阶段,要真正宣告一个对象死亡,最多会经历两次标记过程:
如果对象在进行可达性分析后,发现没有与 GC Roots 相连接的引用链,那它将会被第一次标记,随后进行一次筛选,筛选的条件是此对象是否有必要执行 finalize() 方法:
- 没有必要执行:对象没有覆盖 finalize() 方法,或者 finalize() 方法已被被 JVM 调用过
- 如果有必要执行,那么该对象将会被放置在一个名为 F-Queue 的队列之中,并在稍候由一条由虚拟机自动建立的、低调度优先级的 Finalizer 线程去执行它们的 finalize() 方法。这里所说的“执行”是指虚拟机会触发这个方法开始运行,但并不承诺一定会等待它运行结束。这样做的原因是,如果某个对象的 finalize() 方法执行缓慢,或者更极端地发生了死循环,将很可能导致 F-Queue 队列中的其他对象永久处于等待,甚至导致整个内存回收子系统的崩溃。finalize() 方法在对象的生命周期中只会被调用一次。
finalize() 不推荐被使用,原因:
- 调用时机不确定
- 能否成功被执行完不确定
2.5 回收方法区
JVMS 中没有要求虚拟机在方法区实现垃圾收集,但 HotSpot 是有在方法区实现垃圾收集的。
比如字符串常量池中,有一个“桔子”
的字符串曾进入字符串常量池,但是当前系统又没有任何一个字符串对象的值是"桔子"
,换句话说,已经没有任何字符串对象引用常量池中的“桔子”
常量,且虚拟机中也没有其他地方引用这个字面量。如果在这时发生内存回收,而且垃圾收集器判断确有必要的话,这个"桔子"
常量就会被系统清理出常量池。
判定一个常量是否"废弃"还是相对简单,但判断一个类型是否属于"不再被使用的类"需要同时满足以下三个条件:
- 该类的所有实例都已经被回收,也就是堆中不存在该类及其任何派生子类的实例
- 加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如 OSGI、JSP 的重加载等,否则通常是很难达成的
- 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法
3. 垃圾收集算法
3.1 分代收集理论
分代收集理论建立在两个分代假说之上:
- 弱分代假说:绝大多数对象都是朝生夕灭的
- 强分代假说:熬过越多次垃圾收集过程的对象就越难以消亡
多款常用垃圾收集器的设计原则:收集器应该将 Java 堆划分出不同的区域,然后将回收对象依据其年龄(年龄即对象熬过垃圾收集过程的次数)分配到不同的区域之中存储。
显而易见,如果一个区域中大多数对象都是朝生夕灭,难以熬过垃圾收集过程的话,那么把它们集中放在一起,每次回收时只关注如何保留少量存活的对象,而不是去标记大量将要被回收的对象,就能以较低代价回收到大量的空间;如果剩下的都是难以消亡的对象,那把它们集中放到一块,虚拟机便可以使用较低的频率来回收这个区域,这就同时兼顾了垃圾收集的时间开销和内存的空间有效利用。
有了分代收集理论,才有了后面的 Minor GC、Major GC、Full GC以及标记-复制算法、标记-清除算法、标记-整理算法。
当前的商业 JVM 中,一般会把 Java 堆划分为新生代和老年代两个区域:
- 新生代:每次垃圾收集时都发现有大批对象死去,而每次回收后存活的少量对象,将会逐步晋升到老年代中存放
分代收集有一个问题:分代对象会存在相互引用,那么如果回收新生代对象,需要遍历老年代对象,这样性能很低。为了解决这个问题,引入了第三条规则:
- 跨代引用假说:跨代引用相对于同代引用来说仅占极少数
解决方法:在新生代上建立一个全局的数据结构,称为记忆集,这个结构把老年代划分成若干小块,标识出老年代的哪一块内存会存在跨代引用。此后在发生 Minor GC 时,只有包含了跨代引用的小块内存里的对象才会被加入到 GC Roots 进行扫描。
部分收集(Partial GC):指目标不是完整收集整个 Java 堆的垃圾收集,其中又分为:
- 新生代收集(Minor GC/Young GC):指目标只是新生代的垃圾收集器
- 老年代收集(Major GC/Old GC):指目标只是老年代的垃圾收集。目前只有 CMS 收集器会有单独收集老年代的行为。
- 混合收集(Mixed GC):指目标是收集整个新生代以及部分老年代的垃圾收集。目前只有 G1 收集器会有这种行为。
- 整堆收集(Full GC):收集整个 Java 堆和方法区的垃圾收集。
3.2 标记 - 清除算法
标记 - 清除算法分为标记和清除两个阶段:首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回收所有未标记的对象。
有两个缺点:
- 执行效率不稳定,如果 Java 堆中包含大量对象,而且其中大部分是需要被回收的,这时必须进行大量标记和清除动作,导致标记和清除两个过程的执行效率都随对象数量增长而降低
- 标记、清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致当以后在程序运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。
3.3 标记 - 复制算法
为了解决标记 - 清除算法面对大量可回收对象时执行效率低的问题
标记 - 复制算法将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当着一块的内容用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。
如果内存中多数对象都是存活的,这种算法将会产生大量的内存间复制的开销。(所以不适合用来回收老年代)
优点:
- 实现简单,运行高效
- 不用考虑空间碎片的问题
缺点:
- 可用内存被缩小了一半,空间浪费极大
HotSpot 虚拟机的 Serial、ParNew 等新生代收集器采用了一种名为 Apple 式回收的更优化的半区复制分代策略。
Apple 式回收:
把新生代分为一块较大的 Eden 空间和两块较小的 Survivor 空间,每次分配内存只使用 Eden 和其中一块 Survivor。发生垃圾收集时,将 Eden 和 Survivor 中仍然存活的对象一次性复制到另外一块 Survivor 空间上,然后直接清理掉 Eden 和已用过的那块 Survivor 空间。
HotSpot 虚拟机默认 Eden 和 Survivor 的大小比例是 8:1,也即每次新生代中可用内存空间为整个新生代容量的 90%(Eden 的 80% 加上一个 Suvivor 的 10%),只有一个 Survivor 空间,即 10% 的新生代是会被“浪费”的。
当每次 Minor GC 后,Survivor 空间不足以存放存活的对象,那么就需要依赖其他对象区域(大多数是老年代)进行分配担保
3.4 标记 - 整理算法
由于标记 - 复制算法的特点,使其不能在老年代中使用。因为老年代中的对象大多存活较多,使用标记 - 复制算法将导致大量对象的复制,效率会降低。
标记 - 整理算法是根据老年代的特点设计的,其标记过程跟标记 - 复制算法一样,但整理过程是将存活的对象移动到内存空间的另一端,然后直接清理掉边界以外的内存。
在老年代这种每次回收时都有大量存活对象的内存区域,移动存活对象并更新所有引用这些对象的地方将会是一种极为负重的操作,而且这种对象移动操作必须全程暂停应用程序才能进行,这种暂停操作称为 " Stop The World "。
最新的 ZGC 和 Shenandoah 收集器使用读屏障技术实现了整理过程与用户线程的并发执行
4. HotSpot 的算法实现细节
4.1 根节点枚举
迄今为止,所有收集器在根节点枚举这一步都是必须暂停用户线程的,即 " Stop The World "。
HotSpot 内部有一个 OopMap 来存储哪些地方上存放着对象引用。
4.2 安全点
导致 OopMap 变化的指令非常多,如果为每一条指令都生成对应的 OopMap ,那将会需要大量的额外存储空间。HotSpot 为了解决这个问题,只在"特定的位置"记录这些信息,这些位置被称为安全点。
安全点选定规则:
- 不能太少,不然收集器会等待时间会过长
- 不能太多,不然会增大运行时的内存负荷
安全点选定标准:程序是否具有长时间执行的特征。长时间执行指的是指令复用,例如方法调用、循环跳转、异常跳转等都属于指令复用,所以只有具有这些功能的指令才会产生安全点。
发生垃圾收集时如何让所有线程都跑到安全点:
- 抢先式中断
抢先式中断不需要线程的执行代码主动配合,在发生垃圾收集时,系统首先把所有的用户线程全部中断,如果发现有用户线程中断的地方不在安全点上,就恢复这条线程执行,让它一会儿再重新中断,直到跑到安全点上。现在几乎没有虚拟机实现采用抢先式中断来暂停线程响应 GC 事件。
- 主动式中断
主动式中断的思想是当垃圾收集需要终端线程的时候,不直接对线程操作,仅仅简单设置一个标志位,各个线程执行过程时会不停地主动去轮询这个标志,一旦发现中断标志为真时就自己在最近的安全点上主动中断挂起。轮询标志的地方和安全点是重合的,另外还要加上所有创建对象和其他需要在 Java 堆上分配内存的地方,这是为了检查是否即将要发生垃圾收集,避免没有足够内存分配对象。
4.3 安全区域
安全点机制保证了程序执行时,在不太长的时间内就会遇到可进入垃圾收集过程的安全点。但是当用户线程处于 Sleep 状态或者 Blocked 状态,这时线程无法响应虚拟机的中断请求,不能再走到安全的地方去中断自己,虚拟机也显然不可能持续等待线程重新被激活分配处理器时间。因此,便引入了安全区域来解决这个问题。
安全区域是指能够确保在某一段代码片段之中,引用关系不会发生变化。因此,在这个区域中任意地方开始垃圾收集都是安全的。
- 当用户线程执行到安全区域里面的代码时,首先会标识自己已经进入了安全区域,那么当这段时间里虚拟机要发起垃圾收集时就不必去管这些已经声明自己在安全区域内的线程了。
当线程要离开安全区域时,它要检查虚拟机是否已经完成了根节点枚举(或者垃圾收集过程中其他需要暂停用户线程的阶段)
- 如果完成了,那线程就可以继续执行
- 如果没完成,那线程就必须一直等待,直到收到可以离开安全区域的信号为止
4.4 记忆集与卡片
记忆集是一种用于记录从非收集区域指向收集区域的指针的集合的抽象数据结构。
卡表是记忆集的一种具体实现。HotSpot 虚拟机使用一个字节数组来实现卡表,字节数组的每一个元素对应着其标识的内存区域中一块特定大小的内存块,这个内存块称为卡页。
一个卡页的内存中通常包含不止一个对象,只要卡页内有一个(或多个)对象的字段存在着跨代指针,那就将对应卡表的数组元素的值标识为 0。在垃圾收集发生时,只要筛选出卡表中变脏的元素,就能轻易得出哪些卡页内存块中包含跨代指针,把它们加入 GC Roots 中一并扫描。
4.5 写屏障
写屏障用于解决如何将卡表变脏,写屏障可以看作是虚拟机层面“对引用类型字段赋值”这个动作的 AOP 切面,在引用对象赋值时会产生一个环绕通知。在赋值前的操作称为写前屏障,赋值后的操作称为写后屏障。但直至 G1 收集器出现之前,其他收集器都只用到了写后屏障。
void oop_field_store(oop* field, oop new_value){
// 引用字段赋值操作
*field = new_value;
// 写后屏障,在这里完成卡表状态更新
post_write_barrier(field, new_value);
}
此处的写屏障与低延迟收集器中提到的"读屏障"和解决并发乱序执行问题的“内存屏障”没有任何关系
写屏障还会有一个“伪共享”问题:
一个缓存行是 64 字节,一个卡表元素是 1 字节,那么不同线程更新的对象正好在一个缓存行上的话,就会导致这个缓存行频繁失效,从而影响性能。
为了解决这个问题,在 JDK 7 之后,HotSpot 虚拟机增加了一个参数 -XX:+UseCondCardMark
,这个参数能在更新卡表前增加一个判断,只有这个卡页没有被标记为脏时,才去进行标记。这样能在一定程度上缓解伪共享问题。开启这个判断会增加一次额外判断的开销,但能够避免伪共享问题,两者各有性能损耗,是否打开要根据应用实际运行情况来进行测试权衡。
if (CARD_TABLE [this address >> 9] != 0)
CARD_TABLE [this address >> 9 ] = 0;
4.6 并发的可达性分析
三色标记,把遍历对象图过程中遇到的对象,按照“是否访问过”这个条件标记成以下三种颜色:
- 白色:表示对象尚未被垃圾收集器访问过。显然在可达性分析刚开始的阶段,所有的对象都是白色的,若在分析结束的阶段,仍然是白色的对象,即代表不可达。
- 黑色:表示对象已经被垃圾收集器访问过了,且这个对象的所有引用都已经扫描过。黑色的对象代表已经扫描过,它是安全存活的,如果有其他对象引用指向了黑色对象,无需重新扫描一遍。黑色对象不可能直接(不经过灰色)指向某个白色对象。
- 灰色:表示对象已经被垃圾收集器访问过,但这个对象上至少存在一个引用还没有被扫描过。
可达性分析分析的扫描过程要求用户线程是冻结的才不会出问题。但如果用户线程和收集器并发工作,就会出现以下两个问题:
- 原本消亡的对象错误标记为存活(可以接受)
- 原本存活的对象错误标记为消亡(不能接受)
当且仅当以下两个条件同时满足时,会出现“对象消失”的问题:
- 条件一:赋值器插入了一条或多条从黑色对象到白色对象的新引用
- 条件二:赋值器删除了全部灰色对象到该白色对象的直接或间接引用
要解决这个问题,只要破坏两个条件中的任何一个就可以了。因此有两种解决方案:
- 增量更新(破坏条件一)
当黑色对象插入新的指向白色对象的引用关系时,就将这个新插入的引用记录下来,等并发扫描结束之后,再以这些记录过的引用关系中的黑色对象为根,重新扫描一次。可以简化理解为,黑色对象一旦新插入了指向白色对象的引用之后,它就变回灰色对象。 - 原始快照(破坏条件二)
当灰色对象要删除指向白色对象的引用关系时,就将这个要被删除的引用记录下来,在并发扫描结束之后,再以这些记录过的引用关系中的灰色对象为根,重新扫描一次。可以简化理解为,无论引用关系是否删除,都会按照刚刚开始扫描那一刻的对象图快照来进行搜索。
这两种方案都是通过写屏障实现的。在 HotSpot 虚拟机中,两种方案都有使用。例如在 CMS 中是基于增量更新来做并发标记的,而 G1、Shenandoah 则是用原始快照来实现的。
5. 经典的垃圾收集器
下文中说到的并行与并发的含义:
- 并行(Parallel):并行描述的是多条垃圾收集器线程之间的关系,说明同一时间有多条这样的线程在协同工作,通常默认此时用户线程是处于等待状态。
- 并发(Concurrent):并发描述的是多条垃圾收集器线程与用户线程之间的关系,说明同一时间垃圾收集器线程与用户线程都在运行。由于用户线程并未被冻结,所以程序仍然能响应服务请求,但由于垃圾收集器占用了一部分系统资源,此时应用程序的吞吐量将收到一定影响。
5.1 Serial 收集器
Serial 收集器是最基础、历史最悠久的收集器,在进行垃圾收集时,只会使用一条收集线程,而且必须暂停其他所有工作线程(Stop The World),直到它收集结束。其采用的是标记 - 复制算法。
优点:
- 简单高效,是所有收集器里额外内存消耗最小的
- 对单核处理器或处理器核心较少的环境来说,Serial 收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率
- 在新生代较小(几十兆至一两百兆)的环境下,垃圾收集的停顿时间完全可以控制在十几、几十毫秒,最多一百多毫秒内,只要不是频繁发生,这点停顿时间是可以接受的
5.2 ParNew 收集器
ParNew 收集器实质上是 Serial 收集器的多线程并行版本。其他部分与 Serial 收集器完全一致。
5.3 Parallel Scavenge 收集器
Parallel Scavenge 收集器也是一款新生代收集器,同样基于 标记 - 复制算法 实现。Parallel Scavenge 收集器看似与 ParNew 收集器非常相似,但它的目标是达到一个可控制的吞吐量。
$$吞吐量=\frac {运行用户代码时间}{运行用户代码时间+运行垃圾收集时间}$$
停顿时间越短就越适合需要与用户交互或需要保证服务响应质量的程序,良好的响应速度能提升用户体验;而高吞吐量则可以最高效率地利用处理器资源,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的分析任务。
参数 | 类型 | 含义 |
---|---|---|
-XX:MaxGCPauseMillis | 大于 0 的毫秒数 | 垃圾收集最大停顿时间 |
-XX:GCTimeRatio | 大于 0 小于 100 的整数 | 设置为 19,最大垃圾收集时间占总时间 5%,$\frac {1}{1+19}$ |
-XX:+UseAdaptiveSizePolicy | boolean | 垃圾收集的自适应调节策略 |
5.4 Serial Old 收集器
Serial Old 是 Serial 收集器的老年代版本,同样是单线程,但使用 标记 - 整理算法。这个收集器的主要意义是提供客户端模式下的 HotSpot 虚拟机使用。如果在服务端模式下,它也可能有两种用途:
- 一种是在 JDK 5 以及之前的版本中与 Parallel Scavenge 收集器搭配使用
- 另外一种就是作为 CMS 收集器发生失败时的后备预案,在并发收集发生 Concurrent Mode Failure 时使用
5.5 Parallel Old 收集器
Parallel Old 是 Parallel Scavenge 收集器的老年代版本,支持多线程并发收集,基于 标记 - 整理算法 实现。自 JDK 6 开始提供。在注重吞吐量或者处理器资源较为稀缺的场合,优先考虑 Parallel Scavenge + Parallel Old 的组合。
5.6 CMS 收集器
CMS 收集器是一种使用 标记 - 清除算法 的以获取最短回收停顿时间为目标的收集器。基于 B/S 架构的系统的服务端通常较为关注服务的响应速度,CMS 收集器就非常适合这种场景。
CMS 收集垃圾的过程分为四步:
- 初始标记
仅仅只是标记一下 GC Roots 能直接关联到的对象,速度很快。该过程需要 STW(Stop The World)。
- 并发标记
从 GC Roots 的直接关联对象开始遍历整个对象图的过程。该过程耗时长但不需要 STW。
- 重新标记
为了修正并发标记期间,因用户线程继续运行而导致标记产生变动的那一部分对象的标记记录(使用的是增量更新的方式)。该过程停顿时间比初始标记阶段稍长,但比并发标记阶段低很多,仍然需要 STW。
- 并发清除
清理删除掉标记阶段判断的已经死亡的对象,由于不需要移动存活对象,所以这个阶段也可以与用户线程同时并发。
阶段 | 执行时间 | 是否需要 STW |
---|---|---|
初始标记 | 短 | √ |
并发标记 | 长 | x |
重新标记 | 较短 | √ |
并发清除 | 长 | x |
CMS 有以下三个缺点:
- 资源敏感
CMS 收集器对资源非常敏感。在并发阶段,它虽然不会导致用户线程停顿,但却会因为占用了一部分线程(或者说处理器的计算能力)而导致应用程序变慢,降低总吞吐量。CMS 默认启动的回收线程数是 $\frac {处理器核心数量+3}{4}$,也就是说,如果处理器核心数在四个或以上,并发回收时垃圾收集线程只占用不超过 25% 的处理器运算资源,并且会随处理器核心数量的增加而下降。但是当处理器核心数量不足四个时,CMS 对用户程序的影响就可能变得很大。
- 并发失败(Concurrent Mode Falure)
浮动垃圾是指在 CMS 的并发标记和并发清除阶段,用户线程还在继续运行,程序就会有新的垃圾对象不断产生的垃圾。这部分垃圾在初始标记过程结束后无法在当次收集中处理掉他们,只好在下一次垃圾收集时再进行处理。
因为在垃圾收集阶段用户线程还在运行,那就需要预留足够内存空间提供给用户线程使用,如果预留的内存无法满足程序分配新对象的需要,就会出现一次“并发失败”(Concurrent Mode Failure)。
JDK 5 时,老年代占用达到 68% 就会触发 CMS 收集;JDK 6 时,老年代占用 92 %,才会触发 CMS 收集。
可以通过
-XX:CMSInitiatingOccu-pancyFraction
参数提高 CMS 触发百分比 - 空间碎片
因为 CMS 基于 标记 - 清除算法 实现,所以每次收集结束都可能会产生大量空间碎片。空间碎片过多时,将会给大对象分配带来很大麻烦,往往会出现老年代还有很多剩余空间,但就是无法找到足够大的连续空间来分配当前对象,而不得不提前出发一次 Full GC 的情况。
参数:
参数 | 取值 | 含义 | 备注 |
---|---|---|---|
-XX:CMSInitiatingOccu-pancyFraction | 0-100 | 触发 CMS 收集的老年代占用百分比 | |
boolean | 开启 Full GC 时进行内存碎片合并 | JDK 9 废弃 | |
大于等于 0 的整数 | 在多少次 Full GC 后,下次 Full GC 前进行碎片整理 | JDK 9 废弃 |
5.7 Garbage First 收集器
G1 是一款主要面向服务端应用的垃圾收集器,其目标是替换掉 JDK 5 中发布的 CMS 收集器。JDK 9 发布之日,G1 正式取代 Parallel Scavenge + Parallel Old 的组合。
在规划 JDK 10 功能目标时,HotSpot 虚拟机提出了“统一垃圾收集器接口”,将内存回收的“行为”与"实现"分离,CMS 以及其他收集器都重构成基于这套接口的一种实现。以此为基础,日后要移除或者加入某一款收集器,都会变得容易许多,风险也可以控制,这算是在为 CMS 退出历史舞台铺下最后的道路了。
停顿时间模型:支持指定在一个长度为 M 毫秒的时间片段内,消耗在垃圾收集上的时间大概率不超过 N 毫秒这样的目标。
G1 不再坚持固定大小以及固定数量的分代区域划分,而是把连续的 Java 堆划分为多个大小相等的独立区域(Region),每一个 Region 都可以根据需要,扮演新生代的 Eden 空间、Survivor 空间,或者老年代空间。
Region 中还有一类特殊的 Humongous 区域,专门用来存储大对象。G1 认为只要大小超过一个 Region 容量一半的对象即可判定为大对象。每个 Region 的大小可以通过参数-XX:G1HeapRegionSize
指定,取值范围为 1MB~32MB,且应为 2 的 N 次幂。而对于那些超过了整个 Region 容量的超级大对象,将会被存放在 N 个连续的 Humongous Region 之中。,G1 的大多数行为都把 Humongous Region 作为老年代的一部分来看待。
G1 在后台维护了一个优先级列表,每次根据用户设定的收集停顿时间,优先处理回收价值收益最大的那些 Region。
G1 需要解决的问题:
- 为了解决跨 Region 引用的问题,每个 Region 上都维护了一个记忆集,这些记忆集会记录下别的 Region 指向自己的指针,并标记这些指针分别在哪些卡页的范围之内。因此 G1 收集器至少要耗费大约相当于 Java 堆容量 10% 至 20% 的额外内存来维持收集器工作。
- 在并发标记阶段要保证收集线程与用户线程互不干扰的运行,采用原始快照(SATB)算法来解决用户线程改变对象引用关系的问题。此外,垃圾收集的同时用户线程还在持续运行,那么就要考虑新对象的内存分配问题。G1 为每个 Region 设计了两个名为 TAMS 的指针,把 Region 中的一部分空间划分出来用于并发回收过程中的新对象分配,并发回收时新分配的对象地址都必须要在这两个指针位置以上。G1 收集器默认在这个地址以上的对象是被隐式标记过的,即默认它们是存活的,不纳入回收范围。如果内存回收速度赶不上内存分配的速度,G1 收集器也要被迫冻结用户线程执行,导致 Full GC 而产生长时间 STW。
- 如何建立可靠的停顿预测模型?G1 收集器的停顿预测模型是以衰减均值为理论基础来实现的,在垃圾收集过程中,G1 收集器会记录每个 Region 的回收耗时、每个 Region 记忆集里的脏卡数量等各个可测量的步骤花费的成本,并分析得出平均值、标准偏差、置信度等统计信息。这里强调的“衰减平均值”是指它更容易受到新数据的影响,平均值代表整体平均状态,但衰减平均值更准确地代表“最近的”平均状态。换句话说,Region 的统计状态越新越能决定其回收的价值。
G1 收集器的运作过程大致分为四个步骤:
- 初始标记
仅仅只是标记一下 GC Roots 能直接关联到的对象,并且修改 TAMS 指针,让下一阶段用户线程并发运行时,能正确地在可用的 Region 中分配新对象。该阶段需要 STW,但耗时很短,而且是借用 Minor GC 的时候同步完成的,所以 G1 收集器在这个阶段实际并没有额外的停顿。
- 并发标记
从 GC Roots 开始对堆中对象进行可达性分析,找出要回收的对象,这阶段耗时较长,但无需 STW。
- 最终标记
做一个短暂的 STW,用于处理 SATB 记录。
- 筛选回收
负责更新 Region 的统计数据,对各个 Region 的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个 Region 构成回收集,然后把决定回收的那一部分 Region 的存活对象复制到空的 Region 中,再清理掉整个旧 Region 的全部空间。这里的操作涉及存活对象的移动,必须 STW,由多条线程并行完成。
阶段 | 时间 | STW |
---|---|---|
初始标记 | 短 | √ |
并发标记 | 长 | x |
最终标记 | 短 | √ |
筛选回收 | 短 | √ |
G1 从整体上看是基于 标记 - 整理算法 实现的,但从局部(两个 Region 之间)上看又是基于 标记 - 复制算法 实现的,总之其不会产生内存空间碎片。
G1 相对于 CMS 的缺点:
- G1 的卡表更加复杂,需要更多的额外内存空间(可能占堆容量的 20%)
- CMS 只用到了写后屏障,而 G1 用写前屏障实现原始快照算法,写后屏障维护卡表,相比 CMS 复杂很多,所以其内部使用类似消息队列的结构来异步处理复杂操作。
参数:
参数 | 取值 | 解释 | 默认值 |
---|---|---|---|
-XX:G1HeapRegionSize | 1MB~32MB,需为 2 的 N 次幂 | Region 大小 | |
-XX:MaxGCPauseMillis | 大于等于 0 的整数 | 垃圾收集停顿时间 | 200 毫秒 |
6. 低延迟垃圾收集器
6.1 Shenandoah 收集器
Shenandoah 和 G1 共享了一部分代码,与 G1 有很多相似之处。例如同样基于 Region 的堆内存布局,同样有 Humongous Region,默认回收策略也同样是优先处理回收价值最大的 Region。但它们也至少有三个不同点:
- Shenandoah 支持并发的整理算法
- Shennandoah 目前默认不使用分代收集,即没有专门的新生代 Region 和 老年代 Region。但并不是说以后不会使用分代收集,只是 Shenandoah 开发者基于工作量考虑将其放到优先级较低的位置。
- Shennandoah 摒弃了 G1 中的 记忆集维护方式,采用了一种名为 “连接矩阵” 的全局数据结构来记录跨 Region 的引用关系。
Shenandoah 收集器的工作流程可以分为以下九个阶段:
- 初始标记
与 G1 一样,标记与 GC Roots 直接关联的对象。需要 STW,但停顿时间与堆大小无关,只与 GC Roots 的数量相关。
- 并发标记
与 G1 一样,遍历对象图,标记出全部可达的对象。无需 STW,时间长短取决于堆中存活对象的数量。
- 最终标记
与 G1 一样,处理剩余的 SATB 扫描,并在这个阶段统计出回收价值最高的 Region,将这些 Region 构成一组回收集。会有一小段的 STW。
- 并发清理
这个阶段用于清理那些整个区域内连一个存活对象都没有的 Region(这类 Region 被称为 Immediate Garbage Region)
- 并发回收
该阶段是 Shenandoah 与其他收集器的核心差异。Shenandoah 要把回收集里面的存活对象先复制一份到其他未被使用的 Region 之中。这个阶段不需要 STW,是与用户线程并发执行的。但并发执行就会有很多问题,如在复制过程中,用户线程会对对象进行读写访问。Shenandoah 使用读屏障和被称为 Brooks Pointers 的转发指针来解决这个问题。
- 初始引用更新
并发回收复制对象结束后,需要把堆中所有指向旧对象的引用修正到复制后的新地址,这个操作称为引用更新。引用更新的初始化阶段其实并未做什么具体的处理,设立这个阶段只是为了建立一个线程集合点,确保所有并发回收阶段中进行的收集器线程都已完成分配给它们的对象移动任务而已。初始引用更新时间很短,会产生一个非常短暂的 STW。
- 并发引用更新
真正开始进行引用更新操作,这个阶段是与用户线程一起并发的,时间长短取决于内存中涉及的引用数量的多少。并发引用更新与并发标记不同,它不需要沿着对象图来搜索,只需要按照内存物理地址的顺序,线性地搜索出引用类型,把旧值改为新值即可。
- 最终引用更新
解决了堆中的引用更新后,还要修正存在于 GC Roots 中的引用,这个阶段是 Shenandoah 的最后一次 STW,停顿时间只与 GC Roots 数量相关。
- 并发清理
经过并发回收和引用更新之后,整个回收集中所有的 Region 已再无存活对象,这些 Region 都变成 Immediate Garbage Regions 了,最后再调用一次并发清理过程来回收这些 Region 的内存空间,供以后新对象分配使用。
转发指针:
Shenandoah 在并发回收阶段是与用户线程并发执行的,这里为了保证对象的一致性,引入了一种名为 Brooks Pointer 转发指针的技术。
Brooks Pointer 是在原有对象布局结构的最前面统一增加一个新的引用字段,在正常不处于并发移动的情况下,该引用指向对象自己。
缺点:
每次对象访问会带来一次额外的转向开销,尽管这个开销已经被优化到只有一行汇编指令的程度
mov r13,QWORD PTR [r12+r14*8-0x8]
优点:
- 当对象拥有一份新的副本时,只需要修改一处指针的值,即旧对象上转发指针引用的位置,使其指向新对象,便可将所有对该对象的访问转发到新的副本上。
Brooks Pointer 的设计决定它必然会出现多线程竞争的问题,看下面这种场景:
- 收集器线程复制了新的对象副本
- 用户线程更新对象的某个字段
- 收集器线程更新转发指针的引用值为新副本地址
事件 2 发生在事件 1 和 3 之间,导致用户线程更新的字段丢失了。这里 Shenandoah 使用了 CAS 来保证并发时对象访问的正确性。
猜测 CAS 步骤:
- 首先复制新对象副本
- CAS 修改转发指针,期望值是复制的对象副本,新值是修改了指针的对象副本
- 如果失败,则重新复制对象副本,再次 CAS,直至成功
转发指针的另一点需要注意的是执行频率的问题:
Java 中对对象的访问非常频繁,要覆盖所有对象的访问操作,Shenandoah 必须同时设置读写屏障去拦截,这就导致对对象的读写访问非常笨重。所以 Shenandoah 开发者计划在 JDK 13 中将 Shenandoah 的内存屏障模型改进为基于引用访问屏障的实现。引用访问屏障就是指内存屏障只拦截对象中数据类型为引用类型的读写操作,而不去管原生数据类型等其他非引用字段的读写,这能够省去大量对原生类型、对象比较、对象加锁等场景设置内存屏障所带来的消耗。
6.2 ZGC 收集器
ZGC 收集器是一款基于 Region 内存布局的,(暂时)不设分代的,使用了读屏障、染色指针和内存多重映射等技术来实现可并发 标记 - 整理算法 的,以低延迟为首要目标的一款垃圾收集器。ZGC 没有记忆集,每次都扫描所有 Region。
ZGC 与 G1 不同的是 ZGC 的 Region 具有动态性——动态创建和销毁,以及动态的区域容量大小。在 x64 硬件平台下,ZGC 的 Region 可以具有如图以下三类容量大小:
- 小型 Region:容量固定为 2MB,用于存放小于 256KB 的小对象
- 中型 Region:容量固定为 32MB,用于存放大于等于 256KB 但小于 4MB 的对象
- 大型 Region:
容量不固定,可以动态变化,但必须是 2MB 的整数倍,用于放置 4MB及以上的大对象。每个大型 Region 中只会存放一个大对象,这也预示着虽然名字叫作 “大型 Region”,但它的实际容量完全有可能小于中型 Region,最小容量可低至 4MB。大型 Region 在 ZGC 的实现中是不会被重分配的,因为复制一个大对象的代价非常高昂。
ZGC 收集器的一个标志性设计是它采用了染色指针技术。以前要在对象上存储一些额外的、只供收集器或虚拟机本身使用的数据,通常会在对象头中增加额外的存储字段,如对象的哈希码、分代年龄、锁记录等。这种记录方式在有对象访问的场景下是很自然流畅的,不会有什么额外负担。但如果对象存在被移动过的可能性,即不能保证对象访问能够成功呢?又或者有一些根本就不会去访问对象,但又希望得知该对象的某些信息的应用场景就不行了。
ZGC 把这些额外信息存储在引用对象的指针上,因为 64 位系统中,在 AMD64 架构只支持到 52 位的地址总线和 48 位的虚拟地址空间,所以目前 64 位的硬件实际能够支持的最大内存只有 256 TB。此外操作系统一侧也有自己的约束,64 位 Linux 则分别支持 47 位(128TB)的进程虚拟地址空间和 46 位(64TB)的物理地址空间,64 位的 Windows 系统甚至只支持 44 位的物理地址空间。
ZGC 将 Linux 系统中可用的 46 位地址的高 4 位提取出来存储四个标志信息。通过这些标志位,虚拟机可以直接从指针中看到其引用对象的三色标记状态、是否进入了重分配集(即被移动过)、是否只能通过 finalize() 方法才能被访问到。由于这些标志位进一步压缩了原本就只有 46 位的地址空间,也就直接导致 ZGC 能够管理的内存不可以超过 4TB (暂时)
染色指针的三大优势:
- 染色指针可以使得一旦某个 Region 的存活对象被移走之后,这个 Region 立即就能够被释放和重用掉,而不必等待整个堆中所有指向该 Region 的引用都被修正后才能清理。这点相比起 Shenandoah 是一个颇大的优势,使得理论上只要还有一个空闲 Region ,ZGC 就能完成收集,而 Shenandoah 需要等到引用更新阶段结束以后才能释放回收集中的 Region ,这意味着堆中几乎所有对象都存活的极端情况,需要 1:1 复制对象到新 Region 的话,就必须要有一半的空间 Region 来完成收集。
- 染色指针可以大幅减少在垃圾收集过程中内存屏障的使用数量,设置内存屏障,尤其是写屏障的目的通常是为了记录对象引用的变动情况,如果将这些信息直接维护在指针中,显然就可以省去一些专门的记录操作。实际上,到目前为止 ZGC 都并未使用任何写屏障,只使用了读屏障(一部分是染色指针的功劳,一部分是 ZGC 现在还不支持分代收集,天然就没有跨代引用的问题)。
- 染色指针可以作为一种可扩展的存储结构用来记录更多与对象标记、重定位过程相关的数据,以便日后进一步提高性能。现在 Linux下的 64 位指针还有前18位并未使用,它们虽然不能用来寻址,却可以通过其他手段用于信息记录。如果开发了这 18 位,既可以腾出已用的 4 个标志位,将 ZGC 可支持的最大堆内存从 4TB 拓展到 64TB,也可以利用其余位置再存储更多的标志,譬如存储一些追踪信息来让垃圾收集器在移动对象时能将低频次使用的对象移动到不常访问的内存区域。
ZGC 运作的四个阶段:
- 并发标记
与 G1、Shenandoah 一样,并发标记是遍历对象图做可达性分析的阶段,前后也要经过类似于 G1、Shenandoah 的初始标记、最终标记的短暂停顿,而且这些停顿阶段所做的事情在目标上也是相类似的。与 G1、Shenandoah 不同的是,ZGC 的标记是在指针上而不是在对象上进行的,标记阶段会更新染色指针中的 Marked0、Marked1 标志位
- 并发预备重分配
这个阶段需要根据特定的查询条件统计得出本次收集过程要清理哪些 Region,将这些 Region 组成重分配集。重分配集与 G1 收集器的回收集有一定的区别,ZGC 划分 Region 的目的并非为了像 G1 那样做收益优先的增量回收。相反,ZGC 每次回收都会扫描所有的 Region,用范围更大的扫描成本换取省去 G1 中记忆集的维护成本。因此,ZGC 的重分配集只是决定了里面的存活对象会被重新复制到其他 Region 中,里面的 Region 会被释放,而并不能说回收行为就只是针对这个集合里面的 Region 进行,因为标记过程是针对全堆的。此外,JDK 12 的 ZGC 中开始支持的类卸载以及弱引用的处理,也是在这个阶段中完成的。
- 并发重分配
重分配是 ZGC 执行过程中的核心阶段,这个过程要把重分配集中的存活对象复制到新的 Region上,并为重分配集中的每个 Region 维护一个转发表,记录从旧对象到新对象的转向关系。得益于染色指针的支持,ZGC 收集器能仅从引用上就明确得知一个对象是否处于重分配集之中,如果用户线程此时并发访问了位于重分配集中的对象,这次访问将会被预置的内存屏障所截获,然后立即根据 Region 上的转发表记录将访问转发到新复制的对象上,并同时修正更新该引用的值,使其直接指向新对象,ZGC将这种行为称为指针的“自愈”能力。这样做的好处是只有第一次访问旧对象会陷入转发,也就是只慢一次,对比 Shenandoah 的 Brooks 转发指针,那是每次对象访问都必出的固定开销,简单地说就是每次都,因此 ZGC 对用户程的运行时负载要比 Shenandoah 更低一些。还有另外一个好处是由于染色指针的存在,一旦重分配集中某个 Region 的存活对象复制完毕后,这个 Region 就可以立即释用于新对象的分配(但是转发表还得留着不能释放掉),哪怕堆中还有很多指向这个对象的未更新指针也没关系,这些旧指针一旦被使用它们都是可以自愈的,
- 并发重映射
重映射映所做的就是修正整个堆中指向重分配集中旧对象的所有引用,这一点从目标角度看是与 Sheandeah 并发引用更新阶段一样的,但是 ZGC 的并发重映射并不是一个必须要“迫切”去完成的任务,因为前面说过,即使是旧引用,它是可以自愈的,最多只是第一次使用时多一次转发和修正操作。重映射清理这些引的主要目的是为了不变慢(还有清理结束后可以释放转发表这样的附带收益),所以说这并不是很“迫切”。因此,ZGC 很巧妙地把并发重映射阶段要做的工作,合并到了下一次垃圾收集循环中的并发标记阶段里去完成,反正它们都是要遍历所有对象的,这样合并就节省了一次遍历对象图的开销。一旦所有指针都被修正之后,原来记录新旧对象关系的转发表就可以释放掉了。
ZGC 还有一个优点是支持 “NUMA-Aware” 的内存分配。在 NUMA(Non-Uniform Memory Access,非统一内存访问架构) 架构下,ZGC 收集器会优先尝试在请求线程当前所处的处理器的本地内存上分配对象,以保证高效内存访问。在 ZGC 之前,只有 Parallel Scavenge 支持 NUMA 内存分配。
NUMA(Non- -Uniform Memory Access,非统一内存访问架构)是一种为多处理器或者多核处理器的计算机所设计的内存架构。由于摩尔定律逐渐失效,现代处理器因频率发展受限转而向多核方向发展,以前原本在北桥芯片中的内存控制器也被集成到了处理器内核中,这样每个处理器核心所在的裸晶(DIE)都有属于自己内存管理器所管理的内存,如果要访问被其他处理器核心管理的内存,就必须通过 Inter-Connect 通道来完成,这比访问处理器的本地内存慢得多。
7. 个人补充
7.1 垃圾收集器
垃圾收集器别名汇总:
名称 | 别名 | 新生代 OR 老年代 |
---|---|---|
Serial | Copy | 新生代 |
ParNew | ParNew | 新生代 |
Parallel Scavenge | PS Scavenge | 新生代 |
Parallel Old | PS MarkSweep | 老年代 |
Serial Old | MarkSweepCompact | 老年代 |
CMS | ConcurrentMarkSweep | 老年代 |
收集器别名可通过如下代码获得:
List<GarbageCollectorMXBean> beans = ManagementFactory.getGarbageCollectorMXBeans();
for (GarbageCollectorMXBean bean : beans) {
System.out.println(bean.getName());
}
此处有个例外:当新生代收集器为 Parallel Scavenge 时,不管老年代是 Serial Old 还是 Parallel Old,通过上面的代码获取到的老年代的收集器名称都会显示为 PS MarkSweep。此处可以理解为 PS MarkSweep 只是一个别名,它可以指代两种垃圾收集器。毕竟 Parallel Old 和 Serial Old 公用一份代码,区别仅仅是 Parallel Old 可以并行执行罢了。这时候判断老年代具体使用的哪个收集器,就需要使用下面的第二种方法来判断了。参考文章
除了使用上面的代码判断当前使用的垃圾收集器,还可以通过命令行参数-XX:+PrintGCDetails
获取 GC 相关信息:
java version "1.8.0_261"
Java(TM) SE Runtime Environment (build 1.8.0_261-b12)
Java HotSpot(TM) 64-Bit Server VM (build 25.261-b12, mixed mode)
Heap
PSYoungGen total 38400K, used 1997K [0x00000000d5f00000, 0x00000000d8980000, 0x0000000100000000)
eden space 33280K, 6% used [0x00000000d5f00000,0x00000000d60f35b0,0x00000000d7f80000)
from space 5120K, 0% used [0x00000000d8480000,0x00000000d8480000,0x00000000d8980000)
to space 5120K, 0% used [0x00000000d7f80000,0x00000000d7f80000,0x00000000d8480000)
ParOldGen total 87552K, used 0K [0x0000000081c00000, 0x0000000087180000, 0x00000000d5f00000)
object space 87552K, 0% used [0x0000000081c00000,0x0000000081c00000,0x0000000087180000)
Metaspace used 2362K, capacity 4480K, committed 4480K, reserved 1056768K
class space used 255K, capacity 384K, committed 384K, reserved 1048576K
如上是在 JDK 1.8.0_261 下输出的命令,可以看到在第 5 行,显示PSYoungGen
,代表新生代使用的是 Parallel Scavenge 收集器,第 9 行显示ParOldGen
,代表老年代使用的是 Parallel Old 收集器。各收集器对应的分代名称显示如下表:
新生代收集器 | 新生代名称 | 老年代名称 | 备注 |
---|---|---|---|
Parallel Scavenge + Parallel Old | PSYoungGen | ParOldGen | |
Parallel Scavenge + Serial Old | PSYoungGen | PSOldGen | |
Serial + Serial Old | def new generation | tenured generation | |
def new generation | concurrent mark-sweep generation | JDK 9 废弃 | |
par new generation | tenured generation | JDK 9 废弃 | |
ParNew + CMS + Serial Old | par new generation | concurrent mark-sweep generation |
自 JDK 7u4 开始(包括 JDK 8),开启-XX:+UseParallelGC
就会自动开启-XX:+UseParallelOldGC
。也就是说,自 JDK 7u4 之后,JVM 默认使用 Parallel Scavenge + Parallel Old 的收集器组合。
各版本 JDK 默认垃圾收集器:
JDK 版本 | 新生代 | 老年代 |
---|---|---|
JDK 1.6.0_45 | Parallel Scavenge | Serial Old |
JDK 1.7.0_80 | Parallel Scavenge | Parallel Old |
JDK 1.8.0_261 | Parallel Scavenge | Parallel Old |
JDK 9+181 | G1 | G1 |
JDK 10.0.2+13 | G1 | G1 |
JDK 11.0.8+10-LTS | G1 | G1 |
7.2 命令行参数
不同类型的参数:
-
开头的是标准参数,所有的虚拟机都支持。用于常见操作,如查看 JRE 版本,设置类路径,开启详细输出等。-X
开头的是非标准参数,HotSpot 虚拟机支持,不保证其他虚拟机支持-XX
开头的是高级参数,HotSpot 虚拟机支持,不保证其他虚拟机支持,并且随时可能发生变化
使用如下命令查看所有可用参数:
-
:java -?-X
:java -X-XX
:java -XX:+PrintFlagsInitial
参考链接:JDK 8 命令行文档