闲聊JVM内存结构划分

1 JVM 内存结构

JVM内存结构

JVM 在执行 Java 程序时会把它管理的内存划分为若干个不同的数据区域。根据《Java 虚拟机规范》的规定,JVM 所管理的内存将包括以下几个运行时数据区域:

  • 程序计数器
  • Java 虚拟机栈
  • 本地方法栈
  • 方法区

2 程序计数器

此内存区域是唯一在《Java虚拟机规范》中没有规定任何 OOM 情况的区域

程序计数器是一块比较小的内存空间,当前线程是依赖它知道接下来该执行哪条指令的,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖它完成。

JVM 的多线程是通过线程轮流切换、分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器(对于多核处理器来说就是一个内核)都只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要一个独立的程序计数器,独立存储,互不影响,我们称这类内存区域为“线程私有”的内存。

如果线程正在执行的是 Java 方法,那么这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行本地方法,那么这个计数器的值应该为空(Undefined)。

3 Java 虚拟机栈

此内存区域是线程私有的

Java 虚拟机栈描述的是 Java 方法执行的线程内存模型,具体如下:

  • 每个方法被执行的时候,JVM 都会同步创建一个栈帧用于存储局部变量表操作数栈动态连接方法出口等信息。每个方法被调用直至执行完毕的过程,就对应着一个栈帧在 JVM 中从入栈到出栈的过程。

3.1 局部变量表

局部变量表存放了编译期可知的各种 Java 虚拟机基本数据类型、对象引用和 return Address 类型。

  • 基本数据类型
    boolean、byte、short、int、float、long、double、char
  • 对象引用(reference 类型)
    它可能是一个指向对象起始地址的引用指针;也可能是指向一个代表对象的句柄;又或者是其他与此对象相关的位置
  • return Address 类型
    指向一条字节码指令的地址

这些数据类型在局部变量表中的存储空间以局部变量槽(Slot)来表示,其中 64 位长度的 long 和 double 类型的数据会占用两个 Slot,其余只会占用一个。

局部变量表所需要的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在栈帧中分配多大的局部变量空间是完全确定的,方法运行期间是不会改变局部变量表的大小的。不过这里需要注意!!!这里的大小指的是 Slot 的个数,至于一个 Slot 占用多大的内存是由虚拟机自己决定的

在《Java虚拟机规范》中,对这个内存区域规定了两类异常:

  • 如果线程请求的栈深度大于虚拟机允许的深度,那么将抛出 StackOverflowError
  • 如果 Java 虚拟机栈容量可以动态扩展,当栈扩展时无法申请到足够的内存将会抛出 OutOfMemoryError(HotSpot虚拟机的栈容量是不允许动态扩展的,所以只要线程申请栈空间成功就不会有 OOM,只有申请失败时才会 OOM)

3.2 通过汇编了解 Java 虚拟机栈的工作过程

示例代码

public class Test {
    public static void main(String[] args) {
        int a = 1;
        int b = 2;
        Test test = new Test();
        int result = test.caculate(a, b);
        System.out.printf("result = %d%n", result);
    }
    
    public int caculate(int a, int b) {
        return a + b;
    }
}

要运行 main 方法,肯定先得为它开辟一个线程内存空间,并且创建一个栈帧;等程序运行到调用 caculate 方法时,又要为这个方法创建一个栈帧。线程的内存结构如下图


线程内存图.png

我们先用命令 javap -c D:\IdeaProjects\me\czl-java\target\test-classes\com\czl\java\demo\Test.class得到汇编代码(里面的注释是我根据《JVM 指令手册》写上去的)

public class com.czl.java.demo.Test {
  public com.czl.java.demo.Test();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return

  public static void main(java.lang.String[]);
    Code:
       0: iconst_1 // 将int类型常量1压入操作数栈
       1: istore_1 // 将1赋值给a
       2: iconst_2 // 将int类型常量2压入栈
       3: istore_2 // 将2赋值给b
       4: new           #2                  // class com/czl/java/demo/Test
       7: dup
       8: invokespecial #3                  // Method "<init>":()V
      11: astore_3 // 到这才算完成 Test test = new Test()
      12: aload_3 // 变量test压入操作数栈
      13: iload_1 // 变量a压入操作数栈
      14: iload_2 // 变量b压入操作数栈
      15: invokevirtual #4                  // Method caculate:(II)I
      18: istore        4 // result = 3完成
      20: getstatic     #5                  // Field java/lang/System.out:Ljava/io/PrintStream;
      23: ldc           #6                  // String result = %d%n
      25: iconst_1
      26: anewarray     #7                  // class java/lang/Object
      29: dup
      30: iconst_0
      31: iload         4
      33: invokestatic  #8                  // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
      36: aastore
      37: invokevirtual #9                  // Method java/io/PrintStream.printf:(Ljava/lang/String;[Ljava/lang/Object;)Ljava/io/P
rintStream;
      40: pop
      41: return

