JVM 运行时数据区

这篇文章是阅读《深入理解 Java 虚拟机》第 2 章的笔记,文章内容大都摘自该书,也添加了一些自己的理解。Java 虚拟机在运行程序时会把它所管理的内存划分为若干个不同的数据区域,《Java 虚拟机规范(Java SE 8 版)》的 Run-Time Data Areas 章节给出了这几个区域的定义,但是虚拟机厂商可以有各自不同的实现。

图 1:JVM 运行时数据区

程序计数器

程序计数器(Program Counter Register)是一块较小的内存空间,如果线程正在执行的是一个 Java 方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址,是每个线程私有的内存区域;如果执行的是 Native 方法,这个计数器的值是未定义(Undefined)的,具体实现时是什么值都可以。以 HotSpot VM 的实现为例,它目前在大多数平台上都使用 1:1 的线程映射模型,就是每个 Java 线程直接映射到一个 OS 线程上执行,此时 Native 方法就由原生平台直接执行,程序计数器的值其实就是原生平台 CPU 上 PC 寄存器的值。此内存区域是唯一一个在 Java 虚拟机规范中没有规定任何 OutOfMemoryError 情况的区域。

虚拟机栈

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

在 Java 虚拟机规范中,对这个区域规定了两种异常状况:

  • 如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出 StackOverflowError 异常;
  • 如果虚拟机栈可以动态扩展,在扩展时无法申请到足够的内存,就会抛出 OutOfMemoryError 异常。
// 1. 无限递归导致栈内存溢出
public class StackTest001 {
private static void infiniteRecursion() {
infiniteRecursion();
}
public static void main(String[] args) {
infiniteRecursion();
}
}

// Exception in thread "main" java.lang.StackOverflowError

// 2. 创建线程导致栈内存溢出
public class StackTest002 {
public static void main(String[] args) {
while (true) {
new Thread(() -> { while (true); }).start();
}
}
}

// Exception in thread "main" java.lang.OutOfMemoryError: unable to create new native thread

本地方法栈

本地方法栈(Native Method Stacks)与虚拟机栈所发挥的作用非常相似,只不过它是为虚拟机使用到的 Native 方法服务的,与虚拟机栈一样也可能抛出 StackOverflowError 和 OutOfMemoryError 异常。在虚拟机规范中对本地方法栈中方法使用的语言、使用方式与数据结构并没有强制规定,因此具体的虚拟机可以自己实现它,HotSpot VM 实现就直接把本地方法栈和虚拟机栈合二为一了。

Java 堆

Java 堆(Heap)是被所有线程共享的一块内存区域,在虚拟机启动时创建,几乎所有的对象实例都在这里分配内存,也是垃圾回收(Garbage Collection)作用的主要区域。Java 虚拟机规范规定,Java 堆可以处于物理上不连续的内存空间中,只要逻辑上连续即可。在实现时,既可以是固定大小的,也可以是可扩展的,不过当前主流的虚拟机都是按照可扩展来实现的(通过 -Xmx 和 -Xms 控制)。如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出 OutOfMemoryError 异常。

// -Xms1m -Xmx1m -XX:+HeapDumpOnOutOfMemoryError
public class HeapTest {
public static void main(String[] args) {
List<HeapTest> list = new ArrayList<>();
while (true) {
list.add(new HeapTest());
}
}
}

// java.lang.OutOfMemoryError: Java heap space
// Dumping heap to java_pid16444.hprof ...
// Heap dump file created [2794227 bytes in 0.045 secs]
// Exception in thread "main" java.lang.OutOfMemoryError: Java heap space

方法区

方法区(Method Area)与 Java 堆一样,是各个线程共享的内存区域,它用于存储已经被虚拟机加载的类信息、常量、静态变量、即时编译后的代码等数据。HotSpot VM 实现(JDK 1.8 之前)中,使用永久代(Permanent Generation)实现了方法区,这样垃圾收集器就可以像管理 Java 堆一样管理这部分内存,省去了专门为方法区编写内存管理代码的工作,对其他虚拟机实现来说并不在永久代这个概念。Java 虚拟机规范对方法区的限制非常宽松,除了和 Java 堆一样不需要连续的内存和可以选择固定大小或者可扩展外,还可以选择不实现垃圾回收,当方法区没法满足内存分配需求时,将抛出 OutOfMemoryError 异常。

运行时常量池(Runtime Constant Pool)是方法区的一部分。Class 文件中处理有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池(Constant Pool Table),用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后放入方法区的运行时常量池中。运行时常量池具备动态性,即运行期间也可能将新的常量放入池中,这种特性被开发人员利用得比较多的便是 String 类的 intern() 方法。运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存时会抛出 OutOfMemoryError 异常。

String 类的 intern() 方法是一个 Native 方法,它的作用是:如果字符串常量池中已经包含一个与此 String 对象相同的字符串,则返回此 String 对象的引用;否则,将此 String 对象包含的字符串添加到常量池中,并且返回此 String 对象的引用。在 JDK 1.6 及之前的版本中,由于常量池分配在永久代,可以通过限制永久代大小,然后不断往常量池中添加字符串触发 OutOfMemoryError 异常

// -XX:PermSize=1m -XX:MaxPermSize=1m
public class MethodAreaTest001 {
public static void main(String[] args) {
// 使用 List 持有引用, 防止被对象被回收
List<String> list = new ArrayList<>();
int i = 0;
while (true) {
list.add(String.valueOf(i++).intern());
}
}
}

// JDK 1.6 下会触发异常, 而 JDK 1.7 不会:
// JDK 1.7 中将运行时常量池从永久代移到堆中
// Exception in thread "main" java.lang.OutOfMemoryError: PermGen space

// JDK 1.8 中限制堆内存的大小, 代码抛出异常:
// -Xms1m -Xmx1m -XX:+HeapDumpOnOutOfMemoryError
// java.lang.OutOfMemoryError: Java heap space
// Dumping heap to java_pid21057.hprof ...
// Heap dump file created [2559063 bytes in 0.019 secs]
// Exception in thread "main" java.lang.OutOfMemoryError: Java heap space

// JDK 1.8 中永久代已经被移除, 有如下警告:
// Java HotSpot(TM) 64-Bit Server VM warning: ignoring option PermSize=1m; support was removed in 8.0
// Java HotSpot(TM) 64-Bit Server VM warning: ignoring option MaxPermSize=1m; support was removed in 8.0

在 JDK 1.6 中,intern() 方法会把首次与辅导的字符串实例复制到永久代中,返回的也是永久代中这个字符串实例的引用。而在 JDK 1.7 以后,运行时常量池从永久代移到了堆中,intern() 也不会再复制实例,只是在常量池中记录首次出现的实例引用。美团技术团队的一篇文章 深入解析 String#intern 对 intern() 的使用做了详细的分析,还提供了两个例子展示 intern() 在 JDK1.7 以后的表现,关键点是:字符串字面量是会在常量池中创建实例的, 而 intern() 只在常量池中记录首次出现的实例的引用。

public class ConstantPoolTest001 {
public static void main(String[] args) {
String s = new String("1");
s.intern();
String s2 = "1";
System.out.println(s == s2);
// false

String s3 = new String("1") + new String("1");
s3.intern();
String s4 = "11";
System.out.println(s3 == s4);
// true
}
}

public class ConstantPoolTest002 {
public static void main(String[] args) {
String s = new String("1");
String s2 = "1";
s.intern();
System.out.println(s == s2);
// false

String s3 = new String("1") + new String("1");
String s4 = "11";
s3.intern();
System.out.println(s3 == s4);
// false
}
}
0%