Java与C++之间有一堵由内存动态分配和垃圾收集围城的高墙,墙外面的人想进来,墙里面的人却想出去.

概述

对于从事CC++程序开发的开发人员来说,在内存管理领域,他们既是拥有最高权力的皇帝,又是从事最基础工作的劳动人民——既拥有每一个对象的“所有权”,又担负着每一个对象的生命开始到中积极而的维护责任。

但对于Java程序员来说,在虚拟机的自动内存管理机制的帮助下,不再需要为每一个new操作去写配对的delete/free代码,而且不容易出现内存溢出和内存泄漏的问题,看起来由虚拟机管理内存一切都很美好。

不过,也正是因为Java程序员把内存控制的权力交给了Java虚拟机,一旦出现内存泄漏和溢出方面的问题,如果不了解虚拟机是怎么样使用内存的,那排查错误将会成为一项异常艰难的工作。

运行时数据区域

Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干不同的数据区域。这些区域都有各自的用途,以及创建和销毁的时间,有的区域随着虚拟机进程的启动而存在,有些区域则依赖用户线程的启动和结束而建立和销毁。

Java虚拟机运行时数据区
Java虚拟机运行时数据区

程序计数器

程序计数器(Program Counter Register)是一块较小的内存空间,它的作用可以看成是当前线程所执行的字节码的行号指示器。在虚拟机的概念模型里,字节码解释器工作时通过改变这个计数器的值来选取吓一跳需要执行的字节码指令,分支循环跳转异常处理线程恢复等基础功能都需要依赖这个计数器来完成。

由于Java虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器(对于多核处理器来说是一个内核)只会执行一条线程中的命令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间的计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。

如果线程正在执行的是一个Java方法, 这个计数器记录的是正在执行的虚拟机字节码指令地址;如果正在执行的是Natvie方法, 这个计数器值则为空(Undefined)。此内存区域是唯一一个在Java虚拟机规范中没有规定任何OOM情况的区域。

Java虚拟机栈

Java虚拟机栈(Java Virtual Machine Stacks)也是线程私有的,它的生命周期与线程相同。

虚拟机栈描述的是Java方法执行的内存模型:每个方法被执行的时候都会同事创建一个栈帧(Stack Frame)用于存储局部变量表、操作栈、动态链接、方法出口等信息。

每个方法被调用直至执行完成的过程,就对应着一个栈帧在虚拟机中从入栈道出栈的过程。

经常所说的堆内存(Heap)和栈内存(Stack)是种比较粗糙的分发,Java内存区域的划分实际上会复杂很多。这里所指的“”就是虚拟机栈,或者说虚拟机栈中的局部变量表部分。

局部变量表存放了编译期可知的各种基本数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference类型,它不等同于对象本身,根据不同的虚拟机实现,可能是指向一个对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与对象相关的位置)和returnAddress类型(指向一条字节码指令的地址)。

64位长度的long和double类型的数据会占用2个局部变量空间(Slot),其余的数据只占用一个。

局部变量表所需要的内存空间在编译期间完成分配。当进入一个方法时,这个方法需要在帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。

这个区域规定了两种异常状况:

  • 如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常;
  • 如果虚拟机栈可以动态扩展,当扩展到无法申请到足够的内存时会抛出OutOfMemoryError异常;

当前大部分的Java虚拟机都可以动态扩展,只不过Java虚拟机规范中也允许固定长度的虚拟机栈。

本地方法栈

本地方法栈(Native Method Stacks)与虚拟机栈所发挥的作用时非常相似的。

区别

  • 虚拟机栈为虚拟机执行Java方法(也就是字节码)服务
  • 本地方法栈则是为虚拟机使用到的Native服务。

虚拟机规范中对本地方法栈中的方法使用的语言、使用方式与数据结构并没有强制规定,因此具体的虚拟机可以自由实现它。有些虚拟机(Sun Hotspot)直接把本地方法栈与虚拟机栈合二为一。

与虚拟机栈一样,本地方法栈区域也会抛出StackOverflowErrorOutOfMemoryError异常。

Java堆

