8.2 针对不同操作系统优化JVM

JVM 可以利用一些调优选项来优化操作系统内存的使用。

8.2.1 大页

一般用“页”这个术语来讨论内存分配和交换。页是操作系统管理物理内存的一个单元,还是操作系统分配内存的最小单元:要分配 1 个字节,操作系统一定会分配 1 个整页。程序中后续的内存分配都会从这个页获取,直到分配完毕,这时就会分配一个新页。

操作系统分配的页数一般要比物理内存能容纳的页数多很多,这就是存在分页机制的原因:地址空间中的页会被移入或移出交换空间(或其他存储,跟页中包含的内容有关)。这意味着,这些页和它们在计算机物理内存中所占的位置间存在某种映射。这些映射有两种不同的处理方式。所有的页映射都保存在一个全局页表中(操作系统可以扫描这个表,找到特定的映射),最常用的映射保存在 TLB(Translation Lookaside Buffers)中。TLB 保存在一个快速的缓存中,所以通过 TLB 表项访问页要比通过页表访问快得多。

机器中 TLB 表项的数目有限,TLB 会用作 LRU(Least Recently Used,最近最少使用的)缓存,因此最大化 TLB 表项的命中率就变得非常重要。因为每个表项表示一个内存页,所以增大应用所使用的页的大小一般会有所帮助。如果每个页能表示更多内存,则用更少的 TLB 表项就能涵盖整个程序的内存,这样当需要某个页时,在 TLB 中找到的可能性更大。一般而言,任何程序都是这样。具体到像 Java 应用服务器或堆为中等大小的其他 Java 程序,也是如此。

Java 支持 -XX:+UseLargePages 选项。其默认值跟具体的操作系统配置有关。在 Windows 上,必须在操作系统中启用大页。用 Windows 的术语来讲,这意味着支持各个用户锁定内存页(lock pages into memory), 这在 Windows 的服务器版本中才能实现。在 Windows 操作系统上,除非显式启用了 UseLargePages,否则默认使用常规页。

在 Linux 上,UseLargePages 标志默认不会启用,要支持大页,也要配置一下操作系统。

在 Solaris 上,不需要什么操作系统方面的配置,默认启用大页。

如果在不支持大页的系统上启用了 UseLargePages 标志,JVM 不会给出警告,它会使用常规页。如果系统支持大页,但是没有大页可用(可能因为所有的大页都被用了,也可能也为操作系统配置错误),这时 JVM 会打印警告。

1. Linux大页

在 Linux 上,大页的配置会随发行版的不同而有所不同;想要获得最准确的说明,请参考所用发行版的文档。一般而言,可以分为如下 5 个步骤。

(1) 确定内核支持哪些大页大小。大页大小与计算机的处理器和内核启动参数有关,不过最常见的是 2 MB。

  1. # grep Hugepagesize /proc/meminfo
  2. Hugepagesize: 2048 kB

(2) 计算需要多少大页。如果 JVM 会分配 4 GB 大小的堆,系统支持 2 MB 的大页,则这个堆就需要 2048 个大页。系统可以使用的大页的数目是在 Linux 内核中全局定义的,因此要对将在该系统中运行的所有 JVM(以及其他任何会使用大页的程序)重复这个过程。考虑到非堆部分也有可能会使用大页,所以应多估算 10%(这样,这个例子要使用 2200 个大页)。

(3) 将这个值写到操作系统中(以便立即生效):

  1. # echo 2200 > /proc/sys/vm/nr_hugepages

(4) 将该值保存到 /etc/sysctl.conf 中,这样系统重启后这个值也会保存下来:

  1. sys.nr_hugepages=2200

(5) 在很多 Linux 版本上,一个用户可以分配的内存页数量可能是有限的。编辑 /etc/security/limits.conf 文件,为运行 JVM 的用户(例如这个例子中的 appuser)添加 memlock 条目:

  1. appuser soft memlock 4613734400
  2. appuser hard memlock 4613734400

在修改了 limits.conf 文件之后,用户必须重新登录,这个值才会生效。重启之后,JVM 就应该能够分配必要的大页了。要验证其效果,运行如下命令:

  1. # java -Xms4G -Xmx4G -XX:+UseLargePages -version
  2. java version"1.7.0_17"
  3. Java(TM) SE Runtime Environment (build 1.7.0_17-b02)
  4. Java HotSpot(TM) 64-Bit Server VM (build 23.7-b01, mixed mode)

若这个命令成功完成,说明大页已经正确配置。如果大页内存配置不正确,则会出现如下警告:

  1. Java HotSpot(TM) 64-Bit Server VM warning:
  2. Failed to reserve shared memory (errno = 22).

2. Linux透明大页

