Java性能调优12——原生内存最佳实践

本系列均取自《Java性能权威指南》这里只是一个个人备忘笔录

在 Java 应用中,堆消耗的内存是最多的。但是除堆之外,JVM 还会分配并使用大量的原生内存。上一章从编程的角度探讨了高效管理堆的不同方式,不过,堆的配置以及堆如何与操作系统的原生内存交互,是影响应用程序整体性能的另一个重要因素。

本章将从几个方面探讨原生内存(或者说操作系统内存)。我们将从 JVM 整体内存使用情况入手,目的在于理解如何监控内存的使用情况,以解决性能问题。之后将讨论为达到最理想的内存使用状况而采用的调优 JVM 和操作系统的不同方式。

内存占用

在 JVM 使用的内存中,通常堆消耗的部分最多,但是 JVM 也会为内部操作分配一些内存。这类非堆内存就是原生内存。应用中也可以分配原生内存(通过 JNI 调用 malloc() 和类似方法,或者是使用 New I/O,即 NIO 时)。JVM 使用的原生内存和堆内存的总量,就是一个应用总的内存占用(Footprint)。

从操作系统的视角看,总的内存占用是性能的关键。如果没有足够的物理内存来容纳应用总的内存占用,性能可能就要出问题了。这里有个词很关键:“可能”。部分原生内存只是在启动时使用一下(比如与加载 classpath 下的 JAR 文件有关的内存),如果这类内存被交换出去,我们未必会注意到。有时候,一个 Java 进程使用的原生内存会与系统中的其他 Java 进程共享,还有更少一部分内存会与系统中的其他类型的进程共享。不过多数情况下,为优化性能,我们希望确保所有 Java 进程总的内存占用不超过机器的物理内存(加之可能还要给其他应用留些内存)。

测量内存占用

要测量一个进程总的内存占用,需要根据所用的操作系统选择特定的工具。在基于 Unix 的系统中,像 top 和 ps 这样的程序可以给出基本数据;在 Windows 中,可以使用 perfmon 或 VMMap。不管使用何种工具,何种平台,都需要看看进程实际分配的内存(这与保留的内存完全不同)。

之所以存在已分配内存和保留内存之分,是由 JVM(及所有程序)管理内存的方式导致的。考虑一个使用参数 -Xms512m -Xmx2048m 指定的堆,它一开始会使用 512 MB 的内存,之后会根据需要重新调整大小,以满足应用程序的 GC 目标。

这个概念就是提交内存(或者说已分配内存)和保留内存(有时叫作进程的虚拟内存)的本质区别。JVM 必须告知操作系统,它的堆可能需要多达 2 GB 的内存,所以会保留这么多内存:操作系统承诺,当 JVM 因为要增加堆而尝试分配额外的内存时,这些内存是可以获取到的。

最初分配的内存仍然是只有 512 MB,而且这就是堆实际用到的全部内存。这些已经实际分配的内存,就是提交内存。提交内存的量会随堆的重新调整而波动;特别是,提交内存会随着堆的增加而相应增加。

超量保留有没有问题?

在考察性能时,只有提交的内存才有价值,绝对不会因为保留了太多内存而出现性能问题。

不过有时还是需要确保 JVM 没有保留太多内存。32 位的 JVM 尤是如此。因为 32 位应用的最大进程空间是 4 GB(或者更少,跟操作系统有关),保留过多内存可能会成为问题。如果 JVM 为堆保留了 3.5 GB 的内存,那为栈、代码缓存等部分留下的原生内存就只有 0.5 GB 了。堆是不是只提交了 1 GB 内存并不重要,因为它保留了 3.5 GB,那给其他操作留下的内存就限制为 0.5 GB 了。

64 位的 JVM 没有进程空间大小的这种限制,但是又受限于机器的虚拟内存总量。比如说有一台小型服务器,物理内存有 4 GB,虚拟内存有 10 GB,我们启动一个堆大小为 6 GB 的 JVM。它会保留 6 GB 的虚拟内存(外加一些非堆内存部分)。不管这个堆实际增长到多大(提交多少内存),这台机器上的第二个 JVM 保留的内存都要小于 4 GB。

凡事都有两面,给 JVM 的内部结构多分配些空间,让 JVM 优化其使用,这样比较方便,但未必总是可行。

这种差异几乎存在于 JVM 分配的所有重要内存区域中。随着越来越多的代码被编译,代码缓存会从初始值向最大值增长。单独分配的持久代或元空间也会从初始大小(提交内存)向最大大小(保留内存)增长。

