JVM 内存结构

JVM 简介

JVM(Java Virtual Machine,Java 虚拟机)是 Java 语言的核心,是一个用于解释 Java 字节码的虚拟计算机。它可以在运行 Java 程序时自动管理内存、处理异常等。Java 程序员不需要关心底层硬件和操作系统的细节,只需要编写符合 Java 语法规范的代码,就可以实现跨平台的编程。

当我们编写 Java 程序时,Java 源代码会被编译成为 Java 字节码(.java 文件被编译成 .class 文件)。这些字节码可以在任何安装了 JVM 的平台上运行。JVM 在执行 Java 字节码时,将其转换成特定于底层 CPU 和操作系统的机器代码。

JVM 底层架构图

https://v3.processon.com/view/5ee4ba2ff346fb1ae5602678


运行时数据区

为了执行字节码,JVM 在内存中定义了一系列的数据区,用于在运行时存储各类数据,即运行时数据区(Runtime Data Areas)。理解这些数据区及其作用,是掌握 Java 性能调优和错误排查的关键。

JVM 运行时数据区是 JVM 在执行 Java 程序时用于数据存储的内存区域,这些区域各司其职,确保了 Java 程序的正确执行。JVM 运行时数据区主要分为五个部分:程序计数器(Program Counter Register)、虚拟机栈(VM Stack)、本地方法栈(Native Method Stack)、堆(Heap)、方法区(Method Area)。

JVM 运行时数据区在程序运行时动态地分配和释放内存,内存管理由 JVM 自动完成。不同的数据区域有不同的内存管理机制和垃圾回收算法,以保证程序运行的效率和稳定性。

其中程序计数器、虚拟机栈、本地方法栈属于线程私有区域,跟随线程的启动和结束而建立和销毁。堆和方法区是线程共享区域,跟随虚拟机进程的启动而存在。

1、堆(Heap)

堆是 Java 虚拟机管理内存最大的一块内存区域,堆存放的对象是线程共享的,所以多线程的时候也需要同步机制。

Java 堆在虚拟机启动的时候被创建,Java 堆主要用来为类实例对象和数组分配内存。JVM 规范并没有规定对象在堆中的形式。

