本博文参考Java虚拟机规范1.8官方文档
JVM概述
JVM是什么?
先看看官网在JDK1.8中怎么说
这里我们拿出重要的信息进行翻译,Java虚拟机(Java Virtual Machine)是Java平台的基石 。Java 虚拟机是一个抽象的计算机器,像真正的计算机一样,它有一个指令集并在运行时操作各种内存区域来仿真模拟各种计算机功能。JVM对Java编程语言一无所知,只认识二进制格式的class文件,class文件中包含Java虚拟机的指令(或字节码)和符号表,以及相关辅助信息。
优点:
- 一次编译,处处运行
- 自动内存管理
- 自动垃圾回收功能
Java运行时数据区(Run-Time Data Areas)
JVM运行时数据区内存模型如下所示:
下面讲述的就是这里面的所有东西
程序计数器(The pc Register)
官方介绍:
程序计数器是一个记录着当前线程所执行的字节码的行号指示器。
Java代码编译后的字节码未经过JIT(实时编译器)编译前,其执行的方式是通过“字节码解释器”进行解释执行。简单的工作原理为解释器读取并装载入内存的字节码,按照顺序读取字节码指令。读取一个指令后,将该指令“翻译”成固定的操作,并根据这些操作进行分支、循环、跳转等流程。
假设只有一个线程,可以沿着指令的顺序去执行,那么没有程序计数器是完全可行的,但实际上,程序是多线程协同合作执行的。
首先要搞清楚JVM多线程的实现方式。JVM的多线程是通过CPU时间片轮转(即线程轮流切换并分配处理器的执行时间)算法来实现的。也就是说,某个线程在执行的过程中可能因为时间片耗尽而被挂起,而另一个线程获取时间片开始执行,当被挂起的线程再获取到时间片的时候,它想从被挂起的地方继续执行,就必须知道它上次执行到哪个位置,在JVM中,就是通过程序计数器来记录某个线程的字节码执行位置。因此,程序计数器是具备线程隔离的特性,也就是说,每个线程工作时都有属于自己的独立的计数器。
程序计数器的特点
- 当前线程所执行的字节码行号指示器。
- 每个线程都有一个自己的PC计数器。
- 线程私有的,生命周期与线程相同,随JVM启动而生,JVM关闭而死。
- 线程执行Java方法时,记录其正在执行的虚拟机字节码指令地址。
- 线程执行Native方法时,计数器记录为空(Undefined)。
注意:
- 程序计数器是唯一在Java虚拟机规范中没有规定任何OutOfMemoryError情况区域。
Java虚拟机栈(Java Virtual Machine Stacks)
官方介绍:
先了解栈的数据结构:
栈的数据模型好比一个弹夹,先装进去的子弹最后才能出来,栈存放数据的方式就是这样,先进后出。
Java虚拟机栈是用于描述java方法执行的内存模型。
线程执行期间,每个方法被执行时,都会创建一个栈帧(Frame),无论该方法是正常返回还是异常返回。栈帧用于存储局部变量表、操作栈、动态链接和方法返回地址等信息。每个方法从被调用到执行完成的过程,就对应着一个栈帧在Java虚拟机栈中从入栈到出栈的过程。如下图所示:
栈帧(Frames)的四种组成元素和作用
局部变量表(Local Variables)
局部变量表是Java虚拟机栈的一部分,是一组变量值的存储空间,用于存储方法参数和局部变量。局部变量表在编译期间分配内存空间,可以存放编译期的各种变量类型:
- 基本数据类型:boolean,byte,char,short,int,float,long,double。
- 对象引用类型:reference,指向对象起始地址的引用指针;不等同于对象本身,根据不同的虚拟机实现,它也可能是指向一个代表对象的句柄或者其他与此对象相关的位置。
- 返回地址类型:returnAddress,返回地址的类型,指向了一条字节码指令的地址。
变量槽(Variable Solt):变量槽是局部变量表的最小单位,规定为32位。对于64位的long和double变量,虚拟机会为其分配连续两个Solt的空间。
操作数栈(Operand Stacks)
操作数栈(Operand Stack)也成为操作栈,是一个后入先出栈,Java虚拟机的解释执行引擎被称为基于栈的执行引擎,其中的栈就是操作数栈。
- 和局部变量表一样,操作数栈也是一个以32字长为单位的数组。
- 虚拟机在操作数栈中可以存储的数据类型:int、long、float、double、reference和returnType等类型(对于byte、short和char类型的值在亚茹到操作数栈之前会被转为int)
虚拟机把操作数栈作为它的工作区,大多数指令都要从这里弹出数据,执行运算,然后把结果压回操作数栈。
begin
iload_0 // push the int in local variable 0 onto the stack
iload_1 // push the int in local variable 1 onto the stack
iadd // pop two ints, add them, push result
istore_2 // pop int, store into local variable 2
end
在这个字节码序列里,前两个指令iload_0和iload_1将存储在局部变量表中索引为0和1的整数压入操作数栈中,其后iadd指令从操作数栈中弹出之前压入的两个整数相加,再将结果压入操作数栈。第四条指令istore_2则是从操作数栈中弹出结果,并把它们存储到局部变量表索引为2的位置。
下图详细的描述了这个过程中局部表量表和操作数栈的变化状态(图中没有使用的局部变量表和操作数栈区域以空白表示,每一步代表当前状态的局部变量表和操作数栈状态)。
动态链接(Dynamic Linking)
每一个栈帧都包含一个运行时常量池中的方法引用,持有这个引用是为了支持方法调用过程中的动态链接。
Class文件的常量池中存在大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用为参数。这些符号引用:
- 静态解析:一部分会在类的加载阶段或者第一次使用的时候转化为直接饮用(如final、static域等),成为静态解析。
- 动态解析:另一部分将会在每一次运行期间转化为直接引用,就成为动态链接。
方法返回地址(Return Address)
当一个方法开始执行以后,只有两种方式可以退出当前方法:
- 正常返回:当执行遇到返回指令,会将返回值传递给上层的调用方法,这种退出的方式称为正常完成出口,一般程序计数器作为返回地址。
- 异常返回:当执行遇到异常,并且当前方法体没有全部得到处理,就会导致方法退出,此时没有返回值,称为异常完成出口,返回地址要通过异常处理器表来确定。
当一个方法返回时,可能一次进行一下三个操作:
- 恢复上层方法的局部变量表和操作数栈。
- 把返回值压入调用者栈帧的操作数栈。
- 将程序计数器的值指向下一条方法指令的位置。
Java虚拟机栈总结:
- 线程运行时需要的内存,称为Java虚拟机栈。
- 每个栈有多个栈帧(Frame)组成,对应着每次方法调用时所需要的内存。
- 每个栈只能有一个活动的栈帧,对应着当前正在执行的方法。
- 线程创建的栈帧是该线程的本地栈帧,不能被任何其他线程引用。
注意:
在Java虚拟机规范中,对这个区域规定了两种Error:
- StackOverFlowError:如果当前线程请求的栈深度大于Java虚拟机栈所允许的深度。
- OutOfMemoryError:如果扩展无法申请到足够的内存空间。
来看一下StackOverFlowError,用一个递归方法来无限递归来触发栈最大深度溢出,废话不多说,上代码:
public class StackOverflow {
public static void main(String[] args) {
method();
}
//递归
private static void method() {
method();
}
}
本地方法栈(Native Method Stacks)
本地方法栈和Java虚拟机栈发挥的作用非常相似,主要区别是Java虚拟机栈执行的是Java方法服务,二本地方法栈执行Native方法服务(通常用C编写)。
注意:
- 有些虚拟机发行版本(譬如Sun HotSpot虚拟机)直接将本地方法栈和Java虚拟机栈合二为一。
- 与Java虚拟机栈一样,本地方法栈也会抛出StackOverflowError和OutOfMemoryError异常。
堆(Heap)
官方介绍:
对于大多数应用而言,堆是虚拟机所管理的内存中最大的一块,是被所有线程共享的一块内存区域,在虚拟机启动的时候创建。此内存区域唯一的作用就是存放对象实例,几乎所有的对象实例都是在这里分配的。
由于JAVA中的对象大部分是朝生夕灭,还有一小部分能够长期的驻留在内存中,为了对这两种对象进行最有效的回收,将堆划分为新生代和老年代,并且执行不同的垃圾回收策略。
- 新生代:Young Generation
- 老年代:Old Generation
新生代(Young)又被划分为一个Eden和两个Survivor区:
- Eden区
- From Survivor区
- To Survivor区
新的对象分配是首先放在年轻代 (Young) 的Eden区,Survivor区作为Eden区和老年代(Old)的缓冲,在Survivor区的对象经历若干次收集仍然存活的,就会被转移到老年代(Old)中。
总结:
- 存放对象实例。
- 堆是所有线程共享的内存区域,线程可以拿到同一个对象实例。
- 堆的生命周期随着虚拟机启动而创建。
- 堆是GC回收的主要区域,因此很多时候也被称为GC堆。
注意:
堆内存溢出:OutOfMemoryError:当堆无法分配对象内存且无法再扩展。
堆无法分配对象时会进行一次GC回收,如果GC回收后仍然无法分配对象,才会报内存溢出的错误。可以通过不断生成新的对象但不释放引用来模拟这种情形,废话不多说,上代码:
import java.util.ArrayList;
import java.util.List;
public class HeapOutOfMemory {
public static void main(String[] args) {
List list = new ArrayList();
String str = "hello";
for (int i = 0; i<1000; i++) {
list.add(str);
str = str + str;
}
}
}
方法区(Method Area)
官方介绍:
Java虚拟机有一个在所有Java线程之间共享的方法区域,
- 存储每个类的结构,例如类的信息、常量池、变量和、法数据、以及方法和构造函数的代码,包括特殊方法,用于类和接口的初始化。方法区是在虚拟机启动时创建的。
- 存储运行时常量池:编译期生成的各种字面量常量和符号引用加载后会存放在方法区(JDK1.8之后废除永久代,将StringTable移到堆中 )。
注意:
方法区内存溢出:
- 1.8之前会导致永久代内存溢出OutOfMemoryError:PermGen space,使用 -XX:MaxPermSize=8m 指定永久代内存大小
- 1.8之后会导致堆内存溢出OutOfMemoryError:Java heap space,如果没关UseGCOverheadLimit(-XX:-UseGCOverheadLimit关闭,GC花费98%的时间回收了不到2%堆空间限制开关)开关,就会报OutOfMemoryError:GC overhead limit exceeded,使用 -XX:MaxMetaspaceSize=8m 指定元空间大小;使用-Xmx10m指定堆内存大小。
运行时常量池(Run-Time Constant Pool)
运行时常量池是方法区的一部分,Class文件中除了有类的版本、字段、方法等描述信息外,还常量池(Constant Pool Table)用于存放编译时期生成的各种字面常量和符号引用,这部分内容会在类加载后进入方法区的运行时常量池。
废话不多说,上代码:
public class ConstantPool {
public static void main(String[] args) {
System.out.println("HelloWorld");
}
}
用javac ConstantPool.java编译,然后用javap -v ConstantPool.class解析,人类才能勉强看懂。打印出的结果包含类的基本信息、常量池、方法定义(包含虚拟机指令)。
D:\work\code>javac ConstantPool.java
D:\work\code>javap -v ConstantPool.class
Classfile /D:/work/code/0722/publicVehicle/JVM/src/test/java/method/ConstantPool.class
Last modified 2021-7-29; size 435 bytes
MD5 checksum c49738b1c9b65fd036d6bcae2e380ad3
Compiled from "ConstantPool.java"
public class method.ConstantPool
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #6.#15 // java/lang/Object."<init>":()V
#2 = Fieldref #16.#17 // java/lang/System.out:Ljava/io/PrintStream;
#3 = String #18 // HelloWorld
#4 = Methodref #19.#20 // java/io/PrintStream.println:(Ljava/lang/String;)V
#5 = Class #21 // method/ConstantPool
#6 = Class #22 // java/lang/Object
#7 = Utf8 <init>
#8 = Utf8 ()V
#9 = Utf8 Code
#10 = Utf8 LineNumberTable
#11 = Utf8 main
#12 = Utf8 ([Ljava/lang/String;)V
#13 = Utf8 SourceFile
#14 = Utf8 ConstantPool.java
#15 = NameAndType #7:#8 // "<init>":()V
#16 = Class #23 // java/lang/System
#17 = NameAndType #24:#25 // out:Ljava/io/PrintStream;
#18 = Utf8 HelloWorld
#19 = Class #26 // java/io/PrintStream
#20 = NameAndType #27:#28 // println:(Ljava/lang/String;)V
#21 = Utf8 method/ConstantPool
#22 = Utf8 java/lang/Object
#23 = Utf8 java/lang/System
#24 = Utf8 out
#25 = Utf8 Ljava/io/PrintStream;
#26 = Utf8 java/io/PrintStream
#27 = Utf8 println
#28 = Utf8 (Ljava/lang/String;)V
{
public method.ConstantPool();
descriptor: ()V
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[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=1, args_size=1
0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #3 // String HelloWorld
5: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
LineNumberTable:
line 14: 0
line 15: 8
}
SourceFile: "ConstantPool.java"
StringTable
String table又称为String pool,字符串常量池,在JDK1.7之后其存在于堆中,最重要的一点。String table中存储的并不是String的对象,存储的是指向String对象的索引,真实的对象还是存储在堆中。
此外String table还存在一个hash表的特性,里面不存在相同的两个字符串。
String对象调用intern()方法时,会先在String table中查找是否存在该对象相同的字符串,如果存在直接返回String table中字符串的引用,如果不存在则在String table中创建一个于改对象相同的字符串。
废话不多说,上代码:
public class StringTable {
public static void main(String[] args) {
String str1 = "ab";
String str2 = "a" + "b";
String str3 = new String("a") + "b";
String str4 = str3.intern();
System.out.println(str1 == str2);
System.out.println(str1 == str3);
System.out.println(str1 == str4);
}
}
会输出什么呢?
答案是:true,false,true
第一个为true的原因是JVM在编译时期优化的机制,在编译期(javac *.java)会将可以拼接的字符串常量自动拼接,此时由于String table中已经存在ab的引用了,因此会让str2指向一个于str1相同的那块地址,所以为true。
第二个为false是因为使用new String(),运行过程中会在堆中重新开辟一个存储空间,和不会经过String table,和之前的字符串常量没啥关系,因此为false。
第三个为true的原因在于,调用intern()方法会用自己的值和String table中的字符串常量值(不是地址相等)进行比较,发现里面恰好存在,因此返回该存在的引用,也就是str1的引用,所以为true。
0: ldc #2 // String ab
2: astore_1
3: ldc #2 // String ab
5: astore_2
6: new #3 // class java/lang/StringBuilder
9: dup
10: invokespecial #4 // Method java/lang/StringBuilder."<init>":()V
13: new #5 // class java/lang/String
16: dup
17: ldc #6 // String a
19: invokespecial #7 // Method java/lang/String."<init>":(Ljava/lang/String;)V
22: invokevirtual #8 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
25: ldc #9 // String b
27: invokevirtual #8 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
30: invokevirtual #10 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
33: astore_3
34: aload_3
35: invokevirtual #11 // Method java/lang/String.intern:()Ljava/lang/String;
38: astore 4
40: getstatic #12 // Field java/lang/System.out:Ljava/io/PrintStream;
使用javap解析之后,第一行和第三行的常量导入,"ab"常量来自同一个地方因此第一个为true;对于str3而言利用StringBuilder拼接完成后调用toString();这里intern()看不出具体细节。