深入理解JVM - Java 内存区域与内存溢出异常

时间:2023-02-15 11:54:14

本章节及以后所提及的JVM,均为Sun HotSpot JVM。【其他比较著名的JVM有BEA和IBM的JVM】

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

深入理解JVM - Java 内存区域与内存溢出异常

上图来源于网络,感谢

1.1、程序计数器[线程私有]

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

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

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

1.2、虚拟机栈[线程私有]

虚拟机栈生命周期与线程相同。虚拟机栈描述了Java方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧【是方法运行时的基础数据结构】用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。

以前我们常说的Java栈内存,这个栈其实现在所说的虚拟机栈或者说是虚拟机栈中局部变量表部分。

局部变量表 - 存放编译期间可知的各种基本数据类型、对象引用类型、returnAddress(指向了一条字节码指令的地址)。其中64位的long和double类型数据会占用两个局部变量空间,其余的数据类型都只占用一个。局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。

可能抛出的异常:*Error[线程请求的栈深度大于虚拟机所允许的深度]、OutOfMemoryError[虚拟机栈可以进行动态扩展的(当前大多数的Java JVM都可动态扩展,只不过Java虚拟机规范中允许固定长度的虚拟机栈),如果扩展虚拟机栈时无法申请到足够的内存,则抛该异常]

1.3、本地方法栈[线程私有]

与虚拟机栈(为虚拟机执行Java方法[也就是字节码]服务)的作用非常相似,只不过是为虚拟机使用到的Native方法服务。

【线程私有,随线程而生,随线程而灭】

1.4、Java堆[线程共享]

在虚拟机启动时创建,此区域的唯一的目的就是存放对象的实例。这一点在Java虚拟机规范中的描述是:所有的对象实例以及数组都要在堆上分配。但随着JIT编译器的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化发生,因此,所有的对象都分配在堆上也渐渐的变得不是那么“绝对”了。

Java堆是垃圾回收器管理的主要区域;

从内存回收的角度:由于现在很多收集器基本都采用分代收集算法,所以Java堆还可以细分为新生代和老年代。

从内存分配的角度:可划分为多个线程私有的分配缓冲区。

可抛出异常:OutOfMemoryError

1.5、方法区[线程共享]

用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。该区域内存回收目标主要是针对常量池的回收和对类型的卸载。

对于习惯在HotSpot虚拟机上开发、部署程序的开发者来说,很多人都愿意把方法区成为“永久代”,当然这仅仅是因为HotSpot虚拟机团队选择使用永久代来实现方法区而已,这样HotSpot的垃圾收集器可以像管理Java堆一样管理这部分的内存,能够省去专门为方法区编写内存管理代码的工作。对于其他虚拟机(BEA JRockit、IBM J9)并不存在永久代的概念的。目前,HotSpot虚拟机,根据官方发布的路线信息,也有放弃永久代并逐步改为采用Native Memory来实现方法区的规划。

可抛出的异常:当方法区无法满足内存分配需求时,抛出OutOfMemoryError异常。

1.5.1、运行时常量池

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

运行时常量池相对于Class文件常量池[Java Java中的常量池(字符串常量池、class常量池和运行时常量池)]的另外一个重要特征是具备动态性,Java语言并不要求常量一定只有编译期产生,也就是并非预置入Class文件中常量池的内容才能进入方法区运行时常量池,运行期间也可能将新的常量放入池中,比如String类的intern()方法。

可抛出异常:OOM

1.6、直接内存

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

Java1.4中引入了NIO,基于通道和缓存区,可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆中的DirectByteBuffer对象来作为这块内存的引用进行操作。这样能显著的提升性能,避免了在Java和Native堆中来回复制数据。

1.7、对象的创建

1.7.1、创建

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

在类加载检查通过之后,虚拟机将为新生的对象分配内存。

另外一个需要考虑的问题是对象的创建在虚拟机中是非常频繁的行为,即使是仅仅修改一个指针所指向的位置,在并发的情况下也不是线程安全的。解决方案:

a、对分配内存的操作进行同步处理;

b、把内存分配的动作按照线程划分在不同的空间中进行,即每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer,TLAB)。哪个线程需要分配内存就在哪个线程的TLAB分配,只有TLAB用完才会分配新的TLAB时,才需要同步锁定。

下一步是初始化零值(不包括对象头),即设置默认值。

接下来,虚拟机需要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息。这些都存放在对象的对象头中。

new指令至此执行完成,紧接着执行<init>方法。由此一个真正可用的对象才完全产生出来。

1.7.2、内存布局

对象在内存中存储的布局可以划分为3个区域:对象头、实例数据、对齐填充。

对象头:

包含两个部分的信息:

a、存储对象自身的运行时数据,如对象的哈希码、对象的GC分代年龄、锁状态标志、线程持有的锁、偏向线程的Id、偏向时间戳等,这部分数据的长度在32位和64位的虚拟机中分别为32bit和64bit,官方成为“Mark Word”。对象头信息是与对象自身定义的数据无关的额外存储成本,考虑到虚拟机存储空间的效率,Mark Word被设计成一个非固定的数据结构,以便在极小的空间内存存储尽量多的信息。

b、类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。如果对象是一个Java数组,那么对象头还必须有一块记录数组长度的数据,因为虚拟机可以通过Java对象的元数据信息确定Java对象的大小,但是从数组的元数据中却无法确定数组的大小。

实例数据:对象真正存储的有效信息,也是在程序代码中所定义的各种类型的字段内容。无论是从父类继承下来的,还是在子类中定义的,都要记录下来。

对齐填充:对齐填充并不是必然存在的,也没有特别的含义,它仅仅起着占位的作用。由于HotSpot VM的自动内存管理系统要求对象的起始地址必须是8字节的整数倍,换句话说就是对象的大小必须是8字节的整数倍。而对象头部分正好是8字节的倍数,因此,当对象实例数据没有对齐的时候,就需要通过对齐填充来补全。

1.7.3、访问定位

主流方式有两种:使用句柄和直接指针(Hotspot使用的方式)。

深入理解JVM - Java 内存区域与内存溢出异常

通过句柄访问对象

深入理解JVM - Java 内存区域与内存溢出异常

通过直接指针访问对象

1.8、实战

public class RunTimeConstantPoolOOM {

public static void main(String[] args) {

String str1 = new StringBuilder("计算机").append("软件").toString();
System.out.println(str1.intern() == str1);
String str2 = new StringBuilder("ja").append("va").toString();
System.out.println(str2.intern() == str2);

}

}
如上这段代码,在JDK1.6运行的结果都是false,而在JDK1.7运行的结果是true,false。

原因:

【字符串常量池在Java内存区域的哪个位置?

在JDK6.0及之前版本,字符串常量池是放在Perm Gen区(也就是方法区)中;
在JDK7.0版本,字符串常量池被移到了堆中了。至于为什么移到堆内,大概是由于方法区的内存空间太小了。

JDK1.6中,intern()方法会将首次遇到的字符串实例复制到永久代中,返回的必然是永久代中这个字符串实例的引用,而有StringBuilder创建的字符串实例在Java堆上,所以必然是不同的引用,将返回false。
JDK1.7中,intern()方法不会再复制实例,只是在常量池中记录首次出现的实例引用,因此intern返回的和StringBuilder创建的那个字符串实例是同一个。对于str2因为字符串常量池中已经存在了它的引用,因此和StringBuilder在堆上创建的实例引用是不一样的,因此返回false。