掌握JVM内存模型,不再是面试绊脚石

2023年 8月 26日 50.7k 0

前言

JVM内存模型是Java基础重要的内容,也是面试时的八股文核心之一,很有必要好好深入学习一下。

7797ce65822a692777f6aa23b32b7948.jpeg

一、JDK体系结构

先来看下面的一张图,相信都不陌生。我们刚学Java的时候,应该或多或少都见过这张图。

image.png
官网地址:docs.oracle.com/javase/8/do…

如上图,JDK的体系结构:

  • 开发工具:开发Java应用程序的工具,
    诸如:javajavacjavadocjarjavapjconsoleJava VisualVM等命令工具,在我们日常工作中,这些命令应该都有用过。 这些工具使我们能够便捷地满足我们代码编写、编译、文档生成、打包、代码分析、性能调试等需求。

  • JRE(Java Runtime Environment):即Java运行时环境,支撑Java程序运行的环境。JRE提供了运行Java程序所需的最小必要环境,没有开发工具(如编译器和调试器)等开发者用的内容。JRE由
    Java核心类库和Java虚拟机(JVM)组成。

    • Java核心类库:如/jre/lib/rt.jar包, 对应代码即我们开发时常用的java.lang、java.util、java.math等包

    • JVM: Java Virtual Machine,即Java虚拟机,可以看做是一台抽象化的计算机,它有一套完整的体系架构,包括堆栈 、寄存器、处理器等。

以上,可以简单总结为下图:
未命名文件.png

  • JDK = 开发工具 + JRE
  • JRE = JVM + Java核心类库

二、Java语言的跨平台特性

Java语言其中一个最大的特点就是跨平台特性,这个跨平台能力使得Java能够做到“一次编写,到处运行(Write Once, Run Anywhere)”。

跨平台意味着开发者只需要编写一份代码,无需修改即可以在各种操作系统上运行,例如Windows、Linux和Mac等。

我们刚学Java的时候,都接触过HelloWorld程序,如下图为其大致执行过程:

image.png

  • 不同的操作系统平台,其底层的机器指令以及硬件架构都有很大的差异,尽管Java代码相同,但在不同的操作系统上,生成的可执行二进制机器码是不同的(计算机实际运行的是如0101的二进制编码)。
  • 例如,将上图的HelloWorld程序在Windows上运行生成的二进制机器码可能是1100...,而在Linux上可能是0101...,在Mac上可能是0001...,即会针对不同的操作系统的特定指令,生成对应的二进制机器码。
  • 对于Java,我们只需要写一份代码,而关于不同平台需要生成不同的二进制机器码,不需要我们关心,这由Java虚拟机(JVM)完成。相比之下,非跨平台编程语言如汇编语言在不同平台上运行相同代码可能需要微调。这就是Java跨平台的优势。
  • JVM是实现跨平台的关键。在安装JDK时,需下载对应操作系统的版本。不同JDK版本内嵌不同的JVM,针对特定操作系统进行了优化。因此,跨平台实际上是在不同平台的JVM中运行.class文件,生成特定平台的机器码。

三、JVM整体结构及内存模型

JVM运行时内存模型: 完整的JVM内存模型,主要由如下三部分组成:

  • 类装载子系统
  • 运行时数据区(内存模型)
  • 字节码执行引擎
    image.png

看代码:

package com.jvm.test;

public class Math {
    public int compute() {
        int a = 1;
        int b = 2;
        int c = (a + b) * 10;
        return c;
    }
    public static void main(String[] args) {
        Math math = new Math();
        int result = math.compute();
        System.out.println("执行结果:"+result);
    }
}

如上的代码要执行,实际上最终是通过java指令运行其对应编译后的Math.class字节码文件,即java Math.class

那这条指令,它底层到底都做了哪些事呢?

首先,通过「类装载子系统」(大部分为C++实现),将Math.class字节码文件加载到JVM的内存区域,也就是「运行时数据区」,最终,再通过「字节码执行引擎」(C++实现)来执行内存区域的代码。