在 Java 中,堆被划分成两个不同的区域。

  • 新生代(Young)。程序新创建的对象都是从新生代分配内存。新生代被细分为 Eden 和 两个 Survivor 区域(分别为 From Survivor 和 To Survivor)。默认 Edem:from:to = 8:1:1。(可以通过参数 -XX:SurvivorRatio 来设定。
  • 老年代(Old)。用于存放经过多次新生代 GC 仍然存活的对象,例如缓存对象,新建的对象也有可能直接进入老年代,主要有两种情况:
    1. 大对象,可通过启动参数设置 -XX:PretenureSizeThreshold=1024 (单位为字节,默认为0)来代表超过多大时就不在新生代分配,而是直接在老年代分配。
    2. 大的数组对象,且数组中无引用外部对象。

JVM 每次只会使用 Eden 和其中的一块 Survivor 区域来为对象服务,所以无论什么时候,总是有一块 Survivor 区域是空闲着的。新生代实际可用的内存空间为 9/10 (即90%)的新生代空间。

分代收集算法:采用不同算法处理存放和回收 Java 瞬时对象和长久对象。大部分 Java 对象都是瞬时对象,朝生夕灭,存活很短暂,通常存放在 Young 新生代,采用 标记-复制 算法对新生代进行垃圾回收。老年代对象的生命周期一般都比较长,极端情况下会和JVM生命周期保持一致;通常采用 标记-压缩 算法对老年代进行垃圾回收。

这样划分的目的是为了使 JVM 能够更好的管理堆内存中的对象,包括内存的分配以及回收。

Java 堆可能发生如下异常情况:如果实际所需的堆超过了自动内存管理系统能提供的最大容量,那 JVM 将会抛出一个 OutOfMemoryError 异常。简称(OOM)。

堆大小 = 新生代 + 老年代。堆的大小可通过参数 -Xms(堆的初始容量)-Xmx(堆的最大容量) 来指定。

Java 堆是 GC 的主要区域。

2、方法区(元空间)(Method Area)

方法区同堆一样,是所有线程共享的内存区域,为了区分堆,又被称为非堆。

方法区在虚拟机启动的时候被创建,用于存储每一个被虚拟机加载的类的结构信息,例如运行时常量池、字段和方法数据、构造函数和普通方法的字节码内容、还包括在类、实例、接口初始化时用到的特殊方法。

方法区可能发生如下异常情况:如果方法区的内存空间不能满足内存分配请求,那 JVM 将抛出一个 OutOfMemoryError 异常.

在老版 JDK,方法区也被称为永久代,可以通过 -XX:PermSize-XX:MaxPermSize 来设置永久代大小。

JDK8 废弃永久代,而使用元空间(Metaspace)。通过 -XX:MetaspaceSize-XX:MaxMetaspaceSize 来设置元空间大小。

JVM 对方法区比较宽松,除了跟堆一样可以不存在连续的内存空间,定义空间和可扩展空间,还可以选择不实现垃圾收集。

3、JVM 栈(JVM Stacks)

在 JVM 中,每当一个新的线程被创建,都会创建一个与之关联的私有 JVM 栈。这个栈会随着线程的运行而进行入栈(push)和出栈(pop)操作。它主要用于存储局部变量、操作数堆栈以及方法调用的情况。

JVM 栈是由一系列栈帧(Stack Frame)组成的。每当一个方法被调用,一个新的栈帧就会被压入栈中,每当一个方法调用结束,一个栈帧就会被弹出栈。【栈先进后出】

每个栈帧中都包含了局部变量表(Local Variab1es)、操作数栈(Operand stack)(或表达式栈)、动态链接(Dynamic Linking)(或指向运行时常量池的方法引用)和方法返回地址(Return address)(或方法正常退出或者异常退出的定义)等信息。

对于栈帧的解释参考:https://www.cnblogs.com/noKing/p/8167700.html

JVM 栈允许被实现为固定大小或者可动态扩展的内存大小。

局部变量表

局部变量表是一片连续的内存空间,用来存放方法参数和方法内定义的局部变量。主要存放了编译期可知的各种基本数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference 类型,它不等同于指针,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或者其他与此对象相关的位置)和 returnAddress 类型(指向了一条字节码指令的地址)

局部变量表最小的局部变量表空间单位为Slot,虚拟机没有指明Slot的大小,但在JVM中,long和double类型数据明确规定为64位,这两个类型占2个Slot,其它基本类型固定占用1个Slot。

局部变量表所需要的内存空间在编译期完成分配,当进入一个方法时,这个方法在栈中需要分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表大小。

操作数栈(Operand Stack)

操作数栈也常称为操作栈,它是一个后入先出栈(LIFO)。和局部变量区一样,操作数栈也是被组织成一个以字长为单位的数组。但是和前者不同的是,它不是通过索引来访问,而是通过标准的栈操作——压栈和出栈—来访问的。比如,如果某个指令把一个值压入到操作数栈中,稍后另一个指令就可以弹出这个值来使用。

虚拟机在操作数栈中存储数据的方式和在局部变量区中是一样的:如int、long、float、double、reference和returnType的存储。对于byte、short以及char类型的值在压入到操作数栈之前,也会被转换为int。

虚拟机把操作数栈作为它的工作区——大多数指令都要从这里弹出数据,执行运算,然后把结果压回操作数栈。

操作数栈的每一个元素可以是任意Java数据类型,32位的数据类型占一个栈容量,64位的数据类型占2个栈容量,且在方法执行的任意时刻,操作数栈的深度都不会超过max_stacks中设置的最大值。

当一个方法刚刚开始执行时,其操作数栈是空的,随着方法执行和字节码指令的执行,会从局部变量表或对象实例的字段中复制常量或变量写入到操作数栈,再随着计算的进行将栈中元素出栈到局部变量表或者返回给方法调用者,也就是出栈/入栈操作。一个完整的方法执行期间往往包含多个这样出栈/入栈的过程。

