前言
Java相较于其他编程语言更加容易学习,这其中很大一部分原因要归功于JVM的自动内存管理机制。 对于从事C语言的开发者来说,他们拥有每一个对象的「所有权」,更大的权力也意味着更多的职责,C开发者需要维护每一个对象「从生到死」的过程,当对象废弃不用时必须手动释放其内存,否则就会发生内存泄漏。而对于Java开发者来说,JVM的自动内存管理机制解决了这个让人头疼的问题,不容易出现内存泄漏和内存溢出的问题了,GC让开发者更加专注于程序本身,而不用去关心内存何时分配、何时回收、以及如何回收。
1. JVM运行时数据区
在聊GC前,有必要先了解一下JVM的内存模型,知道JVM是如何规划内存的,以及GC的主要作用区域。
如图所示,JVM运行时会将内存划分为五大块区域,其中「方法区」和「堆」随着JVM的启动而创建,是所有线程共享的内存区域。虚拟机栈、本地方法栈、程序计数器则是随着线程的创建被创建,线程运行结束后也就被销毁了。
1.1 程序计数器
程序计数器(Program Counter Register)是一块非常小的内存空间,几乎可以忽略不计。 它可以看作是线程所执行字节码的行号指数器,指向当前线程下一条应该执行的指令。对于:条件分支、循环、跳转、异常等基础功能都依赖于程序计数器。
对于CPU的一个核心来说,任意时刻只能跑一个线程。如果线程的CPU时间片用完就会被挂起,等待OS重新分配时间片再继续执行,那线程如何知道上次执行到哪里了呢?就是通过程序计数器来实现的,每个线程都需要维护一个私有的程序计数器。
如果线程在执行Java方法,计数器记录的是JVM字节码指令地址。如果执行的是Native方法,计数器值则为Undefined
。
程序计数器是唯一一个没有规定任何OutOfMemoryError情况的内存区域,意味着在该区域不可能发生OOM异常,GC不会对该区域进行回收!
1.2 虚拟机栈
虚拟机栈(Java Virtual Machine Stacks)也是线程私有的,生命周期和线程相同。
虚拟机栈描述的是Java方法执行的内存模型,JVM要执行一个方法时,首先会创建一个栈帧(Stack Frame)用于存放:局部变量表、操作数栈、动态链接、方法出口等信息。栈帧创建完毕后开始入栈执行,方法执行结束后即出栈。
方法执行的过程就是一个个栈帧从入栈到出栈的过程。
局部变量表主要用来存放编译器可知的各种基本数据类型、对象引用、returnAddress类型。局部变量表所需的内存空间在编译时就已经确认,运行期间不会修改局部变量表的大小。
在JVM规范中,虚拟机栈规定了两种异常:
- StackOverflowError
线程请求的栈深度大于JVM所允许的栈深度。 栈的容量是有限的,如果线程入栈的栈帧超过了限制就会抛出StackOverflowError异常,例如:方法递归。
- OutOfMemoryError
虚拟机栈是可以动态扩展的,如果扩展时无法申请到足够的内存,则会抛出OOM异常。
1.3. 本地方法栈
本地方法栈(Native Method Stack)也是线程私有的,与虚拟机栈的作用非常类似。 区别是虚拟机栈是为执行Java方法服务的,而本地方法栈是为执行Native方法服务的。
与虚拟机栈一样,JVM规范中对本地方法栈也规定了StackOverflowError和OutOfMemoryError两种异常。
1.4. Java堆
Java堆(Java Heap)是线程共享的,一般来说也是JVM管理最大的一块内存区域,同时也是垃圾收集器GC的主要管理区域。
Java堆在JVM启动时创建,作用是:存放对象实例。 几乎所有的对象都在堆中创建,但是随着JIT编译器的发展和逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术使得“所有对象都分配在堆上”不那么绝对了。
由于是GC主要管理的区域,所以也被称为:GC堆。 为了GC的高效回收,Java堆内部又做了如下划分:
JVM规范中,堆在物理上可以是不连续的,只要逻辑上连续即可。通过-Xms -Xmx
参数可以设置最小、最大堆内存。
1.5. 方法区
方法区(Method Area)与Java堆一样,也是线程共享的一块内存区域。 它主要用来存储:被JVM加载的类信息,常量,静态变量,即时编译器产生的代码等数据。 也被称为:非堆(Non-Heap),目的是与Java堆区分开来。
JVM规范对方法区的限制比较宽松,JVM甚至可以不对方法区进行垃圾回收。这就导致在老版本的JDK中,方法区也别称为:永久代(PermGen)。
使用永久代来实现方法区不是个好主意,容易导致内存溢出,于是从JDK7开始有了“去永久代”行动,将原本放在永久代中的字符串常量池移出。到JDK8中,正式去除永久代,迎来元空间。
2. GC概述
垃圾收集(Garbage Collection)简称为「GC」,它的历史远比Java语言本身久远,在1960年诞生于麻省理工学院的Lisp是第一门开始使用内存动态分配和垃圾收集技术的语言。
要想实现自动垃圾回收,首先需要思考三件事情:
前面介绍了JVM的五大内存区域,程序计数器占用内存极少,几乎可以忽略不计,而且永远不会内存溢出,GC不需要对其进行回收。虚拟机栈、本地方法栈随线程“同生共死”,栈中的栈帧随着方法的运行有条不紊的入栈、出栈,每个栈帧分配多少内存在编译期就已经基本确定,因此这两块区域内存的分配和回收都具备确定性,不太需要考虑如何回收的问题。
方法区就不一样了,一个接口到底有多少个实现类?每个类占用的内存是多少?你甚至可以在运行时动态的创建类,因此GC需要针对方法区进行回收。
Java堆也是如此,堆中存放着几乎所有的Java对象实例,一个类到底会创建多少个对象实例,只有在程序运行时才知道,这部分内存的分配和回收是动态的,GC需要重点关注。
2.1 哪些对象需要回收
实现自动垃圾回收的第一步,就是判断到底哪些对象是可以被回收的。一般来说有两种方式:引用计数算法和可达性分析算法,商用JVM几乎采用的都是后者。
2.1.1 引用计数算法
在对象中添加一个引用计数器,每引用一次计数器就加1,每取消一次引用计数器就减1,当计数器为0时表示对象不再被引用,此时就可以将对象回收了。
引用计数算法(Reference Counting)虽然占用了一些额外的内存空间,但是它原理简单,也很高效,在大多数情况下是一个不错的实现方案,但是它存在一个严重的弊端:无法解决循环引用。
例如一个链表,按理只要没有引用指向链表,链表就应该被回收,但是很遗憾,由于链表中所有的元素引用计数器都不为0,因此无法被回收,造成内存泄漏。
2.1.2 可达性分析算法
目前主流的商用JVM都是通过可达性分析来判断对象是否可以被回收的。
这个算法的基本思路是:
通过一系列被称为「GC Roots」的根对象作为起始节点集,从这些节点开始,通过引用关系向下搜寻,搜寻走过的路径称为「引用链」,如果某个对象到GC Roots没有任何引用链相连,就说明该对象不可达,即可以被回收。
对象可达指的就是:双方存在直接或间接的引用关系。 根可达或GC Roots可达就是指:对象到GC Roots存在直接或间接的引用关系。
可以作为GC Roots的对象有以下几类:
可达性分析就是JVM首先枚举根节点,找到一些为了保证程序能正常运行所必须要存活的对象,然后以这些对象为根,根据引用关系开始向下搜寻,存在直接或间接引用链的对象就存活,不存在引用链的对象就回收。
关于可达性分析的详细描述,可以看笔者的文章:《大白话理解可达性分析算法》。
2.2 何时回收
JVM将内存划分为五大块区域,不同的GC会针对不同的区域进行垃圾回收,GC类型一般有以下几大类:
- Minor GC
也被称为“Young GC”、“轻GC”,只针对新生代进行的垃圾回收。
- Major GC
也被称为“Old GC”,只针对老年代进行的垃圾回收。
- Mixed GC
混合GC,针对新生代和部分老年代进行垃圾回收,部分垃圾收集器才支持。
- Full GC
整堆GC、重GC,针对整个Java堆和方法区进行的垃圾回收,耗时最久的GC。
什么时候触发GC,以及触发什么类型的GC呢?不同的垃圾收集器实现不一样,你还可以通过设置参数来影响JVM的决策。
一般来说,新生代会在Eden
区用尽后才会触发GC,而Old
区却不能这样,因为有的并发收集器在清理过程中,用户线程可以继续运行,这意味着程序仍然在创建对象、分配内存,这就需要老年代进行「空间分配担保」,新生代放不下的对象会被放入老年代,如果老年代的回收速度比对象的创建速度慢,就会导致「分配担保失败」,这时JVM不得不触发Full GC,以此来获取更多的可用内存。
2.3 如何回收
定位到需要回收的对象以后,就要开始进行回收了。如何回收对象又成了一个问题。 什么样的回收方式会更加的高效呢?回收后是否需要对内存进行压缩整理,避免碎片化呢?针对这些问题,GC的回收算法大致分为以下三类:
- 标记-清除算法
- 标记-复制算法
- 标记-整理算法
具体算法的回收细节,下面会介绍到。
3. GC回收算法
JVM将堆划分成不同的代,不同的代中存放的对象特点不一样,针对不同的代使用不同的GC回收算法进行回收可以提升GC的效率。
3.1 分代收集理论
目前大多数JVM的垃圾收集器都遵循“分代收集”理论,分代收集理论建立在三个假说之上。
3.1.1 弱分代假说
绝大多数对象都是朝生夕死的。
想想看我们写的程序是不是这样,绝大多数时候,我们创建一个对象,只是为了进行一些业务计算,得到计算结果后这个对象也就没什么用了,即可以被回收了。 再例如:客户端要求返回一个列表数据,服务端从数据库查询后转换成JSON响应给前端后,这个列表的数据就可以被回收了。 诸如此类,都可以被称为「朝生夕死」的对象。
3.1.2 强分代假说
熬过越多次GC的对象就越难以回收。
这个假说完全是基于概率学统计来的,经历过多次GC都无法被回收的对象,可以假定它下次GC时仍然无法被回收,因此就没必要高频率的对其进行回收,将其挪到老年代,减少回收的频率,让GC去回收效益更高的新生代。
3.1.3 跨代引用假说
跨代引用相对于同代引用是极少的。
这是根据前两条假说逻辑推理得出的隐含推论:存在互相引用关系的两个对象,应该倾向于同时生存或者同时消亡的。 举个例子,如果某个新生代对象存在跨代引用,由于老年代对象难以消亡,该引用会使得新生代对象在收集时同样得以存活,进而在年龄增长之后晋升到老年代中,这时跨代引用也随即被消除了。
3.2 解决跨代引用
跨代引用虽然极少,但是它还是可能存在的。如果为了极少的跨代引用而去扫描整个老年代,那每次GC的开销就太大了,GC的暂停时间会变得难以接受。如果忽略跨代引用,会导致新生代的对象被错误的回收,导致程序错误。
3.2.1 Remembered Set
JVM是通过记忆集(Remembered Set)来解决的,通过在新生代建立记忆集的数据结构,来避免回收新生代时把整个老年代也加进GC Roots的扫描范围,减少GC的开销。
记忆集是一种由「非收集区域」指向「收集区域」的指针集合的抽象数据结构,说白了就是把「年轻代中被老年代引用的对象」给标记起来。记忆集可以有以下三种记录精度:
- 字长精度:记录精确到一个机器字长,也就是处理器的寻址位数。
- 对象精度:精确到对象,对象的字段是否存在跨代引用指针。
- 卡精度:精确到一块内存区域,该区域内的对象是否存在跨代引用。
字长精度和对象精度太精细化了,需要花费大量的内存来维护记忆集,因此许多JVM都是采用的「卡精度」,也被称作:“卡表”(Card Table)。卡表是记忆集的一种实现,也是目前最常用的一种形式,它定义了记忆集的记录精度、与对内存的映射关系等。
HotSpot使用一个字节数组来实现卡表,它将堆空间划分成一系列2次幂大小的内存区域,这个内存区域就被称作「卡页」(Card Page),卡页的大小一般都是2的幂次方数,HotSpot采用2的9次幂,即512字节。字节数组的每一个元素都对应着一个卡页,如果某个卡页内的对象存在跨代引用,JVM就会将这个卡页标记为「Dirty」脏的,GC时只需要扫描脏页对应的内存区域即可,避免扫描整个堆。
卡表的结构如下图所示:
3.2.2 写屏障
卡表只是用来标记哪一块内存区域存在跨代引用的数据结构,JVM如何来维护卡表呢?什么时候将卡页变脏呢?
HotSpot是通过「写屏障」(Write Barrier)来维护卡表的,JVM拦截了「对象属性赋值」这个动作,类似于AOP的切面编程,JVM可以在对象属性赋值前后介入处理,赋值前的处理叫作「写前屏障」,赋值后的处理叫作「写后屏障」,伪代码如下:
开启写屏障后,JVM会为所有的赋值操作生成相应的指令,一旦出现老年代对象的引用指向了年轻代的对象,HotSpot就会将对应的卡表元素置为脏的。
请将这里的「写屏障」和并发编程中内存指令重排序的「写屏障」区分开,避免混淆。
除了写屏障本身的开销外,卡表在高并发场景下还面临着「伪共享」的问题,现代CPU的缓存系统是以「缓存行」(Cache Line)为单位存储的,Intel的CPU缓存行的大小一般是64字节,多线程修改互相独立的变量时,如果这些变量在同一个缓存行中,就会导致彼此的缓存行无故失效,线程不得不频繁发起load指令重新加载数据,而导致性能降低。
一个Cache Line是64字节,每个卡页是512字节,64✖️512字节就是32KB,如果不同的线程更新的对象处在这32KB之内,就会导致更新卡表时正好写入同一个缓存行而影响性能。为了避免这个问题,HotSpot支持只有当元素未被标记时,才将其置为脏的,这样会增加一次判断,但是可以避免伪共享的问题,设置-XX:+UseCondCardMark
来开启这个判断。
3.3 标记清除
标记清除算法分为两个过程:标记、清除。
收集器首先标记需要被回收的对象,标记完成后统一清除。也可以标记存活对象,然后统一清除没有被标记的对象,这取决于内存中存活对象和死亡对象的占比。
缺点:
- 执行效率不稳定
标记和清除的时间消耗随着Java堆中的对象不断增加而增加。 2. 内存碎片 标记清除后内存会产生大量不连续的空间碎片,不利于后续继续为新生对象分配内存。
3.4 标记复制
为了解决标记清除算法产生的内存碎片问题,标记复制算法进行了改进。
标记复制算法会将内存划分为两块区域,每次只使用其中一块,垃圾回收时首先进行标记,标记完成后将存活的对象复制到另一块区域,然后将当前区域全部清理。
缺点是:如果大量对象无法被回收,会产生大量的内存复制开销。可用内存缩小为一半,内存浪费也比较大。
由于绝大多数对象都会在第一次GC时被回收,需要被复制的往往是极少数对象,那么就完全没必要按照1:1去划分空间。 HotSpot虚拟机默认Eden区和Survivor区的大小比例是8:1,即Eden区80%,From Survivor区10%,To Survivor区10%,整个新生代可用内存为Eden区+一个Survivor区即90%,另一个Survivor区10%用于分区复制。
如果Minor GC后仍存活大量对象,超出了一个Survivor区的范围,那么就会进行分配担保(Handle Promotion),将对象直接分配进老年代。
3.5 标记整理
标记复制算法除了在对象大量存活时需要进行较多的复制操作外,还需要额外的内存空间老年代来进行分配担保,所以在老年代中一般不采用这种回收算法。
能够在老年代中存活的对象,一般都是历经多次GC后仍无法被回收的对象,基于“强分代假说”,老年代中的对象一般很难被回收。针对老年代对象的生存特征,引入了标记整理算法。
标记整理算法的标记过程与标记清除算法一致,但是标记整理算法不会像标记清除算法一样直接清理标记的对象,而是将存活的对象都向内存区域的一端移动,然后直接清理掉边界外的内存空间。
标记整理算法相较于标记清除算法,最大的区别是:需要移动存活的对象。 GC时移动存活的对象既有优点,也有缺点。
缺点 基于“强分代假说”,大部分情况下老年代GC后会存活大量对象,移动这些对象需要更新所有reference引用地址,这是一项开销极大的操作,而且该操作需要暂停所有用户线程,即程序此时会阻塞停顿,JVM称这种停顿为:Stop The World(STW)。
优点 移动对象对内存空间进行整理后,不会产生大量不连续的内存碎片,利于后续为对象分配内存。
由此可见,不管是否移动对象都有利弊。移动则内存回收时负责、内存分配时简单,不移动则内存回收时简单、内存分配时复杂。从整个程序的吞吐量来考虑,移动对象显然更划算一些,因为内存分配的频率比内存回收的频率要高的多的多。
还有一种解决方式是:平时不移动对象,采用标记清除算法,当内存碎片影响到大对象分配时,才启用标记整理算法。
4. 垃圾收集器
按照《Java虚拟机规范》实现的JVM就不胜枚举,且每个JVM平台都有N个垃圾收集器供用户选择,这些不是一篇文章可以说的清楚的。当然,开发者也没必要了解所有的垃圾收集器,以Hotspot JVM为例,主流的垃圾收集器主要有以下几大类:
串行:单线程收集,用户线程暂停。 并行:多线程收集,用户线程暂停。 并发:用户线程和GC线程同时运行。前面已经说过,大多数JVM的垃圾收集器都遵循“分代收集”理论,不同的垃圾收集器回收的内存区域会有所不同,大多数情况下,JVM需要两个垃圾收集器配合使用,下图有虚线连接的代表两个收集器可以配合使用。
4.1 新生代收集器
4.1.1 Serial
最基础,最早的垃圾收集器,采用标记复制算法,仅开启一个线程完成垃圾回收,回收时会暂停所有用户线程(STW)。
使用-XX:+UseSerialGC
参数开启Serial收集器,由于是单线程回收,因此Serial的应用范围很受限制:
- 应用程序很轻量,堆空间不到百MB。
- 服务器CPU资源紧张。
4.1.2 Parallel Scavenge
使用标记复制算法,多线程的新生代收集器。
使用参数-XX:+UseParallelGC
开启,ParallelGC的特点是非常关注系统的吞吐量,它提供了两个参数来由用户控制系统的吞吐量: -XX:MaxGCPauseMillis:设置垃圾回收最大的停顿时间,它必须是一个大于0的整数,ParallelGC会朝着这个目标去努力,如果这个值设置的过小,ParallelGC就不一定能保证了。如果用户希望GC停顿的时间很短,ParallelGC就会尝试减小堆空间,因为回收一个较小的堆肯定比回收一个较大的堆耗时短嘛,但是这样会更频繁的触发GC,从而降低系统的吞吐量。
-XX:GCTimeRatio:设置吞吐量的大小,它的值是一个0~100的整数。假设GCTimeRatio为n,那么ParallelGC将花费不超过1/(1+n)
的时间进行垃圾回收,默认值为19,意味着ParallelGC用于垃圾回收的时间不会超过5%。
ParallelGC是JDK8的默认垃圾收集器,它是一款吞吐量优先的垃圾收集器,用户可以通过-XX:MaxGCPauseMillis
和-XX:GCTimeRatio
来设置GC最大的停顿时间和吞吐量。但这两个参数是互相矛盾的,更小的停顿时间就意味着GC需要更频繁进行回收,从而增加GC回收的整体时间,导致吞吐量下降。
4.1.3 ParNew
ParNew也是一个使用标记复制算法,多线程的新生代垃圾收集器。它的回收策略、算法、及参数都和Serial一样,只是简单的将单线程改为多线程而已,它的诞生只是为了配合CMS
收集器使用而存在的。CMS
是老年代的收集器,但是Parallel Scavenge
不能配合CMS
一起工作,Serial是串行回收的,效率又太低了,因此ParNew就诞生了。
使用参数-XX:+UseParNewGC
开启,不过这个参数已经在JDK9之后的版本中删除了,因为JDK9默认G1收集器,CMS已经被取代,而ParNew就是为了配合CMS而诞生的,CMS废弃了,ParNew也就没有存在价值了。
4.2 老年代收集器
4.2.1 Serial Old
使用标记整理算法,和Serial一样,单线程独占式的针对老年代的垃圾收集器。老年代的空间通常比新生代要大,而且标记整理算法在回收过程中需要移动对象来避免内存碎片化,因此老年代的回收要比新生代更耗时一些。
Serial Old作为最早的老年代垃圾收集器,还有一个优势,就是它可以和绝大多数新生代垃圾收集器配合使用,同时它还可以作为CMS并发失败的备用收集器。
使用参数-XX:+UseSerialGC
开启,新生代老年代都将使用串行收集器。和Serial一样,除非你的应用非常轻量,或者CPU的资源十分紧张,否则都不建议使用该收集器。
4.2.2 Parallel Old
ParallelOldGC是一款针对老年代,多线程并行的独占式垃圾收集器,和Parallel Scavenge一样,属于吞吐量优先的收集器,Parallel Old的诞生就是为了配合Parallel Scavenge使用的。
ParallelOldGC使用的是标记整理算法,使用参数-XX:+UseParallelOldGC
开启,参数-XX:ParallelGCThreads=n
可以设置垃圾收集时开启的线程数量,同时它也是JDK8默认的老年代收集器。
4.2.3 CMS
CMS(Concurrent Mark Sweep)是一款里程碑式的垃圾收集器,为什么这么说呢?因为在它之前,GC线程和用户线程是无法同时工作的,即使是Parallel Scavenge,也不过是GC时开启多个线程并行回收而已,GC的整个过程依然要暂停用户线程,即Stop The World。这带来的后果就是Java程序运行一段时间就会卡顿一会,降低应用的响应速度,这对于运行在服务端的程序是不能被接收的。
GC时为什么要暂停用户线程? 首先,如果不暂停用户线程,就意味着期间会不断有垃圾产生,永远也清理不干净。 其次,用户线程的运行必然会导致对象的引用关系发生改变,这就会导致两种情况:漏标和错标。
漏标
原本不是垃圾,但是GC的过程中,用户线程将其引用关系修改,导致GC Roots不可达,成为了垃圾。这种情况还好一点,无非就是产生了一些浮动垃圾,下次GC再清理就好了。 2. 错标 原本是垃圾,但是GC的过程中,用户线程将引用重新指向了它,这时如果GC一旦将其回收,将会导致程序运行错误。
为了实现并发收集,CMS的实现比前面介绍的几种垃圾收集器都要复杂的多,整个GC过程可以大概分为以下四个阶段:
1、初始标记 初始标记仅仅只是标记一下GC Roots能直接关联到的对象,速度很快。初始标记的过程是需要触发STW的,不过这个过程非常快,而且初试标记的耗时不会因为堆空间的变大而变慢,是可控的,因此可以忽略这个过程导致的短暂停顿。
2、并发标记 并发标记就是将初始标记的对象进行深度遍历,以这些对象为根,遍历整个对象图,这个过程耗时较长,而且标记的时间会随着堆空间的变大而变长。不过好在这个过程是不会触发STW的,用户线程仍然可以工作,程序依然可以响应,只是程序的性能会受到一点影响。因为GC线程会占用一定的CPU和系统资源,对处理器比较敏感。CMS默认开启的GC线程数是:(CPU核心数+3)/4,当CPU核心数超过4个时,GC线程会占用不到25%的CPU资源,如果CPU数不足4个,GC线程对程序的影响就会非常大,导致程序的性能大幅降低。
3、重新标记 由于并发标记时,用户线程仍在运行,这意味着并发标记期间,用户线程有可能改变了对象间的引用关系,可能会发生两种情况:一种是原本不能被回收的对象,现在可以被回收了,另一种是原本可以被回收的对象,现在不能被回收了。针对这两种情况,CMS需要暂停用户线程,进行一次重新标记。
4、并发清理 重新标记完成后,就可以并发清理了。这个过程耗时也比较长,且清理的开销会随着堆空间的变大而变大。不过好在这个过程也是不需要STW的,用户线程依然可以正常运行,程序不会卡顿,不过和并发标记一样,清理时GC线程依然要占用一定的CPU和系统资源,会导致程序的性能降低。
CMS开辟了并发收集的先河,让用户线程和GC线程同时工作成为了可能,但是缺点也很明显: 1、对处理器敏感 并发标记、并发清理阶段,虽然CMS不会触发STW,但是标记和清理需要GC线程介入处理,GC线程会占用一定的CPU资源,进而导致程序的性能下降,程序响应速度变慢。CPU核心数多的话还稍微好一点,CPU资源紧张的情况下,GC线程对程序的性能影响非常大。
2、浮动垃圾 并发清理阶段,由于用户线程仍在运行,在此期间用户线程制造的垃圾就被称为“浮动垃圾”,浮动垃圾本次GC无法清理,只能留到下次GC时再清理。
3、并发失败 由于浮动垃圾的存在,因此CMS必须预留一部分空间来装载这些新产生的垃圾。CMS不能像Serial Old收集器那样,等到Old区填满了再来清理。在JDK5时,CMS会在老年代使用了68%的空间时激活,预留了32%的空间来装载浮动垃圾,这是一个比较偏保守的配置。如果实际引用中,老年代增长的不是太快,可以通过-XX:CMSInitiatingOccupancyFraction
参数适当调高这个值。到了JDK6,触发的阈值就被提升至92%,只预留了8%的空间来装载浮动垃圾。 如果CMS预留的内存无法容纳浮动垃圾,那么就会导致「并发失败」,这时JVM不得不触发预备方案,启用Serial Old收集器来回收Old区,这时停顿时间就变得更长了。
4、内存碎片 由于CMS采用的是「标记清除」算法,这就意味这清理完成后会在堆中产生大量的内存碎片。内存碎片过多会带来很多麻烦,其一就是很难为大对象分配内存。导致的后果就是:堆空间明明还有很多,但就是找不到一块连续的内存区域为大对象分配内存,而不得不触发一次Full GC,这样GC的停顿时间又会变得更长。 针对这种情况,CMS提供了一种备选方案,通过-XX:CMSFullGCsBeforeCompaction
参数设置,当CMS由于内存碎片导致触发了N次Full GC后,下次进入Full GC前先整理内存碎片,不过这个参数在JDK9被弃用了。
4.2.3.1 三色标记算法
介绍完CMS垃圾收集器后,我们有必要了解一下,为什么CMS的GC线程可以和用户线程一起工作。
JVM判断对象是否可以被回收,绝大多数采用的都是「可达性分析」算法,关于这个算法,可以查看笔者以前的文章:大白话理解可达性分析算法。
从GC Roots开始遍历,可达的就是存活,不可达的就回收。
CMS将对象标记为三种颜色:
标记的过程大致如下:
- 刚开始,所有的对象都是白色,没有被访问。
- 将GC Roots直接关联的对象置为灰色。
- 遍历灰色对象的所有引用,灰色对象本身置为黑色,引用置为灰色。
- 重复步骤3,直到没有灰色对象为止。
- 结束时,黑色对象存活,白色对象回收。
这个过程正确执行的前提是没有其他线程改变对象间的引用关系,然而,并发标记的过程中,用户线程仍在运行,因此就会产生漏标和错标的情况。
漏标 假设GC已经在遍历对象B了,而此时用户线程执行了A.B=null
的操作,切断了A到B的引用。
本来执行了A.B=null
之后,B、D、E都可以被回收了,但是由于B已经变为灰色,它仍会被当做存活对象,继续遍历下去。 最终的结果就是本轮GC不会回收B、D、E,留到下次GC时回收,也算是浮动垃圾的一部分。
实际上,这个问题依然可以通过「写屏障」来解决,只要在A写B的时候加入写屏障,记录下B被切断的记录,重新标记时可以再把他们标为白色即可。
错标 假设GC线程已经遍历到B了,此时用户线程执行了以下操作:
B到D的引用被切断,且A到D的引用被建立。 此时GC线程继续工作,由于B不再引用D了,尽管A又引用了D,但是因为A已经标记为黑色,GC不会再遍历A了,所以D会被标记为白色,最后被当做垃圾回收。 可以看到错标的结果比漏表严重的多,浮动垃圾可以下次GC清理,而把不该回收的对象回收掉,将会造成程序运行错误。
错标只有在满足下面两种情况下才会发生:
- 灰色指向白色的引用全部断开。
- 黑色指向白色的引用被建立。
只要打破任一条件,就可以解决错标的问题。
原始快照和增量更新 原始快照打破的是第一个条件:当灰色对象指向白色对象的引用被断开时,就将这条引用关系记录下来。当扫描结束后,再以这些灰色对象为根,重新扫描一次。相当于无论引用关系是否删除,都会按照刚开始扫描时那一瞬间的对象图快照来扫描。
增量更新打破的是第二个条件:当黑色指向白色的引用被建立时,就将这个新的引用关系记录下来,等扫描结束后,再以这些记录中的黑色对象为根,重新扫描一次。相当于黑色对象一旦建立了指向白色对象的引用,就会变为灰色对象。
CMS采用的方案就是:写屏障+增量更新来实现的,打破的是第二个条件。
当黑色指向白色的引用被建立时,通过写屏障来记录引用关系,等扫描结束后,再以引用关系里的黑色对象为根重新扫描一次即可。
伪代码大致如下:
4.3 混合收集器
4.3.1 G1
G1的全称是「Garbage First」垃圾优先的收集器,JDK7正式使用,JDK9默认使用,它的出现是为了替代CMS收集器。
既然要替代CMS,那么毫无疑问,G1也是并发并行的垃圾收集器,用户线程和GC线程可以同时工作,关注的也是应用的响应时间。
G1最大的一个变化就是,它只是逻辑分代,物理结构上已经不分代了。它将整个Java堆划分成多个大小不等的Region,每个Region可以根据需要扮演Eden区、Survivor区、或者是老年代空间,G1可以对扮演不同角色的Region采用不同的策略去处理。
G1之前的所有垃圾收集器,回收的范围要么是整个新生代(Minor GC)、要么是整个老年代(Major GC)、再就是整个Java堆(Full GC)。而G1跳出了这个樊笼,它可以面向堆内任何部分来组成回收集(Collection Set,简称CSet
)进行回收,衡量标准不再是它属于哪个分代,而是判断哪个Region垃圾最多,选择回收价值最高的Region回收,这也是「Garbage First」名称的由来。
虽然G1仍然保留了分代的概念,但是新生代和老年代不再是固定不变的两块连续的内存区域了,它们都是由一系列Region组成的,而且每次GC时,新生代和老年代的空间大小会动态调整。G1之所以能控制GC的停顿时间,建立可预测的停顿时间模型,就是因为它将Region作为单次回收的最小单元,每次回收的内存空间都是Region大小的整数倍,这样就可以避免在整个Java堆内进行全区域的垃圾收集。
G1会跟踪每个Region的垃圾数量,计算每个Region的回收价值,在后台维护一个优先级列表,然后根据用户设置的允许GC停顿的时间来优先回收“垃圾最多”的Region,这样就保证了G1能够在有限的时间内回收尽可能多的可用内存。
G1的整个回收周期大概可以分为以下几个阶段:
- Eden区内存耗尽,触发新生代GC开始回收Eden区和Survivor区。新生代GC后,Eden区会被清空,Survivor区至少会保留一个,其余的对象要么被清理,要么被晋升到老年代。这个过程中,新生代的大小可能会被调整。
- 并发标记周期 2.1 初始标记:仅标记GC Roots直接关联的对象,会伴随一次新生代GC,且会导致STW。 2.2 根区域扫描:初始标记时触发的新生代GC会将Eden区清空,存活对象会移动到Survivor区,这时就需要扫描由Survivor区直接可达的老年代区域,并标记这些对象,这个过程可以并发执行。 2.3 并发标记:和CMS类似会扫描并查找整个堆内存活的对象并标记,不会触发STW。 2.4 重新标记:触发STW,修正并发标记期间因为用户线程继续执行而导致对象间的引用被改变。 2.5 独占清理:触发STW,计算各个Region的回收价值,对Region进行排序,识别可供混合回收的区域。 2.6 并发清理:识别并清理完全空闲的Region,不会造成停顿。
- 混合回收:并发标记周期中的并发清理阶段,G1虽然也回收了部分空间,但是比例还是相当低的。但是在这之后,G1已经明确知道各个Region的回收价值了。在混合回收阶段G1会优先回收垃圾最多的Region,这些Region既包含了新生代,也包含了老年代,故称之为“混合回收”。被清理的Region内的存活对象会被移动到其他Region,这也避免了内存碎片。
和CMS一样,因为并发回收时用户线程仍然在运行,即分配内存,因此如果回收速度跟不上内存分配的速度,G1也会在必要的时候触发一个Full GC来获取更多的可用内存。
使用参数-XX:+UseG1GC
来开启G1收集器,-XX:MaxGCPauseMillis
来设置目标最大停顿时间,G1会朝着这个目标去努力,如果GC停顿时间超过了目标时间,G1就会尝试调整新生代和老年代的比例、堆大小、晋升年龄等一系列参数来企图达到预设目标。 -XX:ParallelGCThreads
用来设置并行回收时GC的线程数量,-XX:InitiatingHeapOccupancyPercent
用来指定整个Java堆的使用率达到多少时触发并发标记周期的执行,默认值是45。
4.3.2 面向未来的ZGC
ZGC是在JDK11才加入的具有实现性质的低延迟垃圾收集器,它的目标是希望在尽可能对吞吐量影响不大的前提下,实现在任意堆内存大小下都可以把GC的停顿时间控制在十毫秒以内。
ZGC面向的是超大堆,最大支持4TB
的堆空间,它和G1一样,也是采用Region的内存布局形式。
ZGC最大的一个特点就是它采用着色指针Colored Pointer
技术来标记对象。以往,如果JVM需要在对象上存储一些额外的、只供GC或JVM本身使用的数据时(如GC年龄、偏向线程ID、哈希码),通常会在对象的对象头上增加额外的字段来记录。ZGC就厉害了,直接把标记信息记录在对象的引用指针上。
Colored Pointer
是什么?为什么对象引用的指针本身也可以存储数据呢? 在64位系统中,理论上可以访问的内存大小为2的64次幂字节,即16EB。但是实际上,目前远远用不到这么大的内存,因此基于性能和成本的考虑,CPU和操作系统都会施加自己的约束。例如AMD64架构只支持54位(4PB)的地址总线,Linux只支持46位(64TB)的物理地址总线,Windows只支持44位(16TB)的物理地址总线。
在Linux系统下,高18位不能用来寻址,剩余的46位能支持最大64TB的内存大小。事实上,64TB的内存大小在目前来说也远远超出了服务器的需要。于是ZGC就盯上了这剩下的46位指针宽度,将其高4位提取出来存储四个标志信息。通过这些标志位,JVM可以直接从指针中看到其引用对象的三色标记状态、是否进入了重分配集(即被移动过)、是否只能通过finalize()方法才能被访问到。这就导致JVM能利用的物理地址总线只剩下42位了,即ZGC能管理的最大内存空间为2的42次幂字节,即4TB。
目前ZGC还处于实验阶段,能查到的资料也不多。