跳至主要內容

运行时数据区域

AruNi_LuJavaJVM约 2055 字大约 7 分钟

本文内容

前言

JVM 的 自动内存管理机制 帮助了我们很友好的使用内存,写 Java 代码时,不用为 new 出来的对象进行释放,JVM 会在合适的时机帮我们进行 垃圾回收

但我们还是有必要去了解一下 Java 的内存区域是怎样的,JVM 是怎样使用内存的,这样在出现内存相关的问题时,比如内存泄漏、内存溢出,才有能力去排查。

本章先来了解 Java 的 运行时数据区域 是如何划分的,每个区域分别有什么作用,保存什么数据?

1. 运行时数据区域有哪些?

JVM 在执行程序时,会将内存划分为几个不同的数据区域,如下所示:

image-20230627230024084

  • 程序计数器:一块比较小的空间,与 OS 的 PC 类似,JVM 的解释器(在执行引擎中)就是通过改变这个计数器的值来取得下一条字节码指令

    在多线程场景下,为了保证线程切换后能恢复到原来的执行位置,因此程序计数器是线程隔离的,即每个线程都有一份。

  • Java 栈:Java 方法执行时都需要创建一个 栈帧,用于存储 局部变量表(比如一些基本数据类型、对象的引用)、方法出口等信息。一个方法从调用到退出的过程,就对应 一个栈帧在 Java 栈中从入栈到出栈的过程

    由于 每个方法执行时存储的信息都不同,所以 Java 栈也是线程私有的

  • 本地方法栈:和 Java 栈类似,不过是用来执行 本地(Native)方法 的;

  • :一块比较大且是所有 线程共享 的内存空间,用于 保存对象实例,Java 中几乎所有的对象实例都在这里面(说 “几乎” 是因为逃逸分析技术的存在),这也是 GC 所管理的区域

  • 方法区:与堆一样,也是 线程共享 的一块内存,但它主要存储 已被加载的类型信息、常量、静态变量 等。

    为了降低内存溢出的风险,JDK 1.8 中使用了元空间来代替方法区的实现。

2. 程序计数器

程序计数器(Program Counter Register)是用来 记录当前线程执行到的指令地址,也就是执行到哪个位置了,通过改变该计数器的值就能获取到下一条需要执行的指令。

由于每个线程执行到的位置都不相同,所以程序计数器肯定是 线程私有 的。这样 在多线程环境下,线程切换后才能恢复到其原来执行的地方(CPU 一个核心同一时刻只能运行一个线程,因此会涉及到线程的切换)。

3. Java 栈

Java 栈(Java Stack)也是 线程私有 的,在方法调用时,就会 把用该方法创建的栈帧压入栈顶。所以每一次方法的调用,就伴随着一个栈帧在 Java 栈中从入栈到出栈的过程。

栈帧中包含哪些内容?

在进行方法调用时,每个方法都会创建其自己的栈帧,然后进入 Java 栈中执行。

image-20230819173227692

一个栈帧中会包含如下数据:

  • 局部变量表:存放了一些编译期就能确定的一些数据,比如 基本数据类型、对象的引用(只是一个指向对象的引用指针,并不是真的对象),以及 方法的返回地址 等;

  • 操作数栈:存放 计算过程的中间结果,以及作为这些 临时变量的存储空间

  • 动态链接:一个 指向该栈帧所属方法的引用,也就是当要调用方法时,通过这个动态链接,就可以找到这个方法的具体位置;

    所以栈帧是不等于方法的,通过栈帧中的动态链接,可以找到该方法。

  • 方法出口:记录了在一个方法调用结束后,应该回到调用该方法的方法中的位置

    比如 funcA 在第 10 行代码调用了 funcB,那么 funcB 栈帧中的方法出口,就会记录 funcA 的第 10 行这个位置,以便继续执行 funcA 后续的代码。

对于 Java 栈,JVM 规定了两类异常:

  • 如果线程请求的 栈帧深度大于虚拟机所允许的最大深度(比如无限进行递归),那么将会抛出 StackOverflowError 异常

  • 对于 Java 栈容量 可动态扩展 的虚拟机来说,如果 栈在扩展时无法申请到足够的内存,则会抛出 OutOfMemoryError 异常

    以前的 Classic 虚拟机就支持 Java 栈容量的动态扩展,不过现在的 HotSpot 虚拟机是不支持的,但它在申请栈空间时,如果申请失败也要抛出 OOM 异常

4. 本地方法栈

本地方法栈(Native Method Stack)和 Java 栈类似,只不过 Java 栈是执行 Java 方法,而 本地方法则是执行 JVM 本身可能需要用到的本地(native)方法

native 方法可以由其他语言编写(常用的就是 C/C++),因为 Java 本身可能不方便与操作系统底层或者硬件进行交互,而且效率也不高,所以提供了一些 native 方法。

本地方法栈与 Java 栈一样,也会在栈深度溢出或扩展失败时出现 StackOverFlowError 和 OutOfMemoryError 异常。

5. 堆

Java 堆(Java Heap)是虚拟机所管理的最大的一块内存,可以被所有 线程共享几乎 所有的 对象实例 都保存在堆中(为什么是几乎,因为可能存在栈上分配、标量替换的优化手段,具体看 逃逸分析open in new window)。

由于 Java 堆占据了内存的绝大部分,所以 JVM 有专门的垃圾收集器来对该内存区域进行垃圾回收。

6. 方法区

方法区(Method Area)也是 线程共享 的一块内存区域,用于存放被虚拟机加载好的 类信息、常量和静态变量 等。

其实方法区是堆的一个逻辑部分,但我们经常称之为非堆,目的就是和堆区分开来。

永久代、元空间的概念

其实,方法区只是一个逻辑上的概念,对应具体的落地实现可以有多种,而 永久代和元空间 就是两种不同的实现方式。

在 JDK 8 以前,方法区是用永久代来实现的,这样能 让垃圾收集器像管理 Java 堆一样管理方法区内存,就不用为这部分内存专门编写一个内存管理模块了。然而后面在具体使用时发现,这样导致 Java 程序更容易发生内存溢出的问题,因为永久代是有内存上限的。

JDK 7 时开始将原本放在永久代中的字符串常量池、静态变量等移出,存放在了堆中

到了 JDK 8 时,JVM 放弃了永久代的概念,改用 元空间来实现方法区,元空间在 本地内存 上,因此只受机器物理内存的大小限制(当然也可以自己限制)。把 JDK 7 中 永久代剩下的内容(主要是类信息)全部移到元空间中

变迁对比如下:

image-20230819170432663

7. 总结

最后再回过头来看这张 JVM 的内存区域划分图,就更加清晰了:

image-20230627230024084

上次编辑于: