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 的内存占用,可以限制用于直接字节缓冲区、线程栈和代码缓存的原生内存(以及堆)的