Linux 内核从 2.6.32 版本开始支持透明大页,这种机制不再需要上述配置。不过仍然需要为 Java 开启透明大页,这可以通过修改 /sys/kernel/mm/transparent_hugepage/enabled 来实现:

  1. # cat /sys/kernel/mm/transparent_hugepage/enabled
  2. always [madvise] never
  3. # echo always > /sys/kernel/mm/transparent_hugepage/enabled
  4. # cat /sys/kernel/mm/transparent_hugepage/enabled
  5. [always] madvise never

该文件中的默认值(见第一条命令的输出)是 madvise。只有明确告诉内核会使用大页的程序才能使用大页。因为无法通过 JVM 做到这一点,所以要将默认值设置为 always(通过第二条命令)。注意,这会影响该系统上的 JVM 和其他任何程序;它们运行的时候都会使用大页。

如果启用了透明大页,就不要指定 UseLargePages 标志。如果显式地设置了该标志,JVM 会使用传统的大页;如果没有配置好传统的大页,则使用标准页。如果该标志设置为默认值,则 JVM 会使用透明大页(如果已经配置)。

3. Windows大页

只有服务器版的 Windows 才支持大页。Windows 7 上的具体操作如下,不同版本间会有一些差别。

(1) 启动 Microsoft 管理控制台(Microsoft Management Center)。点击开始菜单,在搜索框中输入 mmc

(2) 如果左侧的面板中没有出现本地计算机策略图标,则从“文件”菜单中选择“添加 / 删除管理单元”,添加“组策略对象编辑器”。如果找不到该选项,就说明当前使用的 Windows 版本不支持大页。

(3) 在左侧面板中,展开本地计算机策略→计算机配置→ Windows 配置→安全配置→本地策略,点击“用户权限分配”文件夹。

(4) 在右侧面板中,双击“锁定内存页”。

(5) 在弹出菜单中,添加用户或组。

(6) 点击确定。

(7) 退出 Microsoft 管理控制台。

(8) 重新启动。

重启之后,JVM 就应该能够分配必要的大页了。要验证其效果,运行如下命令:

  1. # java -Xms4G -Xmx4G -XX:+UseLargePages -version
  2. java version "1.7.0_17"
  3. Java(TM) SE Runtime Environment (build 1.7.0_17-b02)
  4. Java HotSpot(TM) 64-Bit Server VM (build 23.7-b01, mixed mode)

如果命令像上面这样成功完成,大页就设置正确了。如果配置不正确,会出现如下警告:

  1. Java HotSpot(TM) Server VM warning: JVM cannot use large page memory
  2. because it does not have enough privilege to lock pages in memory.

请记住,在不支持大页的 Windows 系统(比如“home”版本)上,这条命令不会打印错误:不管命令行设置的是什么,JVM 一旦发现操作系统不支持大页,就会将 UseLargePages 标志设置为 false

4. 大页的大小

在大多数 Linux 和 Windows 系统上,操作系统会使用 2 MB 大小的大页,但这个数字会随操作系统的配置而变化。

严格来讲,处理器定义了可能的页大小。大部分当前的 Intel 和 SPARC 处理器支持很多可能的页大小:4 KB、8 KB、2 MB 和 256 MB,等等。然而,实际可以分配多大的页是由操作系统决定的。在 Solaris 上,处理器所支持的各种页大小均能支持,JVM 可以自由分配任意大小的页。在 Linux 内核上(至少在本书写作时),可以在内核启动时指定使用处理器所支持哪种页大小,不过这就是应用实际可以分配的唯一的大页大小。在 Windows 上,大页固定为 2 MB(同样,本书写作时是这样)。

为支持 Solaris,Java 支持通过 -XX:LargePageSizeInBytes=N 标志来设置要分配的大页大小。该标志默认值为 0,这意味着 JVM 应该选择特定于处理器的大页大小。

这个标志在各种平台上均可设置,而且 JVM 不会明示是否使用了指定的页大小。如果在某个 Linux 系统上分配一个非常大的堆,你可能认为应该设置 -XX:LargePageSizeInBytes=256M,以便 TLB 命中率达到最佳。可以这么做,而且 JVM 不会抱怨什么,但它仍然只会分配 2 MB 大小(或者是指定内核支持的某个页大小)的页。事实上,指定根本没有任何意义的页大小都是可能的,比如 -XX:LargePageSizeInBytes=11111。因为这个大小是不可用的,JVM 会直接选择该平台的默认页大小。

因此,至少就目前而言,这个标志实际只有在 Solaris 上才有用。在 Solaris 上,为了使用更大的页,可以选择比默认值(在 SPARC 处理器上默认是 4 MB)更大的页大小。在配备了大量内存的系统上,这会增加能够容纳进 TLB 缓存的页数,提高性能。要查看 Solaris 上可用的页大小,可以使用 pagesize -a 命令。

