GIAC(GLOBAL INTERNET ARCHITECTURE CONFERENCE)是长期关注互联网技术与架构的高可用架构技术社区和msup推出的,面向架构师、技术负责人及高端技术从业人员的年度技术架构大会,是中国地区规模最大的技术会议之一。
今年的第六届GIAC大会上,在大数据架构进化中的JAVA专题,腾讯高级工程师傅杰博士发表了《Tencent JDK国产化CPU架构支持分享》的主题演讲。以下为嘉宾演讲实录:
尊敬的各位来宾,大家下午好!很高兴有机会跟大家一起分享Tencent JDK国产化CPU架构支持的话题。我是来自腾讯JVM团队的jiefu(傅杰),在中科院计算所硕博连读期间开始从事OpenJDK的研发工作,目前是OpenJDK社区的committer。我曾就职于龙芯,是OpenJDK mips分支的核心开发者,在龙芯上开拓并实现了OpenJDK的C2编译器。加入腾讯后,主要致力于KonaJDK在大数据和机器学习等领域的探索和实践。
今天,我首先向大家简单介绍一下Tencent Kona JDK;随后,详细阐述JVM对国产CPU体系结构的支持;最后,和大家一起探讨处理器内存模型对JVM实现的影响。
Tencent Kona JDK简介
Tencent Kona是腾讯基于OpenJDK研发的一款JDK产品,于2019年免费对外开源,并提供长期支持(LTS)。Kona的每个发布版本都经过了腾讯云和内部实际生产环境的测试验证,欢迎大家下载使用。
2020年3月JDK14发布时,我司是国内有限的若干公司,进入全球突出贡献者/组织名单。OpenJDK全球贡献者榜单是对全世界各个公司或个人对OpenJDK贡献的权威统计,由Oracle在新版本JDK发布时对外公布。
腾讯的JVM团队(含多位OpenJDK社区的 author/committer),专门负责Kona的研发和维护。仅最近半年时间,团队已向OpenJDK社区贡献了几十个修复Bug的patch。同时鹅厂也将自身海量生产负载经验和前沿实践,贡献给OpenJDK社区。未来,我们将以更加开放的姿态积极拥抱开源,并持续贡献开源。
JVM对国产CPU体系结构的支持
下面跟大家分享JVM对国产CPU体系结构支持的相关内容。国产处理器是我国发展信创产业的根基。目前,进入官方名录的国产处理器按架构可分为ARM、MIPS、Alpha和X86四大架构。其中,ARM以鲲鹏和飞腾为代表,MIPS以龙芯为代表,Alpha以申威为代表,X86则以兆芯和海光为代表。上述四种架构,除ARM和X86有OpenJDK社区支持外,MIPS和Alpha均无社区支持,全部需要自行开发和维护。因此,掌握JVM对处理器支持的技术,对于打破外国垄断、促进国产处理器持续健康发展具有十分重要的意义。
OpenJDK的HotSpot虚拟机是全世界应用最广的高性能Java虚拟机。从宏观设计层面,HotSpot虚拟机可分为类加载器、运行时、执行引擎和垃圾收集器四个模块。其中,只有执行引擎和处理器体系结构密切相关,其它三个模块几乎平台无关(或仅部分与操作系统相关,如运行时模块)。JVM的执行引擎负责将Java字节码转换为处理器硬件支持的机器指令,故该模块绝大部分与CPU相关。因此,JVM对国产化处理器体系结构的支持,本质上是要实现国产化处理器上的JVM执行引擎。那么,JVM的执行引擎在代码层面又该如何落地实现呢?
这页PPT的左边部分展示了HotSpot虚拟机源代码组织结构。按与底层硬件和操作系统的相关性,HotSpot源代码分为cpu(处理器相关)、os(操作系统相关)、os_cpu(处理器和操作系统同时相关)和share(平台无关)四个子目录。PPT中间部分列举了各个子目录实现的主要功能,其中标黄色的部分为CPU体系结构相关部分。PPT右侧以ARM的aarch64处理器架构为例,量化分析了JVM支持一款处理器架构所需的代码量,其中CPU体系结构相关的代码量约为64000行,剩余部分的代码量约为70万行。故处理器体系结构支持所需的代码占比小于8%。体系结构相关代码主要包括汇编器、解释器和编译器后端。此外,由于Java语言原生支持多线程,故还需要处理器提供原子操作和内存屏障,以保证并发程序的正确性。下面我们将从汇编器、解释器、编译器、CPU原子操作和内存屏障这几个方面逐一展开。
汇编器是第一个需要实现的模块,因为解释器和编译器的构造均依赖于汇编器提供接口。汇编器主要对处理器硬件进行抽象和封装,向上提供编程所需的寄存器和指令。汇编器是几个模块中功能最简单的。但从工程实现上看,由于现代处理器动则支持几千条指令,故汇编器的实现任务繁重,且指令格式和编码稍有不慎很容易引入错误。因此,要求开发人员熟悉处理器指令集,并且在编码过程中务必小心谨慎。
汇编器完成后,紧接着需要实现解释器。问大家一个问题:能不能跳过解释器,直接实现HotSpot虚拟机的编译器?有人觉得解释器性能太低,想剔除解释器模块,以减少JVM对CPU架构支持的工作量。答案是否定的。HotSpot虚拟机必须依赖解释器的功能。首先,对部分特殊的Java方法(如体积超大),编译器会拒绝编译,只能由解释器解释执行。其次,HotSpot的编译器,尤其是C2编译器,大量使用基于某些假设的激进编译优化。但这些假设并不总是成立的,一旦失效,虚拟机需要由编译执行回退到解释器继续执行。最后,在某些要求快速启动和响应的场景,直接解释执行的可能会更优于先编译再执行。因此,对解释器的构建和支持是必须的。
HotSpot的解释器为基于模板的高性能解释器。所谓的“模板”,即一段用于实现Java字节码语义功能的汇编指令序列。这页PPT展示了add方法被javac编译为四条字节码,然后再被解释执行的过程。解释执行,其实就是按程序的控制流,逐一执行字节码对应模板中指令序列的过程。PPT的右边展示了整数加法iadd字节码的解释器模板。上面黄色虚线框中的机器指令用于取操作数。下面黄色虚线框中的机器指令用于跳转到下一个字节码对应的模板继续执行。中间的一条add加法指令用于实现iadd字节码的语义。解释器的模板都遵循一个固定模式,即先取操作数,然后执行,最后跳转到下一个模板继续运行。
解释器调试成功之后,就可以开始编译器的支持了。编译器支持难度最大,调试周期也最长。HotSpot中设计了C1和C2两款编译器。C1编译器编译速度快,但生成的代码质量不高,适用于要求快速启动和响应的场景,因此又被称为client版编译器。C2编译器生成的代码质量高,但编译速度慢,适用于需要长期反复执行的服务类应用,因此又被称为server版编译器。相对于C1,C2采用了更多和更激进的编译优化算法,故C2比C1更复杂。C1和C2的构造有许多相通之处,下面我们以复杂度更高的C2为例,向大家展示如何在JVM上实现一款支持新CPU架构的编译器。
这页PPT展示了C2编译器构造的原理。为了降低编译器移植难度,C2被划分为平台无关和平台相关两个部分。平台无关的代码对所有处理器架构都适用,仅平台相关部分的代码需要对处理器架构进行移植适配。进一步地,为了减少人工编写平台相关部分代码的工作量,C2借助ADL编译器来自动生成处理器体系结构相关的代码。ADL是Architecture Description Language的英文缩写,是内嵌于OpenJDK开源代码中的体系结构描述语言。ADL编译器通过解析体系结构描述文件(以*.ad为后缀的文件,例如aarch64.ad)来生成C2代码。故在新处理器架构上支持C2的大部分工作,是正确编写处理器的体系结构描述文件。体系结构描述文件主要涉及寄存器描述、操作数描述和指令集描述三大方面的内容。
这页PPT以Aarch64为例展示了寄存器描述的实例。寄存器描述通常包括通用寄存器、浮点寄存器和向量寄存器。为了兼容32位操作系统,寄存器描述时以32位长度为基本描述单元。例如,PPT上半部分的R1和R1_H联合起来表示64位的R1寄存器。PPT下半部分的V0、V_H、V_J和V_K联合起来表示128位长度的V0浮点寄存器。
这页PPT展示了操作数描述的实例。操作数描述处理器直接支持的数据种类,包括立即数操作数、寄存器操作数和存储器操作数三大类别。在每个大的类别中,又会进一步细分为字符型、整型、浮点型和指针等具体的子类型。
这页PPT展示了指令描述的实例。需要提醒大家注意的是,指令描述不光描述处理器硬件支持哪些指令,同时还会影响C2编译器的指令选择和生成,从而影响编译器性能。实际上,体系结构文件中的指令描述规定了如何用CPU的机器指令去匹配编译器的中间代码表示。PPT左侧addI_reg_reg的指令描述,会匹配编译器中间代码表示的AddI节点及其操作数src1/src2,如PPT右图所示。
寄存器、操作数和指令描述都完成后,JVM对CPU架构的支持已接近尾声了。此时,大家千万不要忘记了还有之前提到的CPU原子操作和内存屏障。如下页PPT所示,HotSpot中定义了非常清晰的原子操作和内存屏障接口,大家只需根据处理器特性逐一实现即可。原子操作大家都很熟悉,那什么是内存屏障呢?下一节我会为大家详细介绍。
处理器内存模型与JVM实现
下面跟大家一起探讨处理器内存模型对JVM设计的影响。为什么将这个话题单列出来呢?多年的实践经验告诉我们,JVM实现最考验工程师水平的就是处理器内存模型与JVM的适配。这部分工作决定了虚拟机能否在处理器上稳定运行。希望能引起大家的重视。
处理器内存模型存在强弱之分。强内存模型以X86为代表;弱内存模型以ARM和PowerPC架构为代表。那么处理器内存模型的强弱是如何定义的呢?下面这张PPT展示了内存模型强弱划分的依据:按处理器允许访存指令重排序的多少来划分。一般地,允许访存指令重排序的情形越多,处理器内存模型越弱,反之越强。访存指令分为读(Load)和写(Store)两种操作。因此,可能的重排序情形包括读读(Load/Load)、读写(Load/Store)、写读(Store/Load)和写写(Store/Store)重排序。X86架构处理器仅允许写读(Store/Load)重排序,而ARM和PowerPC对上述四种重排序均允许。故X86通常被认为是强内存模型,而ARM和PowerPC被认为是弱内存模型。
然而,我们在编程时,尤其是在并发程序设计时,可能需要禁止处理器的重排序行为。这时就需要借助处内存屏障来完成。所谓的“内存屏障”,是指处理器硬件支持的、专门用于禁止特定访存指令重排序的机器指令。如下页PPT所示,HotSpot虚拟机针对四种可能的重排序情形,提供了对应的内存屏障接口。例如,如果希望禁止X86处理器的写读重排序,只需要调用OrderAccess::storeload()这个内存屏障接口即可。除了上述四种基本的接口外,虚拟机中还定义了acquire、release和fence接口。其中,acquire可禁止读读和读写重排序,release可以禁止读写和写写重排序,fence则禁止所有重排序。
编译器在指令生成阶段需充分适配处理器的内存模型特性。下面的PPT展示的是C2编译器MemBarStoreStore中间节点,在X86架构和Aarch64架构上目标代码的生成情况。MemBarStoreStore中间节点的语义是禁止处理器的写写重排序。由于X86的内存模型不允许写写重排序,故该中间节点在X86架构上无需生成额外机器指令即可保证语义正确。而Aarch64架构处理器本身允许写写重排序,故需要额外生成一条写写的内存屏障才能正确实现该节点语义。一般地,弱内存模型架构通常需要生成更多的内存屏障。
如果JVM对处理器访存模型适配不当会发生什么呢?肯定会引起Bug。此类Bug通常具有随机性、位置发散和表象多样等特点,分析和调试难度很高。下面跟大家分享一个自己解决的OpenJDK访存模型适配不正确的Bug(JDK-8229169)。这个Bug在jdk14中首先被修复,随后也被backport到了jdk8和jdk11等LTS版本。
该Bug位于HotSpot垃圾收集框架的任务窃取(work stealing)阶段,影响除串行GC以外的所有垃圾收集器。Bug的机理是处理器在执行GenericTaskQueue::pop方法时,对_age的两次读操作(见下页PPT中黄色字体所示)被处理器乱序了。解决方法是在两个读操作之间添加读读内存屏障(PPT中绿色字体所示),以禁止处理器的读读乱序。可能有人会问:由于X86处理器不允许读读乱序,故在X86上可以不用添加这个内存屏障,为何不采用PPT右下角的修改方式呢?这个问题的正确答案是X86也需要添加OrderAccess::loadload()进行修复。这是因为虽然X86在执行时不会对读读操作重排序,但是编译器在编译这段代码时可能会发生重排。为了禁止代码在编译阶段被重排序,X86也需要这个patch。从上述分析不难看出,JVM中的OrderAccess访存屏障同时具备禁止处理器和编译器重排序的功能。这一点请大家在今后的开发过程中多多注意。