0%

第2章:Java内存区域与内存溢出异常

运行时数据区域

根据《Java虚拟机规范(java SE 7)》规定,Java 虚拟机所管理的内存将会包括以下几个运行时数据区域:

Java运行时数据区域

程序计数器

程序计数器可以看做是当前线程执行的字节码的行号指示器,它是线程私有的。在虚拟机概念模型里(具体虚拟机可能有更高效实现),字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等功能都需要依赖程序计数器来完成。如果线程在执行Java方法,计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行Native方法,这个计数器值为Undefined。此内存区域是Java虚拟机规范中唯一一个没有规定任何 OutOfMemoryError情况的区域

Java虚拟机栈

Java虚拟机栈也是线程私有的。它描述的是Java方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息,一个方法从调用至完成,就对应一个栈帧在虚拟机栈中入栈到出栈的过程。这个区域被规定了两种异常状况:如果线程请求的栈深度大于虚拟机允许的深度,就抛出 StackOverflowError异常;如果该区域可以动态扩展,则在扩展时无法申请到足够内存,就会抛出OutOfMemoryError异常

本地方法栈

本地方法栈与虚拟机栈类似,区别只不过是虚拟机栈为执行Java方法(即字节码)服务,而本地方法栈为Native方法服务,同样,本地方法栈也可能抛出StackOverflowError异常以及OutOfMemoryError异常.

Java堆

Java堆是被所有线程共享的一块内存,虚拟机启动时创建。虚拟机规范描述是,所有对象的实例以及数组都要在堆上分配(当然,不同虚拟机实现不同)。如果堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出OutOfMemoryError异常

方法区

与Java堆一样,方法区也是各个线程共享的内存区域。它用于存储已经被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。当方法区无法满足内存分配需要时,将抛出OutOfMemoryError异常

提到方法区,不得不提运行时常量池,它是方法区的一个部分。Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是信息常量池,用于存放编译期生成的各种字面量和符号引用(一般来说,翻译出来的直接引用也会存储在运行时常量池中)。

直接内存

直接内存(Direct Memory)不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域。在Java 1.4中新加入了NIO(New Input/Output)类,它可以使用Native函数直接分配堆外内存,然后通过一个存储在Java堆中的DirectBuffer对象作为这块内存额引用进行操作,这样能在一些场景中显著提高性能,因为它避免在 Java 堆和 Native 堆中来回复制数据。当然,该区域空间在动态扩展时也可能出现OutOfMemoryError异常

HotSpot 虚拟机对象探秘

对象的创建

虚拟机遇到一条new指令时,首先检查这个指令的参数是否能在常量池中定位到类的符号引用,并检查这个符号引用代表的类是否已经被加载、解析和初始化过,如果没有,则必须限制性相应的类加载过程。类加载完成后,便可完全确定对象所需的内存大小,接下来为新生的对象真正分配内存。

对象的内存布局

对象在内存中存储的布局可以分为3块区域:对象头(Header)、实例数据(Instance Data)、和对齐填充(Padding)。

对象的访问定位

建立对象是为了使用对象,我们的Java程序需要通过栈上的refrence数据来操作堆上的具体对象。目前主流的访问方式有使用句柄和直接指针,其中前者通过句柄池间接指向堆中的对象,后者直接指向堆中的对象。

实战

OutOfMemoryError异常

Java虚拟机规范中,除了程序计数器之外,其他区域都可能发生 OutOfMemoryError异常。

JAVA堆溢出

Java堆用于存储实例对象,只要不断地创建对象,并且保证GC root 到对象之间有可达路径来避免垃圾回收,那么就能产生OutOfMemoryError异常。

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

虚拟机栈和本地方法栈理论上分配不到足够的内存同样会报 OutOfMemoryError异常,但是一般情况下,首先会出现StackOverflowError,OutOfMemoryError很难出现。

方法区和运行时常量池溢出

由于运行时常量池是方法区的一个部分,因此这两个区域的溢出测试放在一起进行。在此之前我们先了解下String.intern()方法:

String.intern()是一个Native方法,它的作用是,如果字符串常量池中已经包含一个等于此String对象的字符串,则返回代表池中这个字符串的String对象;否则,将此String对象包含的字符串添加到常量池中,并返回此String对象的引用。

在JDK1.6及以前,通过设置永久代区域的大小可以间接限制方法区大小,但是从JDK 1.7以后就“去永久代”了,因此以下代码只在JDK 1.6 ,并且设置了 MaxPermSize 时有效:

1
2
3
4
5
6
7
8
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());
}
}

同样的原因,还可以引申一个更有意思的影响,如下代码:

1
2
3
4
5
6
7
public static void main(String[] args){
String str1 = new StringBuilder("计算机").append(“软件”).toString();
Systemt.out.println(str1.intern() == str1);

String str2 = new StringBuilder("ja").append("va").toString();
Systemt.out.println(str2.intern() == str2);
}

这段代码在JDK 1.6 中运行,会得到两个false(intern 方法会把首次遇到的字符串实例复制到永久代中,而StringBuilder创建的字符串实例在Java堆上,所以必然不是同一个引用);而在JDK 1.7 中会得到一个true(intern 实现不会再复制实例,只是在常量池中记录首次出现的实例引用,因此,intern返回的引用和由StringBuilder创建的字符串实例是同一个实例)一个false(“java”字符串在执行StringBuilder.toString()之前已经出现过,字符串常量池中已经有它的引用了,不符合首次出现,而“计算机软件”这个字符串则是首次出现的—这个解释没看懂,需要再次理解)。

本机直接内存溢出

直接或者间接地使用NIO,就可能出现本机直接内存溢出。虽然使用DirectByteBuffer分配内存也会抛出内存溢出异常,但是抛出异常时并没有真正向操作系统申请分配内存,而是通过计算得知内存无法分配。因此可以通过以下代码手动抛出异常,真正抛出异常的地方是unsafe.allocateMemory()。

1
2
3
4
5
6
7
8
9
10
11
//以下代码能够抛出异常的前提是,设置虚拟机参数 -XX: MaxDirectMemorySize 的值

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);
}
}
谢谢你的鼓励