动态连接

JVM 栈中,每个栈帧中都包含一个指向运行时常量区的引用支持当前方法的动态链接。在Class文件中,方法调用和访问成员变量都是通过符号引用来表示的,动态链接的作用就是将符号引用转化为实际方法的直接引用或者访问变量的运行是内存位置的正确偏移量。

虚拟机运行的时候,运行时常量池会保存大量的符号引用,这些符号引用可以看成是每个方法的间接引用。如果代表栈帧A的方法想调用代表栈帧B的方法,那么这个虚拟机的方法调用指令就会以B方法的符号引用作为参数,但是因为符号引用并不是直接指向代表B方法的内存位置,所以在调用之前还必须要将符号引用转换为直接引用,然后通过直接引用才可以访问到真正的方法。

如果符号引用是在类加载阶段或者第一次使用的时候转化为直接应用,那么这种转换成为静态解析,如果是在运行期间转换为直接引用,那么这种转换就成为动态连接。

符号引用(Symbolic References)

符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能够无歧义的定位到目标即可。

例如,在 Class 文件中它以 CONSTANT_Class_infoCONSTANT_Fieldref_infoCONSTANT_Methodref_info 等类型的常量出现。符号引用与虚拟机的内存布局无关,引用的目标并不一定加载到内存中。

在 Java 中,一个 Java 类将会编译成一个 class 文件。在编译时,Java 类并不知道所引用的类的实际地址,因此只能使用符号引用来代替。比如 org.simple.People 类引用了 org.simple.Language 类,在编译时 People 类并不知道 Language 类的实际内存地址,因此只能使用符号 org.simple.Language(假设是这个,当然实际中是由类似于 CONSTANT_Class_info 的常量来表示的)来表示 Language 类的地址。

各种虚拟机实现的内存布局可能有所不同,但是它们能接受的符号引用都是一致的,因为符号引用的字面量形式明确定义在 JVM 规范的 Class 文件格式中。

直接引用

直接引用包括

  1. 直接指向目标的指针(比如,指向“类型”【Class对象】、类变量、类方法的直接引用可能是指向方法区的指针)
  2. 相对偏移量(比如,指向实例变量、实例方法的直接引用都是偏移量)
  3. 一个能间接定位到目标的句柄

直接引用是和虚拟机的布局相关的,同一个符号引用在不同的虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经被加载入内存中了。

返回地址

方法的返回分为两种情况,一种是正常退出,退出后会根据方法的定义来决定是否要传返回值给上层的调用者,一种是异常导致的方法结束,这种情况是不会传返回值给上层的调用方法。

不过无论是那种方式的方法结束,在退出当前方法时都会跳转到当前方法被调用的位置,如果方法是正常退出的,则调用者的 PC 计数器的值就可以作为返回地址,如果是因为异常退出的,则是需要通过异常处理表来确定。

方法的的一次调用就对应着栈帧在虚拟机栈中的一次入栈出栈操作,因此方法退出时可能做的事情包括:恢复上层方法的局部变量表以及操作数栈,如果有返回值的话,就把返回值压入到调用者栈帧的操作数栈中,还会把PC计数器的值调整为方法调用入口的下一条指令。

异常

在Java 虚拟机规范中,对虚拟机栈规定了两种异常状况:

  • 如果 JVM 栈被实现为固定大小内存,线程请求分配的栈容量超过 JVM 栈允许的最大容量时,JVM 将会抛出一个 StackOverflowError 异常。
  • 如果 JVM 栈被实现为动态扩展内存大小,并且扩展的动作已经尝试过,但是目前无法申请到足够的内存去完成扩展,或者在建立新的线程时没有足够的内存去创建对应的虚拟机栈,那JVM将会抛出一个OutOfMemoryError异常。

4、本地方法栈(Native Method Stack)

