JVM 内存模型剖析

337 阅读8分钟

一、JDK 体系结构

在这里插入图片描述 JDK (Java Development Kit)即我们最常用的 Java 开发工具包。JDK 包括了 JRE (Java Runtime Environment) 即 Java 运行时环境。 从上图可以看出 JRE 就包含了我们的 JVM 以及各种我们常用的核心类库。而 JDK 在其上由拓展了工具命令,例如 java javac javap 这些常用命令。

二、 Java 语言的跨平台特性

Java 的跨平台特性主要时 JVM 虚拟机的功劳,Java 官方为我们提供了不同平台不同版本的 JDK 下载包

在这里插入图片描述 在这里插入图片描述

三、JVM 内存模型

在这里插入图片描述 Java 虚拟机分为三部分组成:类装载子系统、运行时内存区域即 Java 内存模型、字节码执行引擎。 类装载子系统即类加载的过程可以参考我的上一篇博文:JVM 类加载机制深度剖析 字节码执行引擎用来真正执行 Java 的字节码。 我们这里重点剖析一下 JVM 内存模型,JVM 内存模型分为以下几部分:

线程共享

  • 堆:存储 new 出来的对象
  • 方法区(元空间、永久代):常量池、静态变量、类元信息

线程独享

  • 栈(线程栈、虚拟机栈):存储每个线程的局部变量
  • 本地方法栈:存储 Java 本地方法
  • 程序计数器:记录线程方法运行位置

、又称之为虚拟机栈或者线程栈,我觉得称为线程栈可能更明了一些。 我们先看一段简单的代码:

package think_in_jvm;

public class JvmDemoTest {
    
    private  void calculation(){
        int a=2;
        int b=3;
        int c=(a+b)*10;
    }
    
    public static void main(String[] args) {
        JvmDemoTest jvmDemoTest=new JvmDemoTest();
        jvmDemoTest.calculation();
        System.out.println("hello jvm");
    }
}

上述代码在运行时首先会开启一个线程运行 main 方法 ,JVM 会为其在栈中分配一块内存空间用来存储数据,那么实际上 JVM 会为每一个运行的线程在栈上分配一块栈内存空间,同时在每执行到一个方法的时候会在该内存空间中给该方法分配一块栈帧内存空间 用来存储每个方法的局部变量其结构就如同我们数据结构中的栈,先入栈的栈帧后释放。栈帧内存空间中分为:局部变量表、操作数栈、动态链接、方法出口。同时每个线程都有一个独有的程序计数器来记录当前代码执行的位置,及一个存储本地方法的本地方法栈 在这里插入图片描述 使用 javap 命令对该类进行反汇编,可以得到一个较 Java 字节码更为可读的命令文件

javap -c -p JvmDemoTest.class > JvmDemoTest.txt
Compiled from "JvmDemoTest.java"
public class think_in_jvm.JvmDemoTest {
  public think_in_jvm.JvmDemoTest();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return

  private int calculation();
    Code:
       0: iconst_2
       1: istore_1
       2: iconst_3
       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 think_in_jvm/JvmDemoTest
       3: dup
       4: invokespecial #3                  // Method "<init>":()V
       7: astore_1
       8: aload_1
       9: invokespecial #4                  // Method calculation:()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."<init>":()V
      23: ldc           #8                  // String hello jvm
      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
}

相关指令的意思可以参考以下链接,这里详细剖析以下 calculation() 的执行过程 docs.oracle.com/javase/spec… www.jianshu.com/p/190e94e31…

private int calculation();
    Code:
       0: iconst_2   将int 型常量 2 压入操作数栈
       1: istore_1   将操作数栈顶 int 数值存入第 1 局部变量 (即将 a=2 存入局部变量表)
       2: iconst_3   将int 型常量 3 压入操作数栈
       3: istore_2   将操作数栈顶 int 数值存入第 2 局部变量 (即将 b=3 存入局部变量表)
       4: iload_1    将第 1int 局部变量压入操作数栈
       5: iload_2    将第 2int 局部变量压入操作数栈
       6: iadd       对操作数栈顶上的两个数值进行加运算,并把结果重新存入到操作栈顶
       7: bipush        10      常量 10 压入操作数栈
       9: imul       对操作数栈顶上的两个数值进行乘运算,并把结果重新存入到操作栈顶
      10: istore_3   将操作数栈顶 int 数值存入第 3 局部变量 (即将 c=50 存入局部变量表)
      11: iload_3    将第 3int 局部变量压入操作数栈
      12: ireturn    当前方法返回 int