8.2 针对不同操作系统优化JVM - 图1 快速小结

1. 使用大页通常可以明显提升应用的速度。

2. 在大多数操作系统上,必须显式开启大页支持。

8.2.2 压缩的oop

第 4 章曾提到,对于同一任务,32 位 JVM 的性能要比 64 位 JVM 的好 5%~20%。当然,这要假定应用可以容纳进 32 位的进程空间中,这限定了堆要小于 4 GB。(在实践中,这往往意味着堆要小于 3.5 GB,因为 JVM 还需要一些原生内存空间;而且在某些 Windows 版本上,限制是 3 GB。)

性能差距是 64 位的对象引用导致的。主要原因是,在堆中,32 位的对象引用占 4 字节,而 64 位的对象引用占 8 字节,是前者的 2 倍。这就致使需要更多 GC 周期,因为堆中留给其他数据的空间少了。

JVM 可以使用压缩的 oop 来弥补额外的内存消耗。“oop”代表的是“ordinary object pointer”,即普通对象指针,JVM 将其用作对象引用的句柄。在 oop 只有 32 位长时,只能引用 4 GB 内存(232),这就是为什么 32 位 JVM 有 4 GB 堆空间限制的原因。(同样的限制也适用于操作系统层面,32 位的进程的地址空间限制为 4 GB。)而在 oop 为 64 位长时, 可以引用的内存就是 TB 级了。

有一个中间方案:如果有 35 位的 oop,又会怎么样呢?这样的指针可以引用 32 GB 的内存(235),在堆中占的空间也比 64 位的引用少。问题是没有 35 位长的寄存器可以存放这样的引用。不过 JVM 可以假设引用的最后 3 位都是 0。现在,就不是所有的引用都能保存在堆中了。当应用被存入 64 位的寄存器时,JVM 可以将其左移 3 位(末尾添加 3 个 0)。而当从寄存器读出时,JVM 又可以右移 3 位,丢弃末尾的 0。

这样 JVM 就有了可以引用 32 GB 内存的指针,而且每个指针在堆中只占用 32 位。然而这也意味着,对于不能被 8 整除的地址上的任何一个对象,JVM 都无法访问,因为从压缩的 oop 得到的任何地址均以 3 个 0 结尾。第一个可能的 oop 是 0x1,移位之后是 0x8。下一个 oop 是 0x2,在移位后变成了 0x10(16)。所以对象必须位于 8 字节的边界上。

其实在 JVM 中(不管是 32 位的还是 64 位的),对象已经按 8 字节边界对齐了;对于大部分处理器,这种对齐方案都是最优的。所以使用压缩的 oop 并不会损失什么。如果 JVM 中的第一个对象保存到位置 0,占用 57 字节,那下一个对象就要保存到位置 64,浪费了 7 字节,无法再分配。这种内存取舍是值得的(而且不管是否使用压缩的 oop,都是这样),因为在 8 字节对齐的位置,对象可以更快地访问。

不过这也是为什么 JVM 没有尝试模仿 36 位引用(可以访问 64 GB 的内存)的原因。在那种情况下,对象就要在 16 字节的边界上对齐,在堆中保存压缩指针所节约的成本,就被为对齐对象而浪费的内存抵消了。

这里有两点启示。第一,对于大小在 4 GB 和 32 GB 之间的堆,应该使用压缩的 oop。压缩的 oop 可以使用 -XX:+UseCompressedOops 标志启用;在 Java 7 和更新的版本中,只要堆的最大值小于 32 GB,压缩的 oop 默认就是启用的。(在 7.2.1 节我们曾指出,默认情况下,在堆空间为 32 GB 的 64 位 JVM 上,对象引用的大小为 4 个字节,这是因为压缩 oop 默认就是启用的。)

第二,使用了 31 GB 的堆,并启用压缩 oop 的程序,通常要快于使用了 33 GB 的堆的程序。尽管后者的堆更大,但是堆中的指针要占据额外的空间,这意味着更大的堆执行 GC 的频率会更频繁,性能也更差。

因此,最好是使用小于 32 GB 的堆空间,或者使用比 32 GB 至少多若干 GB 的堆空间。如果有额外的空间来弥补非压缩引用所使用的空间,GC 周期数就会有所减少。但是增加多少内存可以改善非压缩 oop 对 GC 的影响,并没有硬性的规则。不过平均而言,对象引用会占用 20% 的堆空间,所以 38 GB 是个不错的起点。

8.2 针对不同操作系统优化JVM - 图2 快速小结

1. 压缩的 oop 会在最有用的时候默认开启。

2. 使用了压缩 oop 的 31 GB 的堆,与稍微大一些、但因为堆太大而无法使用压缩 oop 的堆相比,性能通常要好一些。