本地方法栈与虚拟机栈发挥的作用十分相似,区别是虚拟机栈执行的是 Java 方法(也就是字节码)服务,而本地方法栈则为虚拟机使用到的 native 方法服务,可能底层调用的 c 或者 c++,我们打开 JDK 安装目录可以看到也有很多用 c 编写的文件,可能就是 native 方法所调用的 c 代码。

对于一个运行中的 Java 程序而言,它还可能会用到一些跟本地方法相关的数据区。当某个线程调用一个本地方法时,它就进入了一个全新的并且不再受虚拟机限制的世界。本地方法可以通过本地方法接口来访问虚拟机的运行时数据区,但不止如此,它还可以做任何它想做的事情。

本地方法本质上时依赖于实现的,虚拟机实现的设计者们可以自由地决定使用怎样的机制来让 Java 程序调用本地方法。

任何本地方法接口都会使用某种本地方法栈。当线程调用 Java 方法时,虚拟机会创建一个新的栈帧并压入 Java 栈。然而当它调用的是本地方法时,虚拟机会保持 Java 栈不变,不再在线程的 Java 栈中压入新的帧,虚拟机只是简单地动态连接并直接调用指定的本地方法。

如果某个虚拟机实现的本地方法接口是使用 C 连接模型的话,那么它的本地方法栈就是 C 栈。当 C 程序调用一个 C 函数时,其栈操作都是确定的。传递给该函数的参数以某个确定的顺序压入栈,它的返回值也以确定的方式传回调用者。同样,这就是虚拟机实现中本地方法栈的行为。

很可能本地方法接口需要回调 JVM 中的 Java 方法,在这种情况下,该线程会保存本地方法栈的状态并进入到另一个 Java 栈。

下图描绘了这样一个情景,就是当一个线程调用一个本地方法时,本地方法又回调虚拟机中的另一个Java方法。

这幅图展示了JAVA虚拟机内部线程运行的全景图。一个线程可能在整个生命周期中都执行Java方法,操作它的Java栈;或者它可能毫无障碍地在Java栈和本地方法栈之间跳转。 

该线程首先调用了两个Java方法,而第二个Java方法又调用了一个本地方法,这样导致虚拟机使用了一个本地方法栈。假设这是一个C语言栈,其间有两个C函数,第一个C函数被第二个Java方法当做本地方法调用,而这个C函数又调用了第二个C函数。之后第二个C函数又通过本地方法接口回调了一个Java方法(第三个Java方法),最终这个Java方法又调用了一个Java方法(它成为图中的当前方法)。

Navtive 方法是 Java 通过 JNI 直接调用本地 C/C++ 库,可以认为是 Native 方法相当于 C/C++ 暴露给 Java 的一个接口,Java 通过调用这个接口从而调用到 C/C++ 方法。当线程调用 Java 方法时,虚拟机会创建一个栈帧并压入 JVM栈。然而当它调用的是 native 方法时,虚拟机会保持 JVM栈不变,也不会向 JVM栈中压入新的栈帧,虚拟机只是简单地动态连接并直接调用指定的 native 方法。

5、程序计数器(Program Counter Register)

程序计数器是一个记录着当前线程所执行的字节码的行号指示器,换句话说,它指向了下一条将要被执行的 JVM 字节码指令。它是线程私有的,可以认作为当前线程的行号指示器。

JAVA 代码编译后的字节码在未经过 JIT(实时编译器)编译前,其执行方式是通过“字节码解释器”进行解释执行。简单的工作原理为解释器读取装载入内存的字节码,按照顺序读取字节码指令。读取一个指令后,将该指令“翻译”成固定的操作,并根据这些操作进行分支、循环、跳转等流程。

从上面的描述中,可能会产生程序计数器是否是多余的疑问。因为沿着指令的顺序执行下去,即使是分支跳转这样的流程,跳转到指定的指令处按顺序继续执行是完全能够保证程序的执行顺序的。假设程序永远只有一个线程,这个疑问没有任何问题,也就是说并不需要程序计数器。但实际上程序是通过多个线程协同合作执行的。