其中,最为核心的是「运行时数据区」,我们在日常工作中如果要做JVM性能调优,围绕的也是这个区域。

根据JVM规范,运行时数据区又分为以下几个主要部分:

  • 线程栈
  • 本地方法栈
  • 程序计数器
  • 方法区(元空间)

官方文档地址:docs.oracle.com/javase/spec…
这里贴出部分截图看一下:
image.png

接下来,结合前面的代码示例,我们对这几个区域做详细的了解。

  • 线程栈:线程栈,即对应的官方的Java Virtual Machine Stacks(虚拟机栈),是每个线程独有的内存区域,用于存储方法调用的局部变量、操作数栈等。 每个线程在执行方法时,都会在线程栈上创建栈帧(Stack Frame),栈帧用于记录方法的执行状态和局部变量的值。

    例如,在我们的代码示例中,当运行main方法时,会创建一个主线程,JVM为其分配独立的线程栈,用于存储方法执行所需的局部变量,如main方法中的math变量,以及compute方法中的abc变量。这些局部变量需要内存空间存放,而这些存储单元位于线程栈上。

    每个线程都拥有自己的线程栈,当新线程加入时,JVM会分配独立的线程栈空间。这种独立分配确保每个线程都有自己的内存区域,专门用于存放线程内部的局部变量。

    实际上,线程栈内部结构比较复杂,其中比较关键的一个组成是栈帧。栈帧是方法执行时的一块专属内存区域。对于我们的示例,当main方法开始执行时,JVM会为其分配线程栈空间,并在栈上分配一个栈帧,用于保存main方法的局部变量,例如math。当compute方法被调用时,同样,JVM会为其分配独立的栈帧内存区域,用于存放compute方法的局部变量abc。事实上,每个方法都有其专用的栈帧,用于隔离方法的内存空间。

    在方法嵌套调用时,会不断创建新的栈帧内存空间。这是因为不同局部变量的作用范围仅限于其所属方法,通过栈帧的方式,实现了方法之间内存的隔离。

    这里的栈与数据结构中的栈有什么不一样吗?数据结构中的栈的特点是先进后出(FILO)结构,其实这里线程栈中的栈的结构就是数据结构中的栈。这是因为,例如在代码示例中,main方法先被调用,所以首先为其分配内存空间,接着调用compute方法,再为其分配内存空间。然而,由于方法执行顺序的关系,后调用的方法往往会先执行完,因此其对应的栈帧内存空间会被释放(出栈)。

    栈帧内部主要组成:

    • 局部变量表:存放局部变量,如compute方法内abc,其内部结构类似数组。

    • 操纵数栈:用于执行方法时临时存放操作数和中间结果的内存区域。
      要理解操纵数栈,需要结合字节码文件来进行理解。我们示例代码的字节码文件部分截图如下:

      image.png

      这个字节码文件,不具备可读性。我们通过javap命令 javap -c Math.class 反汇编生成更可读的JVM汇编指令码(都是表示Math类,只是表现形式不一样;这里的汇编是JVM内部的汇编,不是汇编编程语言的汇编):

      Compiled from "Math.java"
      public class com.jvm.test.Math {
        public com.jvm.test.Math();
          Code:
             0: aload_0
             1: invokespecial #1                  // Method java/lang/Object."":()V
             4: return
      
        public int compute();
          Code:
             0: iconst_1
             1: istore_1
             2: iconst_2
             3: istore_2
             4: iload_1
             5: iload_2
             6: iadd
             7: bipush        10
             9: imul
            10: istore_3
            11: iload_3
            12: ireturn
      
        public static void main(java.lang.String[]);
          Code:
             0: new           #2                  // class com/jvm/test/Math
             3: dup
             4: invokespecial #3                  // Method "":()V
             7: astore_1
             8: aload_1
             9: invokevirtual #4                  // Method compute:()I
            12: istore_2
            13: getstatic     #5                  // Field java/lang/System.out:Ljava/io/PrintStream;
            16: new           #6                  // class java/lang/StringBuilder
            19: dup
            20: invokespecial #7                  // Method java/lang/StringBuilder."":()V
            23: ldc           #8                  // String 执行结果:
            25: invokevirtual #9                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
            28: iload_2
            29: invokevirtual #10                 // Method java/lang/StringBuilder.append:(I)Ljava/lang/StringBuilder;
            32: invokevirtual #11                 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
            35: invokevirtual #12                 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
            38: return
      }
      
      

      这里拿一个方法来分析,在分析前,需要借助一份JVM指令手册(在前面提供的JVM规范官方文档链接中就可以找到 JVM官方文档,比较多,这里仅贴出我们分析时将要涉及的部分。

      JVM指令手册(部分):

      栈和局部变量操作
      
      将常量压入栈的指令
      aconst_null 将null对象引用压入栈
      iconst_m1 将int类型常量-1压入栈
      iconst_0 将int类型常量0压入栈
      iconst_1 将int类型常量1压入操作数栈
      iconst_2 将int类型常量2压入栈
      iconst_3 将int类型常量3压入栈
      iconst_4 将int类型常量4压入栈
      iconst_5 将int类型常量5压入栈
      lconst_0 将long类型常量0压入栈
      lconst_1 将long类型常量1压入栈
      fconst_0 将float类型常量0压入栈
      fconst_1 将float类型常量1压入栈
      dconst_0 将double类型常量0压入栈
      dconst_1 将double类型常量1压入栈
      bipush 将一个8位带符号整数压入栈
      sipush 将16位带符号整数压入栈
      ldc 把常量池中的项压入栈
      ldc_w 把常量池中的项压入栈(使用宽索引)
      ldc2_w 把常量池中long类型或者double类型的项压入栈(使用宽索引)
      
      从栈中的局部变量中装载值的指令
      iload 从局部变量中装载int类型值
      lload 从局部变量中装载long类型值
      fload 从局部变量中装载float类型值
      dload 从局部变量中装载double类型值
      aload 从局部变量中装载引用类型值(refernce)
      iload_0 从局部变量0中装载int类型值
      iload_1 从局部变量1中装载int类型值
      iload_2 从局部变量2中装载int类型值
      iload_3 从局部变量3中装载int类型值
      lload_0 从局部变量0中装载long类型值
      lload_1 从局部变量1中装载long类型值
      lload_2 从局部变量2中装载long类型值
      lload_3 从局部变量3中装载long类型值
      fload_0 从局部变量0中装载float类型值
      fload_1 从局部变量1中装载float类型值
      aload 从数组中装载int类型值
      laload 从数组中装载long类型值
      faload 从数组中装载float类型值
      daload 从数组中装载double类型值
      aaload 从数组中装载引用类型值
      baload 从数组中装载byte类型或boolean类型值
      caload 从数组中装载char类型值
      saload 从数组中装载short类型值
      
      将栈中的值存入局部变量的指令
      istore 将int类型值存入局部变量
      lstore 将long类型值存入局部变量
      fstore 将float类型值存入局部变量
      dstore 将double类型值存入局部变量
      astore 将将引用类型或returnAddress类型值存入局部变量
      istore_0 将int类型值存入局部变量0
      istore_1 将int类型值存入局部变量1
      istore_2 将int类型值存入局部变量2
      istore_3 将int类型值存入局部变量3
      lstore_0 将long类型值存入局部变量0
      lstore_1 将long类型值存入局部变量1
      lstore_2 将long类型值存入局部变量2
      lstore_3 将long类型值存入局部变量3
      fstore_0 将float类型值存入局部变量0
      fstore_1 将float类型值存入局部变量1
      fstore_2 将float类型值存入局部变量2
      fstore_3 将float类型值存入局部变量3
      dstore_0 将double类型值存入局部变量0
      dstore_1 将double类型值存入局部变量1
      
      整数运算
      iadd 执行int类型的加法
      ladd 执行long类型的加法
      isub 执行int类型的减法
      lsub 执行long类型的减法
      imul 执行int类型的乘法
      lmul 执行long类型的乘法
      idiv 执行int类型的除法
      ldiv 执行long类型的除法
      irem 计算int类型除法的余数
      lrem 计算long类型除法的余数
      ineg 对一个int类型值进行取反操作
      lneg 对一个long类型值进行取反操作
      iinc 把一个常量值加到一个int类型的局部变量上
      

      代码分析:
      compute方法为例,其jvm汇编指令码如下:

         public int compute();
            Code:
               0: iconst_1
               1: istore_1
               2: iconst_2
               3: istore_2
               4: iload_1
               5: iload_2
               6: iadd
               7: bipush        10
               9: imul
              10: istore_3
              11: iload_3
              12: ireturn
      
      代码汇编指令 指令含义 手册含义
      0: iconst_1 把常量1压到操作数栈 iconst_1 将int类型常量1压入操作数栈
      1: istore_1 这里的“局部变量1”,实际就是变量a。查看jvm指令手册,可以发现有局部变量0、局部变量1、局部变量2等,这里的1、2、3,实际上是局部变量表的索引或数组下标。 局部变量0,是this,即调用这个方法的对象,默认会内置这样一个对象;局部变量1,就是我们代码中的变量a,依此类推。 由此,istore_1意思就是,从操作数栈中,把1拿出来,放到局部变量表中赋给局部变量a,也就是变成a=1。(操作数栈的内部,也是栈结构) istore_1 将int类型值存入局部变量1
      2: iconst_2 同「0: iconst_1」,就是把常量2压到操作数栈中。 iconst_2 将int类型常量2压入操作数栈
      3: istore_2 同「1: istore_1」,从操作数栈中,把2拿出来,放到局部变量表中赋给局部变量b。 istore_2 将int类型值存入局部变量2
      4: iload_1 把局部变量a的值,也就是1,装载出来,放到操作数栈 iload_1 从局部变量1中装载int类型值
      5: iload_2 同「iload_1」,把局部变量b的值,也就是2,装载出来,放到操作数栈 iload_2 从局部变量2中装载int类型值
      6: iadd 这里要做的是 a+b的加法运行操作,是要从操作数栈中把值1、2拿出来(弹到CPU内部寄存器),然后做操作(在CPU中运算),即1+2=3,然后再把3压入操作数栈中。 iadd 执行int类型的加法
      7: bipush 10 把数值10压入操作数栈 bipush 将一个8位带符号整数压入栈
      9: imul 就是做乘法运算,即3*10=30,然后再把30压入操作数栈中。 mul 执行int类型的乘法
      10: istore_3 操作数栈中,把30拿出来,放到局部变量表中给局部变量c赋值 。 将int类型值存入局部变量3
      11: iload_3 把局部变量c的值,也就是30,装载出来,放到操作数栈 从局部变量3中装载int类型值
      12: ireturn 返回方法结果值30 从方法中返回int类型的数据
    • 动态链接:在程序运行时将符号引用(如方法、变量等)解析为实际内存地址的过程。比如说,在调用方法compute时,JVM会将代表compute方法的符号引用解析为实际的内存地址,从而找到要执行的compute方法的位置。

    • 方法出口: 指一个方法执行完成后,程序应该继续执行的下一条指令或位置。
      比如compute执行完了,要回到main方法的哪个位置?这些相关的信息,就是放到方法出口

    • 程序计数器:存放正在执行的指令的地址或下一条将要执行的指令的地址。

      通过前面的JVM指令手册,还可以简单理解为类似7: bipush 10这样的指令码中的7,那么此时程序计数器=7。

      每执行完一行代码,程序计数器会被动态修改。为什么能修改?代码被加载到内存中的方法区,由字节码执行引擎执行,那么将要执行到哪一行代码,它肯定是会知道的,所以就会把程序对应的程序计数器的值给修改。

      设计程序计数器的目的:用于追踪当前线程正在执行的字节码指令位置。当线程执行方法调用、循环、分支等操作时,程序计数器会被更新,确保下一条要执行的指令可以被获取并执行。它在线程切换时也是非常重要的,因为这能确保每个线程都能继续从正确的地方执行。

    • 方法区:一块用于存储类信息、静态变量、常量池等内容的内存区域。

      • 类信息存储:方法区存储已加载的类的结构信息,包括类的字段、方法、构造函数、继承关系、接口等。
      • 静态变量:类的静态变量(也称为类变量)存储在方法区中。静态变量在类加载时初始化,它们的值在类的生命周期内都是相同的。
      • 常量池:常量池是方法区的一部分,用于存储类文件中的字面量(如字符串、整数、浮点数)、符号引用(如类和接口的全限定名、字段的名称和描述符、方法的名称和描述符等)以及其他常量信息。
      • 运行时常量池:运行时常量池相对于class温江常量池的一个重要特征是具有动态性。例如,字符串拼接时会在运行时常量池中生成新的字符串常量。
    • 堆:存放对象实例的主要内存区域。如我们代码示例中,math实例对象存放在堆区域。其内部又分为年轻代、老年代,其中,年轻代又分为Eden区、Survivor区,后者又分为S0区、S1区。JVM的垃圾回收通常伴随着STW(Stop-The-World)事件,影响用户线程的正常执行。因此,在JVM调优过中,堆是关注的重点。

    • 本地方法:用于执行本地方法(如我们在Java源码中看到的一些native方法,C、C++ 等编写)的线程私有的内存区域。

    另外,按线程共享和线程私有进行归类:
    线程共享的内存区域: 堆、方法区(Method Area 或元空间 Metaspace)
    线程私有的内存区域: 线程栈、本地方法栈、程序计数器

    至此,我们对JVM内存模型应该有了一个比较深入的了解。结合我们所学习的,下面的图示为更详细的jvm内存模型结构:

    未命名文件.jpg

    四、JVM参数内存参数设置

    image.png

    Spring Boot程序的JVM参数设置格式:

    java ‐Xms2048M ‐Xmx2048M ‐Xmn1024M ‐Xss512K ‐XX:MetaspaceSize=256M ‐XX:MaxMetaspaceSize=256M ‐jar xxx.jar
    
  • 元空间JVM参数
    关于元空间的JVM参数有两个:-XX:MetaspaceSize=N-XX:MaxMetaspaceSize=N

    • -XX:MaxMetaspaceSize: 设置元空间最大值, 默认是-1, 即不限制, 或者说只受限于本地内存大小。
    • -XX:MetaspaceSize: 指定元空间触发Fullgc的初始阈值(元空间无固定初始大小), 以字节为单位,默认是21M,达到该值就会触发full gc进行类型卸载。 同时收集器会对该值进行调整: 如果释放了大量的空间, 就适当降低该值; 如果释放了很少的空间, 那么在不超过-XX:MaxMetaspaceSize(如果设置了的话) 的情况下, 适当提高该值。这个跟早期j期jdk版本的-XX:PermSize参数意思不一样,-XX:PermSize代表永久代的初始容量。由于调整元空间的大小需要Full GC,这是非常昂贵的操作,如果应用在启动的时候发生大量Full GC,通常都是由于永久代或元空间发生了大小调整,基于这种情况,一般建议在JVM参数中将MetaspaceSize和MaxMetaspaceSize设置成一样的值,并设置得比初始值要大,对于8G物理内存的机器来说,一般建议可将这两个值都设置为256M。
  • 线程栈JVM参数
    StackOverflowError代码示例:

    package com.jvm.test;
    
    public class StackOverflowTest {
    
        static int count = 0;
        static void redo() {
            count++;
            redo();
        }
        public static void main(String[] args) {
            try {
                redo();
            }catch (Throwable e) {
                e.printStackTrace();
                System.out.println(count);
            }
    
        }
    }
    

    1、不对线程栈设置额外jvm参数(默认值取决于平台,64位的linux或mac默认最大1M)
    运行结果:

     java.lang.StackOverflowError
    at com.jvm.test.StackOverflowTest.redo(StackOverflowTest.java:8)
    at com.jvm.test.StackOverflowTest.redo(StackOverflowTest.java:8)
    at com.jvm.test.StackOverflowTest.redo(StackOverflowTest.java:8)
    at com.jvm.test.StackOverflowTest.redo(StackOverflowTest.java:8)
    at com.jvm.test.StackOverflowTest.redo(StackOverflowTest.java:8)
    at com.jvm.test.StackOverflowTest.redo(StackOverflowTest.java:8)
    ...
    at com.jvm.test.StackOverflowTest.redo(StackOverflowTest.java:8)
    at com.jvm.test.StackOverflowTest.redo(StackOverflowTest.java:8)
    21642
    

    2、设置:-Xss256k
    运行结果:

     java.lang.StackOverflowError
    at com.jvm.test.StackOverflowTest.redo(StackOverflowTest.java:8)
    at com.jvm.test.StackOverflowTest.redo(StackOverflowTest.java:8)
    at com.jvm.test.StackOverflowTest.redo(StackOverflowTest.java:8)
    at com.jvm.test.StackOverflowTest.redo(StackOverflowTest.java:8)
    at com.jvm.test.StackOverflowTest.redo(StackOverflowTest.java:8)
    at com.jvm.test.StackOverflowTest.redo(StackOverflowTest.java:8)
    ...
    at com.jvm.test.StackOverflowTest.redo(StackOverflowTest.java:8)
    at com.jvm.test.StackOverflowTest.redo(StackOverflowTest.java:8)
    2077
    

    结论:
    -Xss设置越小count值越小,说明一个线程栈里能分配的栈帧就越少,但是对JVM整体来说能开启的线程数会更多

    3、关于堆的jvm参数设置,比较复杂,后续将单独学习和更新。

  • 写到最后

    今天学习了JVM的内存模型, 做个小结:

  • JDK体系机构:JDK=开发工具+JRE,JRE=核心内库+JVM(Java虚拟机)。
  • Java的跨平台特性:通过Java虚拟机(JVM),从软件层面屏蔽不同操作系统在底层硬件与指令上的区别,使得同一份Java代码,无需做任何修改就可以在不同的操作系统上运行,实现“一次编写,到处运行”的跨平台目标。
  • VM体系结构和内存模型:
    • JVM体系结构: JVM主要由三个部分组成:类装载子系统、字节码执行引擎、运行时数据区(内存模型)。

    • 运行时数据区(内存模型):包括线程栈、本地方法栈、程序计数器、堆和方法区。

      • 线程栈、本地方法栈、程序计数器是线程私有区域
      • 堆和方法区是线程共享区域
    • 具体区域的功能:

      • 线程栈:存储方法调用的局部变量、操作数栈和返回信息。每个方法调用都会创建一个栈帧,栈帧包括局部变量表、操作数栈等。
      • 程序计数器:存放当前执行指令的地址,类似于代码行号。
      • 本地方法栈:执行本地方法的内存区域,针对native方法调用。
      • 方法区(元空间) :存储类信息、静态变量、常量池等。
      • 堆:存放对象实例,如代码示例中的math对象实例。
  • 4.JVM参数设置:

    • 元空间JVM参数设置: 通过-XX:MetaspaceSize=N-XX:MaxMetaspaceSize=N设置元空间的初始大小和最大大小。通常建议设置一致且适当大小。比如对于8G物理内存的机器来说,一般建议可将这两个值都设置为256M。

    • 线程栈参数设置:通过-Xss设置线程栈的大小,调整该参数可影响线程数量和栈帧数。过小可能导致栈溢出。

    • jvm参数设置,官方地址:docs.oracle.com/javase/spec…

    相关文章

    服务器端口转发,带你了解服务器端口转发
    服务器开放端口,服务器开放端口的步骤
    产品推荐:7月受欢迎AI容器镜像来了,有Qwen系列大模型镜像
    如何使用 WinGet 下载 Microsoft Store 应用
    百度搜索:蓝易云 – 熟悉ubuntu apt-get命令详解
    百度搜索:蓝易云 – 域名解析成功但ping不通解决方案

    发布评论