4.JVM(虚拟机)和JMM(内存模型)深度剖析与优化

225 阅读11分钟

JDK体系结构

image.png JDK:JDK(Java Development Kit) 是 Java 语言的软件开发工具包(SDK)。 JDK :1.命令工具 (java/javac/jar) 2.JRE:(JavaRuntimeEnvironment) 指Java运环境, 包括各种Libraies(类库,表现形式jar包),比如:lang and util Base Libraries,Other Base Libraries 等,最重要的是包含JVM Java Virtual Machine(Java虚拟机)的缩写,JVM是一种用于计算设备的规范,它是一个虚构出来的计算机,是通过在实际的计算机上仿真模拟各种计算机功能来实现的。

Java语言的跨平台特性

image.png

Jvm 整体结构及JMM内存模型

1.JVM由三部分组成:

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


image.png 其中类装载子系统是C++实现的,他把类加载进来,放入到虚拟机中。这一块就是之前分析过的类加载器加载类,采用双亲委派机制,把类加载进来放入到JVM虚拟机中。 然后,字节码执行引擎去虚拟机中读取数据。字节码执行引擎也是C++实现的。我们重点研究运行时的数据区。

2.运行时数据区的构成

运行时数据区主要由5个部分构成:堆,栈,本地方法栈,方法区,程序计数器。

3.JVM三部分密切配合工作

下面我们来看看一个程序运行的时候,类装载子系统,运行时数据区,字节码执行引擎是如何密切配合工作的? 我们举个列子说明一下:

package com.jason;

public class Math {
    public static final int initData = 666;
    public static User user = new User();

    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();
        math.compute();
    }
}

当我们在执行main方法的时候,都做了什么事情呢? 第一步: 类加载子系统加载Math.class类, 然后将其丢到内存区域, 这个就是前面博客研究的部分,类加载的过程, 我们看源码也发现,里面好多代码都是native本地的, 是c++实现的

第二步: 在内存中处理字节码文件, 这一部分内容较多, 也是我们研究的重点, 后面会对每一个部分详细说

第三步: 由字节码执行引擎执行java虚拟机中的内存代码, 而字节码执行引擎也是由c++实现的

这里最核心的部分是第二部分运行时数据区(内存模型), 我们后面的调优, 都是针对这个区域来进行的.

下面详细来说内存区域

image.png

四. 栈

栈是用来存放变量的

4.1. 栈空间

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();
    math.compute();
}

开始运行main方法时,会创建一个线程,创建线程的时候,就会在大块的栈空间分配一块小空间,用来存放当前要运行的线程的变量,这里math这个局部变量就会保存在分配的小空间里,在这里我们运行了math.compute()方法,这里面的a,b,c这样的局部变量也放在分配的小空间里面

image.png 如果多个线程,会再次在栈空间分配一块小的空间,用来存放新的线程内部变量

image.png 同样是变量, main方法中的变量和compute()方法中的变量放在一起么?他们是怎么放得呢?这就涉及到栈帧的概念。

4.2. 栈帧

1.什么是栈帧呢?

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();
    math.compute();
}

以上面代码为例 :当我们启动一个线程执行main 方法的时候,会先在栈空间分配一小块空间。然后在这一小块空间中分配一块区域给main()方法,这个区域就叫做栈帧空间。当程序运行到compute()方法的时候,就会去调用compute方法,这时候会在分配一个栈帧空间,给compute()方法使用。

image.png 注:一个方法对应一个栈帧内存区域

2.为什么要将一个线程中的不同方法放在不同的栈帧空间里面呢?

一方面:  我们不同方法里的局部变量是不能相互访问的. 比如compute的a,b,c在main里不能被访问到。使用栈帧做了很好的隔离作用。

另一方面:  方便垃圾回收, 一个方法用完了, 值也返回了, 那他里面的变量就是垃圾了, 后面直接回收这个栈帧就好了.

