JVM内存模型

JAVA的主题是其著名的WOTA:“一次编写,随处运行”。为了应用它,Sun Microsystems创建了Java虚拟机,它是解释编译的java代码的底层操作系统的抽象。该JVM是JRE(Java运行环境)的核心组件,创建运行Java代码,但现在所使用的其他语言(Scala, Groovy, JRuby, Closure …)。

在本文中,我将重点介绍JVM规范中描述的运行时数据区。这些区域旨在存储程序或JVM本身使用的数据。我将首先概述JVM然后是什么字节码,并以不同的数据区域结束。

全球概览

JVM是底层操作系统的抽象。它确保无论运行JVM的硬件或操作系统如何,相同的代码都将以相同的行为运行。例如:

  • 无论JVM是在16位/ 32位/ 64位操作系统上运行,原始类型int的大小始终是从-2 ^ 31到2 ^ 31-1的32位有符号整数。
  • 每个JVM以big-endian顺序(首先是高字节)存储和使用内存中的数据,无论底层OS /硬件是big-endian还是little endian。

注意:有时,JVM实现的行为与另一个不同,但它通常是相同的。

 

JVM内存模型

 

此图提供了JVM的概述:

  • JVM 解释由编译类的源代码产生的字节码。虽然术语JVM代表“Java虚拟机”,但它运行其他语言,如scala或groovy,只要它们可以编译成java字节码。
  • 为了避免磁盘I / O,字节码由其中一个运行时数据区中的类加载器加载到JVM 中。此代码保留在内存中,直到JVM停止或类加载器(加载它)被销毁。
  • 然后由执行引擎解释并执行加载的代码。
  • 执行引擎需要存储数据,如指向正在执行的代码的指针。它还需要存储开发人员代码中处理的数据。
  • 执行引擎还负责处理底层操作系统。

注意:如果经常使用,许多JVM实现的执行引擎将字节码编译为本机代码,而不是总是解释字节码。它被称为Just In Time(JIT)编译并大大加快了JVM的速度。编译后的代码暂时保存在通常称为代码缓存的区域中  。由于该区域不符合JVM规范,因此在本文的其余部分我将不再讨论它。

 

 基于堆栈的架构

JVM使用基于堆栈的体系结构。虽然它对开发人员来说是不可见的,但它对生成的字节码和JVM架构产生了巨大的影响,这就是为什么我将简要解释这个概念。

JVM通过执行Java字节码中描述的基本操作来执行开发人员的代码(我们将在下一章中看到它)。操作数是指令操作的值。根据JVM规范,这些操作要求参数通过称为操作数堆栈的堆栈传递。

JVM内存模型

例如,让我们基本添加2个整数。此操作称为iadd(对于i nteger add ition )。如果想在字节码中添加3和4:

  • 他首先在操作数堆栈中推送3和4。
  • 然后调用iadd指令。
  • iadd将从操作数堆栈中弹出最后2个值。
  • int结果(3 + 4)被推入操作数堆栈以供其他操作使用。

这种功能方式称为基于堆栈的体系结构。还有其他方法可以处理基本操作,例如基于寄存器的体系结构将操作数存储在小寄存器而不是堆栈中。这种基于寄存器的体系结构由桌面/服务器(x86)处理器和前Android虚拟机Dalvik使用。

 

字节码

由于JVM解释字节码,因此在深入了解之前了解它是有用的。

java字节码是将java源代码转换为一组基本操作。每个操作由一个字节组成,该字节表示要执行的指令(称为操作码操作码),以及用于传递参数的零个或多个字节(但大多数操作使用操作数堆栈来传递参数)。在256个可能的一个字节长的  操作码(从十六进制的值0x00到0xFF)中,当前在java8规范中使用了204个。

