跳至主要內容

JVM 内存结构(JVM Structure)

Mars大约 14 分钟JAVAJVMJAVA

本文主要分享的是 Java 内存区域结构,它和 JMM 内存模型不是同一个概念。JVM 内存结构和 Java 虚拟机的运行时区域(Runtime Data Areas)相关,定义了 JVM 在运行时如何分区存储程序数据,比如堆主要用于存放对象实例。而 Java 内存模型(JMM)和 Java 的并发编程相关。

我们知道所有的程序都是在内存中运行的,内存包括虚拟内存,在内存运行的过程中,需要不断的将内存中的逻辑地址与物理地址做映射,找到相关的指令与数据去执行。Java 虚拟机在执行 Java 程序的过程中会把它所管理的内存划分为若干个不同的数据区域。这些区域有各自的用途,以及创建和销毁的时间,有的区域随着虚拟机进程的启动而一直存在,有些区域则是依赖用户线程的启动和结束而建立和销毁。根据《Java虚拟机规范》的规定,Java 虚拟机所管理的内存将会包括以下几个运行时数据区域了,下面这张图是 JDK 1.8 的 JVM 内存结构图。

Java源码首先被编译成字节码,再由不同平台的 JVM 进行解析,Java 语言在不同的平台上运行时不需要进行重新编译,Java 虚拟机在执行字节码的时候,把字节码转换成具体平台上的机器指令。

JVM 主要由 Class Loader Subsystem、Runtime Data Areas、Execution Engine 以及 Native Interface 和 Native Library 组成。

Class Loader Subsystem

本小节将学习类从编译到执行的过程,包括编译、加载、链接、初始化几个主要步骤。

编译(Compile)

编译器将我们写好的 java 源文件通过 javac 编译为相同名称的 class 字节码文件,也就是我们常说的 .class 文件,这一阶段我们称为源码的"编译"。

加载(Loading)

加载阶段主要是获取字节码后,将字节码文件中的类信息加载进内存,并在内存中解析生成对应 java.lang.Class 对象的过程,这个阶段涉及到 JVM 内存结构 Runtime Data Areas 中的 Method Area (方法区), 主要需要完成以下3件事:

  1. ClassLoader 系统通过一个类的全限定名来获取此类的二进制字节流;
  2. 将这个字节流所代表的静态存储结构转换为方法区的运行时数据结构;
  3. 在内存中生成一个代表这个类的Class [1] 对象,作为在方法区中这个类的各种数据的访问入口。

加载这一步主要是由 类加载器 完成的,类加载器的种类有很多种,具体是由 双亲委派模型 [2] 来决定使用哪个类加载器进行加载。

加载阶段有两个重点:

  • 字节码来源:一般的加载来源包括从本地路径下编译生成的 .class 文件,从 jar 包中的 .class 文件,从远程网络,以及动态代理实时编译得到的字节码文件。
  • 类加载器:一般包括 Bootstrap Class LoaderExtension Class LoaderApplication Class Loader,我们还可以根据需要编写我们自定义的 ClassLoader [3]

链接(Linking)

加载阶段与链接阶段的部分动作(如一部分字节码文件格式验证动作)是交叉进行的,加载阶段尚未结束,链接阶段可能就已经开始了。链接阶段分为三个步骤:

校验 ----> 准备 ----> 解析

校验(Verify)

该阶段主要是字节码验证器检查加载的字节码的正确性和安全性,为了确保 Class 文件的字节流中包含的信息符合《Java 虚拟机规范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全。

验证阶段主要由四个检验阶段组成:

  • 文件格式验证(Class 文件格式检查),比如常量中是否有不被支持的常量,文件中是否有不规范的或者附加的其他信息;
  • 元数据验证(字节码语义检查),比如该类是否继承了被 final 修饰的类,类中的字段,方法是否与父类冲突,是否出现了不合理的重载;
  • 字节码验证(程序语义检查),保证程序语义的合理性,比如要保证类型转换的合理性;
  • 符号引用验证(类的正确性检查),比如校验符号引用中通过全限定名是否能够找到对应的类,校验符号引用中的访问性(privatepublic 等)是否可被当前类访问。这个阶段发生在类加载过程中的解析阶段,当 JVM 将符号引用 [4] (字面量 [5] ) 转换成直接引用 [6] 的使用的时候。

准备(Prepare)

准备阶段是为类变量(静态变量)分配内存空间并设置类变量的默认值的阶段。

注意事项如下:

  1. 该阶段仅会分配 static 修饰的静态变量,不包括实例变量;
  2. JDK 8 中已经将原本放在永久代的字符串常量池、静态变量移动到堆中,所以这个时候类变量会随着 Class 对象一起存放在 Java 堆(Heap)中。
  3. 该阶段只会对类变量设置默认值,在初始化阶段才会被赋上真正的值。

