深入理解JVM
作者声明:原创,转载请注明出处!目前未完待续,会持续更新的
一、什么是JVM
JVM是Java Virtual Machine(Java虚拟机)的缩写。既然是虚拟机,则需要建立在操作系统之上。JVM有很多种,目前被运用最广泛的为HOTSPOT,本文以HOTSPOT虚拟机来讲,所以接下来的JVM均为HOTSPOT。
二、JAVA的运行机制
从图中我们可以看出,java之所以能操作我们的CPU进行计算,其实是JVM在和CPU打交道,而JVM所能支持的编码格式,也就是.class文件(字节码文件)。我们都知道JAVA因JVM而成为了跨平台的语言,从图中我们可以看出,只要能编译程.class文件,那么这门语言就可以在JVM上运行,所以我们说,JVM是跨语言的平台。只要满足字节码文件的规范,就能被JVM接收。也就能在JVM上跑起来。
三、JVM架构图
从图中我们可以清晰地看到在JVM拿到我们的字节码文件后,首先将Class文件进入类加载器子系统,然后进入我们地运行时数据区,最后由执行引擎将字节码文件转化成机器指令,与CPU交互。
四、类加载器子系统
1、类加载器子系统作用
类加载器子系统是用来加载Class文件的,在我们的字节码文件开头都有CA FE BA BE作为特定的字节码文件头标识。ClassLoader只负责加载Class,至于能不能运行,是由我们的执行引擎来决定的。加载的类信息存放于方法区中,方法区同时回储存我们的运行时常量池信息。
2、加载(Loading)
加载指的是将类的class文件读入到内存,也就是创建一个java.lang.Class对象放入内存,作为方法区中这个类的各种数据的访问入口。我们也不难看出,加载的作用就是将静态的二进制字节流转化程我们的运行时数据结构。
3、链接(Linking)
3.1 验证 (Verify)
验证,顾名思义,目的在于验证 Class文件的字节流信息符合当前虚拟机的要求,确保加载类的正确性和安全性。保障JVM自身不会 被影响。
验证过程主要包括4种验证:
文件格式验证,元数据验证,字节码验证,符号引用验证。
3.2 准备(Prepare)
准备阶段为类的静态变量分配内存,并设置默认初始值即零值(数值类型为0 字符类型空)。
注意:1、准备阶段不会分配包含final的静态变量,因为带有final的静态变量在编译的时候就已经分配了。
2、实例变量不会在这里被分配,实例变量会跟着对象一起分配到java堆中。
3.3 解析(Resolve)
解析的最重要的一点在于将常量池中的符号引用转换为直接引用。
符号引用:符号引用是以一组符号来描述所引用的目标,符号可以是任何的字面形式的字面量,只要不会出现冲突能够定位到就行。布局和内存无关。
直接引用:是指向目标的指针,偏移量或者能够直接定位的句柄。
我们可以通过一段代码来看
/**
* Created by baimao
* Time:2020/3/11
*/
public class ResolveTest {
public void methodA(){
System.out.println("Hello MethodA");
methodB();
}
public void methodB(){
System.out.println("Hello MethodB");
}
public static void main(String[] args) {
ResolveTest resolveTest = new ResolveTest();
resolveTest.methodA();
}
}
我们用javap进行反编译一下,并找到methodA
public void methodA();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=2, locals=1, args_size=1
0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #3 // String Hello MethodA
5: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: aload_0
9: invokevirtual #5 // Method methodB:()V
12: return
LineNumberTable:
line 9: 0
line 10: 8
line 11: 12
LocalVariableTable:
Start Length Slot Name Signature
0 13 0 this LResolveTest;
可以看出 在0 3 5 9码行我们都用到了符号引用,这里的#加数字就是符号引用,比如我们看到第九行,指向#5,再去常量池寻找
#1 = Methodref #10.#27 // java/lang/Object."<init>":()V
#2 = Fieldref #28.#29 // java/lang/System.out:Ljava/io/PrintStream;
#3 = String #30 // Hello MethodA
#4 = Methodref #31.#32 // java/io/PrintStream.println:(Ljava/lang/String;)V
#5 = Methodref #7.#33 // ResolveTest.methodB:()V
#6 = String #34 // Hello MethodB
#7 = Class #35 // ResolveTest
#8 = Methodref #7.#27 // ResolveTest."<init>":()V
#9 = Methodref #7.#36 // ResolveTest.methodA:()V
#10 = Class #37 // java/lang/Object
#11 = Utf8 <init>
#12 = Utf8 ()V
#13 = Utf8 Code
#14 = Utf8 LineNumberTable
#15 = Utf8 LocalVariableTable
#16 = Utf8 this
#17 = Utf8 LResolveTest;
#18 = Utf8 methodA
#19 = Utf8 methodB
#20 = Utf8 main
#21 = Utf8 ([Ljava/lang/String;)V
#22 = Utf8 args
#23 = Utf8 [Ljava/lang/String;
#24 = Utf8 resolveTest
#25 = Utf8 SourceFile
#26 = Utf8 ResolveTest.java
#27 = NameAndType #11:#12 // "<init>":()V
#28 = Class #38 // java/lang/System
#29 = NameAndType #39:#40 // out:Ljava/io/PrintStream;
#30 = Utf8 Hello MethodA
#31 = Class #41 // java/io/PrintStream
#32 = NameAndType #42:#43 // println:(Ljava/lang/String;)V
#33 = NameAndType #19:#12 // methodB:()V
#34 = Utf8 Hello MethodB
#35 = Utf8 ResolveTest
#36 = NameAndType #18:#12 // methodA:()V
#37 = Utf8 java/lang/Object
#38 = Utf8 java/lang/System
#39 = Utf8 out
#40 = Utf8 Ljava/io/PrintStream;
#41 = Utf8 java/io/PrintStream
#42 = Utf8 println
#43 = Utf8 (Ljava/lang/String;)V
我们在methodA中调用methodB方法,通过符号引用,#5–>#33–>#19
3、初始化
初始化阶段将静态变量真正意义上赋初始值。前面的准备阶段我们赋初始值是赋值为零值,而这里我们将真正的初始值赋值。
如:private static int num = 5;
在准备阶段 num = 0,而在初始化阶段num才=5。
4、类加载器
类加载器主要有4种:引导类加载器、扩展类加载器、系统类加载器和自定义加载器,通常情况下我们只会用到前面三种默认类加载器。
引导类加载器(Bootstrap Class Loader)
引导类加载器用于加载JAVA的核心类,这个类是由C和C++编写的,由于涉及到虚拟机的本地实现,所以Bootstrap加载器,我们是无法获取到的。
** 扩展类加载器(Extension Class Loader) **
扩展类加载器用于加载jre中的扩展类,由JAVA代码实现,间接继承ClassLoader类,用于加载jre/lib/ext下的所有类。
**系统类加载器(System Class Loader) **
说的通俗易懂一点,就是系统类加载器负责加载我们自己写的所有的类。
下面我们通过代码来看看,三种加载器
public class ClassLoaderTest {
public static void main(String[] args) {
ClassLoader classLoader = ClassLoader.getSystemClassLoader();
ClassLoader extClassLoader = classLoader.getParent();
ClassLoader bootstrapClassLoader = extClassLoader.getParent();
System.out.println(classLoader);
System.out.println(extClassLoader);
System.out.println(bootstrapClassLoader);
}
}
打印结果
sun.misc.Launcher$AppClassLoader@18b4aac2
sun.misc.Launcher$ExtClassLoader@1b6d3586
null
首先我们通过getSystemClassLoader()获取当前类的加载器为系统类加载器,然后通过getParent()获取上层到扩展类加载器,再通过getParent()获取上层到引导类加载器,刚刚我们知道引导类加载器无法被获取,所以打印出来为空。
PS:一个类的加载首先会加载他的父类
双亲委派机制
双亲委派机制看起来是一个很难懂的词语,我们先放一张图
当一个类需要加载,他会先询问上层加载器知道到达引导类加载器,如果上层加载器并不能加载,则子类又继续加载。如图当一个类需要加载的时候,会按照1–>2–>3–>4–>5–>6的顺序去加载。
双亲委派机制的意义(优势)
双亲委派机制看起来是比较麻烦的,但是却保障了调度顺序,以引导类加载器优先,能够有效防止Java核心类库被修改,同时防止了类的重复加载。不妨来看看代码。
package java.lang;
/**
* Created by baimao
* Time:2020/3/11
*/
public class String {
public String() {
System.out.println("this is String");
}
}
我们首先创建了一个包叫做java.lang,并且在该包下我们创建了一个String这样一个类,我们知道在Java内库中我们是存在java.lang.String中这样一个类的,那么我们再创建一个类,来调用我们的String大家不妨想想到底会不会报错如果不报错可以看看到底会加载哪个类。
/**
* Created by baimao
* Time:2020/3/11
*/
public class ParentalTest {
public static void main(String[] args) {
java.lang.String s = new java.lang.String();
System.out.println("success");
}
}
为了确保我们加载的类是java.lang包下的,我们发现打印的只有"success";这里我们就不免看出,我们自己写的String类并没有被加载。这就是双亲委派机制,而有些初学者就晕了,我并没有看到双亲委派机制的“双”啊,这里呢其实是翻译的问题,不能单纯的从字面意思去理解。
PS:而这种保护机制被称为沙箱安全机制。
五、运行时数据区(Runtime Data Area)
运行时数据区其实就是JVM操作内存的区域。该部分大致由5部分组成,堆区、方法区、PC寄存器(也叫程序计数器)、虚拟机栈、本地方法栈。
1、PC寄存器(Program Counter Register)
PC寄存器主要作用是记录下一行指令的地址。那么寄存器到底什么意思呢?这里的寄存器名字源于CPU的寄存器,CPU只有把数据装载到寄存器才能够运行。但是这里的寄存器并非物理上的寄存器,可以说是CPU寄存器的一种抽象吧。
在我们的PC寄存器中,就只用于存放下一条指令的地址。PC寄存器也被称作行号指示器,就比如,这一行代码执行完了,下一行代码该执行谁。由我们的执行引擎去读取下一条指令。这里理解起来可能稍微比较抽象。
等后面讲完虚拟机栈可能再回过头来理解就比较容易了。
先说这么几个PC寄存器的属性
1、PC寄存器是一块很小的内存,是运行最快的存储区域,因为它只存放下一条指令的地址,所以内存很小且快。
2、是线程私有的,其生命周期和线程同步
3、我们知道任何一个线程在执行的时候,只有一个方法会被执行,这个方法也被称为当前方法,这个方法在执行的时候PC寄存器会记录下一条指令的地址。
4、它不存在OutOtMemory(内存溢出)和StackOverFlow(栈溢出)、也没有GC(垃圾回收)。
2、虚拟机栈
所谓虚拟机栈,可以看出是栈结构,有先进后出的特性,栈的操作也就只有入栈出栈两个操作。在讲虚拟机栈我们先来看看栈和堆在JVM当中的区别:栈负责成勋的运行,而堆来管数据存储。
那么虚拟机栈如何理解,我们先看一下虚拟机栈的内存模型
从这张图片我们很容易看出来,我们的虚拟机栈是线程私有的,每当一个线程被创建,一个虚拟机栈也随之出现。每个虚拟机栈中有很多个栈帧(Stack Frame),而栈帧其实就是方法。栈顶栈帧也就是当前方法。如果理解起来比较抽象,我们可以来看看代码。
public class JVMStackTest {
private void methodA(){
methodB();
System.out.println("Hello MethodA!");
}
private void methodB(){
System.out.println("Hello MethodB!");
}
public static void main(String[] args) {
JVMStackTest jvmStackTest = new JVMStackTest();
jvmStackTest.methodA();
}
}
我们通过虚拟机栈来解析这段代码,首先我们调用A方法,即讲A方法压入栈,A方法就为当前方法,A方法随即调用B方法,即将B方法压入栈,此时B方法处于栈顶,打印"Hello MethodB!"后,return;,B方法出栈,A方法处于栈顶成为当前方法,打印"Hello MethodA!"后出栈。
总的来说就是当一个方法被调用,则这个方法的栈帧入栈,当这个方法return,则出栈。如为void,在字节码文件中也会return。值得注意的是,栈帧不能被共享,也就是说线程和线程之间不能够共享栈帧。
JVM如何支持多线程
我们知道多线程的运行机制,并非所有线程同时执行,而是你执行一点我执行一点然后你又执行一点。如两个线程A、B,A线程执行2句然后PC寄存器该线程的PC寄存器记住下一条指令的地址,又换B线程执行,B线程执行几句又继续循环这个过程,但是整个调度是由JVM去执行的。
虚拟机栈StackOverFlowError
虚拟机栈既然是栈结构,那么一定存在栈溢出,我们怎么模拟栈溢出的过程呢,先看看代码
public class StackOverFlowTest {
private static int count=0;
public static void main(String[] args) {
count++;
System.out.println(count);
main(args);
}
}
打印结果为
11404
11405
11406
11407
11408
Exception in thread "main" java.lang.StackOverflowError
我们发现当执行到11408层的时候就提示栈溢出了,在Java虚拟机规范中我们是可以动态扩展虚拟机栈的大小的,也可以固定大小,那么如何设置虚拟机栈的大小呢,通过添加参数-Xss 即可设置栈的大小。
2.1 栈帧
上面描述了大概描述了栈帧。栈帧由五部分构成:
局部变量表、操作数栈、方法返回地址、动态链接、附加信息。
2.1.1局部变量表
说到局部变量表,那么肯定得理解什么叫局部变量。局部变量,也称内部变量,是指在一个函数内部或复合语句内部定义的变量,也就是说一个方法的参数和里面定义的变量就被称作局部变量。局部变量的最小单元为变量槽(SLOT)。
局部变量表中,存放编译期可知的8种基本数据类型,引用类型和返回地址类型。
局部变量表中,32位以内的类型占用一个SLOT,64位的类型占用2个SLOT(Long和Double)。
byte,short,char和boolean在存储钱会变成int型,占用一个SLOT。
局部变量表是存在于栈上的,而虚拟机栈是线程私有的,那么也就证明局部变量表也是线程私有的,则不存在数据安全问题。
局部变量表需要在编译期就确定其容量,一旦确定不可修改。
我们可以通过代码来加深理解。
public class LocalTest {
private void methodA(String name){
int a = 10;
byte b = 1;
}
public static void main(String[] args) {
LocalTest localTest = new LocalTest();
localTest.methodA("aaa");
}
}
我们使用javap -v 来反编译一下,找到main方法的LocalVariableTable
LocalVariableTable:
Start Length Slot Name Signature
0 15 0 args [Ljava/lang/String;
8 7 1 localTest LLocalTest;
可以看到我们的局部变量表种有两个局部变量,一个是参数args 还有一个是引用类型localTest.
在栈帧中,局部变量表是性能调优的关键所在。局部变量表的变量是重要的垃圾回收根节点。
2.2 操作数栈
每个独立的栈帧都有自己的操作数栈。这样说起来感觉不太能够理解,我们还是来看代码。
public class OperantStackTest {
public static void main(String[] args) {
methodA();
}
public static void methodA(){
int i = 1;
int j = 2;
int k = i + j;
}
public static void methodB(){
int i1 = 0;
i1 = i1++ + ++i1;
System.out.println(i1);
}
}
首先我们来看methodA,通过javap -v 反编译一下,并且找到我们的methodA.
public static void methodA();
descriptor: ()V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=3, args_size=0
0: iconst_1 //将常量1压入操作数栈
1: istore_0 //取出操作数栈栈顶数据,放入局部变量表中索引为0的位置
2: iconst_2 //将常量2压入操作数栈
3: istore_1 //取出操作数栈栈顶数据,放入局部变量表中索引为1的位置
4: iload_0 //将局部变量表中索引为0的数据数据压入操作数栈
5: iload_1 //将局部变量表中索引为1的数据数据压入操作数栈
6: iadd //栈顶两数据相加,并压入操作数栈
7: istore_2 //取出操作数栈栈顶数据,放入局部变量表中索引为2的位置
8: return //返回void
LineNumberTable:
line 13: 0
line 14: 2
line 15: 4
line 16: 8
LocalVariableTable:
Start Length Slot Name Signature
2 7 0 i I
4 5 1 j I
8 1 2 k I
我们通过逐行注释来加深对操作数栈的理解,如果还是比较抽象,我们再来看个图
如图我们应该就能很容易理解到操作数栈的作用了,有兴趣的同学可以看看我们的methodB的打印值,从字节码的角度去分析问题。
栈顶缓存技术
通过上面这个例子我们也可以看出,操作数栈是处于内存之中的,过于频繁的读写数据势必影响效率,于是HOTSPOT就提出了栈顶缓存技术,将栈顶数据缓存在物理CPU寄存器上,提高了我们执行引擎的效率。