线程栈是个例外。JVM 每次创建线程时,操作系统会分配一些原生内存来保存线程栈,向进程提交更多内存(至少要等到线程退出)。线程栈是在创建时全部分配的。

在 Unix 系统中,一个应用实际的内存占用,可以用各种操作系统工具所报告的进程驻留集大小(Resident Set size,RSS)来估算。在评估一个进程使用的提交内存量时,这个值不失为一个好的衡量依据,不过它有两个不够精确的地方。其一,在 JVM 和其他进程之间,有些在操作系统层面共享的页面(共享库的 text 部分),会被计算在每个进程的 RSS 中。其二,随时可能会出现这样的情况,即一个进程的提交内存多于实际调入的页面。即便如此,跟踪一个进程的 RSS 仍是监控整体内存使用情况的不错的第一步。在较新的 Linux 内核中,PSS 是对 RSS 的改进,去掉了和其他程序共享的数据。

在 Windows 系统中,与 Unix 中的 RSS 等同的概念叫作应用的“工作集”(working set),这个信息是任务管理器提供的。

内存占用最小化

为将 JVM 的内存占用最小化,应该限制以下几个部分的内存使用量。

1. 堆
堆是最大的一块内存,尽管有些出人意料,它可能只占总内存占用的 50% 到 60%。可以将堆的最大值设置为一个较小的值(或者设置 GC 调优参数,比如控制堆不会被完全占满),以此限制程序的内存占用。

2. 线程栈
线程栈非常大,特别是对 64 位 JVM 而言。下一章会探讨限制线程栈消耗的内存量的不同方式。

3. 代码缓存
代码缓存使用原生内存来保存编译后的代码。前面已经讲过,代码缓存也可以调优(不过,如果因为空间的限制而导致所有代码无法编译,对性能也会有不利影响)。

4. 直接字节缓冲区
下面详讲 👇

原生NIO缓冲区

开发者可以通过 JNI 调用来分配原生内存,但是如果 NIO 字节缓冲区是通过 allocateDirect() 方法创建的,则也会分配原生内存。从性能的角度看,原生字节缓冲区非常重要,因为它们支持原生代码和 Java 代码在不复制的情况下共享数据。最常见的例子是用于文件系统和套接字(socket)操作的缓冲区。把数据写入一个原生 NIO 缓冲区,然后再发送给通道(channel,比如文件或套接字),不需要在 JVM 和用于传输数据的 C 库之间复制数据。如果使用的是堆字节缓冲区,JVM 则必须复制该缓冲区的内容。

调用 allocateDirect() 方法非常昂贵,所以应该尽可能重用直接字节缓冲区。理想的情况是,线程是独立的,而且每个线程持有一个直接字节缓冲区作为线程局部变量。如果有很多线程需要大小不同的缓冲区,有时可能会消耗过多原生内存,因为每个线程的缓冲区最终可能会达到最大值。对于这种情况,或者应用的设计不适合使用线程局部缓冲区时,直接字节缓冲区的对象池可能更有用。

字节缓冲区也可以切割管理。应用可以分配一个非常大的直接字节缓冲区,然后每个请求使用 ByteBuffer 类的 slice() 方法从中分配一部分。如果不能保证每次分配相同的大小,这种方案就很难处理:就像在分配和释放不同大小的对象时堆会呈现出碎片化一样,最初分配的这个字节缓冲区也会变得碎片化。然而与堆不同的是,字节缓冲区的不同片段是无法压缩的,所以只有当所有片段大小都相同时,这种解决方案才好用。

从调优的角度看,有一点需要知道,即不管使用上述哪种编程模型,应用可以分配的直接字节缓冲区的量都可以通过 JVM 加以限制。直接字节缓冲区所分配的内存总量,可以通过设置 -XX:MaxDirectMemorySize=N 标志来指定。从 Java 7 开始,这个标志的默认值为 0,这意味着没有限制(当然还是要受制于地址空间大小,以及操作系统对进程的各种限制)。可以使用这个标志来限制应用中直接字节缓冲区的使用(还可以利用它实现与 Java 以前版本的兼容,早期版本中,这个限制是 64 MB)。