操作数栈和局部变量表通过上述对 calculation 方法的执行指令解析已经很清晰了。我们所有对数的操作都会压入操作数栈最后出栈到 cpu 寄存器中执行,而局部变量表会存储方法的所有局部变量,而对于我们 new 出来的对象它时存储在中的,类似栈中的局部变量存储的是它在堆中的地址或者说引用,而元空间中的静态成员变量也是如此。 在这里插入图片描述 那么所谓的动态链接即在运行时将符号引用转变为直接引用。可以理解为直接指向代码所存地址的内存指针,静态链接是在类加载时将符号引用转变为直接引用,也就时那些静态方法类似于 main() 方法,而非静态方法就需要在运行时去动态链接了。如下代码中的 calculation() 方法就需要动态链接

   public static void main(String[] args) {
        JvmDemoTest jvmDemoTest=new JvmDemoTest();
        int s=jvmDemoTest.calculation();
        System.out.println("hello jvm"+s);
    }

方法出口会记录这个方法最终执行完成返回主方法的位置。 做一个总结:

  1. 局部变量表:存储方法局部变量
  2. 操作数栈:存储需要进行操作的数据
  3. 动态链接:运行时将符号引用转变为直接引用
  4. 方法出口:存储记录方法出口,即方法结束后代码运行位置。

堆这边主要分三部分来说明

  1. 堆内存区域的划分
  2. 堆内存流转模型
  3. GC ROOT 和 STW 机制
1.堆内存区域划分

堆中又划分为:Eden 区 、Survivor 区 及 老年代。 Eden 区和 Survivor 区 又称为年轻代,这里就有一个分代的概念。其中 年轻代默认占整个堆内存的 1/3 ,老年代占 2/3 。Eden 区和 Survivor 区占比为 8:2 ,而 Survivor 区又分为 S0 和 S1 这个会有一个对象流转的过程,其占比为 1:1。 年轻代的对象是属于那种朝生夕死的对象,而老年代的对象是长久存在的,同时垃圾回收时也会分为 minor gc 或者叫 young gc 其主要回收年轻代的对象以及 full gc 其会对整个堆进行垃圾回收包括年轻代和老年代的对象。 在这里插入图片描述

2.堆内存流转模型

一般来说我们 new 出来的对象是在堆中存储的,一般情况下会先存入 Eden 区,当 Eden 区满时会触发 minor gc 也叫做 young gc,这个时候会对年轻代的对象进行垃圾回收,字节码执行引擎开启的垃圾回收线程会首先到堆、栈、元空间、本地方法栈中寻找 GC ROOT 并去标记被 GC ROOT 引用的所有对象,剩余的对象即为垃圾对象,垃圾对象直接清除掉,存活下来的对象会移动到 survivor 区,那么已经在 survivor 区的对象仍然存活,他会增加分代年龄,并在 s0 区和 s1 区流转,当分代年龄达到 15 时这个对象会被放到老年代进行较为长久的保存。当老年代空间存满时会触发 full gc 对堆内存及元空间进行全面的垃圾回收,这个过程时较为耗时的会进行相对 minor gc 更长的 STW 。当 full gc 后老年代。

在这里插入图片描述 使用 Java 自带的 jvisualvm 工具可以较为值观的看到整个堆内存的流转过程。 OOM 示例代码如下

package think_in_jvm;

import java.util.ArrayList;
import java.util.List;

//堆内存流转测试
public class JvmHeapCirculation {

    private static Integer count=0;

    private byte[][] a=new byte[10][1024];



    public static void main(String[] args) throws InterruptedException {
        List<JvmHeapCirculation> list=new ArrayList<>(16);
        while (true){
            list.add(new JvmHeapCirculation());
            Thread.sleep(10);
        }
    }

}

运行时设置:-Xms150M -Xmx150M 设置堆空间大小为 150 M 示例代码中的:List<JvmHeapCirculation> list=new ArrayList<>(16); 就是一个 GC ROOT 在循环中增加的对象会一直被引用所以不会被回收掉,直至 OOM。

这里简单说一下 STW ,Stop The World,是指在垃圾回收的过程中,gc 会停止用户线程的运行,简单的理解就是所有非垃圾回收的线程都会被挂起停止运行,对于 Web 应用来说比较直观的感受就是系统突然卡了一下。

四、JVM 参数设置通用模型

在这里插入图片描述 较为通用的设置为(实际情况可以根据服务器动态调整参数):

java -Xms2048M -Xmn2048M -Xmn1024M -Xss512K -XX:MetaspaceSize=256M -XX:MaxMetaspaceSize=256M -jar server.jar

一般来说初始大小与最大大小都会设置相同的值,避免扩容带来的开销。 尤其对于元空间来说,如果没有设置值,默认为 -1 其大小值只受物理内存大小限制,且初始值为 21M 。其扩容需要进行 full gc 是很大的一笔开销。