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 都以大端顺序(高字节在前)存储和使用内存中的数据。
注意:有时,JVM 实现的行为与另一个不同,但通常是相同的。
此图给出了 JVM 的概述:
- JVM解释通过编译类的源代码产生的字节码。尽管 JVM 一词代表“Java 虚拟机”,但它可以运行其他语言,如 scala 或 groovy,只要它们可以编译成 java 字节码。
- 为了避免磁盘 I/O,字节码由运行时数据区域之一中的类加载器加载到 JVM。这段代码一直保留在内存中,直到 JVM 停止或类加载器(加载它的)被销毁。
- 加载的代码然后由执行引擎解释和执行。
- 执行引擎需要存储数据,例如指向正在执行的代码行的指针。它还需要存储在开发人员代码中处理的数据。
- 执行引擎还负责处理底层操作系统。
注意:如果经常使用,许多 JVM 实现的执行引擎会将字节码编译为本机代码,而不是总是解释字节码。它被称为即时 ( JIT ) 编译,大大加快了 JVM。编译后的代码临时保存在通常称为 代码缓存的区域中。由于该区域不在 JVM 规范中,因此我不会在本文的其余部分讨论它。
基于堆栈的架构
JVM 使用基于堆栈的体系结构。虽然它对开发人员来说是不可见的,但它对生成的字节码和 JVM 架构有巨大的影响,这就是为什么我将简要解释这个概念的原因。
JVM 通过执行 Java 字节码中描述的基本操作来执行开发人员的代码(我们将在下一章中看到)。操作数是指令对其进行操作的值。根据 JVM 规范,这些操作要求参数通过称为操作数堆栈的堆栈传递。
例如,让我们以 2 个整数的基本加法为例。此操作称为iadd (用于整数加法)。如果想在字节码中添加 3 和 4:
- 他首先将 3 和 4 压入操作数堆栈。
- 然后调用 iadd 指令。
- iadd 将从操作数堆栈中弹出最后两个值。
- int 结果 (3 + 4) 被压入操作数堆栈以供其他操作使用。
这种运行方式称为基于堆栈的架构。还有其他处理基本操作的方法,例如基于寄存器的体系结构将操作数存储在小寄存器中而不是堆栈中。桌面/服务器 (x86) 处理器和以前的 android 虚拟机 Dalvik 使用这种基于寄存器的架构。
字节码
由于 JVM 解释字节码,因此在深入之前了解它是有用的。
java字节码是将java源代码转化为一组基本操作。每个操作由一个字节组成,表示要执行的指令(称为操作码或操作码),以及用于传递参数的零个或多个字节(但大多数操作使用操作数堆栈来传递参数)。在 256 个可能的 1 字节长的 操作码中,有 204 个当前在 java8 规范中使用。
这是不同类别的字节码操作的列表。对于每个类别,我添加了一个小描述和操作码的十六进制范围:
- 常量:用于将值从常量池(我们稍后会看到)或从已知值推送到操作数堆栈中。从值 0x00 到 0x14
- Loads:用于将局部变量中的值加载到操作数堆栈中。从值 0x15 到 0x35
- Stores:用于从操作数堆栈存储到局部变量中。从值 0x36 到 0x56
- Stack:用于处理操作数堆栈。从值 0x57 到 0x5f
- Math:用于对操作数堆栈中的值进行基本数学运算。从值 0x60 到 0x84
- 转换:用于从一种类型转换为另一种类型。从值 0x85 到 0x93
- 比较:用于两个值之间的基本比较。从值 0x94 到 0xa6
- 控制:基本操作,如 goto、return ……允许更高级的操作,如循环或返回值的函数。从值 0xa7 到 0xb1
- 引用:用于分配对象或数组,获取或检查对象、方法或静态方法的引用。也用于调用(静态)方法。从值 0xb2 到 0xc3
- Extended:之后添加的其他类别的操作。从值 0xc4 到 0xc9
- 保留:供每个 Java 虚拟机实现内部使用。3 个值:0xca、0xfe 和 0xff。
这204个操作很简单,例如:
- 操作数ifeq (0x99 ) 检查 2 个值是否相等
- 操作数iadd (0x60) 添加 2 个值
- 操作数i2l (0x85) 将整数转换为长整数
- 操作数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 中不同运行时数据区域的概览。有些区域是独一无二的,其他区域是每个线程的。
堆
堆是所有 Java 虚拟机线程之间共享的内存区域。它是在虚拟机启动时创建的。所有类实例和数组都在堆中分配(使用new运算符)。
MyClass myVariable = new MyClass();
MyClass[] myArrayClass = new MyClass[1024];
该区域必须由垃圾收集器 管理,以在不再使用时删除开发人员分配的实例。清理内存的策略取决于 JVM 的实现(例如,Oracle Hotspot 提供了多种算法)。
堆可以动态扩展或收缩,并且可以具有固定的最小和最大大小。例如,在 Oracle Hotspot 中,用户可以通过以下方式使用 Xms 和 Xmx 参数指定堆的最小大小“java -Xms=512m -Xmx=1024m ...”
注意:堆不能超过最大大小。如果超过此限制,JVM 将抛出OutOfMemoryError。
方法区
方法区是所有 Java 虚拟机线程之间共享的内存。它是在虚拟机启动时创建的,并由类加载器从字节码加载。只要加载它们的类加载器还活着,方法区中的数据就会保留在内存中。
方法区存储:
- 类信息(字段/方法的数量、超类名称、接口名称、版本……)
- 方法和构造函数的字节码。
- 每个加载的类都有一个运行时常量池。
规范不强制在堆中实现方法区。例如,在 JAVA7 之前,Oracle HotSpot使用名为 PermGen 的区域来存储方法区域。这个PermGen与 Java 堆(以及像堆一样由 JVM 管理的内存)是连续的,并且被限制为 64Mo 的默认空间(由参数 -XX:MaxPermSize 修改)。从 Java 8 开始,HotSpot 现在将方法区存储在称为Metaspace的独立本机内存空间中,最大可用空间是可用的系统总内存。
注意:方法区域不能超过最大大小。如果超过此限制,JVM 将抛出OutOfMemoryError。
运行时常量池
该池是方法区的子部分。由于它是元数据的重要组成部分,Oracle 规范将运行时常量池与方法区分开描述。每个加载的类/接口都会增加这个常量池。这个池就像传统编程语言的符号表。换句话说,当一个类、方法或字段被引用时,JVM 通过运行时常量池在内存中搜索实际地址。它还包含常量值,如字符串文字或常量原语。
String myString1 = “This is a string litteral”;
static final int MY_CONSTANT=2;
pc 寄存器(每个线程)
每个线程都有自己的 pc(程序计数器)寄存器,与线程同时创建。在任何时候,每个 Java 虚拟机线程都在执行单个方法的代码,即该线程的当前方法。pc 寄存器包含当前正在执行的 Java 虚拟机指令(在方法区域中)的地址。
注意:如果线程当前正在执行的方法是native,则Java虚拟机的pc寄存器的值是未定义的。Java虚拟机的pc寄存器足够宽,可以保存特定平台上的returnAddress或native指针。
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 中的工作方式:
在 functionA() 中,Frame A 是堆栈帧的顶部,并且是当前帧。在对 add() 的内部调用开始时,将一个新帧(Frame B)放入堆栈中。帧 B 成为当前帧。帧 B 的局部变量数组通过弹出帧 A 的操作数堆栈来填充。当 add() 完成时,帧 B 被销毁并且帧 A 再次成为当前帧。add() 的结果被放入 Frame A 的操作数堆栈,以便 functionA() 可以通过弹出其操作数堆栈来使用它。
注意:此堆栈的功能使其可动态扩展和收缩。堆栈不能超过最大大小,这限制了递归调用的数量。如果超过此限制,JVM 会抛出 StackOverflowError。
对于 Oracle HotSpot,您可以使用参数 -Xss 指定此限制。
本机方法堆栈(每线程)
这是用 Java 以外的语言编写并通过 JNI(Java 本地接口)调用的本地代码的堆栈。由于它是“本机”堆栈,因此该堆栈的行为完全取决于底层操作系统。
来填充。当 add() 完成时,帧 B 被销毁并且帧 A 再次成为当前帧。add() 的结果被放入 Frame A 的操作数堆栈,以便 functionA() 可以通过弹出其操作数堆栈来使用它。
注意:此堆栈的功能使其可动态扩展和收缩。堆栈不能超过最大大小,这限制了递归调用的数量。如果超过此限制,JVM 会抛出 StackOverflowError。
对于 Oracle HotSpot,您可以使用参数 -Xss 指定此限制。
本机方法堆栈(每线程)
这是用 Java 以外的语言编写并通过 JNI(Java 本地接口)调用的本地代码的堆栈。由于它是“本机”堆栈,因此该堆栈的行为完全取决于底层操作系统。
关于JVM内存模型,你学废了么?