  public int caculate(int, int);
    Code:
       0: iload_1 // 1压入操作数栈
       1: iload_2 // 2压入操作数栈
       2: iadd // 2 + 1
       3: ireturn // 返回3
}

操作数栈
看上面的汇编代码的 caculate 方法,先将 a 入栈,再将 b 入栈,然后才能进行计算,最后出栈并返回结果3给 result。这些操作都是在操作数栈完成的。

方法出口
指的是方法执行结束的地方,也就是方法的最后一条指令执行完成后的位置。在 JVM 中,方法出口通常是指令序列的最后一条指令,该指令负责将方法的返回值加载到操作数栈上,并将控制权返回给调用方。

动态连接
是指在程序运行时,将代码中的符号引用与符号定义进行关联的过程。在编译阶段,源代码中的函数调用通常是通过符号引用来表示的,而这些符号引用需要在程序运行时与相应的符号定义进行关联,以便正确地执行代码。
动态连接的过程包括以下几个步骤:
1. 符号解析(Symbol Resolution): 在程序运行时,动态连接器(Dynamic Linker)会根据符号引用的名称和相关的信息,查找符号定义的位置。这个过程称为符号解析。
2. 地址重定位(Address Relocation): 找到符号定义后,动态连接器会将符号引用的地址更新为符号定义的地址,以便程序能够正确地调用函数或访问变量。这个过程称为地址重定位。
3. 符号绑定(Symbol Binding): 在完成地址重定位后,符号引用和符号定义已经成功地关联起来,程序就可以通过引用来调用定义的函数或访问定义的变量。这个过程称为符号绑定。

动态连接使得程序可以在运行时动态地加载和链接共享库(如动态链接库 .dll 或 .so 文件),以及进行函数的动态调用。这种机制使得程序具有更大的灵活性和可扩展性,能够在运行时动态加载所需的库,并根据需要链接相应的函数,从而实现模块化和动态性。

4 本地方法栈

本地方法栈的作用与 Java 虚拟机栈的作用是非常相似的,区别在于 Java 虚拟机栈服务于 Java 方法,而本地方法栈服务于本地方法。
虚拟机可以根据自己需要自由实现本地方法栈,《Java 虚拟机规范》中对本地方法栈并没有任何强制规定。HotSpot 虚拟机更是直接把本地方法栈和 Java 虚拟机栈合二为一了。

5 Java 堆

我们都知道大多数对象都是在堆上分配的,它也是 JVM 管理内存中最大的一块,它在 JVM 启动时创建。《Java虚拟机规范》规定 Java 堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续的。但是对于大对象(如数组对象),多数虚拟机出于实现简单、存储高效的考虑,可能会要求连续的内存空间。

Java 堆可以被实现成固定的,也可以是可扩展的。目前主流的 JVM 都是按照可扩展来实现的,通过参数 -xmx 和 -xms 来设定。如果在 Java 堆中没有内存完成实例分配,并且堆也无法扩展时,JVM 将会抛出 OutOfMemoryError 异常。

堆对于 JVM 调优来说太重要了,要了解的地方也十分多,所以有机会再拎出来补充详细。

6 方法区

方法区与 Java 堆一样,也是线程共享的内存区域。它用于存储已被 JVM 加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。

说到方法区,就不得不提一下 JDK7 以前很多老程序员会把方法区说成是永久代,其实这是不正确的。不过他们会这么说的原因是因为当时的 HotSpot 虚拟机设计团队将垃圾收集器的分代设计扩展至方法区(即用永久代实现了方法区),这样就可以直接让垃圾收集器管理方法区这块内存,不需要专门为管理方法区专门写内存管理代码。不过从 JDK6 开始,HotSpot 就开始有放弃永久代实现方法区,而采用本地内存的元空间实现的计划了。直至 JDK8,完全使用元空间实现方法区。

根据《Java虚拟机规范》的规定,当方法区无法满足新的内存分配需求时,将抛出 OutOfMemoryError 异常。

6.1 运行时常量池

运行时常量池是方法区的一部分

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

运行时常量池相对于 Class 文件常量池的另一个重要特性是“动态性”。也就是说并不是常量一定只能在编译期才能产生,运行期间也是可以的。最典型,最常见的例子就是 String.intern() 方法。

需要注意的是,当运行时常量池无法申请到内存时将会抛出 OOM。

7 直接内存

直接内存不是《Java 虚拟机规范》中定义的内存区域,但是这块也会被经常使用,并且也可能导致 OOM 出现。

下面举个例子:
众所周知,自 JDK1.4 开始新加入了 NIO 类,它可以使用本地函数库直接分配堆外内存,减少了 Java堆 和 JVM 管理外的内存之间的复制,从而提升性能。但是有时服务器管理人员可能会忽略本地内存,从而把 -xmx 等设置较大,并且使得各大内存区域之和大于服务器本身的物理内存(包括无力的和操作系统的限制),从而导致 OOM 发生。

©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

相关阅读更多精彩内容

友情链接更多精彩内容