原生NIO缓冲区小结

  1. JVM 总的内存占用对性能影响很大,特别是当机器上的物理内存有限时。在做性能测试时,内存占用通常应该是要监控的一个方面。

  2. 从调优角度看,要控制 JVM 的内存占用,可以限制用于直接字节缓冲区、线程栈和代码缓存的原生内存(以及堆)的使用量。

原生内存跟踪

从 Java 8 开始,借助 -XX:NativeMemoryTracking=off|summary|detail 这个选项,JVM 支持我们一窥它是如何分配原生内存的。原生内存跟踪(Native Memory Tracking,NMT)默认是关闭的(off 模式)。如果开启了概要模式(summary)或详情模式(detail),可以随时通过 jcmd 命令获得原生内存的信息:

% jcmd process_id VM.native_memory summary

如果 JVM 是使用 -XX:+PrintNMTStatistics 参数(默认为 false)启动的,它会在程序退出时打印原生内存分配信息。如下是一个初始堆大小为 512 MB,最大为 4 GB 的 JVM 的概要输出:

Native Memory Tracking:

 Total:  reserved=4787210KB,  committed=1857677KB

尽管 JVM 保留了总计 4.7 GB 的内存,但使用量远小于这个值——只有 1.8 GB。这非常典型(之所以没有特别注意 OS 工具中显示的进程虚拟大小,原因之一是它反映的只是保留内存)。

内存使用情况可以分解成如下几个部分:

Java Heap (reserved=4296704KB, committed=1470428KB)
          (mmap: reserved=4296704KB, committed=1470428KB)

不出所料,堆本身是 4 GB 保留内存中最大的一部分。但是堆的动态大小意味着它仅增长到了 1.4 GB。

