Java™ 堆耗尽并不是造成 java.lang.OutOfMemoryError 的惟一原因。如果本机内存 耗尽,则会发生普通调试技巧无法解决的 OutOfMemoryError。本文将讨论本机内存的概念,Java 运行时如何使用它,它被耗尽时会出现什么情况,以及如何在 AIX® 上调试本机 OutOfMemoryError 。针对 Linux® 和 Windows® 系统的相同主题将在 另一篇同类文章 中介绍。
Java 堆(每个 Java 对象在其中分配)是您在编写 Java 应用程序时使用最频繁的内存区域。JVM 设计用于将我们与主机的特性隔离,所以将内存当作堆来考虑再正常不过了。您一定遇到过 Java 堆 OutOfMemoryError,它可能是由于对象泄漏造成的,也可能是因为堆的大小不足以存储所有数据,您也可能了解这些场景的一些调试技巧。但是随着您的 Java 应用程序处理越来越多的数据和越来越多的并发负载,您可能就会遇到无法使用常规技巧进行修复的 OutOfMemoryError。在一些场景中,即使 java 堆未满,也会抛出错误。当这类场景发生时,您需要理解 Java 运行时环境(Java Runtime Environment,JRE)内部到底发生了什么。
Java 应用程序在 Java 运行时的虚拟化环境中运行,但是运行时本身是使用 C 之类的语言编写的本机程序,它也会耗用本机资源,包括本机内存。本机内存是可用于运行时进程的内存,它与 Java 应用程序使用的 java 堆内存不同。每种虚拟化��源(包括 Java 堆和 Java 线程)都必须存储在本机内存中,虚拟机在运行时使用的数据也是如此。这意味着主机的硬件和操作系统施加在本机内存上的限制会影响到 Java 应用程序的性能。
本系列文章共分两篇,讨论不同平台上的相应话题。本文是其中一篇。在这两篇文章中,您将了解什么是本机内存,Java 运行时如何使用它,本机内存耗尽之后会发生什么情况,以及如何调试本机 OutOfMemoryError。本文将讨论 AIX 并专注于 IBM® Developer Kit for Java。另一篇 类似的文章 讨论 Windows 和 Linux 上的这一主题,并且不会介绍任何特定的 Java 运行时。
本机内存简介
我将首先解释一下操作系统和底层硬件给本机内存带来的限制。如果您熟悉使用 C 等语言管理动态内存,那么您可以直接跳到 下一节。
硬件限制
本机进程遇到的许多限制都是由硬件造成的,而与操作系统没有关系。每台计算机都有一个处理器和一些随机存取存储器(RAM),后者也称为物理内存。处理器将数据流解释为要执行的指令,它拥有一个或多个处理单元,用于执行整数和浮点运算以及更高级的计算。处理器具有许多寄存器 —— 常快速的内存元素,用作被执行的计算的工作存储,寄存器大小决定了一次计算可使用的最大数值。
处理器通过内存总线连接到物理内存。物理地址(处理器用于索引物理 RAM 的地址)的大小限制了可以寻址的内存。例如,一个 16 位物理地址可以寻址 0x0000 到 0xFFFF 的内存地址,这个地址范围包括 2^16 = 65536 个惟一的内存位置。如果每个地址引用一个存储字节,那么一个 16 位物理地址将允许处理器寻址 64KB 内存。
处理器被描述为特定数量的数据位。这通常指的是寄存器大小,但是也存在例外,比如 32 位 390 指的是物理地址大小。对于桌面和服务器平台,这个数字为 31、32 或 64;对于嵌入式设备和微处理器,这个数字可能小至 4。物理地址大小可以与寄存器带宽一样大,也可以比它大或小。如果在适当的操作系统上运行,大部分 64 位处理器可以运行 32 位程序。
表 1 列出了一些流行的架构以及它们的寄存器和物理地址大小:
表 1. 一些流行处理器架构的寄存器和物理地址大小
架构
寄存器带宽(位)
物理地址大小(位)
(现代)Intel® x86
32
32
36,具有物理地址扩展(Pentium Pro 和更高型号)
x86 64
64
目前为 48 位(以后将会增大)
PPC64
64
在 POWER 5 上为 50 位
390 31 位
32
31
390 64 位
64
64
操作系统和虚拟内存
如果您编写无需操作系统,直接在处理器上运行的应用程序,您可以使用处理器可以寻址的所有内存(假设连接到了足够的物理 RAM)。但是要使用多任务和硬件抽象等特性,几乎所有人都会使用某种类型的操作系统来运行他们的程序。
在 Aix 等多任务操作系统中,有多个程序在使用系统资源。需要为每个程序分配物理内存区域来在其中运行。可以设计这样一个操作系统:每个程序直接使用物理内存,并且可以可靠地仅使用分配给它的内存。一些嵌入式操作系统以这种方式工作,但是这在包含多个未经过集中测试的应用程序的环境中是不切实际的,因为任何程序都可能破坏其他程序或者操作系统本身的内存。
虚拟内存允许多个进程共享物理内存,而且不会破坏彼此的数据。在具有虚拟内存的操作系统(比如 Windows、Linux 和许多其他操作系统)中,每个程序都拥有自己的虚拟地址空间 —— 一个逻辑地址区域,其大小由该系统上的地址大小规定(所以,桌面和服务器平台的虚拟地址空间为 31、32 或 64 位)。进程的虚拟地址空间中的区域可被映射到物理内存、文件或任何其他可寻址存储。操作系统可以将物理内存中的数据移动到未使用的交��区,以便于最充分地利用物理内存。当程序尝试使用虚拟地址访问内存时,操作系统结合片上硬件将该虚拟地址映射到物理位置。该位置可以是物理 RAM、文件或交换区。如果一个内存区域被移动到交换空间,那么它将在被使用之前加载回物理内存中。图 1 展示了虚拟内存如何将进程地址空间区域映射到共享资源:
图 1. 虚拟内存将进程地址空间映射到物理资源
本机程序的每个实例都作为进程运行。在 AIX 上,进程是关于 OS 控制资源(比如文件和套接字信息)、虚拟地址空间以及至少一个执行线程的一系列信息。
虽然 32 位地址可以引用 4GB 数据,但程序不能独自使用整个 4GB 地址空间。与其他操作系统一样(比如 Windows 和 Linux),地址空间分为多个部分,程序只能使用其中的一些部分;其余部分供操作系统使用。与 Windows 和 Linux 相比,AIX 内存模型更加复杂并且可以更加精��地进行优化。
AIX 32 位内存模型被分成 16 个 256MB 分段进行管理。图 2 显示了默认 32 位 AIX 内存模型的布局:
图 2. 默认 AIX 内存模型
不同分段的作用如下:
· 分段 0:AIX 内核数据(用户程序不能直接访问)
· 分段 1:应用程序文本(可执行代码)
· 分段 2:线程栈和本机堆(通过 malloc/free 控制的区域)
· 分段 3-C 和 E:内存映射区域(包括文件)和共享内存
· 分段 D:共享库文本(可执行代码)
· 分段 F:共享库数据
用户程序只能直接控制 16 个分段中的 12 个 — 即 4GB 中的 3GB。最大的限制是,本机堆和所有线程栈都保存在分段 2 中。为了适应对数据需求较高的程序,AIX 提供了一个大内存模型。
大内存模型允许程序员或用户附加一些共享/映射分段作为本机堆使用,通过在构建可执行程序时提供一个链接器选项或者在程序启动之前设置 LDR_CNTRL 环境变量。要在运行时支持大内存模型,需要设置 LDR_CNTRL=MAXDATA=0xN0000000。其中, N位于 1 和 8 之间。超过此范围的任何值都会造成操作系统使用默认内存模型。在大内存模型中,本机堆从分段 3 开始;分段 2 仅用于原始(初始)线程栈。
当您使用大内存模型时,分段分配是静态的;也就是说,如果你请求 4 个数据分段(1GB 本机堆),但是仅分配 1 个本机堆分段(256MB),则其他 3 个数据分段将不能用于内存映射。
如果您希望本机堆大于 2GB,并且运行的是 AIX 5.1 或更高版本,那么您可以使用 AIX 超大内存模型。与大内存模型类似,可以通过一个链接器选项或在运行时使用 LDR_CNTRL 环境变量来为编译时的可执行程序启用超大内存模型。要在运行时启用超大内存模型,需要设置 LDR_CNTRL=MAXDATA=0xN0000000@DSA。其中, N位于 0 和 D 之间(如果您使用 AIX 5.2 或更高版本),或位于 1 和 A 之间(如果您使用 AIX 5.1)。 N值指定可用于本机堆的分段数量,但与大内存模型不同,这些分段可以在必要时用于映射。
通常,IBM Java 运行时使用超大内存模型,除非它被 LDR_CNTRL 环境变量覆盖。
将 N设置为 1 和 A 之间,这会使用 3 和 C 之间的分段作为本机存储。在 AIX 5.2 中,将 N设置为 B 或更多会更改内存布局 — 它不再使用 D 和 F 作为共享库,并且允许它们用于本机存储或映射。将 N设置为 D 可分配最多 13 个分段(3.25GB)的堆。将 N设置为 0 可允许分段 3 到 F 用于映射 — 本机堆保存在分段 2 中。图 3 展示了不同 AIX 内存模型所使用的不同地址空间布局:
图 3. 各种 MAXDATA 值的 AIX 内在模型
本机内存泄漏或本机内存过度使用会造成各种问题,这取决于您是耗尽了地址空间还是用完了物理内存。耗尽地址空间通常只发生在 32 位进程中 — 因为可以轻松地分配最大 4GB 地址空间。64 位进程的用户空间可以达到上千 GB,并且难以用完。如果您确实耗尽了 Java 进程的地址空间,则 Java 运行时会开始出现一些奇怪的症状,本文将在稍后讨���这些情况。在进程地址空间大于物理内存的系统中,内存泄漏或本机内存过度使用会迫使操作系统提供一些虚拟地址空间。访问操作系统提供的内存地址要比读取(物理内存中的)常驻地址慢很多,因为必须硬盘驱动器加载它。
如果您同时尝试使用过多 RAM 虚拟内存,造成数据无法存储在物理内存中,则系统挂起(thrash)— 也就是花费大多数时间在交换空间与内存之间来回复制数据。出现这种情况时,计算机和各应用程序的性能将变得很差,用户会立即觉察到出现了问题。当 JVM 的 Java 堆被换出时,垃圾收集器的性能将变得极差,甚至会造成应用程序挂起。如果多个 Java 运行时在一台机器上同时运行,则物理内存必须满足所有 Java 堆的需要。
Java 运行时如何使用本机内存
Java 运行时是一个 OS 进程,它受上一节所提到的硬件及操作系统限制。运行时环境提供由一些未知用户代码驱动的功能;这使得无法预测运行时环境在各种情况下需要哪些资源。Java 应用程序在托管 Java 环境中采取的每一个措施都有可能影响提供该环境的运行时的资源需求。本节讨论 Java 应用程序消耗本机内存的方式及原因。
Java 堆和垃圾收集
Java 堆是分配给对象的内存区。IBM Developer Kits for Java Standard Edition 拥有一个物理堆,但一些专门的 Java 运行时,比如 IBM WebSphere Real Time,则有多个堆。堆可以分为多个部分,例如 IBM gencon 策略的 nursery 和 tenured 区。大多数 Java 堆都是作为本机内存的相邻 slab 实现的。
控制堆大小的方法是在 Java 命令行中使用 -Xmx 和 -Xms 选项(mx 是堆的最大大小,ms 是初始大���)。虽然逻辑堆(活跃使用的内存区)将根据堆中对象的数量和垃圾收集(CG)所花费的时间增大或缩小,但所使用的本机内存量仍然保持不变,并且将由 -Xmx 值(最大堆大小)决定。内存管理器依赖作为相邻内存 slab 的堆,因此当堆需要扩展时无法分配更多本机内存;所有堆内存必须预先保留。
保留本机内存与分配它不同。保留本机内存时,它不受物理内存或其他存储的支持。虽然保留地址空间块不会耗尽物理资源,但它确实能防止内存用于其他目的。保留从未使用的内存造成的泄漏与已分配内存的泄漏同样严重。
AIX 上的 IBM 垃圾收集器将最大限度减少物理内存的使用,当使用的堆区域减少时,它会释放堆的备份存储。
对于大多数 Java 应用程序,Java 堆是最大的进程地址空间使用者,因此 Java 启动程序使用 Java 堆大小来确定如何配置地址空间。表 2 列出了不同堆大小范围的默认内存模型配置。您可以覆盖内存模型,方法是在启动 Java 启动程序之前设置 LDR_CNTRL 环境变量。如果您正嵌入 Java 运行时或编写自己的启动程序,则需要自己配置内存模型 — 通过指定适当的链接器标记或在启动 Java 启动程序之前设置 LDR_CNTRL。
表 2. 不同堆大小的默认 LDR_CNTRL 设置
堆范围
LDR_CNTRL 设置
最大本机堆大小
最大映射空间(不占用本机堆)
-Xmx0M to -Xmx2304M
MAXDATA=0xA0000000@DSA
2.5GB
512MB
-Xmx2304M to -Xmx3072M
MAXDATA=0xB0000000@DSA
2.75GB
512MB
> -Xmx2304M
MAXDATA=0x0@DSA
256MB
3.25GB
即时(Just-in-time,JIT)编译器
JIT 编译器在运行时将 Java 字节码编译为优化的二进制码。这将极大地改善 Java 运行时的速度,并允许 Java 应用程序的运行速度能与本机代码相提并论。
编译字节码将使用本机内存(就像静态编译器一样,比如 gcc,需要内存才能运行),但是 JIT 的输出(可执行代码)也可以存储在本机内存中。包含许多经过 JIT 编译的方法的 Java 应用程序比较小的应用程序使用更多本机内存。
类和类加载器
Java 应用程序由定义对象结构和方法逻辑的类组成。它们还使用 Java 运行时类库中的类(比如 java.lang.String),并且可以使用第三方库。这些类需要在它们的使用期间存储在内存中。
Java 5 之后的 IBM 实现为各类加载器分配本机内存 slab,用于存储类数据。Java 5 中的共享类技术将共享内存中的某个区域映射到存储只读(因此可以共享)类数据的地址空间。当多��� JVM 在同一台机器上运行时,这将减少存储类数据所需的物理内存量。共享类还可以改善 JVM 的启动时间。
共享类系统将固定大小的共享内存区域映射到地址空间。可以不完全占用共享类缓存,并且其中还可以包含当前未使用的类(由其他 JVM 载入),因此使用共享类将比未使用共享类占用更多地址空间(但物理内存较少)。需要重点注意,共享类不能防止类加载器取消加载 — 但它会造成类数据的一个子集保留在类缓存中。参见 参考资料 了解更多关于共享类的信息。
加载更多类需要使用更多本机内存。每个类加载器还有各自的本机内存开销 — 因此让许多类加载分别加载一个类会比让一个类加载器许多类使用更多本机内存。记住,不仅您的应用程序类需要占用内存;框架、应用服务器、第三方库和 Java 运行时都包含根据需要加载且占用空间的类。
Java 运行时可以卸载类以回收空间,但仅限于一些严格的条件下。不能卸载单个类;而应卸载类加载器,其对象是加载的所有类。卸载类加载器的条件仅限于:
· Java 堆未包含到表示该类加载器的 java.lang.ClassLoader 对象的引用。
· Java 堆未包含到表示该类加载器加载的类的任何 java.lang.Class 对象的引用。
· 该类加载器加载的任何类的对象在 Java 堆中都处于非活动状态(即未被引用)。
注意,Java 运行时为所有 Java 应用程序创建的 3 个类默认加载器 — bootstrap、extension 和 application — 永远都无法满足这些标准;因此,通过应用程序类加载器加载的任何系统类(比如 java.lang.String)或任何应用程序类都不能被释放。
即使类加载器可用于收集,但运行时只将类加载器作为 GC 周期的一部分进行收集。IBM gencon GC 策略(通过 -Xgcpolicy:gencon 命令行参数启用)仅卸载主要(tenured)收集上的类加载器。如果某个应用程序正在运行 gencon 策略并创建和释放许多类加载器,则您会发现大量本机内存在 tenured 收集期间由可收集的类加载器保存。参见 参考资料,了解关于不同 IBM GC 策略的更多信息。
还可以在运行时生成类,而不需要您释放它。许多 JEE 应用程序使用 JavaServer Pages (JSP) 技术生成 Web 页面。使用 JSP 为执行的各个 . jsp 页面生成类,该类的持续时间为加载它们的类加载器的生存期 — 通常为 Web 应用程序的生存期。
生成类的另一个种常用方法是使用 Java 反射。使用 java.lang.reflect API 时,Java 运行时必须将反射对象的方法(比如 java.lang.reflect.Field)连接到被反射的对象或类。这种 “访问方法” 可以使用 Java Native Interface (JNI),它需要的设置非常少但运行缓慢,或者它可以在运行时动态地为您希望反射的各对象类型构建一个类。后一种方法设置较慢,但运行更快,因此它对于经常反射特定类的应用程序非常理想。
在最初几次反射类时,Java 运行时使用 JNI 方法。但是在使用了几次之后,访问方法将扩展到字节访问方法中,该方法涉及构建一个类并通过一个新的类加载器来加载它。执行大量反射会造成创建许多访问程序类和类加载器。保留到反射对象的引用会造成这些类保持为活动状态并继续占用空间。由于创建字节码访问程序相当慢,因此 Java 运行时可以缓存这些访问程序供稍后使用。一些应用程序和框��还缓存反射对象,因此会增加它们的本机内存占用。
您可以使用系统属性控制反射访问程序行为。IBM Developer Kit for Java 5.0 的默认扩展阀值(JNI 存取器在扩展到字节码存取器中之前的使用次数)是 15。您可以通过设置 sun.reflect.inflationThreshold 系统属性来修改该值。您可以在 Java 命令行中通过 -Dsun.reflect.inflationThreshold=N来设置它。如果您将 inflationThreshold 设置为 0 或更小的值,则存取器将永远不会扩展。如果您发现应用程序要创建许多 sun.reflect.DelegatingClassloader(用于加载字节码存取器的类加载器),则这种设置非常有用。
另一种(极易造成误解的)设置也会影响反射存取器。-Dsun.reflect.noInflation=true 会完全禁用扩展,但它会造成字节码存取器滥用。使用 -Dsun.reflect.noInflation=true 会增加反射类加载器占用的地址空间量,因为会创建更多的类加载器。
您可以通过 javacore 转储来测量类和 JIT 代码在 Java 5 及以上版本中使用了多少内存。javacore 是一个纯文本文件,它包含转储发生时 Java 运行时的内部状态的概述 — 包括关于已分配本机内存分段的信息。较��版本的 IBM Developer Kit for Java 5 和 6 将内存使用情况归讷在 javacore 中,对于较老版本(Java 5 SR10 和 Java 6 SR3 之前),本文的示例代码包包括一个 Perl 脚本,可以用于分配和呈现数据(参见 下载)。如果要运行它,您需要 Perl 解释器,它可以用于 AIX 和其他平台。参见 参考资料 了解详细信息。
Javacores 将在抛出 OutOfMemoryError 时生成(地址空间耗尽时可能会出现这种情况)。您还可以通过向 Java 进程发送一个 SIGQUIT 来触发此事件(kill -3 <pid>)。要获取内存分段使用的概要信息,运行:
脚本的输出如下所示:
perl get_memory_use.pl javacore.<date>.<time>.<pid>.txt
JNI
JNI 允许本机代码(使用 C 和 C++ 等本机语言编写的应用程序)调用 Java 方法,反之亦然。Java 运行时本身在很大程度上依赖 JNI 代码实现类库功能,例如文件和网络 I/O。JNI 应用程序可以通过三种方式增加 Java 运行时的本机内存占用:
· JNI 应用程序的本机代码编译成加载到进程地址空间中的共享库或可执行程序。较大的本机应用程序在加载时就会占用大量的进程地址空间。
· 本机代码必须与 Java 运行时共享地址空间。本机代码执行的任何本机内存分配或内存映射都从 Java 运行时中取出内存。
· 某些 JNI 功能可以将本机内存作为它们普通操作的一部分使用。GetTypeArrayElements 和 GetTypeArrayRegion functions 可以将 Java 堆数据复制到本机内存缓冲区中,供本机代码使用。是否执行复制将由运行时实现决定;IBM Developer Kit for Java 5.0 及更高版本能执行本机复制。它们做出更改以避免对象受到堆的限制(需要在内存中修复它们。因为 JVM 外部的代码需要引用它);这意味着 Java 堆不能进行分段(但它在 1.4.2 中可以),但是却增加了运行时的本机内存占用。通过复制实现访问大量 Java 堆数据会相应地使用大量本机堆。
NIO
Java 1.4 中增加的新 I/O (NIO) 类引入了一种全新的基于渠道和缓冲区执行 I/O 的方式。再加上 Java 堆中的内存支持的 I/O 缓冲,NIO 添加了对直接 ByteBuffer 的支持(使用 java.nio.ByteBuffer.allocateDirect() 方法分配),它的备份方式是本机内存而不是 Java 堆。直接 ByteBuffer 可以直接传递给本机 OS 库函数用于执行 I/O — 在一些场景中显著提高它们的速度,因为它们可以避免在 Java 堆和本机堆之间复制数据。
直接 ByteBuffers 的存储位置很容易让人感到疑惑。应用程序仍然使用 Java 堆上的对象来编制 I/O 操作,但是数据所在的缓冲保存位于本机内存中 — Java 堆对象仅包含到本机堆缓冲的引用。非直接 ByteBuffer 将其数据保存在 Java 堆上的一个 byte[] 数组中。图 4 显示了直接��非直接 ByteBuffer 对象之间的不同:
图 4. 直接和非直接 java.nio.ByteBuffer 的内存拓扑
直接 ByteBuffer 对象会自动消除它们的本机缓冲,但是它们只能作为 Java 堆 GC 的一部分 — 因此它们不会自动响应本机堆的压力。仅当 Java 堆过满以至于不能响应堆分配请求,或者当 Java 应用程序明确请求它时(不建议这样做,因为会产生性能问题),才会执行 GC。
一种出现问题的情况是本机堆变满且一个或多个直接 ByteBuffers 可以胜任 GC 的工作(并且可以释放以增加本机堆的空闲空间),但是 Java 堆基本为空,因此不会执行 GC。
线程
应用程序中的每个线程都需要内存来保存它的栈(用于在调用函数时保存本地变量和维持状态的内存区)。根据实现的不同,Java 线程可以有单独的本机和 Java 栈。除了栈空间之外,每个线程都需要一些本机内存用于线程本地存储和内存数据结构。
栈大小因 Java 实现和架构而异。一些实现允许您指定 Java 线程的栈大小。值通常位于 256KB 和 756KB 之间。
虽然每个线程使用的内存量相当少,但对于拥有几百个线程的应用程序,线程栈的内存使用总量会达到很高。当应用程序的线程比可用处理器多时,运行它们的效率会很低,并且会造成性能较差且内存使用增加。