- Java性能权威指南(第2版)
- (美)斯科特·奥克斯
- 4222字
- 2025-02-28 19:08:21
1.2 平台和约定
本书讲的是Java性能,它受一些因素的影响:Java本身的版本,以及对应的硬件和软件平台。
1.2.1 Java平台
本书介绍Oracle HotSpot JVM和Java开发工具包(JDK)的性能优化,对应的版本是Java 8和Java 11。这也是我们所熟知的Java标准版(Java SE,Java Standard Edition)。Java运行时环境(Java Runtime Environment,JRE)是JDK的子集,只包含JVM,但是JDK中的一些工具对性能分析很重要,所以JDK是本书的重点。实际上,本书还会介绍从OpenJDK仓库衍生出来的其他平台,包括AdoptOpenJDK项目中的JVM。严格意义上,在生产环境中使用Oracle公司的二进制文件需要得到许可,而AdoptOpenJDK的二进制文件使用的是开源协议,无须得到许可便能使用。对本书来说,两者是一样的,我们都称之为JDK或Java平台1。
1二者的差异很小,但的确存在。比如,AdoptOpenJDK版本的Java在JDK 11中包含了新的垃圾回收器。我会在需要时指出这些差异。
这些大版本经历了许多bug修复版本。在我写这段话的时候,最新的Java 8版本是jdk8u222(222版本),Java 11版本是11.0.5。如果不能使用更新的版本,至少要使用这两个版本,这很重要,特别是在用Java 8的时候。Java 8的早期版本(大约到jdk8u60)不涉及本书讨论的很多重要的性能增强措施和特性,尤其是关于垃圾回收和G1垃圾回收器的。
之所以选择上述版本的JDK,是因为可以得到Oracle公司的长期支持(LTS)。Java社区可以自由地发展自己的支持模式,但是目前他们都遵循了Oracle公司的模式。所以,上述版本在一段时间内都可以使用,并会得到免费的维护、更新等支持:Java 8至少可以用到2023年(前期通过AdoptOpenJDK,后期通过延长的Oracle公司支持协议);Java 11至少可以用到2026年。
至于过渡版本,虽然Oracle公司和整个社区都已经不再支持了,但在讨论Java 11的时候必然会涉及Java 9和Java 10引入的特性。有时候,我提及的版本信息会不准确,我可能会说X特性和Y特性最初是由Java 11引入的,而其实它们在Java 9和Java 10中就已经使用了。Java 11是首个包含这些特性的LTS版本,而且Java 9和Java 10已经不再使用,那么某个特性最初的出现时间就不重要了。同样,在本书出版时Java 13会发布,但是本书并不会涵盖Java 12和Java 13的很多特性。你可以在生产环境中使用这些版本,但是6个月之后就需要升级到更新的版本了。(当你读到这里的时候,Java 12可能已经不被支持了,如果正在使用Java 13,那它马上也会被Java 14替代。)我们会粗略地了解这些过渡版本的一些特性,但是因为这些版本在大多数情况下不会用于生产环境,所以本书的重点还是Java 8和Java 11。
Java语言规范还有其他的实现,包括开源实现的分支。AdoptOpenJDK提供了其中一个开源实现(Eclipse OpenJ9),剩下的由其他厂商提供。虽然所有平台必须通过兼容性测试才能使用Java名称,但是本书讨论的内容不会扩展到兼容性方面,调优标志尤其如此。所有的JVM实现都有一个或多个垃圾回收器,而在每个厂商的GC实现中,调优标志都是其产品所特有的。因此,虽然本书所述概念适用于任何Java实现,但具体的调优标志和建议只适用于HotSpot JVM。
调优标志及其默认值会随着版本的变化而变化,早期版本的HotSpot JVM也是如此。本书讨论的调优标志对Java 8(特别是222版本)和Java 11(特别是11.0.5版本)有效。这些信息在之后的版本中可能会有细微的变化。请随时查阅版本说明以知晓重要的变化。
在API层面,不同的JVM更具兼容性。尽管如此,在Oracle HotSport Java平台和其他平台上,某一特定类的实现可能仍然存在微小的差异。这些类在功能上等效,但是实际的实现方式可能有所不同。幸运的是,这种情况很少见,不太可能对性能产生重大影响。
在本书的剩余内容中,Java和JVM特指Oracle HotSpot实现。严格意义上,“JVM在第一次执行时不编译代码”这种说法是错误的。有些Java实现在第一次执行时的确会编译代码,但无论是写起来还是读起来,JVM都要比Oracle HotSpot JVM简单得多。
JVM调优标志
除了少数例外,JVM接收两种标志:布尔标志和附带参数的标志。
布尔标志使用的语法是:-XX:+FlagName表示开启,-XX:-FlagName表示关闭。
附带参数的标志使用的语法是:-XX:FlagName=something,表示设置FlagName的值为something。其中,something指表示任意值的符号。例如,-XX:NewRatio=N表示NewRatio标志可以设成任意值N(N的含义将是讨论的重点)。
本书在介绍每个标志时都会讨论其默认值。默认值的设定通常基于两个因素:JVM所运行的平台和传给JVM的其他命令行参数。如有疑问,可参见3.2.1节。该节展示了当给出特定命令时,如何使用-XX:+PrintFlagsFinal标志(默认为false,即“关闭”状态)来确定特定环境中特定标志的默认值。根据运行环境自动调优标志的过程被称为自行优化(ergonomics)。
从Oracle网站和AdoptOpenJDK网站下载的JVM,被称作产品版 JVM。当从源码构建JVM时,可以构建多个其他版本:调试版、开发版等。这些版本通常有其他的附加功能。特别是开发版,它包含更多的调优标志,让开发人员可以尝试JVM各种算法最微小的操作。本书基本不涉及这些标志。
1.2.2 硬件平台
本书第1版出版时,硬件环境和今天不同。那时多核机器很受欢迎,但是32位处理器和单核CPU仍大量使用。现在使用更多的是虚拟机和软件容器。下面概述这些平台对本书主题的影响。
01. 多核硬件
如今,几乎所有的计算机都有多个核心。对于JVM(或任何其他程序)来说,这表现为多个CPU。每个核心往往都应用了超线程技术。超线程是Intel常用的术语,AMD(和其他厂商)则使用同时多线程,一些芯片制造商称之为核心内的硬件线程(hardware strands within a core)。它们指的是同一个东西,本书将这种技术称为超线程。
对于性能来说,一台机器的核心数量很重要。以一台4核机器为例:(多数情况下)每个核心都可以独立于其他核心运行,所以一台4核机器可以实现单核机器4倍的吞吐量(当然这还取决于软件情况)。
在大多数情况下,每个核心会包含两个硬件或超线程。这些线程并不是相互独立的,一个核心每次只能运行一个线程。线程有时候会暂停。例如,从主内存加载值时线程会暂停,这个过程需要几个CPU周期。单核心运行单线程时,线程暂停,这些CPU周期就浪费了。单核心运行两个线程时,核心可以切换并执行另一个线程的指令。
所以,如果4核机器开启超线程,似乎就可以同时执行8个线程的指令(尽管从技术上讲,每个CPU周期只能执行4条指令)。这对于操作系统,也就是Java和其他应用程序来说,机器可以表现得像拥有8个CPU一样。但从性能的角度来讲,这些CPU并不等价。如果运行一个CPU密集型任务,它只会使用1个核心;运行两个CPU密集型任务才会用到第2个核心;以此类推,直到第4个核心。也就是说,独立运行4个CPU密集型任务可以得到4倍的吞吐量。
如果添加第5个任务,它只能在任何其他任务暂停时运行。该任务的运行时间平均占总时间的20%到40%。额外的任务都面临这一挑战。因此,添加第5个任务只能提高约30%的性能。结论就是,使用超线程技术得到的8个CPU能使机器性能达到单核的五六倍。
你将在后文的几个章节中看到这个例子。垃圾回收很大程度上是CPU密集型任务,所以第5章介绍了超线程如何影响垃圾回收算法的并行化。第9章大致讨论了如何充分利用Java的多线程能力,你也会看到扩展超线程核心的例子。
02. 软件容器
这些年,Java部署的最大变化是,应用程序经常部署在软件容器中。这一改变当然不只局限于Java。这是行业发展的趋势,迈向云计算的步伐加快了这一趋势。
有两个容器很重要。一个是虚拟机,它在运行的硬件子集上建立了完全隔离的操作系统副本。云计算的基础是其厂商必须有包含大型机器的数据中心。这些机器可能有128个核心,不过由于成本原因,有些机器可能核心数较少。从虚拟机的角度讲,这并不重要,因为虚拟机只可以访问硬件的子集。因此,给定的虚拟机可能有2个核心(4个CPU,因为它们通常支持超线程技术)和16 GB内存。
从Java和其他应用程序的角度讲,这个虚拟机和常规的2核16 GB内存的机器的区别很小。出于本书讲解优化性能的目的,你把它们当成一样的就行。
另一个比较重要的容器是Docker。Docker容器只是操作系统中的一个进程(可能受资源限制),因此,运行于其内的Java进程不一定知道它运行于其中(即便可以检测出来)。也正是如此,它对于其他进程的CPU和内存的隔离有些不同。你会在本书中看到Java 8的早期版本(192版本之前)和后期版本(包括Java 11)在处理方式上的异同。
虚拟机超额分配
云厂商可以在物理机上对虚拟机进行超额分配。假设物理机有32个核心,云厂商通常会部署8个4核虚拟机,这样每个虚拟机预期有4个专用核心。
为了省钱,厂商也可以部署16个4核虚拟机。原理是,16个虚拟机不太可能同时处于工作状态;如果其中一半的虚拟机在工作,就有足够的物理核心满足它们;如果太多核心处于工作状态,它们就会争夺CPU周期,机器性能就会受到影响。
同样,云厂商还可以对虚拟机使用的CPU降频。这就允许虚拟机在突发情况下使用它所分配的CPU,但是不会长时间维持这个状态。这在免费或者试用的产品中经常见到,此时你对性能会有不同的预期。
这些对性能的影响很大,并且不局限于影响Java。其影响对于Java和虚拟机中运行的其他程序来说并无区别。
默认情况下,Docker容器可以自由使用机器上的所有资源,包括所有可用的CPU和内存。如果使用Docker仅仅是为了在机器上快速部署单一应用程序,并且机器只运行这一个Docker容器,那没问题。但其实经常需要在机器上运行多个Docker容器,还要限制每个容器的资源。鉴于我们的4核机器有16 GB内存,我们可能希望运行两个Docker容器,每个可获取2个核心和8 GB内存。
配置Docker来达到这个预期很简单,但在Java层面可能出现复杂的问题。这是因为大量的Java资源是根据运行JVM的机器的大小自动(或自行)分配的,包括默认的堆大小和垃圾回收器使用的线程数量。关于垃圾回收器的详细内容会在第5章讲到,一些线程池的设置会在第9章讲到。
如果你使用的是最新版本的Java 8(192版本或更新的)或者Java 11,那么JVM会像你希望的那样处理上述问题:如果限制Docker容器只能使用2个核心,本来基于机器CPU数量设置的值,就会根据Docker容器的限制2进行调整。同样,堆和其他默认基于机器内存数量的设置,也会随着Docker容器的限制而调整。
在Java 8的早期版本中,JVM无法得知任何容器的限制:当检测机器剩余内存以确定默认的堆大小时,它会检测机器的所有内存,但我们更希望看到的是允许Docker容器使用的内存。同样,检查有多少CPU可以用来优化垃圾回收器时,它会检查机器上所有的CPU,而不是只检查分配给Docker容器的CPU。结果是,JVM运行状态不理想:启用的线程过多,设置的堆过大。线程过多会导致性能下降,但真正的问题出在内存上:堆的最大大小比分配给Docker容器的内存还大,当堆增大到这个大小时,Docker容器(以及JVM)会停止运行。
在早期的Java 8版本里,可以手动设置合理的内存和CPU数量。在进行这些优化时,我会指出这种情况下需要怎么调整,但最好还是升级到Java 8的后期版本或者Java 11。
Docker容器给Java提出了另一个难题:Java有丰富的性能问题诊断工具集,而Docker容器里没有。我们将在第3章进一步讨论这个问题。
2可以在Docker中以小数形式限制CPU数量,Java会对小数向上取整。