Java核心技术系列
点击查看第二章
点击查看第三章
JVM G1源码分析和调优
彭成寒 编著
第1章
垃圾回收概述
Java的发展已经超过了20年,已是最流行的编程语言。为了更好地了解和使用Java,越来越多的开发人员开始关注Java虚拟机(JVM)的实现技术,其中垃圾回收(也称垃圾收集)是最热门的技术点之一。目前G1作为JVM中最新、最成熟的垃圾回收器受到很多的人关注,本书从G1的原理出发,介绍新生代收集、混合收集、Full GC、并发标记、Ref?ine、Evacuation等内容。本章先回顾Java语言的发展历程,然后介绍JVM中一些常用的概念以便与读者统一术语,随后介绍垃圾回收的主要算法以及JVM中实现了哪些垃圾回收的算法。
1.1 Java发展概述
Java平台和语言最开始是SUN公司在1990年12月进行的一个内部研究项目,我们通常所说的Java一般泛指JDK(Java Developer Kit),它既包含了Java语言和开发工具,也包含了执行Java的虚拟机(Java Virtual Machine,JVM)。从1996年1月23日开始,JDK 1.0版本正式发布,到如今Java已经经历了23个春秋。以下是Java发展历程中值得纪念的几个时间点:
□1998年12月4日JDK迎来了一个里程碑版本1.2。其技术体系被分为三个方向,J2SE、J2EE、J2ME。代表技术包括EJB、Java Plug-in、Swing;虚拟机第一次内置了JIT编译器;语言上引入了Collections集合类等。
□2000年5月8日,JDK1.3发布。在该版本中Hotspot正式成为默认的虚拟机,Hotspot是1997年SUN公司收购LongView Technologies公司而获得的。
□2002年2月13日,JDK1.4发布。该版本是Java走向成熟的一个版本。从此之后,每一个新的版本都会增加新的特性,比如JDK5改进了内存模型、支持泛型等;JDK6增强了锁同步等;JDK7正式支持G1垃圾回收、升级类加载的架构等;JDK8支持函数式编程等。
□2006年11月13日的JavaOne大会上,SUN公司宣布最终会把Java开源,由OpenJDK组织对这些源码独立管理,从此之后Java程序员多了一个研究JVM的官方渠道。
□2009年4月20日,Oracle公司宣布正式以74亿美元的价格收购SUN公司,Java商标从此正式归Oracle所有,自此Oracle对Java的管理和发布进入了一个新的时期。
随着时间的推移,JDK 9和JDK 10也已经正式发布,但是JDK 9和JDK 10并不是Oracle长期支持的版本(Long Term Support),这意味着JDK 9和JDK 10只是JDK 11的一个过渡版本,它们只用于整合新的特性,当下一个版本发布之后,这些过渡版本将不再更新维护。2018年9月25日JDK 11正式发布,随着新版本的发布,Oracle公司未来对JDK的支持也会变化。按照现在的声明,从2019年1月起对于商业用户,Oracle公司对JDK 8不再提供公共的更新,从2020年12月起对个人用户也不再提供公共的更新。
G1作为CMS的替代者,一直吸引着众多Java开发者的目光,自从JDK 7正式推出以来,G1不断地增强,并从JDK 8开始越来越成熟,在JDK 9、JDK 10、JDK 11中都成为默认的垃圾回收器。实际上也有越来越多的公司开始在生产环境中使用G1作为垃圾回收器,有一篇文章描述了JDK 9中GC的基准测试(benchmark),表明G1已经优于其他的GC。可以预见随着JDK 11的推出,会有越来越多的公司和个人使用G1作为生产环境中的垃圾回收器。
G1的目标是在满足短时间停顿的同时达到一个高的吞吐量,适用于多核处理器、大内存容量的系统。其实现特点为:
□短停顿时间且可控:G1对内存进行分区,可以应用在大内存系统中;设计了基于部分内存回收的新生代收集和混合收集。
□高吞吐量:优化GC工作,使其尽可能与Mutator并发工作。设计了新的并发标记线程,用于并发标记内存;设计了Ref?ine线程并发处理分区之间的引用关系,加快垃圾回收的速度。
新生代收集指针对全部新生代分区进行垃圾回收;混合收集指不仅仅回收新生代分区,同时回收一部分老生代分区,这通常发生在并发标记之后;Full GC指内存不足时需要对全部内存进行垃圾回收。
并发标记是G1新引入的部分,指的是在Mutator运行的同时标记哪些对象是垃圾,看到这里大家一定非常好奇G1到底是怎么实现的,举一个简单的例子。比如你的妈妈正在打扫房间,扫房房间需要识别哪些物品有用哪些无用,无用的物品就是垃圾。同时你正在房间活动,活动的同时你可能往房间增加了新的物品,也可能把房间的物品重新组合,也可能产生新的无用物品。最简单的垃圾回收器如串行回收器的做法就是在打扫房间标识物品的时候,你要暂停一切活动,这个时候你的妈妈就能完美地识别哪些物品有用哪些无用。但最大的问题就是需要你暂停一切活动直到房间里面的物品识别完毕,在实际系统中意味着这段时间应用程序不能提供服务。G1的并发标记就是在打扫房间识别物品有用或者无用的同时,你还可以继续活动,怎么正确做标记呢?一个简单的办法就是在打扫房间识别垃圾物品开始的时候记录你增加了哪些物品,动过哪些物品。然后在物品标记结束的时候对这些变更过的物品重新标记一次,当然在这一次标记时需要你暂停一切活动,否则永远也没有尽头,这通常称为再标记(Remark)。这个就是所谓的增量并发标记,在G1中具体的算法是Snapshot-At-The-Beginning(SATB),关于这个算法我们会在第6章详细介绍。Ref?ine线程也是G1新引入的,它的目的是为了在进行部分收集的时候加速识别活跃对象,具体介绍参见第4章。
本书依托于jdk8u的源代码来介绍JVM如何实现G1,通过源代码的分析理解算法以及了解G1提供的参数的具体意义;最后还会给出一些例子,通过日志,分析该如何调整参数以达到性能优化。
这里提到的jdk8u是指OpenJDK的代码,OpenJDK是SUN公司(现Oracle)推出的JDK开源代码,因为标准的JDK(这里指Oracle版的JDK)会有一些内部功能的代码,那些代码在开源的时候并未公开。在2017年9月Oracle公司宣布Oracle JDK和OpenJDK将能*切换,Oracle JDK也会依赖OpenJDK的代码进行构建,所以通常都是使用OpenJDK的代码进行分析和研究。读者可以自行到OpenJDK的官网上下载源代码,值得一提的是,JDK的代码会随着bug修复不断改变,所以为了保持阅读的一致性,我把本书使用的代码推送到GitHub上,也使用该版本进行编译调试。
1.2 本书常见术语
JVM系统非常复杂,市面上有很多中英文书籍从不同的角度来介绍JVM,其中都用到了很多术语,但是大家对某些术语的解释并不完全相同。为了便于读者的理解,在这里统一定义和解释本书使用的一些术语。这些术语有些是我们约定俗成的叫法,有些是JVM里面的特别约定,还有一些是G1算法引入的。为了保持准确性,这里仅仅解释这些术语的含义,后续会进一步解释相关内容,本书将尽量使用这里定义的术语。
□并行(parallelism),指两个或者多个事件在同一时刻发生,在现代计算机中通常指多台处理器上同时处理多个任务。
□并发(concurrency),指两个或多个事件在同一时间间隔内发生,在现代计算机中一台处理器“同时”处理多个任务,那么这些任务只能交替运行,从处理器的角度上看任务只能串行执行,从用户的角度看这些任务“并行”执行,实际上是处理器根据一定的策略不断地切换执行这些“并行”的任务。
在JVM中,我们也常看到并行和并发。比如,典型的ParNew一般称为并行收集器,CMS一般称为并发标记清除(Concurrent Mark Sweep)。这看起来很奇怪,因为并行和并发是从处理器角度出发,但是这里明显不是,实际上并行和并发在JVM被重新定义了。
JVM中的并行,指多个垃圾回收相关线程在操作系统之上并发运行,这里的并行强调的是只有垃圾回收线程工作,Java应用程序都暂停执行,因此ParNew工作的时候一定发生了STW。本书提到的ParTask(例如G1ParTask)指的就是在这些任务运行的时候应用程序都必须暂停。
JVM中的并发,指垃圾回收相关的线程并发运行(如果启动多个线程),同时这些线程会和Java应用程序并发运行。本书提到的ConcurrentThread(例如ConcurrentG1RefineThread)就是指这些线程和Java应用程序同时运行。
□Stop-the-world(STW),直译就是停止一切,在JVM中指停止一切Java应用线程。
□安全点(Safepoint),指JVM在执行一些操作的时需要STW,但并不是任何线程在任何地方都能进入STW,例如我们正在执行一段代码时,线程如何能够停止?设计安全点的目的是,当线程进入到安全点时,线程就会主动停止。
□Mutator,在很多英文文献和JVM源码中,经常看到这个单词,它指的是我们的Java应用线程。Mutator的含义是可变的,在这里的含义是因为线程运行,导致了内存的变化。GC中通常需要STW才能使Mutator暂停。
□记忆集(Remember Set),简称为RSet。主要记录不同代际对象的引用关系。
□Refine,尚未有统一的翻译,有时翻译为细化,但是不太准确,本书中不做翻译。G1中的ConcurrentG1Ref?ineThread主要指处理RSet的线程。
□Evacuation,转移、撤退或者回收,简称为Evac,本书中不做翻译。在G1中指的是发现活跃对象,并将对象复制到新地址的过程。
□回收(Reclaim),通常指的是分区对象已经死亡或者已经完成Evac,分区可以被JVM再次使用。
□Closure,闭包,本书中不做翻译。在JVM中是一种辅助类,类似于我们已知的iterator,它通常提供了对内存的访问。
□GC Root,垃圾回收的根。在JVM的垃圾回收过程中,需要从GC Root出发标记活跃对象,确保正在使用的对象在垃圾回收后都是存活的。
□根集合(Root Set)。在JVM的垃圾回收过程中,需要从不同的GC Root出发,这些GC Root有线程栈、monitor列表、JNI对象等,而这些GC Root就构成了Root Set。
□Full GC,简称为FGC,整个堆的垃圾回收动作。通常Full GC是串行的,G1的
Full GC不仅有串行实现,在JDK10中还有并行实现。
□再标记(Remark)。在本书中指的是并发标记算法中,处理完并发标记后,需要更新并发标记中Mutator变更的引用,这一步需要STW。
1.3 回收算法概述
垃圾回收(Garbage Collection,GC)指的是程序不用关心对象在内存中的生存周期,创建后只需要使用对象,不用关心何时释放以及如何释放对象,由JVM自动管理内存并释放这些对象所占用的空间。GC的历史非常悠久,从1960年Lisp语言开始就支持GC。垃圾回收针对的是堆空间,目前垃圾回收算法主要有两类:
□引用计数法:在堆内存中分配对象时,会为对象分配一段额外的空间,这个空间用于维护一个计数器,如果对象增加了一个新的引用,则将增加计数器。如果一个引用关系失效则减少计数器。当一个对象的计数器变为0,则说明该对象已经被废弃,处于不活跃状态,可以被回收。引用计数法需要解决循环依赖的问题,在我们众所周知的Python语言里,垃圾回收就使用了引用计数法。
□可达性分析法(根引用分析法),基本思路就是将根集合作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链,当一个对象没有被任何引用链访问到时,则证明此对象是不活跃的,可以被回收。
这两种算法各有优缺点,具体可以参考其他文献。JVM的垃圾回收采用了可达性分析法。垃圾回收算法也一直不断地演化,主要有以下分类:
□垃圾回收算法实现主要分为复制(Copy)、标记清除(Mark-Sweep)和标记压缩(Mark-Compact)。
□在回收方法上又可以分为串行回收、并行回收、并发回收。
□在内存管理上可以分为代管理和非代管理。
我们首先看一下基本的收集算法。
1.3.1 分代管理算法
分代管理就是把内存划分成不同的区域进行管理,其思想来源是:有些对象存活的时间短,有些对象存活的时间长,把存活时间短的对象放在一个区域管理,把存活时间长的对象放在另一个区域管理。那么可以为两个不同的区域选择不同的算法,加快垃圾回收的效率。我们假定内存被划分成2个代:新生代和老生代。把容易死亡的对象放在新生代,通常采用复制算法回收;把预期存活时间较长的对象放在老生代,通常采用标记清除算法。
1.3.2 复制算法
复制算法的实现也有很多种,可以使用两个分区,也可以使用多个分区。使用两个分区时内存的利用率只有50%;使用多个分区(如3个分区),则可以提高内存的使用率。我们这里演示把堆空间分为1个新生代(分为3个分区:Eden、Survivor0、Survivor1)、1个老生代的收集过程。
普通对象创建的时候都是放在Eden区,S0和S1分别是两个存活区。第一次垃圾收集前S0和S1都为空,在垃圾收集后,Eden和S0里面的活跃对象(即可以通过根集合到达的对象)都放入了S1区,如图1-1所示。
回收后Mutator继续运行并产生垃圾,在第二次运行前Eden和S1都有活跃对象,在垃圾收集后,Eden和S1里面的活跃对象(即可以通过根节点到达的对象)都被放入到S0区,一直这样循环收集,如图1-2所示。
1.3.3 标记清除
从根集合出发,遍历对象,把活跃对象入栈,并依次处理。处理方式可以是广度优先搜索也可以是深度优先搜索(通常使用深度优先搜索,节约内存)。标记出活跃对象之后,就可以把不活跃对象清除。下面演示一个简单的例子,从根集合出发查找堆空间的活跃对象,如图1-3所示。
这里仅仅演示了如何找到对象,没有进一步介绍找到对象后如何处理。对于标记清除算法其实还需要额外的数据结构(比如一个链表)来记录可用空间,在对象分配的时候从这个链表中寻找能够容纳对象的空间。当然这里还有很多细节都未涉及,比如在分配时如何找到最合适的内存空间,有First Fit、Best Fit和Worst Fit等方法,这里不再赘述。标记清除算法最大的缺点就是使内存碎片化。
1.3.4 标记压缩
标记压缩算法是为了解决标记清除算法中使内存碎片化的问题,除了上述的标记动作之外,还会把活跃对象重新整理从头开始排列,减少内存碎片。
1.3.5 算法小结
垃圾回收的基础算法自提出以来并没有大的变化。表1-1对几种算法的优缺点进行了比较,更加详细的介绍请参考其他书籍。
1.4 JVM垃圾回收器概述
为了达到最大性能,基于分代管理和回收算法,结合回收的时机,JVM实现垃圾回收器了:串行回收、并行回收、并发标记回收(CMS)和垃圾优先回收。
1.4.1 串行回收
串行回收使用单线程进行垃圾回收,在回收的时候Mutator需要STW。新生代通常采用复制算法,老生代通常采用标记压缩算法。串行回收典型的线程交互图如图1-4所示。
1.4.2 并行回收
并行回收使用多线程进行垃圾回收,在回收的时候Mutator需要暂停,新生代通常采用复制算法,老生代通常采用标记压缩算法。线程交互如图1-5所示。
1.4.3 并发标记回收
并发标记回收(CMS)的整个回收期间划分成多个阶段:初始标记、并发标记、重新标记、并发清除等。在初始标记和重新标记阶段需要暂停Mutator,在并发标记和并发清除期间可以和Mutator并发运行,如图1-6所示。这个算法通常适用于老生代,新生代可以采用并行回收。
1.4.4 垃圾优先回收
垃圾优先回收器(Garbage-First,也称为G1)从JDK7 Update 4开始正式提供。G1
致力于在多CPU和大内存服务器上对垃圾回收提供软实时目标和高吞吐量。G1垃圾回收器的设计和前面提到的3种回收器都不一样,它在并行、串行以及CMS GC针对堆空间的管理方式上都是连续的,如图1-7所示。
连续的内存将导致垃圾回收时收集时间过长,停顿时间不可控。因此G1将堆拆成一系列的分区(Heap Region),这样在一个时间段内,大部分的垃圾收集操作只针对一部分分区,而不是整个堆或整个(老生)代,如图1-8所示。
在G1里,新生代就是一系列的内存分区,这意味着不用再要求新生代是一个连续的内存块。类似地,老生代也是由一系列的分区组成。这样也就不需要在JVM运行时考虑哪些分区是老生代,哪些是新生代。事实上,G1通常的运行状态是:映射G1分区的虚拟内存随着时间的推移在不同的代之间切换。例如一个G1分区最初被指定为新生代,经过一次新生代的回收之后,会将整个新生代分区都划入未使用的分区中,那它可以作为新生代分区使用,也可以作为老生代分区使用。很可能在完成一个新生代收集之后,一个新生代的分区在未来的某个时刻可用于老生代分区。同样,在一个老生代分区完成收集之后,它就成为了可用分区,在未来某个时候可作为一个新生代分区来使用。
G1新生代的收集方式是并行收集,采用复制算法。与其他JVM垃圾回收器一样,一旦发生一次新生代回收,整个新生代都会被回收,这也就是我们常说的新生代回收(Young GC)。但是G1和其他垃圾回收器不同的地方在于:
□G1会根据预测时间动态改变新生代的大小。
注意:其他垃圾回收新生代的大小也可以动态变化,但这个变化主要是根据内存的使用情况进行的。G1中则是以预测时间为导向,根据内存的使用情况调整新生代分区的数目。
□G1老生代的垃圾回收方式与其他JVM垃圾回收器对老生代处理有着极大的不同。G1老生代的收集不会为了释放老生代的空间对整个老生代做回收。相反,在任意时刻只有一部分老生代分区会被回收,并且,这部分老生代分区将在下一次增量回收时与所有的新生代分区一起被收集。这就是我们所说的混合回收(Mixed GC)。在选择老生代分区的时候,优先考虑垃圾多的分区,这也正是垃圾优先这个名字的由来。后续我们将逐一介绍这些内容。
在G1中还有一个概念就是大对象,指的是待分配的对象大小超过一定的阈值之后,为了减少这种对象在垃圾回收过程的复制时间,直接把对象分配到老生代分区中而不是新生代分区中。
从实现角度来看,G1算法是复合算法,吸收了以下算法的优势:
□列车算法,对内存进行分区,参见图1-8。
□CMS,对分区进行并发标记。
□最老优先,最老的数据(通常也是垃圾)优先收集。
关于列车算法、CMS和最老优先可以参考其他的书籍,这里不再赘述。