解析(Resolve)

解析阶段就是将常量池内的符号引用转换为直接引用的过程。 在解析阶段,JVM 虚拟机会把所有的类名,方法名,字段名这些符号引用 [4:1] 替换为具体对象的内存地址或偏移量,也就是直接引用 [6:1]

初始化(Initialization)

初始化阶段是类加载的最后一个阶段,主要是将类变量赋上真正的值,执行静态代码块。所有需要被使用到的类,都需要完成初始化操作,如果初始化一个类的时候,其父类尚未初始化,则优先初始化其父类。

Runtime Data Areas

🚩

Runtime Data Areas 分为所有线程共享的区域(方法区、JVM 堆)以及每个线程私有的区域(程序计数器、JVM 栈、Native 栈)。

Program Counter Register(程序计数器)

程序计数器是JVM 内存管理区域中最小的一块内存区域,它用来记录当前线程所执行的字节码的行号指示器(逻辑计数器),通过改变这个计数器的值来选取下一条需要执行的字节码指令,通过它来实现跳转、循环、恢复线程等功能。 它和线程是一对一的关系即线程私有。

JVM Stacks(虚拟机栈)

虚拟机栈是线程私有的,随线程生灭。虚拟机栈描述的是线程中的方法执行的内存模型,每个栈中的数据都是私有的,其它栈不能访问。

每个方法被执行的时候,JVM 虚拟机都会同步创建一个栈帧(Stack Frame),每个方法从被调用到执行完的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程,方法调用结束后,对应的栈帧就会被自动释放掉,这就是栈的内存空间不需要 GC 去回收的原因。

栈帧中存储了局部变量表、操作数栈、动态链接、方法返回地址等。

  • 局部变量表:存储着方法里用到的所有基本数据类型的变量以及对象的引用 (包含方法执行过程中的所有变量 )(byte,short,int,long,float,double,boolean,char)
  • 操作数栈:入栈、出栈、复制、交换、产生消费变量(javap -cjavap -verbose 执行后看到的 iload 等操作)
  • 方法返回地址: 指向了一条字节码指令的地址

虚拟机栈可能抛出的异常

  • 如果线程请求的栈深度大于虚拟机所规定的栈深度,则会抛出 StackOverFlowError 即栈溢出;
  • 虚拟机的栈容量可以动态扩展,当虚拟机栈申请不到内存时会抛出 OutOfMemoryErrorOOM 内存溢出。

Native Method Stacks(本地方法栈)

  • 本地方法栈与虚拟机栈的作用相似,主要作用于标注了 native [7] 的方法, 也可能会抛出 StackOverFlowErrorOutOfMemoryError 错误

Heap(Java 堆)

🚩

Java 堆是虚拟机管理的最大一块内存空间,是 GC 管理的主要区域所以也被称为 GC 堆。

Java 堆中存放的内容:

  • 所有的对象实例都在这里进行分配内存,基本类型的数组也是对象实例也保存在堆中;
  • 字符串常量池原本存放于方法区,jdk7 开始放置于堆中,字符串常量池存储的是字符串对象的直接引用,而不是直接存放的对象,是一张 String table;
  • static 修饰的类变量, jdk7 后从方法区迁移至堆中;
  • 线程分配缓冲区 (TLAB: Thread Local Allocation Buffer),它是为了提升对象分配时的效率。

Java 堆即可以是固定大小的,也可以是可扩展的,目前主流都是设计成可扩展的。虚拟机启动时可以指定堆大小,空间大小可以通过最大值参数 -Xmx 和最小值参数 -Xms 进行设定,当 Java 堆中没有足够的内存来完成实例分配,并且堆也无法再扩展时,Java 虚拟机将会抛出 OutOfMemoryError 异常。

Method Area(方法区)

每个 JVM 只有一个方法区,方法区也被称为 “非堆”,目的是与 Java 堆进行区分,方法区由元空间(MetaSpace)实现,并直接放到了本地内存中,不受 JVM 参数的限制,当本地物理内存被占满了,方法区也会报 OOM。

提示

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

方法区存放的内容:

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

Execution Engine

Execution Engine 对命令进行解析,解析成对应平台能执行的机器指令,然后再提交到操作系统中去运行。

执行引擎主要由3个部分组成:

  • Interpreter: 解释字节码更快但是执行较慢,当一个方法被多次调用,每次都需要重新解释;
  • JIT Compiler (Just In Time):解决了 Interpreter 的缺点,提升了性能,主要分为 Client Compiler (C1) 和 Server Compiler (C2) 这两种。
  • Garbage Collector: 用于收集并删除无用的引用

Java Native Interface

