8.1 内存占用
在 JVM 使用的内存中,通常堆消耗的部分最多,但是 JVM 也会为内部操作分配一些内存。这类非堆内存就是原生内存。应用中也可以分配原生内存(通过 JNI 调用 malloc() 和类似方法,或者是使用 New I/O,即 NIO 时)。JVM 使用的原生内存和堆内存的总量,就是一个应用总的内存占用(Footprint)。
从操作系统的视角看,总的内存占用是性能的关键。如果没有足够的物理内存来容纳应用总的内存占用,性能可能就要出问题了。这里有个词很关键:“可能”。部分原生内存只是在启动时使用一下(比如与加载 classpath 下的 JAR 文件有关的内存),如果这类内存被交换出去,我们未必会注意到。有时候,一个 Java 进程使用的原生内存会与系统中的其他 Java 进程共享,还有更少一部分内存会与系统中的其他类型的进程共享。不过多数情况下,为优化性能,我们希望确保所有 Java 进程总的内存占用不超过机器的物理内存(加之可能还要给其他应用留些内存)。
8.1.1 测量内存占用
要测量一个进程总的内存占用,需要根据所用的操作系统选择特定的工具。在基于 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),这个信息是任务管理器提供的。
8.1.2 内存占用最小化
为将 JVM 的内存占用最小化,应该限制以下几个部分的内存使用量。
堆
堆是最大的一块内存,尽管有些出人意料,它可能只占总内存占用的 50% 到 60%。可以将堆的最大值设置为一个较小的值(或者设置 GC 调优参数,比如控制堆不会被完全占满),以此限制程序的内存占用。
线程栈
线程栈非常大,特别是对 64 位 JVM 而言。第 9 章会探讨限制线程栈消耗的内存量的不同方式。
代码缓存
代码缓存使用原生内存来保存编译后的代码。第 4 章讨论过,代码缓存也可以调优(不过,如果因为空间的限制而导致所有代码无法编译,对性能也会有不利影响)。
直接字节缓冲区
将在 8.1.3 节讨论。
8.1.3 原生NIO缓冲区
开发者可以通过 JNI 调用来分配原生内存,但是如果 NIO 字节缓冲区是通过 allocateDirect() 方法创建的,则也会分配原生内存。从性能的角度看,原生字节缓冲区非常重要,因为它们支持原生代码和 Java 代码在不复制的情况下共享数据。最常见的例子是用于文件系统和套接字(socket)操作的缓冲区。把数据写入一个原生 NIO 缓冲区,然后再发送给通道(channel,比如文件或套接字),不需要在 JVM 和用于传输数据的 C 库之间复制数据。如果使用的是堆字节缓冲区,JVM 则必须复制该缓冲区的内容。
调用 allocateDirect() 方法非常昂贵,所以应该尽可能重用直接字节缓冲区。理想的情况是,线程是独立的,而且每个线程持有一个直接字节缓冲区作为线程局部变量。如果有很多线程需要大小不同的缓冲区,有时可能会消耗过多原生内存,因为每个线程的缓冲区最终可能会达到最大值。对于这种情况,或者应用的设计不适合使用线程局部缓冲区时,直接字节缓冲区的对象池可能更有用。
字节缓冲区也可以切割管理。应用可以分配一个非常大的直接字节缓冲区,然后每个请求使用 ByteBuffer 类的 slice() 方法从中分配一部分。如果不能保证每次分配相同的大小,这种方案就很难处理:就像在分配和释放不同大小的对象时堆会呈现出碎片化一样,最初分配的这个字节缓冲区也会变得碎片化。然而与堆不同的是,字节缓冲区的不同片段是无法压缩的,所以只有当所有片段大小都相同时,这种解决方案才好用。
从调优的角度看,有一点需要知道,即不管使用上述哪种编程模型,应用可以分配的直接字节缓冲区的量都可以通过 JVM 加以限制。直接字节缓冲区所分配的内存总量,可以通过设置 -XX:MaxDirectMemorySize=N 标志来指定。从 Java 7 开始,这个标志的默认值为 0,这意味着没有限制(当然还是要受制于地址空间大小,以及操作系统对进程的各种限制)。可以使用这个标志来限制应用中直接字节缓冲区的使用(还可以利用它实现与 Java 以前版本的兼容,早期版本中,这个限制是 64 MB)。
快速小结
1. JVM 总的内存占用对性能影响很大,特别是当机器上的物理内存有限时。在做性能测试时,内存占用通常应该是要监控的一个方面。
2. 从调优角度看,要控制 JVM 的内存占用,可以限制用于直接字节缓冲区、线程栈和代码缓存的原生内存(以及堆)的使用量。
8.1.4 原生内存跟踪
从 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 Heapfrom [ReservedSpace::initialize(unsigned long, unsigned long,bool, char*, unsigned long, bool)+0xc2][0x00000006c0000000 - 0x00000006fb100000] committed 967680KBfrom [PSVirtualSpace::expand_by(unsigned long)+0x53][0x000000076ab00000 - 0x00000007c0000000] committed 1397760KBfrom [PSVirtualSpace::expand_by(unsigned long)+0x53]4 GB 的堆空间是在
initialize()函数中保留的,两次分配实际是在expand_by()函数中进行的。对于整个进程空间而言,这类信息是重复的。对于 JVM 工程师,它能提供很有意义的线索,但是对于我们这类开发人员,概要信息就够用了。
NMT 提供了两类关键信息:
总提交大小
进程的总提交大小,是该进程将要消耗的实际物理内存量。这个值和应用的 RSS(或工作集)很接近,但是操作系统提供的那些测量值存在一个问题,即有些内存虽然已经提交,但是其页面被置换出去了(paged out),RSS 是不会将其计算在内的。实际上,如果进程的 RSS 小于已提交内存,就通常表明操作系统很难将 JVM 的所有信息都放到物理内存中。
每部分的提交大小
当需要调优堆、代码缓存或元空间等不同部分的最大值时,了解此类内存在 JVM 中实际使用了多少非常有用。超量分配通常只会影响内存的保留,不过有些情况下,保留内存也很重要,而 NMT 可以帮助我们跟踪那些最大值可以再缩减的情况。
NMT跟踪
NMT 也支持跟踪内存分配随时间的变化情况。如果 JVM 在启动时启用了 NMT,可以使用如下命令确定内存的基线使用情况:
% jcmd process_id VM.native_memory baseline
这样,JVM 就会把当时的内存分配情况标记下来,作为基线。利用如下命令,可以比较 JVM 当前的内存分配情况与基线的差别:
% jcmd process_id VM.native_memory summary.diffNative 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 提交了多少内存(以及这些内存用于干什么了)。
快速小结