首先我们要搞清楚 JVM 的多线程实现方式。JVM 的多线程是通过 CPU 时间片轮转(即线程轮流切换并分配处理器执行时间)算法来实现的。也就是说,某个线程在执行过程中可能会因为时间片耗尽而被挂起,而另一个线程获取到时间片开始执行。

当被挂起的线程重新获取到时间片的时候,它要想从被挂起的地方继续执行,就必须知道它上次执行到哪个位置,在 JVM 中,通过程序计数器来记录某个线程的字节码执行位置。

程序计数器的特点

  1. 线程隔离性,每个线程工作时都有属于自己的独立计数器。生命周期与线程的生命周期一致。
  2. 执行java方法时,程序计数器是有值的,且记录的是正在执行的字节码指令的地址(参考上一小节的描述)。
  3. 执行 native 本地方法时,程序计数器的值为空(Undefined)。因为 native 方法是 Java 通过 JNI 直接调用本地 C/C++ 库,可以近似的认为 native 方法相当于 C/C++ 暴露给 Java 的一个接口,Java 通过调用这个接口从而调用到 C/C++ 方法。由于该方法是通过 C/C++ 而不是 Java 进行实现。那么自然无法产生相应的字节码,并且 C/C++ 执行时的内存分配是由自己语言决定的,而不是由 JVM 决定的。
  4. 程序计数器占用内存很小,在进行 JVM 内存计算时,可以忽略不计。
  5. 程序计数器,是唯一一个在 java 虚拟机规范中没有 OutOfMemoryError 的区域。

线程栈

线程堆栈也称线程调用堆栈,是虚拟机中线程(包括锁)状态的一个瞬间状态的快照,即系统在某一个时刻所有线程的运行状态,包括每一个线程的调用堆栈,锁的持有情况。虽然不同的虚拟机打印出来的格式有些不同,但是线程堆栈的信息都包含:

  1. 线程名字,id,线程的数量等。
  2. 线程的运行状态,锁的状态(锁被哪个线程持有,哪个线程在等待锁等)
  3. 调用堆栈(即函数的调用层次关系)调用堆栈包含完整的类名,所执行的方法,源代码的行数。

因为线程栈是瞬时快照包含线程状态以及调用关系,所以借助堆栈信息可以帮助分析很多问题,比如线程死锁,锁争用,死循环,识别耗时操作等等。线程栈是瞬时记录,所以没有历史消息的回溯,一般我们都需要结合程序的日志进行跟踪,一般线程栈能分析如下性能问题:

  1. 系统无缘无故的 cpu 过高
  2. 系统挂起,无响应
  3. 系统运行越来越慢
  4. 性能瓶颈(如无法充分利用 cpu 等)
  5. 线程死锁,死循环等
  6. 由于线程数量太多导致的内存溢出(如无法创建线程等)

JVM内存参数设置

  • -Xms:设置堆的最小空间大小。
  • -Xmx:设置堆的最大空间大小。
  • -Xmn:设置年轻代大小
  • -XX:NewSize:设置新生代最小空间大小。
  • -XX:MaxNewSize:设置新生代最大空间大小。
  • -XX:PermSize:设置永久代最小空间大小。
  • -XX:MaxPermSize:设置永久代最大空间大小。
  • -Xss:设置每个线程的堆栈大小
  • -XX:+UseParallelGC:选择垃圾收集器为并行收集器。此配置仅对年轻代有效。即上述配置下,年轻代使用并发收集,而年老代仍旧使用串行收集。
  • -XX:ParallelGCThreads=20:配置并行收集器的线程数,即:同时多少个线程一起进行垃圾回收。此值最好配置与处理器数目相等。

典型JVM参数配置参考:java -Xmx3550m -Xms3550m -Xmn2g -Xss128k -XX:ParallelGCThreads=20 -XX:+UseConcMarkSweepGC-XX:+UseParNewGC


JVM 内存结构
https://flepeng.github.io/021-Java-42-JVM-JVM-内存结构/
作者
Lepeng
发布于
2024年4月19日
许可协议