Java 也不总是完美的,它的运行速度比 C++ 慢上许多,还有它无法直接访问操作系统底层如硬件系统,所以 Java 提供了 JNI 来实现对于底层的访问。

提示

JNI 是 Java SDK 的一部分,JNI 允许 Java 代码使用以其他语言编写的代码和代码库,本地程序中的函数也可以调用 Java 层的函数,即 JNI 实现了 Java 和本地代码间的双向交互。

Native Method Library

Execution Engine 需要的 Native Libraries 的集合。

本文总结

  1. 了解了类从编译到执行的过程,包括编译、加载、链接、初始化
    • javac 将源代码编译成 class 字节码;
    • ClassLoader 将字节码转换成 JVM 中的 Class<?> 对象装载到内存中;
    • JVM 利用 Class<?> 对象实例化对象,交由 Execution Engine 解析字节码,然后再交给操作系统去执行;
    • Java 通过 JNI 去调用本地 Native 库;
    • 类的字节码被使用后,然后再被卸载;
  2. 了解了 JVM 运行时内存的各个区域;
    • Runtime Data Areas 分为所有线程共享的区域(方法区、JVM 堆)以及每个线程私有的区域(程序计数器、JVM 栈、Native 栈)。

参考

Oracle Docopen in new window

《深入理解 JAVA 虚拟机》第三版(2019.12)读书笔记open in new window

终于搞懂了java8的内存结构,再也不纠结方法区和常量池了!open in new window

Java内存区域详解open in new window


  1. 什么是 Class 对象

    类是程序的一部分,每个类都有一个Class 对象。我们每编写并且编译一个新类时,JVM 就会使用“类加载器”来产生一个 Class 对象(保存在与类同名的 .class 文件中)。

    Java 中类的动态加载

    Java 中所有的类都是在对其第一次使用时,动态加载到 JVM 中的。当程序创建第一个对类的静态成员的引用时,就会加载这个类。我们使用 new 操作符调用构造器来创建类的新对象也会被当做对类的静态成员的引用,即使构造器的声明之前并没有 static 关键字。

    类加载器首先检查这个类的Class 对象是否已经加载,如果尚未被加载,默认的类加载器就会根据类名查找对应的 .class 文件。类的字节码被加载时,它们会接受验证,以确保其没有被破坏,并且不包含不良的 Java 代码。

    一旦某个类的 Class 对象被载入内存,它就可以被用来创建这个类的其他所有对象。 ↩︎

  2. 什么是双亲委派模型

    由于 ClassLoader 的种类有多种,而且每种 loader 都可以加载 class, 所以它们之间就需要一种机制来协调工作,避免字节码被重复加载,这就是我们常说的双亲委派机制(模型)。

    • 先是自底向上检查类是否已经加载,如果已加载则返回加载的 Class 对象,如果没有加载则自顶向下尝试加载类并返回,如果类找不到则报错 ClassNotFoundException
    ↩︎
  3. 什么时候需要自定义 ClassLoader

    主要在以下两种场景中需要使用自定义 ClassLoader

    1. 由于 java 字节码可以被反编译,如果需要对字节码加密,可以对编译后的字节码进行加密,然后再通过实现自己的自定义类加载器进行解密,最后再加载。
    2. 当从非标准的来源(比如从网络来源)加载代码,那就需要自己实现一个类加载器,从指定源进行加载。
    ↩︎
  4. 什么是符号引用

    由于在编译过程中并不知道每个类的地址,因为可能这个类还没有加载,所以如果你在一个类中引用了另一个类,那么你完全无法知道他的内存地址,那怎么办,我们只能用他的类名作为符号引用,在类加载完后用这个符号引用去获取他的内存地址。符号引用可以是任何新式的字面量,只要是能无歧义地定位到目标即可。比如我在 com.csthink.Robot 类中引用了 com.csthink.service.OrderService,那么我会把 com.csthink.service.OrderService 作为符号引用存储到方法区的类常量池表(Constant Pool Table),等类加载完后,拿着这个引用去方法区找这个类的内存地址。 ↩︎ ↩︎

  5. 什么是字面量

    java 代码在编译过程中是无法构建引用的,字面量就是在编译时对于数据的一种表示

    int a = 1; // 1 是字面量
    String b = "Hello"; // Hello 是字面量
    
    ↩︎
  6. 什么是直接引用

    可以理解为一个内存地址,或者一个偏移量。比如类方法,类变量的直接引用是指向方法区的指针;而实例方法,实例变量的直接引用则是从实例的头指针开始算起到这个实例变量位置的偏移量。 ↩︎ ↩︎

  7. 什么是 native 方法

    native 方法的实现是由非 java 语言实现,比如 c,c++。 ↩︎