Zohar's blog

Java - 虚拟机:Java 内存模型

Javajavajvmjava memory model

了解 Java 内存模型,是探究 JVM 的第一步。

Java 内存模型,分为线程私有区域、线程共享区域和直接内存。线程私有区域包括程序计数器虚拟机栈本地方法栈。线程共享区域包括方法区

线程私有区域的生命周期与线程相同,随用户线程的启动而创建,随用户线程的结束而销毁。

线程共享区域的生命周期与虚拟机相同,随虚拟机的启动而出创建,随虚拟机的关闭而销毁。

Java 运行时数据区

1. 程序计数器

程序计数器(Program Counter Register)用于存储下一条运行的字节码指令地址。是线程私有的。是 Java 运行时数据区中唯一一个不会抛出 OutOfMemoryError 的内存区域。

如果此时正在执行 Java 方法,计数器记录的是虚拟机字节码指令的地址。如果此时正在执行 Native 方法,则为空。

2. 虚拟机栈

虚拟机栈(Java Virtual Machine Stack)是线程的内存模型,而虚拟机栈中的栈帧则是 Java 方法的内存模型栈帧(Stack Frame)虚拟机栈中元素,每个方法被执行的时候,JVM 都会同步创建一个栈帧用于存储局部变量表、操作数栈、动态链接、方法出口和一些额外信息。每一个方法从调用开始直到执行结束的过程,都对应着栈帧的入栈和出栈过程(无论方法是正常返回还是抛出异常,都算作方法执行结束)。

虚拟机栈是线程私有的。如果线程请求的栈深度大于虚拟机所允许的栈深度,会抛出 StackOverFlowError。如果虚拟机栈的栈深度可以动态扩展,当栈扩展时无法申请到足够的内存,就会抛出 OutOfMemoryError

2.1. 局部变量表

局部变量表(Local Variable Table)用于存储方法参数和方法内部定义的局部变量,它的存储单位是变量槽(Variable Slot)。

局部变量表有三个需要注意的点:

  1. 变量槽大小

    虚拟机规范中并没有规定变量槽的大小,仅仅建议一个变量槽应该能够存放 4 个字节的数据类型。在变量槽为 4 个字节的时候,对于 long 和 double 类型,会以高位对齐的方式为其分配连续两个变量槽空间,同时规定无法单独访问这两个变量槽中的任何一个。由于虚拟机栈是线程私有的,因此不必担心这种非原子操作会带来线程安全问题。

  2. 局部变量表的最大容量

    局部变量表的最大容量是编译期可知的。class 文件 code 属性中的 max_locals 数据项确定了局部变量表的最大容量。如果执行的方法是实例方法,则会将 this 指针放在第 0 个变量槽。

  3. 变量槽复用

    方法体中局部变量的作用域通常无法覆盖整个方法体,因此当程序计数器的指令编号已经超出某局部变量作用域时,这个变量槽就会交给其他变量使用。但这会对垃圾收集造成影响,因为如果变量槽没有被成功复用到的话,变量槽依旧会保持着对原对象的引用。如果这个对象在后面并没有被使用到,因为局部变量表是 GC Root 的一部分,所以会造成浮动垃圾的问题。

     public static void main(String[] args) {
         {
             byte[] buffer = new byte[64 * 1024 * 1024];
         }
         System.gc();
     }
    

    GC 结果:

     [GC (System.gc())  70620K->66416K(241664K), 0.0013887 secs]
     [Full GC (System.gc())  66416K->66195K(241664K), 0.0051875 secs]
    

    buffer 变量所占用的变量槽即使超出作用域,由于没有被复用,该变量槽依旧保持着对该对象的引用,因此 GC 不会对它的内存进行回收。如果复用这个变量槽则会进行回收:

     public static void main(String[] args) {
         {
             byte[] buffer = new byte[64 * 1024 * 1024];
         }
         int a = 0;
         System.gc();
     }
    

    GC 结果:

     [GC (System.gc())  70620K->66416K(241664K), 0.0016502 secs]
     [Full GC (System.gc())  66416K->659K(241664K), 0.0052463 secs]
    

2.2. 操作数栈

操作数栈(Operand Stack)是用来存储字节码指令操作数的栈。在方法的执行过程中,JVM 会将各种运算所涉及到的操作数压入栈顶,然会调用指令进行执行。JVM 的解释执行引擎被称为“基于栈的执行引擎”,说的就是操作数栈。

操作数栈有两个需要注意的点:

  1. 操作数栈的最大深度

    操作数栈的最大深度是编译期可知的。class 文件 Code 属性中的 max_stacks 数据项记录了操作数栈的最大深度。

  2. 栈帧重叠

    由于操作数栈中的变量往往会成为新调用方法的参数,因此虚拟机实现往往会做一些优化处理,将下面栈帧的操作数栈和上面栈帧的局部变量表重叠,不仅节约了内存空间,还可以省去参数传递的消耗。

    Stack Frame Memory Sharing

2.3. 动态连接

动态连接(Dynamic Linking)用来保存方法体中的动态调用点限定符在运行时解析出的内存地址。

