Java内存区域与内存溢出异常
引言
对于Java程序员来说,在虚拟机自动内存管理机制的帮助下,不再需要为每一个new操作去写配对的delete/free代码,不容易出现内存泄漏和内存溢出问题,由虚拟机管理内存。但正是因为Java程序员把内存控制权利交给了Java虚拟机,一旦出现内存泄漏和溢出方面,如果不了解虚拟机是如何使用内存的,那么排查错误将会非常困难。
2.2 运行时数据区域
2.2.1 程序计数器
程序计数器是一块较小的内存空间。它可以看作是当前线程所执行的字节码的行号指示器。字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令。
-
为什么需要引入程序计数器?
由于Java虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,在任何一个时刻,一个处理器都只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要一个独立的程序计数器,各条线程之间计数器不受影响,独立存储,我们称这类内存区域为“线程私有”的内存。
简而言之,就是存取当前线程执行的位置,以便下次执行时正确恢复。 -
注意:
如果线程执行的是一个Java方法,那么计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是native方法,那么这个计数器值为空。此内存区域是唯一一个在Java虚拟机规范中没有规定任何OutOfMemoryError情况的区域。
2.2.2 Java虚拟机栈
Java虚拟机栈是线程私有的,其生命周期与线程相同。虚拟机栈描述的是Java方法执行的内存模型:每个方法在执行时都会创建一个栈帧用于存储局部变量表,操作数栈,动态链接,方法出口等。每个方法从调用直至执行完成的过程,就对应了一个栈帧在虚拟机中入栈到出栈的过程。
局部变量表
-
概念:存放了编译器可知的各种基本数据类型(boolean,byte,char,short,int,float,long,double),对象引用和returnAddress类型(指向一条字节码指令的地址);
-
注意:
a. 只有64位长度的long和double类型的数据会占用2个局部变量空间,其余的类型只会占用1个局部变量空间。
b. 局部变量表所需的内存空间在编译器完成分配,在方法运行期间不会改变局部变量表的大小。
2.2.3 本地方法栈
本地方法栈和虚拟机栈的作用是非常相似的,它们之间的区别在于虚拟机栈为虚拟机执行Java方法,而本地方法栈为虚拟机执行native方法。
2.2.4 Java堆
- Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。该内存区域的唯一目的是存放对象实例。
- 几乎所有的对象实例以及数组都要在堆上分配。
- Java堆是垃圾收集器的主要区域。
- Java堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可。
- 如果在堆中没有足够的内存空间分配,并且堆也无法再扩展时,那么将会抛出OutOfMemoryError异常。
2.2.5 方法区
方法区是各个线程共享的内存区域。它用于存储已被虚拟机加载的类信息,常量,静态变量,即时编译器编译后的代码等数据。
2.2.6 运行时常量池
运行时常量池为方法区的一部分,用于存放编译期生成的各种字面量和符号引用。
2.3 HotSpot虚拟机对象探秘
2.3.1 对象的创建
在语言层面上,创建对象(例如克隆,反序列化)通常仅仅是一个new关键字而已,但在虚拟机中,对象的创建却是一个复杂的过程。
对象创建的具体过程:
-
a. 类加载检查:虚拟机遇到一条new指令时,首先会去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载,解析和初始化过。如果没有,则必须先执行相应的操作。
-
b. 为对象分配内存:为对象分配空间的任务等同于把一块确定大小的内存从Java堆中划分出来。
划分方法有两种:”指针碰撞“和”空闲列表“
(1) 指针碰撞:假设Java堆中内存是绝对规整的,所有可用的内存放在一边,空闲的内存放在一边,中间放着一个指针作为分界点的指示器,那么分配内存就仅仅是把那个指针向空闲空间那边挪动一段与对象大小相等的距离。
(2) 空闲列表:假设Java堆内存并不是规整的,已使用的内存空间和空闲的内存空间是相互交错的,那么就无法通过指针碰撞来划分了,虚拟机就必须维护一个表,记录哪些内存块是可用的,在分配内存时找到一块足够大的空间划分给对象,并更新表中的记录。 -
关于在并发情况下,使用指针碰撞分配空间存在的问题:
我们知道“指针碰撞”方法是通过修改指针的位置来划分空间,那么在并发的情况下,存在线程不安全性。可能出现正在给对象A分配空间,对象B又同时使用原来的指针位置来分配空间。
解决方法:
a. 对分配内存空间的动作进行同步处理。
b. 把内存分配的动作按照线程划分在不同的空间之中进行。即每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲(简称:TLAB)。哪个线程要分配内存,就在哪个线程的TLAB上分配,只有TLAB用完并分配新的TLAB时,才需要同步锁定。 -
c. 将分配的内存空间都初始化为零值:如果使用的是TLAB,这一工作过程可以提前至TLAB分配时进行。这一步操作保证了对象的实例字段在Java代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。
-
d. 对对象进行必要的设置:例如这个对象是哪个类的实例,如何找到类的元数据类型信息等,将这些信息存放在对象头之中。
2.3.2 对象的内存布局
对象在内存中存储的布局分为:对象头,实例数据和对齐填充。
-
对象头包含两部分信息:一部分是用于存储对象自身的运行时数据,另一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。如果对象是一个Java数组,那么在对象头中还必须有一块用于记录数组长度的数据。
-
实例数据:对象真正存储的有效信息,也是在程序代码中所定义的各种类型的字段内容。
-
对齐填充:并不是必然存在的,仅仅起的是占位符的作用。因为HotSpot VM的自动内存管理要求对象起始地址必须是8字节的整数倍。对象头部分正好是8字节的倍数,但当对象实例部分没有对齐时,就需要通过对齐填充来补全。
2.3.3 对象的访问定位
建立对象是为了使用对象,我们的Java程序需要通过栈上的reference数据来操作堆上的具体对象。由于reference类型在Java虚拟机规范中只规定了一个指向对象的引用,并没有定义这个引用通过何种方式去定位,访问堆中对象的具体位置,所以对象的访问方式也是取决于虚拟机实现而定的。目前主要的访问方式为使用句柄和直接指针。
- 使用句柄:Java堆划分一块内存来作为句柄池,reference中存储的是对象的句柄地址,而句柄包好了对象实例与类型数据各自的具体地址信息。
- 使用直接指针:如果使用指针访问,那么Java堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,而reference中存储的直接就是对象地址。
- 两者优缺点:
使用句柄的最大好处是reference中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而reference本身不会移动。
使用直接指针访问的最大好处是速度更快,它节省了一次指针定位的时间开销,由于对象的访问在Java中是非常频繁的,因此这类开销积少成多也是一项非常可观的执行成本。
2.4 实战:OutOfMemoryError异常
在Java虚拟机中,除了程序计数器外,虚拟机内存的其他几个运行时区域都有发生OutOfMemoryError异常的可能。
2.4.1 Java堆溢出
Java堆用于存储对象,只要不断地创建对象,并且保证GC Roots到对象之间有可达路径来避免垃圾回收机制清除这些对象。
我们来看一个例子:
public class HeapOOM {
static class OOMObject{
}
public static void main(String[] args){
List<OOMObject> list = new ArrayList<>();
//不断创建对象,并添加到list中,以保留引用,防止被垃圾回收器回收
while (true){
list.add(new OOMObject());
}
}
}
在这里设置Java堆的大小为20MB,不可扩展(将堆的最小值-Xms参数与最大值-Xmx参数设置为一样即可避免堆自动扩展),通过参数-XX:+HeapDumpOnOutOfMemoryError可以让虚拟机在出现内存溢出异常时Dump出当前的内存堆转储快照以便事后进行分析。
设置.png
输出结果如下:
结果.png
2.4.2 虚拟机栈和本地方法栈溢出
关于虚拟机栈和本地方法栈,在Java虚拟机规范中描述了两种异常:
- 如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出StackOverflowError异常。
- 如果虚拟机在扩展栈时无法申请到足够的内存空间,则抛出OutOfMemoryError异常。
我们通过设置-Xss:128k来设定栈容量为128k,然后通过不断调用方法来增加栈的深度。
public class JavaVMStackSOF {
private int stackLength = 1;
//不断调用方法,从而增加栈深度
public void stackLength(){
stackLength ++;
stackLength();
}
public static void main(String[] args){
JavaVMStackSOF oom = new JavaVMStackSOF();
try {
oom.stackLength();
} catch (Throwable e){
System.out.println("stack length: " + oom.stackLength);
throw e;
}
}
}
输出结果:
栈异常.png2.4.3 方法区和运行时常量池溢出
由于常量池分配在永久代内,我们可以通过-XX:PermSize 和 -XX:MaxPermSize限制方法区大小,从而间接限制其中常量池的容量。
String.intern()是一个Native方法,它的作用是:如果字符串常量池中已经包含了一个等于此String对象的字符串,则返回代表池中这个字符串的String对象;否则,将此String对象包含的字符添加到常量池中,并且返回此String对象的引用。
/**
* VM Args: -XX:PermSize=20M -XX:MaxPermSize=20M
*/
public class RuntimeConstantPoolOOM {
public static void main(String[] args){
//使用List保持着常量池引用,避免Full GC回收常量池行为
List<String> list = new ArrayList<>();
int i = 0;
while (true){
list.add(String.valueOf(i ++).intern());
}
}
}