3.java内存模型中的栈算法

先进后出

4.3 栈帧的内部构成

栈帧内部有很多部分,我们主要关注下面四个部分: 1.局部变量表 2.操作数栈 3.动态链接 4.方法出口

4.3.1 局部变量表

存放局部表量

4.3.2操作数栈

那么操作数栈,动态链接, 方法出口他们是干什么的呢? 我们用例子来说明操作数栈

image.png 那么这四个部分是如何工作的呢?

我们用代码的执行过程来对照分析.

我们要看的是jvm反编译后的字节码文件, 使用javap命令生成反编译字节码文件.

javap命令是干什么用的呢? 我们可以查看javap的帮助文档

image.png 主要使用javap -c和javap -v

javap -c: 对代码进行反编译
javap -v: 输出附加信息, 他比javap -c会输出更多的内容

下面使用命令生成一个Math.class的字节码文件. 我们将其生成到文件

javap -c Math.class > Math.txt

打开Math.txt文件, 如下. 这就是对java字节码反编译成jvm汇编语言.

Compiled from "Math.java"
public class com.jason.Math {
  public static int initData;

  public static com.jason.User user;

  public com.jason.Math();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()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/jason/Math
       3: dup
       4: invokespecial #3                  // Method "<init>":()V
       7: astore_1
       8: aload_1
       9: invokevirtual #4                  // Method compute:()I
      12: pop
      13: return

  static {};
    Code:
       0: sipush        666
       3: putstatic     #5                  // Field initData:I
       6: new           #6                  // class com/jason/User
       9: dup
      10: invokespecial #7                  // Method com/jason/User."<init>":()V
      13: putstatic     #8                  // Field user:Lcom/jason/User;
      16: return
}

这就是jvm生成的反编译字节码文件.

我们以compute()方法为例来说说这个方法是如何在在栈中处理的

源代码 public int compute() { 
    int a = 1;
    int b = 2; 
    int c = (a + b) * 10; 
    return c; 
}
反编译后的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

jvm的反编译代码是什么意思呢? 我们对照着查询手册

0: iconst_1 将int类型常量1压入操作数栈, 这句话的意思就是先把int a=1;中的1先压入操作数栈

image.png 1: istore_1 将int类型值存入局部变量1-->意思是将int a=1; 中的a放入局部变量表的第二个位置, 然后让操作数栈中的1出栈, 赋值给a

注意: 这里的1不是变量的值, 他指的是局部变量的一个下标. 我们看手册上有局部变量0,1,2,3 0表示的是this, 1表示将变量放入局部变量的第二个位置, 2表示放入第三个位置.

image.png

对应到compute()方法,0表示的是this, 1表示的局部变量a, 2表示局部变量b,3表示局部变量c

image.png 2: iconst_2 将int类型常量2压入栈-->意思是将int b=2;中的常量2 压入操作数栈

image.png 3: istore_2 将int类型值存入局部变量2 -->意思是将int b=2;中的变量b存入局部变量表中第三个位置, 然后让操作数栈中的数字2出栈, 给局部变量表中的b赋值为2

image.png 4: iload_1 加载局部变量第1个变量压入操作数栈 要想更好的理解iload_1,我们要先来研究程序计数器。

程序计数器

在JVM虚拟机中,程序计数器是其中的一个组成部分。 重点:程序计数器是每个线程独有的,他用来存放马上要执行的哪行代码的内存位置,也可以叫行号。我们看到jvm反编译代码里,都会有 0 1 2 3 这样的位置(如下图),我们可以将其认为是一个标识。而程序计数器简单理解为是记录这些数字的。而实际上这些数字对应的是内存里的地址

image.png

当字节码执行引擎执行到第4行的时候,将执行到4: iload_1,我们可以简单理解为程序计数器记录代码位置是4,我们的方法Math.class 是放在方法区的,字节码执行引擎执行,每次执行完一行代码,字节码执行引擎都会修改程序计数器的位置,让其向下移动一位