以下是不同类别的字节码操作的列表。对于每个类别,我添加了一个小描述和操作代码的十六进制范围:

  • 常量:用于从常量池(我们稍后会看到)或从已知值推送到操作数堆栈中的值。从值0x00到0x14
  • 加载:用于将局部变量的值加载到操作数堆栈中。从值0x15到0x35
  • 存储:用于从操作数堆栈存储到局部变量。从值0x36到0x56
  • 堆栈:用于处理操作数堆栈。从值0x57到0x5f
  • 数学:用于对操作数堆栈中的值进行基本数学运算。从值0x60到0x84
  • 转换:用于从一种类型转换为另一种类型。从值0x85到0x93
  • 比较:用于两个值之间的基本比较。从值0x94到0xa6
  • 控制:基本操作,如goto,return,...允许更高级的操作,如循环或返回值的函数。从值0xa7到0xb1
  • 引用:用于分配对象或数组,获取或检查对象,方法或静态方法的引用。也用于调用(静态)方法。从值0xb2到0xc3
  • 扩展:之后添加的其他类别的操作。从值0xc4到0xc9
  • 保留:供每个Java虚拟机实现内部使用。3个值:0xca,0xfe和0xff。

这204个操作非常简单,例如:

  • 操作数ifeq(0x99)检查2个值是否等于
  • 操作数iadd(0x60)增加了2个值
  • 操作数i2l(0x85)将整数转换为long
  • 操作数arraylength(0xbe)给出数组的大小
  • 操作数pop(0x57)弹出操作数堆栈中的第一个值

要创建字节码,需要编译器,JDK中包含的标准java编译器是javac

我们来看一个简单的补充:

public class Test {

  public static void main(String[] args) {

    int a =1;

    int b = 15;

    int result = add(a,b);

  }

 

  public static int add(int a, int b){

    int result = a + b;

    return result;

  }

}

“javac Test.java”命令在Test.class中生成一个字节码。由于java字节码是二进制代码,因此人类无法读取。Oracle在其JDK javap中提供了一个工具  ,它将二进制字节码转换为JVM规范中标记的操作代码的可读集合。

命令“javap -verbose Test.class”给出以下结果:

Classfile /C:/TMP/Test.class

  Last modified 1 avr. 2015; size 367 bytes

  MD5 checksum adb9ff75f12fc6ce1cdde22a9c4c7426

  Compiled from "Test.java"

public class com.codinggeek.jvm.Test

  SourceFile: "Test.java"

  minor version: 0

  major version: 51

  flags: ACC_PUBLIC, ACC_SUPER

Constant pool:

   #1 = Methodref          #4.#15         //  java/lang/Object."<init>":()V

   #2 = Methodref          #3.#16         //  com/codinggeek/jvm/Test.add:(II)I

   #3 = Class              #17            //  com/codinggeek/jvm/Test

   #4 = Class              #18            //  java/lang/Object

   #5 = Utf8               <init>

   #6 = Utf8               ()V

   #7 = Utf8               Code

   #8 = Utf8               LineNumberTable

   #9 = Utf8               main

  #10 = Utf8               ([Ljava/lang/String;)V

  #11 = Utf8               add

  #12 = Utf8               (II)I

  #13 = Utf8               SourceFile

  #14 = Utf8               Test.java

  #15 = NameAndType        #5:#6          //  "<init>":()V

  #16 = NameAndType        #11:#12        //  add:(II)I

  #17 = Utf8               com/codinggeek/jvm/Test

  #18 = Utf8               java/lang/Object