对于大多数应用来说,Java堆(Java Heap)是Java虚拟机所管理的内存中最大的一块。

Java虚拟机规范中描述:所有对象实例以及数组都要在堆上分配。

  1. Java堆事被所有线程共享的一块内存区域,在虚拟机启动时创建。
  2. 此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都是在这里分配内存。
  3. Java堆事垃圾收集器管理的主要区域,因此很多时候也称作“GC堆”(Garbage Collected Heap)。
  4. Java堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可,就像我们的磁盘空间一样。

从内存回收角度看,由于现在收集齐基本都是采用分代手机算法,所以Java堆中还可以细分为:新生代和老年代;再细分有Eden空间、From Survivor空间、To Survivor空间等。

从内存分配的角度看,线程共享的Java堆中可能划分出多个线程私有的分配缓冲区。

无论如何划分,都与存放内容无关,无论哪个区域,存储的都仍然是对象实例,进一步划分是为了更好的回收内存,或者更快的分配内存。主流的虚拟机都是按照可扩展来实现的(通过-Xmx-Xms控制)。如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出OutOfMemoryError异常。

方法区

方法区(Method Area)和Java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机家在的类信息、常量、静态变量、即时编译后的代码等数据。

虽然Java虚拟机规范把方法区描述为堆的一个逻辑部分,但是它有一个别名叫NonHeap(非堆),目的应该是与Java堆区分开来。

Java虚拟机规范堆这个区域的限制非常宽松,除了和Java堆一样不需要连续的内存和可以选择固定大小或者可扩展外,还可以选择不实现垃圾收集。

相对而言,垃圾收集行为在这个区域是比较少出现的,但并非数据进入了方法区就如永久代的名字一样“永久”存在了。

这个区域的内存回收目标主要是针对常量池的回收和对类型的卸载,这个区域的回收“成绩”比较难以令人满意,尤其是类型的卸载,条件相当苛刻,但是这部分区域的回收的确是有必要的。

根据Java虚拟机规范的规定,当方法区无法满足内存分配需求时,将会抛出OutOfMemoryError异常。

运行时常量池

运行时常量池(Runtime Constant Pool)是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息时常量池(Constant Pool Table),用于存放编译期生成的各种字面亮和符号饮用,这部分内容将在类加载后存放到方法区运行时常量池中。

Java虚拟机对Class的每一部分的格式都有严格的规定,每一个字节用于存储哪种数据都必须符合规范上的要求,这样才会被虚拟机认可、装载和执行。但是对于常量池,Java虚拟机规范没有做任何细节的要求。一般来说,除了保存Class文件中描述的符号引用外,还会把翻译出来的直接饮用也存储在运行时常量池中。

运行时常量池对于Class文件常量池的另外一个重要特征是具有动态性,Java语言并不要求常量一定只能在编译期产生,也就是并非预置入Class文件中常量池的内容才能进入方法区运行时的常量池,运行期间也可能将新的常量放入池中,这种特性被开发人员利用的比较多的便是String类的intern()方法。

public String intern();

返回字符串对象的规范化表示形式。

一个初始时为空的字符串池,它由类 String 私有地维护。

当调用 intern 方法时,如果池已经包含一个等于此 String 对象的字符串(该对象由 equals(Object) 方法确定),则返回池中的字符串。否则,将此 String 对象添加到池中,并且返回此 String 对象的引用。

它遵循对于任何两个字符串 s 和 t,当且仅当 s.equals(t) 为 true 时,s.intern() == t.intern() 才为 true。

所有字面值字符串和字符串赋值常量表达式都是内部的。

返回:

一个字符串,内容与此字符串相同,但它保证来自字符串池中。

运行时常量池是方法区的一部分,自然会收到方法区内的限制,当常量池无法再申请到内存时会抛出OutOfMemoryError异常。

直接内存

直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域,但是这部分内存也被频繁地使用,而且也可能导致OutOfMemoryError异常出现。

本机内存的分配不会收到Java堆大小的限制,但是,既然是内存,则肯定还是会收到本机总内存(RAM及SWAP区或者分页文件)的大小及处理器寻址空间的限制。分配虚拟机参数时,容易忽略掉直接内存,使得各个内存区域的总和大于物理内存限制,从而导致动态扩展时出现OutOfMemoryError异常。