Class (reserved=65817KB, committed=60065KB)
      (classes #19378)
      (malloc=6425KB, #14245)
      (mmap: reserved=59392KB, committed=53640KB)

这是用于保存类的元数据的原生内存。再次提醒,与实际用于保存程序中的 19 378 个类而占用的内存相比,JVM 保留的内存要更多。

Thread (reserved=84455KB, committed=84455KB)
       (thread #77)
       (stack: reserved=79156KB, committed=79156KB)
       (malloc=243KB, #314)
       (arena=5056KB, #154)

77 个线程栈,每个分配了大约 1 MB 的空间。

Code (reserved=102581KB, committed=15221KB)
      (malloc=2741KB, #4520)
      (mmap: reserved=99840KB, committed=12480KB)

这是 JIT 的代码缓存:19 378 个类并不是非常多,所以提交的代码缓存只是很小的一部分。

GC (reserved=183268KB, committed=173156KB)
   (malloc=5768KB, #110)
   (mmap: reserved=177500KB, committed=167388KB)

这是 GC 算法的处理所使用的一些堆外空间。

Compiler (reserved=162KB, committed=162KB)
         (malloc=63KB, #229)
         (arena=99KB, #3)

类似地,这个区域是供编译器自身操作使用的,这与生成的代码放在代码缓存中是不同的。

Symbol (reserved=12093KB, committed=12093KB)
       (malloc=10039KB, #110773)
       (arena=2054KB, #1)

保留字符串(Interned String)的引用与符号表引用放在这里。

Memory Tracking (reserved=22466KB, committed=22466KB)
                (malloc=22466KB, #1872)

NMT 本身的操作也需要一些空间。

详细的内存跟踪信息

如 果 JVM 是用 -XX:NativeMemoryTracking=detail 启动的,jcmd(最后加上一个 detail 参数)就会提供原生内存分配相关的非常详细的信息。其中会包括整个内存空间的一个映射,包括像这样的一些行:

0x00000006c0000000 - 0x00000007c0000000] reserved 4194304KB for Java Heap
        from [ReservedSpace::initialize(unsigned long, unsigned long,
                            bool, char*, unsigned long, bool)+0xc2]
    [0x00000006c0000000 - 0x00000006fb100000] committed 967680KB
        from [PSVirtualSpace::expand_by(unsigned long)+0x53]
    [0x000000076ab00000 - 0x00000007c0000000] committed 1397760KB
        from [PSVirtualSpace::expand_by(unsigned long)+0x53]

4 GB 的堆空间是在 initialize() 函数中保留的,两次分配实际是在 expand_by() 函数中进行的。

对于整个进程空间而言,这类信息是重复的。对于 JVM 工程师,它能提供很有意义的线索,但是对于我们这类开发人员,概要信息就够用了。

NMT 提供了两类关键信息:

1. 总提交大小

进程的总提交大小,是该进程将要消耗的实际物理内存量。这个值和应用的 RSS(或工作集)很接近,但是操作系统提供的那些测量值存在一个问题,即有些内存虽然已经提交,但是其页面被置换出去了(paged out),RSS 是不会将其计算在内的。实际上,如果进程的 RSS 小于已提交内存,就通常表明操作系统很难将 JVM 的所有信息都放到物理内存中。

2. 每部分的提交大小

  当需要调优堆、代码缓存或元空间等不同部分的最大值时,了解此类内存在 JVM 中实际使用了多少非常有用。超量分配通常只会影响内存的保留,不过有些情况下,保留内存也很重要,而 NMT 可以帮助我们跟踪那些最大值可以再缩减的情况。

NMT跟踪

NMT 也支持跟踪内存分配随时间的变化情况。如果 JVM 在启动时启用了 NMT,可以使用如下命令确定内存的基线使用情况:

% jcmd process_id VM.native_memory baseline

这样,JVM 就会把当时的内存分配情况标记下来,作为基线。利用如下命令,可以比较 JVM 当前的内存分配情况与基线的差别:

% jcmd process_id VM.native_memory summary.diff
Native Memory Tracking:

Total:  reserved=5896078KB  -3655KB, committed=2358357KB -448047KB

-             Java Heap (reserved=4194304KB, committed=1920512KB -444927KB)
                        (mmap: reserved=4194304KB, committed=1920512KB -444927KB)
 ....

在这个例子中,JVM 保留了 5.8 GB 的内存,正在使用的是 2.3 GB。与基线相比,提交的内存减少了 448 MB。类似地,可以看出提交的堆内存减少了 444 MB(可以观察其余的输出内容,来确定另外 4 MB 内存是哪部分区域减少的)。

在检查 JVM 的内存占用随时间的变化情况时,这一技术非常有用。

原生内存跟踪小结

  1. 在 Java 8 中,原生内存跟踪(NMT)提供了 JVM 所使用的原生内存的详细信息。从操作系统的角度看,其中包含 JVM 堆(对 OS 而言,堆也是原生内存的一部分)。

  2. 对大多数分析而言,NMT 的概要模式足够了。它支持我们确定 JVM 提交了多少内存(以及这些内存用于干什么了)。

针对不同操作系统优化JVM

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

大页

一般用“页”这个术语来讨论内存分配和交换。页是操作系统管理物理内存的一个单元,还是操作系统分配内存的最小单元:要分配 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。

# grep Hugepagesize /proc/meminfo
Hugepagesize:       2048 kB

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

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

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

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

sys.nr_hugepages=2200

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

appuser soft    memlock    4613734400
appuser hard    memlock    4613734400

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

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

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

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

2.Linux透明大页

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

# cat /sys/kernel/mm/transparent_hugepage/enabled
always [madvise] never
# echo always > /sys/kernel/mm/transparent_hugepage/enabled
# cat /sys/kernel/mm/transparent_hugepage/enabled
[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 就应该能够分配必要的大页了。要验证其效果,运行如下命令:

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

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

Java HotSpot(TM) Server VM warning: JVM cannot use large page memory
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 命令。

大页小结

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

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

压缩的oop

前面章节曾提到,对于同一任务,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 默认就是启用的。(默认情况下,在堆空间为 32 GB 的 64 位 JVM 上,对象引用的大小为 4 个字节,这是因为压缩 oop 默认就是启用的。)

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

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

压缩oop小结

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

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

原生内存小结

尽管 Java 的堆是最受关注的内存区域,但整个 JVM 的内存占用对性能至关重要,特别是与操作系统相关的部分。可以利用本章所探讨的工具跟踪 JVM 内存占用随时间的变化情况(关键在于关注 JVM 的提交内存,而非保留内存)。

也可以通过调整 JVM 使用操作系统内存(特别是大页)的方式来改进性能。对于长期运行的,特别是堆特别大的 JVM 而言,使用大页几乎总是有好处的。

更新时间:2020-03-14 12:16:23

本文由 寻非 创作,如果您觉得本文不错,请随意赞赏
采用 知识共享署名4.0 国际许可协议进行许可
本站文章除注明转载/出处外,均为本站原创或翻译,转载前请务必署名
原文链接:https://www.zhouning.group/archives/java性能调优12原生内存最佳实践
最后更新:2020-03-14 12:16:23

评论

Your browser is out of date!

Update your browser to view this website correctly. Update my browser now

×