{

  public com.codinggeek.jvm.Test();

    flags: ACC_PUBLIC

    Code:

      stack=1, locals=1, args_size=1

         0: aload_0

         1: invokespecial #1                  // Method java/lang/Object."<init>":()V

         4: return

      LineNumberTable:

        line 3: 0

 

  public static void main(java.lang.String[]);

    flags: ACC_PUBLIC, ACC_STATIC

    Code:

      stack=2, locals=4, args_size=1

         0: iconst_1

         1: istore_1

         2: bipush        15

         4: istore_2

         5: iload_1

         6: iload_2

         7: invokestatic  #2                  // Method add:(II)I

        10: istore_3

        11: return

      LineNumberTable:

        line 6: 0

        line 7: 2

        line 8: 5

        line 9: 11

 

  public static int add(int, int);

    flags: ACC_PUBLIC, ACC_STATIC

    Code:

      stack=2, locals=3, args_size=2

         0: iload_0

         1: iload_1

         2: iadd

         3: istore_2

         4: iload_2

         5: ireturn

      LineNumberTable:

        line 12: 0

        line 13: 4

}

可读的.class显示字节码不仅包含java源代码的简单转录。它包含:

  • 类的常量池的描述。常量池是JVM的数据区域之一,它存储有关类的元数据,如方法名称,参数......当在JVM中加载类时,此部分进入常量池。
  • LineNumberTable或LocalVariableTable之类的信息,用于指定函数的位置(以字节为单位)及其在字节码中的变量。
  • 在开发人员的java代码(加上隐藏的构造函数)的字节码中的转录。
  • 处理操作数堆栈的特定操作,以及更广泛地传递和获取参数的方式。

仅供参考,这里是存储在.class文件中的信息的简要描述:

ClassFile {

  u4 magic;

  u2 minor_version;

  u2 major_version;

  u2 constant_pool_count;

  cp_info constant_pool[constant_pool_count-1];

  u2 access_flags;

  u2 this_class;

  u2 super_class;

  u2 interfaces_count;

  u2 interfaces[interfaces_count];

  u2 fields_count;

  field_info fields[fields_count];

  u2 methods_count;

  method_info methods[methods_count];

  u2 attributes_count;

  attribute_info attributes[attributes_count];

}

 

运行时数据区

运行时数据区域是用于存储数据的内存区域。这些数据由开发人员的程序或JVM用于其内部工作。

 

JVM内存模型

此图显示了JVM中不同运行时数据区域的概述。每个线程的某些区域是唯一的。

 

堆是所有Java虚拟机线程之间共享的内存区域。它是在虚拟机启动时创建的。所有类实例数组都在堆中分配(使用new运算符)。

MyClass myVariable = new MyClass();

MyClass[] myArrayClass = new MyClass[1024];

此区域必须由垃圾收集器 管理,以删除开发人员在不再使用时分配的实例。清理内存的策略取决于JVM实现(例如,Oracle Hotspot提供了多种算法)。

堆可以动态扩展或收缩,并且可以具有固定的最小和最大大小。例如,在Oracle Hotspot中,用户可以通过以下方式“java -Xms = 512m -Xmx = 1024m ...”指定具有Xms和Xmx参数的堆的最小大小。

 

注意:堆的最大大小不能超过。如果超出此限制,JVM将抛出OutOfMemoryError。

方法区域

Method区域是所有Java虚拟机线程之间共享的内存。它是在虚拟机启动时创建的,由类加载器从字节码加载。只要加载它们的类加载器处于活动状态,方法区域中的数据就会保留在内存中。

方法区域存储:

  • 类信息(字段/方法的数量,超类名称,接口名称,版本......)
  • 方法和构造函数的字节码。
  • 每个类加载的运行时常量池。

规范不强制在堆中实现方法区域。例如,在JAVA7之前,Oracle HotSpot使用名为PermGen的区域来存储方法区域。此PermGen与Java堆(以及由JVM管理的内存,如堆)连续,并且限制为64Mo的默认空间(由参数-XX:MaxPermSize修改)。从Java 8开始,HotSpot现在将方法区域存储在称为Metaspace的独立本机内存空间中,最大可用空间是可用的总系统内存。

 

注意:方法区域不能超过最大大小。如果超出此限制,JVM将抛出OutOfMemoryError。

运行时常量池

