运行时数据区域

󰃭 2016-05-15


title: Java内存区域与内存溢出异常 date: 2016-05-07 categories: 深入理解Java虚拟机 tags: JVM

运行时数据区域

  • 方法区(所有线程共享)
  • 堆(所有线程共享)
  • 虚拟机栈(线程隔离)
  • 本地方法栈(线程隔离)
  • 程序计数器(线程隔离)

程序计数器

程序计数器可以看作是当前线程所执行字节码的行号指示器。虚拟机概念模型中,字节码解释器工作时就是通过改变中国计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖计数器。

Java虚拟机栈

和程序计数器一样,线程私有。生命周期与线程相同。
虚拟机栈描述的是Java方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧(Stack Frame),用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈道出栈的过程。

局部变量表存放了编译器可知的各种基本数据类型(boolean, byte, char, short, int, float, long, double)、对象引用。

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

Java虚拟机规范中,对这个区域规定了2中异常情况:

  1. 线程请求栈深度大雨虚拟机所允许的深度,将抛出StackOverflowError异常;
  2. 虚拟机栈可以动态扩展,当扩展式无法申请到足够内容,就会抛出OutOfMemoryError(OOM)异常。

本地方法栈

与虚拟机栈作用相似。虚拟机栈执行Java方法,本地方法栈执行虚拟机用到的Native方法。不过,虚拟机规范中对本地方法栈中方法使用的语言、使用方式与数据结构并没有强制规定,因此虚拟机可自由实现。

Java堆

Java堆(Java Heap)是Java虚拟机所管理的内存中最大的一块,Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区唯一目的就是存放对象实例,几乎所有对象实例都在这分配内存。

方法区

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

运行时常量池

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

直接内存

直接内存并不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域。但是这部分内存也被频繁地使用,也可能导致OOM出现。
在JDK1.4,新加了NIO(New Input/Output)类,引入了一种基于通道与缓冲区的I/O方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆中的DirectByteBuffer对象作为这块内存的引用进行操作。这样能在一些场合显著提高性能,因为避免了在Java堆中和Native堆中来回复制数据。
配置JVM参数时,会根据实际内存设置-Xmx等参数信息,但经常忽略直接内存。

对象的创建

当虚拟机遇到一条new指令时,首先会检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过,如果没有,必须先执行相应的类加载过程。

  1. 类加载检查通过。
  2. 为新生对象分配内存,对象所需内存大小在加载完毕后可完全确定。
  • Java堆内存绝对规整时,采用“指针碰撞”分配:所有用过的内存在一边,没用过的在另一边,中间放着一个指针作为分界点的指示器。
  • Java堆中内存不规整时,采用"空闲列表"分配:已使用的内存和未使用的会相互交错,虚拟机必须维护一个列表记录哪些内存快可用,在分配的时候从列表找到一块足够大的空间划分给对象实例,并更新列表记录。
  1. 内存分配完成后,虚拟机将分配到的内存空间都初始化为零值(不包括对象头)。这一步保证了对象的实例字段在Java代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。
  2. 虚拟机对对对象进行必要设置,例如对象是哪个类的实例,如何能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息。这些信息存放在对象的对象头(Object Header)中。根据虚拟机当前运行状态不同,如是否启用偏向锁等,对象头会有不同的设置方式。
  3. 上面工作完成之后,从虚拟机角度来看,一个新的对象已经产生,但从Java程序的角度来看,对象创建才刚刚开始—-init方法还没有执行,所有的字段都还为零。一般来说,执行new指指令之后会接着执行init方法,把对象按照程序员的医院进行初始化,这样一个真正可用的对象才算完全产生出来。

对象内存布局

在HotSpot虚拟机中,对象在内存中存储的布局可分为3块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。
对象头包括2部分信息,第一部分用于存储对象自身的运行时数据,如哈希码、GC分代年龄、锁状态标识、线程持有的锁、偏向线程ID、偏向时间戳等。另一部分信息是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个来的实例。如果对象是一个Java数组,那在对象头中还必须有一块用于记录数组长度的数据,因为虚拟机可以通过普通Java对象的元数据信息确定Java对象大小,但是从数组的元数据却无法确定数组的大小。

对象访问定位

Java需要通过栈上的reference数据来操作堆上的具体对象,由于reference类型在Java虚拟机规范中只规定了一个指向对象的引用,并没有定义这个引用应该通过何种方式去定位、访问堆中对象的具体位置,所有对象访问方式也是取决于虚拟机实现而定。目前主流访问方式有使用句柄和直接指针两种。

  • 句柄访问,Java堆中将会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息。
  • 指针访问,Java堆对象布局就必须考虑如何放置访问类型数据的相关信息,而reference中存储的直接就是对象地址。
    句柄访问最大好处是reference中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而reference本身不需要修改。
    指针访问最大好处是速度更快,节省了一次指针定位的时间开销。

JVM参数

  • -Xms和-Xmx 堆最小、最大值,大小值相同可避免自动扩展。
  • -XX:+HeapDumpOnOutOfMemoryError可以让虚拟机在出现内存溢出错误时Dump出当前的内存堆转存储快照以便事后进行分析。
  • -Xss 栈大小
  • -XX:PermSize和-XX:MaxPermSize 方法区最小、最大值。
  • -XX: MaxDirectMemorySize,如不指定默认与-Xmx一样。

方法区溢出

/**
 * VM args: -XX:PermSize=10M -XX:MaxPermSize=10M
 */
public static void main(String[] args){
	List<String> list = new ArrayList<String>();
	int i = 0;
	while(true){
		list.add(String.valueOf(i++).intern());
	}
} 

上述代码在JDK1.7之前方法区内存不足时提示信息为"PermGen space",说明运行时常量池属于方法区(HotSpot JVM中永久代)。而在JDK1.7运行时while将一直进行下去,不受PermSize限制,因为1.7开始逐步“去永久代”。

String.intern()

public static void main(String[] args){
	String str1 = new StringBuilder("abc").append("xyz").toString();
	System.out.println(str1.intern() == str1);
	String str2 = new StringBuilder("ja").append("va").toString();
	System.out.println(str2.intern() == str2);
}

上述代码在JDK1.6中执行,会得到2个false。而在JDK1.7中执行,会得到一个true和一个false。
在JDK1.6中,intern()方法会把首次遇到的字符串实例复制到永久代中。返回的也是永久代中这个字符串实例的引用,而有StringBuilder创建的字符串实例在Java堆上,所以必然不是同一个引用,将返回false。 在JDK1.7中,intern()的实现将不会再复制实例,只是在常量池中记录首次出现的实例引用,因此intern()返回的引用和由StringBuilder创建的那个字符串实例是同一个。对str2返回false是因为"java"这个字符串在执行StringBuilder.toString()之前已经出现过,字符串常量池中已经有它的引用了,不符合"首次出现"的原则。