说说你理解的JVM运行时数据区

 2023-01-22
原文作者:布吉_岛 原文地址:https://juejin.cn/post/6997569778978144292

本文个人博客地址:JVM运行时数据区 (leafage.top)

JVM 的运行时数据区分为:

  1. 程序计数器;
  2. 虚拟机栈;
  3. 本地方法栈;
  4. 堆;
  5. 方法区;

其中堆、方法区是线程共享的,程序计数器、虚拟机栈、本地方法栈是线程隔离的,结构图示如下:

202301011552343661.png

1. 程序计数器:

Java虚拟机的多线程是通过线程轮流切换、分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器(对于多核处理器来说是一个内核)都只会执行一条线程中的指令。

为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,可以看作是当前线程所执行的字节码的行号指示器(存储指向下一条指令的地址),即将要执行的指令代码,各条线程之间计数器互不影响,独立存储。

通过 idea 的 debug 模式可以看到具体的信息:

202301011552350422.png

202301011552359393.png

其特点是:

  • 程序计数器是一块较小的内存空间,线程私有的;
  • 记录非本地方法执行时的字节码指令地址,如果是本地方法,值为 undefined;
  • 唯一一个在《Java虚拟机规范》中没有规定任何 OutOfMemoryError 情况的区域;

2. 虚拟机栈:

虚拟机栈描述的是Java方法执行的线程内存模型:每一个Java虚拟机线程创建的同时会创建一个独自的虚拟机栈,其内部保存一个个栈帧(Stack Frame)对应着一次次方法调用。

栈帧:

线程执行方法的内容保存在栈帧中,每一个方法都有自己的栈帧,而对于多层嵌套调用的方法,是根据方法的调用链,向栈中设置栈帧的(栈的操作都是先入,后出),示例如下所示:

202301011552364204.png

同样,通过代码 debug 可以看到其效果:

202301011552373175.png

202301011552379096.png

每个栈帧(Stack Frame)中存储着:

  1. 局部变量表(Local Variables);
  2. 操作数栈(Operand Stack);
  3. 动态链接(Dynamic Linking):指向运行时常量池的方法引用 ;
  4. 方法返回地址(Return Address):方法正常退出或异常退出的地址 一些附加信息;
  5. 附加信息

栈帧结构示例图如下所示:

202301011552384627.png

3. 本地方法栈:

什么是本地方法?

答: 由其它语言编写的,编译成和处理器相关的机器代码。

本地方法保存在动态链接库中,即.dll(Windows系统)文件中,格式是各个平台专有的(Java是平台无关的,但是本地方法不是,这也是为什么 JDK 分 Linux、Windows、MacOS版本)

为什么要使用本地方法?

答: 使用本地方法的原因有以下几点:

  1. 与 Java 环境外交互:有时 Java 应用需要与 Java 外面的环境交互;
  2. 与操作系统交互:JVM 支持 Java 语言本身和运行时库,但是有时仍需要依赖一些底层系统的支持。通过本地方法,我们可以实现用 Java 与实现了 jre 的底层系统交互, JVM 的一些部分就是 C 语言写的。
  3. Sun‘s Java:Sun的解释器就是C实现的,这使得它能像一些普通的C一样与外部交互。比如:类 java.lang.Thread 的 setPriority() 的方法是用Java 实现的,但它实现调用的是该类的本地方法 setPrioruty(),该方法是C实现的,并被植入 JVM 内部。

本地方法栈的特点:

  • 线程私有,允许线程固定或者可动态扩展的内存大小(同虚拟机栈一样):

    • 如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常;
    • 如果Java虚拟机栈容量可以动态扩展(HotSpot虚拟机的栈容量是不可以动态扩展的),当栈扩展时无法申请到足够的内存会抛出OutOfMemoryError异常;
  • 通过本地方法接口来访问虚拟机内部的运行时数据区;

  • 并不是所有 JVM 都支持本地方法。因为 《Java 虚拟机规范》并没有明确要求本地方法栈的使用语言、具体实现方式、数据结构等;

  • HotSpot虚拟机中,虚拟机栈和本地方法栈合二为一;