该池是方法区的子部分。由于它是元数据的重要组成部分,因此Oracle规范描述了除方法区域之外的运行时常量池。每个加载的类/接口都会增加此常量池。该池就像传统编程语言的符号表。换句话说,当引用类,方法或字段时,JVM使用运行时常量池搜索内存中的实际地址。它还包含常量值,如字符串litterals或常量基元。

String myString1 = “This is a string litteral”;

static final int MY_CONSTANT=2;

 

电脑注册(每线程)

每个线程都有自己的pc(程序计数器)寄存器,与线程同时创建。在任何时候,每个Java虚拟机线程都在执行单个方法的代码,即该线程的当前方法。pc寄存器包含当前正在执行的Java虚拟机指令的地址(在方法区域中)。

注意:如果线程当前正在执行的方法是本机的,则Java虚拟机的pc寄存器的值是未定义的.Java虚拟机的pc寄存器足够宽,可以在特定平台上保存returnAddress或本机指针。

 

Java虚拟机堆栈(每线程)

堆栈区域存储多个帧,因此在讨论堆栈之前,我将呈现帧。

框架

框架是一种数据结构,它包含表示当前方法(被调用方法)中线程状态的多个数据:

  • 操作数堆栈:我已经在有关基于堆栈的体系结构的章节中介绍了操作数堆栈。字节码指令使用该堆栈来处理参数。此堆栈还用于在(java)方法调用中传递参数,并在调用方法的堆栈顶部获取被调用方法的结果。
  • 局部变量数组:此数组包含当前方法范围内的所有局部变量。此数组可以包含基本类型,引用或returnAddress的值。此数组的大小在编译时计算。Java虚拟机使用局部变量在方法调用上传递参数,被调用方法的数组是从调用方法的操作数堆栈创建的。
  • 运行时间常量池参考:参考的常量池中当前类的的当前方法被执行。JVM使用它将符号方法/变量引用(例如:myInstance.method())转换为实际内存引用。

 堆

每个Java虚拟机线程都有一个私有Java虚拟机堆栈,与线程同时创建。Java虚拟机堆栈存储帧。每次调用方法时,都会创建一个新帧并将其放入堆栈中。当方法调用完成时,框架将被销毁,无论该完成是正常还是突然(它会抛出未捕获的异常)。

只有一个帧(执行方法的帧)在给定线程中的任何点处都是活动的。该帧被称为当前帧,并且其方法被称为当前方法。定义当前方法的当前类。局部变量和操作数堆栈的操作通常参考当前帧。

 

让我们看看下面的例子,它是一个简单的添加

public int add(int a, int b){

  return a + b;

}

 

public void functionA(){

// some code without function call

  int result = add(2,3); //call to function B

// some code without function call

}

以下是在运行functionA()时JVM内部的工作方式:

JVM内存模型

 

在functionA()内部,帧A是堆栈帧的顶部,是当前帧。在内部调用add()的开始,将一个新帧(Frame B)放入Stack中。帧B成为当前帧。从弹出框架A的操作数堆栈填充框架B的局部变量数组。当add()完成时,框架B被销毁,框架A再次成为当前框架。add()的结果放在Frame A的操作数堆栈上,这样functionA()就可以通过弹出它的操作数堆栈来使用它。

 

注意:此堆栈的功能使其可动态扩展和收缩。堆栈不能超过的最大大小限制了递归调用的数量。如果超出此限制,JVM将抛出  StackOverflowError

使用Oracle HotSpot,您可以使用参数-Xss指定此限制。

本机方法堆栈(每线程)

这是用Java以外的语言编写并通过JNI(Java Native Interface)调用的本机代码的堆栈。由于它是一个“本机”堆栈,因此该堆栈的行为完全取决于底层操作系统。

 

结论

我希望本文可以帮助您更好地理解JVM。在我看来,最棘手的部分是JVM堆栈,因为它与JVM的内部功能密切相关。