通过我这次在 Java 预科班的“回炉重造”(不得不说这两个月时间的预科内容是真的十足!),勾起了我对Java 底层及优化的一些兴趣及思考,紧接着就安排自己记录一下自己曾经模糊的一些概念和知识。
结合课程和一些书籍资料,本文将着重讲述 JVM 的内存模型。
Java 的主要动机是其著名的 **WORA **“一次编写,到处运行”(Write once, run anywhere)。为了应用它,Sun Microsystems创建了Java虚拟机,它是底层OS的抽象,用于解释编译的Java代码。该 JVM 是JRE(Java运行环境)的核心组件,创建运行Java代码,但现在所使用的其他语言(Scala, Groovy, JRuby, Closure …)。
全局概括
JVM 是底层操作系统的抽象。它确保无论 JVM 在什么硬件或操作系统上运行,相同的代码都将以相同的行为运行。 例如:
-
无论 JVM 是在16位/ 32位/ 64位的OS上运行,基本类型
int的大小始终是从-2 ^ 31到2 ^ 31-1的32位有符号整数。 -
无论底层的OS /硬件是big-endian还是little-endian(即低位字节排放在内存的低地址端,高位字节排放在内存的高地址端),每个JVM都以大端顺序(高字节在前)存储和使用内存中的数据。
上图概述了 JVM:
- JVM 解释由编译类的源代码产生的字节码(指令集)。尽管 JVM 代表“Java虚拟机”,但它可以运行其他语言,例如 scala 或 groovy,只要它们可以被编译为Java字节码即可。
- 为了避免磁盘 I/O,字节码由运行时数据去之一中的类加载器加载到 JVM 中。此代码将保留在内存中,直到 JVM 停止或销毁(加载了它)类加载器为止。
- 然后,加载的代码由执行引擎解释并执行。
- 执行引擎需要指针一样存储数据以执行代码。它还需要存储在开发人员代码中处理的数据。
- 执行引擎还负责处理底层操作系统。
注意:许多 JVM 实现的执行引擎并不总是解释字节码,而是将字节码编译为本地代码(如果经常使用的话)。它称为即时(JIT)编译,可以大大加快 JVM 的速度。编译后的代码临时保存在通常称为“代码缓存(Code Cache)”的区域中,由于该区域不在 JVM 规范中,因此在本文的其余部分先不讨论。
基于堆栈的架构 Stack based architecture
JVM 使用基于堆栈的体系结构。尽管它对于开发人员是不可见的,但它对生成的字节码和 JVM 体系结构具有巨大的影响。
JVM 通过执行 Java 字节码中描述的基本操作来执行开发人员的代码。操作数是在其上操作指令的值。根据 JVM 规范,这些操作要求参数通过称为操作数堆栈的堆栈传递。
例如,我们对2个整数进行基本的加法,该操作被称为IADD(integer additon)。如果要在字节码中添加3和4:
- 首先在操作数堆栈中压入3和4.
- 然后在调用iadd指令。
- iadd 将弹出操作数堆栈的最后2个值。
- 将 int 结果(3+4)压入操作数堆栈,以提供给其它操作使用。
字节码 Bytecode
这里有一些字节码操作不同类别的说明,对于每个类别我都添加了一个简短的描述和十六进制范围:
- 常量Constants:用于将常量池种的值或已知值中的值推入操作数堆栈中。值从 0x00 到 0x14
- 加载Loads:用于将局部变量的值加载到操作数堆栈中。值从 0x15 到 0x35
- 存储Stores:用于从操作数堆栈存储到局部变量。值从 0x36 到 0x56
- 堆栈Stack:用于处理操作数堆栈。值从 0x57 到 0x5f
- 数学Math:用于对操作数堆栈中的值进行基本数学运算。值从 0x60 到 0x84
- 转换Conversions:用于从一种类型转换为另一种类型。值从 0x94 到 0xa6
- 比较Comparisons:用于两个值之间的基本比较。值从 0x94 到 0xa6
- 控制Control:诸如goto、return等基本操作,允许需要更高级的操作,例如循环或有返回值的函数。值从 0xa7 到 0xb1
- 引用References:用于分配对象或数组,获取或检查对象,方法或静态方法的引用。也用于调用(静态)方法。值从 0xb2 到 0xc3
- 扩展Extended:为了之后添加的其它类别的操作。值从 0xc4 到 0xc9
- 保留Reserved:供每个Java虚拟机实现内部使用。3个值 0xca,0xfe 和 0xff。
其中,这204个操作非常简单,例如:
- 操作数
ifeq(0x99):检查2个值是否相等 - 操作数
iadd(0x66):将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_SUPERConstant 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];}
运行时数据区 Runtime Data Areas
堆 Heap
MyClass myVariable = new MyClass();MyClass[] myArrayClass = new MyClass[1024];
堆可以动态扩展或收缩,并且可以具有固定的最小和最大大小。例如,在Oracle Hotspot中,用户可以通过以下方式用Xms和Xmx参数指定堆的最小大小
java -Xms = 512m -Xmx = 1024m…
方法区 Method area
方法区域存储:
- 类信息(字段/方法的数量、父类名称、接口名称、版本等)。
- 方法和构造方法的字节码。
- 每个类加载的运行时常量池。
运行时常量池 Runtime constant pool
该常量池是方法区的一部分。由于它是元数据的重要组成部分,因此 Oracle 规范描述了方法区之外的运行时常量池。对于每个加载的类或接口,此常量池都会增加。该常量池就像常规编程语言的符号表。换句话说,当引用类、方法或字段时,JVM 使用运行常量池在内存中搜索实际地址。它还包含常量值,例如字符串或常量。
String myString1 = “This is a string litteral”;static final int MY_CONSTANT=2;
寄存器(每个线程)pc Register
每个线程都有自己的pc(程序计数器)寄存器,与该线程同时被创建。在任何时候,每个Java虚拟机线程都在执行单个方法的代码,即该线程的当前方法。pc寄存器包含当前正在执行的Java虚拟机指令的地址(位于方法区中)。
注意:如果线程当前正在执行的方法是本地的,则Java虚拟机的pc寄存器的值是未定义的。Java虚拟机的pc寄存器足够大,可以在特定平台上保存
returnAddress或本地指针。
Java虚拟机堆栈(每个线程)JVM Stacks
堆栈区域存储多个帧(Frames),因此在讨论堆栈之前,将介绍这些帧(Frame)。
栈帧 Frames
帧是一种数据结构,{Frame [ReturnValue] [LocalVariables[][][][]...] [OperandStack [][][]...] [ConstPoolRef] },其中包含多个数据,这些数据表示当前方法中线程的状态:
- 操作数堆栈:字节码指令使用此堆栈来处理参数。此堆栈还用于在方法调用中传递参数,并在调用方法的堆栈顶部获取被调用方法的结果。Frame被创建时,操作栈是空的。操作栈的每个项可以存放JVM的各种类型数据,包括long/double。操作栈有个栈深,long/double贡献两个栈深。操作栈调用其它有返回结果的方法时,会把结果push到栈上。
- 局部变量数组:此数组包含当前方法范围内的所有局部变量。该数组可以保存基本类型,引用或
returnAddress的值。该数组的大小是在编译时计算的。Java虚拟机在方法调用时传递参数使用局部变量,被调用方法的数组是从调用方法的操作数堆栈创建的。 - 运行时常量池引用:引用正在执行的当前方法的的当前类的常量池。JVM 使用它将符号方法/变量引用(例如
myInstance.method())转换为真正的的内存引用。
堆栈 Stack
每个Java虚拟机线程都有一个私有Java虚拟机堆栈,与该线程同时被创建。Java虚拟机堆栈中存储栈帧。每次调用方法时,都会创建一个新的帧并将其放入堆栈中。帧的方法调用完成时,无论结果是正常的还是突然的(引发了未能捕获的异常),它都会被销毁。
给定线程中的任何一点都只有一个帧(用于执行方法的Frame)处于活动状态。该帧被称为当前帧,其方法称为当前方法。定义当前方法的类是当前类。局部变量和操作数堆栈上的操作通常参考当前帧。
下面还是让我们来看一个简单的执行加法的过程:
public int add(int a, int b){
return a + b;
}
public void functionA(){
int result = add(2, 3);
}
这是functionA()在 JVM 内部的运行情况:
在functionA()内部,Frame A是当前帧并且是堆栈中顶部的帧。在内部调用add()的开始处,将新的帧(Frame B)放入堆栈中。这时Frame B称为当前帧。通过pop出Frame A的操作数堆栈来填充Frame B的局部变量数组。add()完成后,Frame B被销毁,Frame A再次成为当前帧。add()的结果放在Frame A的操作数堆栈上,以便于functionA()可以通过pop其操作数堆栈来使用它。
注意:此堆栈的功能使其可以动态扩展和收缩。堆栈不能超过最大大小,这限制了递归调用的数量。如果超出此限制,JVM 将抛出
StackOverflowError。使用 Oracle HotSpot,可以使用参数
-Xss指定此限制。
本地方法栈(每个线程)Native method stack
这是用非Java语言编写并通过JNI(Java本机接口)调用的本机代码的堆栈。 由于是“本地/本机(native)”堆栈,因此该堆栈的行为完全取决于基础操作系统。
-
本地方法栈是一个后入先出(Last In First Out)栈,同样是线程私有的,随着线程启动而产生,随着线程的结束而消亡。
-
允许被实现成固定或者是可动态扩展的内存大小。
-
如果线程请求分配的栈容量超过本地方法栈允许的最大容量,Java 虚拟机将会抛出
StackOverflowError异常。 -
如果本地方法栈可以动态扩展,并且在尝试扩展的时候无法申请到足够的内存,或者在创建新的线程时没有足够的内存去创建对应的本地方法栈,那么 Java 虚拟机将会抛出
OutOfMemoryError异常 -
它的具体做法是在 Native Method stack 中登记 native 方法,在Execution Engine执行时加载本地方法库。
当某个线程调用一个本地方法时,它就进入了一个全新的并且不再受虚拟机限制的世界。它和虚拟机拥有同样的权限。
- 本地方法可以通过本地方法接口来访问虚拟机内部的运行时数据区。
- 它甚至可以直接使用本地处理器中的寄存器。
- 直接从本地内存的堆中分配任意数量的内存。
本地方法是如何工作的?
当一个线程调用一个本地方法时,本地方法又回调虚拟机中的另一个Java方法。下面这幅图展示了java虚拟机内部线程运行的全景图。一个线程可能在整个生命周期中都执行Java方法,操作他的Java栈;或者他可能毫无障碍地在Java栈和本地方法栈之间跳转。
该线程首先调用了两个Java方法,而第二个Java方法又调用了一个本地方法,这样导致虚拟机使用了一个本地方法栈。图中的本地方法栈显示为一个连续的内存空间。假设这是一个C语言栈,期间有两个C函数,他们都以包围在虚线中的块表示。第一个C函数被第二个Java方法当做本地方法调用, 而这个C函数又调用了第二个C函数。之后第二个C函数又通过本地方法接口回调了一个Java方法(第三个Java方法)。最终这个Java方法又调用了一个Java方法(它成为图中的当前方法)。
就像其他运行时内存区一样,本地方法栈占用的内存区也不必是固定大小的,他可以根据需要动态扩展或者收缩。某些是实现也允许用户或者程序员指定该内存区的初始大小以及最大,最小值。
并不是所有的JVM都支持本地方法。因为Java虚拟机规范并没有明确要求本地方法栈的使用语言、具体实现方式、数据结构等。如果JVM产品不打算支持native方法,也可以无需实现本地方法栈。在Hotspot JVM中,直接将本地方法栈和虚拟机栈合二为一。