4. 堆:

对于大多数应用,Java 堆是 Java 虚拟机管理的内存中最大的一块,被所有线程共享。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数据都在这里分配内存。

为了进行高效的垃圾回收,虚拟机把堆内存逻辑上划分成三块区域,目的只是为了更好地回收内存,或者更快地分配内存。

老年代的内存大小和年轻代的大小默认比例为 2:1,老年代默认的最小值为:操作系统运行内存/64,默认最大内存:操作系统运行内存/4;

202301011552390928.png

这个我们可以通过代码,来看一看是否和描述的一致:

    public class HeapMemory {
    
        public static void main(String[] args) {
            //返回 JVM 堆大小
            long initalMemory = Runtime.getRuntime().totalMemory() / 1024 / 1024;
            //返回 JVM 堆的最大内存
            long maxMemory = Runtime.getRuntime().maxMemory() / 1024 / 1024;
    
            System.out.println("-Xms : " + initalMemory + "M");
            System.out.println("-Xmx : " + maxMemory + "M");
    
            System.out.println("系统内存大小:" + initalMemory * 64 / 1024 + "G");
            System.out.println("系统内存大小:" + maxMemory * 4 / 1024 + "G");
        }
    }

运行结果如下:

    D:\env\jdk11\bin\java.exe ...
    -Xms : 250M
    -Xmx : 3996M
    系统内存大小:15G
    系统内存大小:15G

说到堆,那就离不开一个重要的概念,垃圾回收,JVM 垃圾回收针对不同的分代年龄,有不同的执行策略或算法。

什么是 Minor GC、Major GC、Mixed GC、Full GC?

答: 在进行 GC 时,并非每次都对堆内存(新生代、老年代;方法区)区域一起回收的,大部分时候回收的都是指新生代。

针对 HotSpot VM 的实现,它里面的 GC 按照回收区域又分为两大类:

  1. 部分收集:不是完整收集整个 Java 堆的垃圾收集。其中又分为:

    • 新生代收集(Minor GC):只是新生代的垃圾收集;
    • 老年代收集(Major GC):只是老年代的垃圾收集;
    • 混合收集(Mixed GC):收集整个新生代以及部分老年代的垃圾收集(目前只有 G1 GC 会有这种行为 );
  2. 整堆收集(Full GC):收集整个 Java 堆和方法区的垃圾;

5. 方法区:

方法区存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据;

很多人都更愿意把方法区称呼为“永久代”(PermanentGeneration),或将两者混为一谈。本质上这两者并不是等价的,因为仅仅是当时的HotSpot虚拟机设计团队选择把收集器的分代设计扩展至方法区,或者说使用永久代来实现方法区而已,这样使得HotSpot的垃圾收集器能够像管理Java堆一样管理这部分内存,省去专门为方法区编写内存管理代码的工作。

方法区随着 JDK 的发展,进行了几次大的变更,最重要的变更发生在 JDK6、JDK7、JDK8 ,其变化为:

  1. JDK6 --> JDK7: 将字符串常量池,静态变量移出老年代,放到堆中;
  2. JDK7 --> JDK8: 将老年代废弃,使用元空间实现,同时将数据存储变更为使用物理内存,不在占用 JVM 内存空间;

变更示例如下图所示:

202301011552396029.png

为什么要替换永久代,使用元空间?

答: 原因有以下几点:

  1. 为永久代设置空间大小是很难确定的;

在某些场景下,如果动态加载类过多,容易产生 Perm 区的 OOM。如果某个实际 Web 工程中,因为功能点比较多,在运行过程中,要不断动态加载很多类,经常出现 OOM。而元空间和永久代最大的区别在于,元空间不在虚拟机中,而是使用本地内存,所以默认情况下,元空间的大小仅受本地内存限制

  1. 对永久代进行调优较困难;