对象访问

对象的访问在Java语言中无处不在,时最普通的程序行为,但即使时最简单的访问,也会去涉及Java栈、Java堆、方法区这三个最重要的内存区域之间的关联关系,如:

1
Object obj = new Object();

假设这句代码出现在方法体中:

  • “Object obj”这部分的语义将会反映到Java栈的本地变量表中,作为一个reference类型数据出现。
  • “new Object( )”这部分语义将会反映到Java堆

形成一块存储了Object类型所有实例数据值(Instance Data,对象中各个实例字段的数据)的结构化内存,根据具体类型以及虚拟机实现的对象内存布局的不同,这块内存的长度时不固定的。Java堆中还必须包含能查找到此对象类型数据(如对象类型、父类、实现的接口、方法等)的地址信息,这些类型数据则存储在方法区中。

不同虚拟机实现的对象访问方式会有所不同。主流的访问方式有两种:

  • 使用句柄

    Java堆中会划分出一块内存作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据和类型数据各自的具体地址信息

    通过句柄访问对象

    句柄访问方式最大的好处就是reference中存储的事稳定的句柄地址,在对象被移动(垃圾收集时移动对象时非常普遍的行为)只会改变句柄中的实例数据指针,而reference本身不需要被修改。

  • 直接指针

    Java堆对象的布局中必须考虑如何放置访问类型数据的相关信息,reference中直接存储的就是对象地址

    通过句柄访问对象

    指针访问方式在最大好处就是速度更快,它节省了一次指针定位的时间开销,由于对象的访问在Java中非常频繁,因此这类开销积少成多后也是一项非常可观的执行成本。

重现OOM异常

在Java虚拟机规范的描述中,除了程序计数器外,虚拟机内存的其他几个运行时区域都有发生OOM异常的可能。

Java堆溢出

Java堆用于存储对象实例,我们只要不断的创建对象,并保证GC Roots到对象之间有可达路径来避免垃圾回收机制清除这些对象,就会在对象数量达到最大堆容量限制后产生内存溢出异常。

在IDE(如IDEA)里设置JVM参数:

-Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError

1
2
3
4
5
6
7
8
9
10
11
12
13
public class HeapOOM {
static class OOMObject {

}

public static void main(Stringp[] args) {
List<OOMObject> list = new ArrayList<OOMObject>;

while(true) {
list.add(new OOMObject());
}
}
}

运行代码将会抛出:java.lang.OutOfMemoryError: Java heap space.

要解决这些问题,重点是确认内存中的对象是否时必要的,也就是要先分清楚到底时出现了内存泄漏还是内存溢出。

如果时内存泄漏,可进一步通过工具查看泄漏对象到GC Roots的引用链。于是就能找到泄漏对象是通过怎样的路径与GC Roots相关联并导致垃圾收集器无法自动回收它们的。

如果不存在内存泄漏,那就应该检查虚拟机堆参数(-Xmx 和 -Xms)与机器无力内存对比是否还可以调大,从代码上检查是否存在某些对象的生命周期过长、持有状态时间过长的情况。尝试减少程序运行期的内存消耗。

虚拟机栈和本地方法栈溢出

对于HotSpot来说,-Xoss参数(设置本地方法栈大小)虽然存在,但实际上是无效的,栈容量只由-Xss参数设定。

  • 如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出StackOverflowError异常。
  • 如果虚拟机在拓展栈时无法申请到足够的内存空间,则抛出OutOfMemoryError异常。

在IDE(如IDEA)里设置JVM参数:

-Xss128k

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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:" + ooo.stackLength);
throw e;
}
}
}

运行结果:

Stack length:2402

Exception in thread “main” java.lang.StackOverflowError

··········后续异常栈信息省略。

结果证明:在单个线程下,无论是由于栈帧太大,还是虚拟机栈容量太小,当内存无法分配的时候, 虚拟机抛出的都是StackOverflowError异常。

在IDE(如IDEA)里设置JVM参数:

-Xss2M

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class JavaVMStackOOM {

private void dontStop() {
while (true) {

}
}

public void stackLeakThread() {
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.stackLeakThread();
}
}

注:如果要运行这顿啊代码,记得要先保存当前的工作,由于在Windows平台的虚拟机中,Java的线程是映射到操作系统的内核线程上的,所以上述代码执行时有较大的风险,可能会导致操作系统假死。

运行结果:

Exception in thread “main” java.lang.OutOfMemoryError: unable to create new native thread

实验证明:

通过不断的建立线程的方式可以产生内存溢出异常,但是,这样产生的内存溢出异常与栈空间是否足够大并不存在任何联系,或者准确的说,在这种情况下,给每个线程的栈分配的内存越大,反而越容易产生内存溢出异常。

如果是建立过多线程导致的内存溢出,在不能减少线程数或者更换64位虚拟机的情况下,就只能通过减少最大堆和减少栈容量来换取更多的线程。如果没有这方面的经验,这种通过“减少内存”的手段来解决内存溢出的方式会比较难以想到。

运行时常量池溢出

如果要向运行时常量池中添加内容, 嘴贱的做法就是使用String.intern()这个Native方法。该方法的作用是:如果池中已经包含一个等于此String对象的字符串,则返代表池中这个字符串的String对象;否则,将此String对象包含的字符串的添加到常量池中,并且返回此String对象的引用。

在IDE(如IDEA)里设置JVM参数:

-XXPermSize=10M -XX:MaxPermSize=10M

1
2
3
4
5
6
7
8
9
10
11
public class RuntimeConstantPoolOOM {
public static void main(String[] args) {
// 使用List保存着常量池引用,避免Full GC回收常量池行为
List<String> list = new ArrayList<String>();

int i = 0;
while (true) {
list.add(String.valueOf(i++).intern());
}
}
}

运行结果:

Exception in thread “main” java.lang.OutOfMemoryError: PermGen space

实验结果:

运行时常量池溢出,在OOM后面跟随着的提示信息是PermGen space,说明运行时常量池属于方法区(HotSpot虚拟机中的永久代)的一部分。

方法区溢出

方法区用于存放Class的相关信息,如类名、访问修饰符、常量池、字段描述、方法描述等。这个实验需要通过生成大量的动态类来实现,使用CGLib这类字节码技术,增强的类越多,就需要越大的方法区来保证动态生成的Class可以加载如内存。

在IDE(如IDEA)里设置JVM参数:

-XX:PermSize=10M -XX:MaxPermSize=10M

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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 object, Method method,
Object[] args, MethodProxy mhodProxy) throws Throwable {
return proxy.invokeSuper(obj, args);
}
});
enhancer.create();
}
}

static class OOMObject();
}

运行结果:

Caused by: java.lang.OutOfMemoryError: PermGen space

实验结果:

方法区溢出也是一种常见的内存溢出异常,一个类如果被垃圾收集器回收掉,判断条件是非常苛刻的。在经常动态生成大量Class的应用中,需要特别注意类的回收状态。

本机直接内存溢出

通过反射获取Unsafe实例进行内存分配(Unsafe类的getUnSafe()方法限制了只有引导类加载器才会返回实例,也就是设计者希望只有rt.jar中的类可以使用Unsafe的功能)。因为,虽然使用DirectByteBuffer分配内存也会抛出内存溢出异常,但它抛出异常时并没有真正向操作系统申请分配内存,而是通过计算得知内存无法分配,于是手动抛出异常,真正申请分配内存的方法是unsafe.allocateMemory()。

在IDE(如IDEA)里设置JVM参数:

-Xmx20M -XX:MaxDirectMemorySize=10M

1
2
3
4
5
6
7
8
9
10
11
12
13
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

小结

  • 内存是如何划分的
  • 哪部分区域、什么样的代码和操作可能导致内存溢出异常

虽然Java有垃圾收集机制,但内存溢出异常离我们并不遥远,本章讲解了解了各个区域出现内存溢出异常的原因。

参考

  • 周志明,深入理解Java虚拟机:JVM高级特性与最佳实践,机械工业出版社