Java 与 C++ 之间有一堵由内存动态分配和垃圾收集技术所围成的高墙,墙外面的人想进去,墙里面的人却想出来。
概述:
对于从事 C 、 C++ 程序开发的开发人员来说,在内存管理领域,他们即是拥有最高权力的皇帝又是执行最基础工作的劳动人民——拥有每一个对象的“所有权”,又担负着每一个对象生命开始到终结的维护责任。
对于 Java 程序员来说,不需要在为每一个 new 操作去写配对的 delete/free ,不容易出现内容泄漏和内存溢出错误,看起来由 JVM 管理内存一切都很美好。不过,也正是因为 Java 程序员把内存控制的权力交给了 JVM ,一旦出现泄漏和溢出,如果不了解 JVM 是怎样使用内存的,那排查错误将会是一件非常困难的事情。
VM 运行时数据区域
JVM 执行 Java 程序的过程中,会使用到各种数据区域,这些区域有各自的用途、创建和销毁时间。根据《 Java 虚拟机规范(第二版)》(下文称 VM Spec )的规定, JVM 包括下列几个运行时数据区域:
1. 程序计数器( Program Counter Register ):
每一个 Java 线程都有一个程序计数器来用于保存程序执行到当前方法的哪一个指令,对于非 Native 方法,这个区域记录的是正在执行的 VM 原语的地址,如果正在执行的是 Natvie 方法,这个区域则为空( undefined )。此内存区域是唯一一个在 VM Spec 中没有规定任何 OutOfMemoryError 情况的区域。
2. Java 虚拟机栈( Java Virtual Machine Stacks )
与程序计数器一样, VM 栈的生命周期也是与线程相同。 VM 栈描述的是 Java 方法调用的内存模型:每个方法被执行的时候,都会同时创建一个帧( Frame )用于存储本地变量表、操作栈、动态链接、方法出入口等信息。每一个方法的调用至完成,就意味着一个帧在 VM 栈中的入栈至出栈的过程。在后文中,我们将着重讨论 VM 栈中本地变量表部分。
经常有人把 Java 内存简单的区分为堆内存( Heap )和栈内存( Stack ),实际中的区域远比这种观点复杂,这样划分只是说明与变量定义密切相关的内存区域是这两块。其中所指的“堆”后面会专门描述,而所指的“栈”就是 VM 栈中各个帧的本地变量表部分。本地变量表存放了编译期可知的各种标量类型( boolean 、 byte 、 char 、 short 、 int 、 float 、 long 、 double )、对象引用(不是对象本身,仅仅是一个引用指针)、方法返回地址等。其中 long 和 double 会占用 2 个本地变量空间( 32bit ),其余占用 1 个。本地变量表在进入方法时进行分配,当进入一个方法时,这个方法需要在帧中分配多大的本地变量是一件完全确定的事情,在方法运行期间不改变本地变量表的大小。
在 VM Spec 中对这个区域规定了 2 中异常状况:如果线程请求的栈深度大于虚拟机所允许的深度,将抛出 StackOverflowError 异常;如果 VM 栈可以动态扩展( VM Spec 中允许固定长度的 VM 栈),当扩展时无法申请到足够内存则抛出 OutOfMemoryError 异常。
3. 本地方法栈( Native Method Stacks )
本地方法栈与 VM 栈所发挥作用是类似的,只不过 VM 栈为虚拟机运行 VM 原语服务,而本地方法栈是为虚拟机使用到的 Native 方法服务。它的实现的语言、方式与结构并没有强制规定,甚至有的虚拟机(譬如 Sun Hotspot 虚拟机)直接就把本地方法栈和 VM 栈合二为一。和 VM 栈一样,这个区域也会抛出 StackOverflowError 和 OutOfMemoryError 异常。
4.Java 堆( Java Heap )
对于绝大多数应用来说, Java 堆是虚拟机管理最大的一块内存。 Java 堆是被所有线程共享的,在虚拟机启动时创建。 Java 堆的唯一目的就是存放对象实例,绝大部分的对象实例都在这里分配。这一点在 VM Spec 中的描述是:所有的实例以及数组都在堆上分配(原文: The heap is the runtime data area from which memory for all class instances and arrays is allocated ),但是在逃逸分析和标量替换优化技术出现后, VM Spec 的描述就显得并不那么准确了。
Java 堆内还有更细致的划分:新生代、老年代,再细致一点的: eden 、 from survivor 、 to survivor ,甚至更细粒度的本地线程分配缓冲( TLAB )等,无论对 Java 堆如何划分,目的都是为了更好的回收内存,或者更快的分配内存,在本章中我们仅仅针对内存区域的作用进行讨论, Java 堆中的上述各个区域的细节,可参见本文第二章《 JVM 内存管理:深入垃圾收集器与内存分配策略》。
根据 VM Spec 的要求, Java 堆可以处于物理上不连续的内存空间,它逻辑上是连续的即可,就像我们的磁盘空间一样。实现时可以选择实现成固定大小的,也可以是可扩展的,不过当前所有商业的虚拟机都是按照可扩展来实现的(通过 -Xmx 和 -Xms 控制)。如果在堆中无法分配内存,并且堆也无法再扩展时,将会抛出 OutOfMemoryError 异常。
5. 方法区( Method Area )
叫“方法区”可能认识它的人还不太多,如果叫永久代( Permanent Generation )它的粉丝也许就多了。它还有个别名叫做 Non-Heap (非堆),但是 VM Spec 上则描述方法区为堆的一个逻辑部分(原文: the method area is logically part of the heap ),这个名字的问题还真容易令人产生误解,我们在这里就不纠结了。
方法区中存放了每个 Class 的结构信息,包括常量池、字段描述、方法描述等等。 VM Space 描述中对这个区域的限制非常宽松,除了和 Java 堆一样不需要连续的内存,也可以选择固定大小或者可扩展外,甚至可以选择不实现垃圾收集。相对来说,垃圾收集行为在这个区域是相对比较少发生的,但并不是某些描述那样永久代不会发生 GC (至少对当前主流的商业 JVM 实现来说是如此),这里的 GC 主要是对常量池的回收和对类的卸载,虽然回收的“成绩”一般也比较差强人意,尤其是类卸载,条件相当苛刻。
6. 运行时常量池( Runtime Constant Pool )
Class 文件中除了有类的版本、字段、方法、接口等描述等信息外,还有一项信息是常量表 (constant_pool table) ,用于存放编译期已可知的常量,这部分内容将在类加载后进入方法区(永久代)存放。但是 Java 语言并不要求常量一定只有编译期预置入 Class 的常量表的内容才能进入方法区常量池,运行期间也可将新内容放入常量池(最典型的 String.intern() 方法)。
运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池无法在申请到内存时会抛出 OutOfMemoryError 异常。
7. 本机直接内存( Direct Memory )
直接内存并不是虚拟机运行时数据区的一部分,它根本就是本机内存而不是 VM 直接管理的区域。但是这部分内存也会导致 OutOfMemoryError 异常出现,因此我们放到这里一起描述。
在 JDK1.4 中新加入了 NIO 类,引入一种基于渠道与缓冲区的 I/O 方式,它可以通过本机 Native 函数库直接分配本机内存,然后通过一个存储在 Java 堆里面的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在 Java 对和本机堆中来回复制数据。
显然本机直接内存的分配不会受到 Java 堆大小的限制,但是即然是内存那肯定还是要受到本机物理内存(包括 SWAP 区或者 Windows 虚拟内存)的限制的,一般服务器管理员配置 JVM 参数时,会根据实际内存设置 -Xmx 等参数信息,但经常忽略掉直接内存,使得各个内存区域总和大于物理内存限制(包括物理的和操作系统级的限制),而导致动态扩展时出现 OutOfMemoryError 异常。
实战 OutOfMemoryError
上述区域中,除了程序计数器,其他在 VM Spec 中都描述了产生 OutOfMemoryError (下称 OOM )的情形,那我们就实战模拟一下,通过几段简单的代码,令对应的区域产生 OOM 异常以便加深认识,同时初步介绍一些与内存相关的虚拟机参数。下文的代码都是基于 Sun Hotspot 虚拟机 1.6 版的实现,对于不同公司的不同版本的虚拟机,参数与程序运行结果可能结果会有所差别。
Java 堆
Java 堆存放的是对象实例,因此只要不断建立对象,并且保证 GC Roots 到对象之间有可达路径即可产生 OOM 异常。测试中限制 Java 堆大小为 20M ,不可扩展,通过参数 -XX:+HeapDumpOnOutOfMemoryError 让虚拟机在出现 OOM 异常的时候 Dump 出内存映像以便分析。(关于 Dump 映像文件分析方面的内容,可参见本文第三章《 JVM 内存管理:深入 JVM 内存异常分析与调优》。)
清单 1 : Java 堆 OOM 测试
/** * VM Args : -Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError * @author zzm */ public class HeapOOM {
static class OOMObject { }
public static void main(String[] args) { List<OOMObject> list = new ArrayList<OOMObject>();
while (true) { list.add(new OOMObject()); } } } |
运行结果:
java.lang.OutOfMemoryError: Java heap space Dumping heap to java_pid3404.hprof ... Heap dump file created [22045981 bytes in 0.663 secs] |
VM 栈和本地方法栈
Hotspot 虚拟机并不区分 VM 栈和本地方法栈,因此 -Xoss 参数实际上是无效的,栈容量只由 -Xss 参数设定。关于 VM 栈和本地方法栈在 VM Spec 描述了两种异常: StackOverflowError 与 OutOfMemoryError ,当栈空间无法继续分配分配时,到底是内存太小还是栈太大其实某种意义上是对同一件事情的两种描述而已,在笔者的实验中,对于单线程应用尝试下面 3 种方法均无法让虚拟机产生 OOM ,全部尝试结果都是获得 SOF 异常。
1. 使用 -Xss 参数削减栈内存容量。结果:抛出 SOF 异常时的堆栈深度相应缩小。
2. 定义大量的本地变量,增大此方法对应帧的长度。结果:抛出 SOF 异常时的堆栈深度相应缩小。
3. 创建几个定义很多本地变量的复杂对象,打开逃逸分析和标量替换选项,使得 JIT 编译器允许对象拆分后在栈中分配。结果:实际效果同第二点。
清单 2 : VM 栈和本地方法栈 OOM 测试(仅作为第 1 点测试程序)
/** * VM Args : -Xss128k * @author zzm */ public class JavaVMStackSOF {
private int stackLength = 1;
public void stackLeak() { stackLength++; stackLeak(); }
public static void main(String[] args) throws Throwable { JavaVMStackSOF oom = new JavaVMStackSOF(); try { oom.stackLeak(); } catch (Throwable e) { System.out.println("stack length:" + oom.stackLength); throw e; } } } |
运行结果:
stack length:2402 Exception in thread "main" java.lang.StackOverflowError at org.fenixsoft.oom.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:20) at org.fenixsoft.oom.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:21) at org.fenixsoft.oom.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:21) |
如果在多线程环境下,不断建立线程倒是可以产生 OOM 异常,但是基本上这个异常和 VM 栈空间够不够关系没有直接关系,甚至是给每个线程的 VM 栈分配的内存越多反而越容易产生这个 OOM 异常。
原因其实很好理解,操作系统分配给每个进程的内存是有限制的,譬如 32 位 Windows 限制为 2G , Java 堆和方法区的大小 JVM 有参数可以限制最大值,那剩余的内存为 2G (操作系统限制) -Xmx (最大堆) -MaxPermSize (最大方法区),程序计数器消耗内存很小,可以忽略掉,那虚拟机进程本身耗费的内存不计算的话,剩下的内存就供每一个线程的 VM 栈和本地方法栈瓜分了,那自然每个线程中 VM 栈分配内存越多,就越容易把剩下的内存耗尽。
清单 3 :创建线程导致 OOM 异常
/** * VM Args : -Xss2M (这时候不妨设大些) * @author zzm */ public class JavaVMStackOOM {
private void dontStop() { while (true) { } }
public void stackLeakByThread() { while (true) { Thread thread = new Thread(new Runnable() { @Override public void run() { dontStop(); } }); thread.start(); } }
public static void main(String[] args) throws Throwable { JavaVMStackOOM oom = new JavaVMStackOOM(); oom.stackLeakByThread(); } } |
特别提示一下,如果读者要运行上面这段代码,记得要存盘当前工作,上述代码执行时有很大令操作系统卡死的风险。
运行结果:
Exception in thread "main" java.lang.OutOfMemoryError: unable to create new native thread |
运行时常量池
要在常量池里添加内容,最简单的就是使用 String.intern() 这个 Native 方法。由于常量池分配在方法区内,我们只需要通过 -XX:PermSize 和 -XX:MaxPermSize 限制方法区大小即可限制常量池容量。实现代码如下:
清单 4 :运行时常量池导致的 OOM 异常
/** * VM Args : -XX:PermSize=10M -XX:MaxPermSize=10M * @author zzm */ public class RuntimeConstantPoolOOM {
public static void main(String[] args) { // 使用 List 保持着常量池引用,压制 Full GC 回收常量池行为 List<String> list = new ArrayList<String>(); // 10M 的 PermSize 在 integer 范围内足够产生 OOM 了 int i = 0; while (true) { list.add(String.valueOf(i++).intern()); } } } |
运行结果:
Exception in thread "main" java.lang.OutOfMemoryError: PermGen space at java.lang.String.intern(Native Method) at org.fenixsoft.oom.RuntimeConstantPoolOOM.main(RuntimeConstantPoolOOM.java:18) |
方法区
上文讲过,方法区用于存放 Class 相关信息,所以这个区域的测试我们借助 CGLib 直接操作字节码动态生成大量的 Class ,值得注意的是,这里我们这个例子中模拟的场景其实经常会在实际应用中出现:当前很多主流框架,如 Spring 、 Hibernate 对类进行增强时,都会使用到 CGLib 这类字节码技术,当增强的类越多,就需要越大的方法区用于保证动态生成的 Class 可以加载入内存。
清单 5 :借助 CGLib 使得方法区出现 OOM 异常
/** * VM Args : -XX:PermSize=10M -XX:MaxPermSize=10M * @author zzm */ public class JavaMethodAreaOOM {
public static void main(String[] args) { while (true) { Enhancer enhancer = new Enhancer(); enhancer.setSuperclass(OOMObject.class); enhancer.setUseCache(false); enhancer.setCallback(new MethodInterceptor() { public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable { return proxy.invokeSuper(obj, args); } }); enhancer.create(); } }
static class OOMObject {
} } |
运行结果:
Caused by: java.lang.OutOfMemoryError: PermGen space at java.lang.ClassLoader.defineClass1(Native Method) at java.lang.ClassLoader.defineClassCond(ClassLoader.java:632) at java.lang.ClassLoader.defineClass(ClassLoader.java:616) ... 8 more |
本机直接内存
DirectMemory 容量可通过 -XX:MaxDirectMemorySize 指定,不指定的话默认与 Java 堆( -Xmx 指定)一样,下文代码越过了 DirectByteBuffer ,直接通过反射获取 Unsafe 实例进行内存分配( Unsafe 类的 getUnsafe() 方法限制了只有引导类加载器才会返回实例,也就是基本上只有 rt.jar 里面的类的才能使用),因为 DirectByteBuffer 也会抛 OOM 异常,但抛出异常时实际上并没有真正向操作系统申请分配内存,而是通过计算得知无法分配既会抛出,真正申请分配的方法是 unsafe.allocateMemory() 。
/** * VM Args : -Xmx20M -XX:MaxDirectMemorySize=10M * @author zzm */ public class DirectMemoryOOM {
private static final int _1MB = 1024 * 1024;
public static void main(String[] args) throws Exception { Field unsafeField = Unsafe.class.getDeclaredFields()[0]; unsafeField.setAccessible(true); Unsafe unsafe = (Unsafe) unsafeField.get(null); while (true) { unsafe.allocateMemory(_1MB); } } } |
运行结果:
Exception in thread "main" java.lang.OutOfMemoryError at sun.misc.Unsafe.allocateMemory(Native Method) at org.fenixsoft.oom.DirectMemoryOOM.main(DirectMemoryOOM.java:20) |
总结
到此为止,我们弄清楚虚拟机里面的内存是如何划分的,哪部分区域,什么样的代码、操作可能导致 OOM 异常。虽然 Java 有垃圾收集机制,但 OOM 仍然离我们并不遥远,本章内容我们只是知道各个区域 OOM 异常出现的原因,下一章我们将看看 Java 垃圾收集机制为了避免 OOM 异常出现,做出了什么样的努力。