image.png

java虚拟机为什么要设计程序计数器呢?

因为多线程。当一个线程正在执行, 被另一个线程抢占了cpu, 这时之前的线程就要挂起, 当线程2执行完以后, 再执行线程1. 那么线程1之前执行到哪里了呢? 程序计数器帮我们记录了. 4: iload_1 从局部变量1中装载int类型值--> 意思是从局部变量表的第二个位置取出int类型的变量值, 将其放入到操作数栈中.此时程序计数器指向的是4

image.png

4.3.3 动态链接

在之前说过什么是动态链接: 参考文章: juejin.cn/post/705087…
搜索:动态链接 在程序运行过程中,把符号引用转换变为直接引用

静态链接是在程序加载的时候一同被加载进来的. 通常用静态常量, 静态方法等, 因为他们在内存地址中只有一份, 所以, 为了性能, 就直接被加载进来了

而动态链接, 是使用的时候才会被加载进来的链接, 比如compute方法. 只要在执行到math.compute()方法的时候才会真的进行加载.

4.3.4 方法出口

当我们运行完compute()方法以后, 还要返回到main方法的math.comput()方法的位置, 那么他怎么返回回来呢?返回回来以后该执行哪一句代码了呢?在进入compute()方法之前,就在方法出口里记录好了, 我应该如何返回,返回到哪里. 方法出口就是记录一些方法的信息的.

五. 堆和栈的关系

上面研究了compute()方法的栈帧空间,再来看一下main方法的栈帧空间。整体来说,都是一样的,但有一块需要说明一下,那就是局部变量表。来看看下面的代码

public static void main(String[] args) {
        Math math = new Math();
        math.compute();
    }

main方法的局部变量和compute()有什么区别呢? main方法中的math是一个对象. 我们知道通常对象是被创建在堆里面的. 而math是在局部变量表中, 记录的是堆中new Math对象的地址。

说的明白一些,math里存放的不是具体的内容,而是实例对象的地址。

image.png 那么堆和栈的关系就出来了,如果栈中有很多new 对象,这些对象是创建在堆里面的,栈里面存的是这些创建的对象的内存地址。

六. 方法区

我们可以通过javap -v Math.class > Math.txt命令, 打印更详细的jvm反编译后的代码

image.png 这次生成的代码,和使用javap -c生成的代码的区别是多了Constant pool常量池。这些常量池是放在哪里的呢?放在方法区。这里看到的常量池叫做运行时常量池。还有很多其他的常量池,比如:八大数据类型的对象常量池,字符串常量池等。

这里主要理解运行时常量池。运行时常量池放在方法区里。

方法区主要有哪些元素呢?

常量 + 静态变量 + 类元信息(就是类的代码信息)+运行时常量池

在Math.class类中, 就有常量(常量和变量都属于变量,只不过常量是赋过值后不能再改变的变量,而普通的变量可以再进行赋值操作)和静态变量

public static final int initData = 666; //常量
public static User user = new User();//静态变量

他们就放在方法区里面. 这里面 new User()是放在堆里面的, 在堆中分配了一个内存地址,而user对象是放在方法区里面的. 方法区中user对象指向了在堆中分配的内存空间。

image.png 堆和方法区的关系是: 方法区中对象引用的是堆中new出来的对象的地址

类元信息: Math.class整个类中定义的内容就是类元信息, 也放在方法区。

七. 本地方法栈

本地方法栈是有c++代码实现的方法. 方法名带有native的代码.

比如:

new Thread().start();

这里的start()调用的就是本地方法

这就是本地方法

本地方法栈: 运行的时候也需要有内存空间去储存, 这些内存空间就是本地方法栈提供的

image.png 每一个线程都会分配一个栈空间,本地方法栈和程序计数器。如上图main线程:包含线程栈,本地方法栈,程序计数器。