在将 Java 文件编译成 class 文件的时候,class 文件的常量池中存在大量的符号引用,编译时不可能知道这些符号引用所指向的内存地址,只能在运行过程中解析这些符号引用在方法区中的内存地址,这个过程称为连接,具体是指类加载机制中的解析阶段。如果对一个符号引用进行解析后,后续在使用这个符号引用时会一直返回之前解析的结果,就称为静态解析。如果每次使用该符号引用都需要重新解析,就称为动态解析

静态解析出来的符号引用到内存地址的映射信息存储在方法区的运行时常量池中。由于动态解析每一次解析出来的结果是不同的,无法确定具体指向的是哪一个内存地址,因此需要在栈帧中保存动态解析出来的结果。

需要动态解析的符号引用被称为“动态调用点限定符”,之所以需要动态解析是因为调用该符号引用的字节码指令为 invokedynamic 指令。

2.4. 方法出口

方法出口(returnAddress)用于存储当前方法在主调方法中的调用地址,方法执行完毕会通过方法出口返回主调方法执行的位置。遇到异常调用完成,根据异常管理器确定方法出口。

方法有两种退出方式:分别是正常调用完成异常调用完成,无论哪种方式在方法执行完毕都要回到主调方法执行的位置。因此需要在栈帧中保存当前方法的返回地址,栈帧出栈的过程中,程序计数器的值就是那个返回地址。而异常调用完成,返回地址由异常处理器确定,不会使用栈帧中的返回地址。

3. 本地方法栈

虚拟机栈为 Java 方法服务,而本地方法栈(Native Method Stack)为 Native 方法服务。本地方法栈与虚拟机栈都是线程私有的。抛出的异常和条件也与虚拟机栈一致。如果线程请求的栈深度大于虚拟机所允许的栈深度,会抛出 StackOverFlowError。如果虚拟机栈的栈深度可以动态扩展,当栈扩展时无法申请到足够的内存,就会抛出 OutOfMemoryError

不过 HotSpot 虚拟机直接将本地方法栈和虚拟机栈合二为一了。

4. Java 堆

Java 堆(Heap)是线程共享的数据区域,用于存放对象实例,目前阶段几乎所有对象实例都是在堆上分配的。堆也是进行垃圾收集最重要的数据区域。当堆内存不够时,会抛出 OutOfMemoryError

5. 本地方法区

本地方法区(Method Area)是线程共享的数据区域,用于存储被虚拟机加载的类型信息、常量、静态变量和代码缓存等数据。当方法区内存不够时,会抛出 OutOfMemoryError

方法区与永久代
JVM 规范中把方法区描述为堆的一个逻辑部分,但它有一个别名称为“非堆”,目的是与堆区分开来。
JDK8 以前,HotSpot 虚拟机设计团队使用永久代来实现方法区,使得 HotSpot 的垃圾收集器能够像管理堆一样管理永久代内存。这种设计导致了 Java 应用更容易遇到内存溢出的问题,因为永久代有上限设置,即使不设置也有默认值。
JDK7 开始,HotSpot 逐渐开始“去永久代”计划,在 JDK7 中将运行时常量池移到 Java 堆中。
到了 JDK8,则完全废弃了永久代的概念,改为在本地内存中实现的元空间(Metaspace)来代替永久代,即方法区。

5.1. 运行时常量池

运行时常量池(Runtime Constant Pool)是方法区的一部分。用于存储类加载时 class 文件中包含的各种常量、字面量和符号引用。同时,在运行期间符号引用静态解析出来的结果同样存储在常量池中。当运行时常量池无法分配内存时,会抛出 OutOfMemoryError

JDK7 以后常量池移到堆中,所以虚拟机可以对常量池进行垃圾收集,当常量池中的对象没有被引用时,会被回收。

String::intern 方法的作用可以将字符串主动添加到常量池中,如果常量池中存在要添加的字符串,则返回常量池中已有的字符串,如果不存在,则在常量池中添加该字符串再返回。判断是否存在通过 String::equals 方法进行逻辑比较。

由于 JDK7 开始常量池被移入堆中,因此 String::intern 在 JDK6 和 JDK7 会有不同的表现:

JDK6 中,如果字符串不存在于常量池中,则会在常量池中创建待添加的字符串并返回。JDK7 中如果字符串不存在于常量池中,则会在常量池中添加当前字符串的引用,再返回该引用。因此带来了以下的差异:

String str = new StringBuilder("HELLO").append(" WORLD").toString();
System.out.println(str == str.intern());

以上代码在 JDK6 中返回 false,因为是两个不同的对象,而在 JDK7 中返回 true,因为都指向堆中的同一个对象。

6. 直接内存

直接内存(Direct Memory)并不是 JVM 内存区域的一部分,但 JVM 同样可以操作直接内存。尤其是在 JDK1.4 引入的 NIO 提供了基于 Channel 与 Buffer 的 IO 方式,它可以使 Native 函数库直接分配堆外内存,然后使用 DirectByteBuffer 对象作为这块内存的引用进行操作。这样就避免了在 Java 堆和 Native 堆中来回复制数据的操作,因此在一些场景中可以显著提高性能。

在主机内存不足时,直接内存也会抛出 OutOfMemoryError