会写JAVA程序的不一定懂JVM,就像会开车的不一定会修车一样
大部分的JAVA程序员对那些高精尖的的上层框架,都愿意去学习使用,因为它们用着确实很爽
就像买了新车,大家都会去研究新车有哪些高逼格的配置,却很少有人会打开引擎盖研究汽车的运行原理
试想一下,小伍美滋滋的开着新买的二手奥拓去上班。突然车子抛锚了,小伍不会修车,只能乖乖的等待救援。心里想着迟到要罚款,心疼的不行
在访问量大的系统中,交易量分分钟几百万上下。一旦程序出现问题,而你不是那个修车师傅的话,就算不被拿来祭天,升职加薪也是不可能了
所以,学习JVM的执行原理并不仅仅可以和面试官撕逼,关键时刻还能 救命
JVM介绍
JAVA虚拟机因为全称是Java Virtual Machine,通常我们检查简称为JVM
目前市面上流行的JVM有sun公司的HotSpot VM,BEA公司号称世界上最快的JRockit VM,IBM公司的J9 VM等。oracle公司在先后收购了BEA和sun之后同时拥有了HotSpot VM和JRockit VM,并把HotSpot作为JAVA内置的JVM
JAVA号称一次编译到处运行,全都要归功于JVM
我们都知道程序的执行依靠一系列的CPU指令来完成,但不同的操作系统CPU指令集是不同的
比如,想要删除一个文件,在linux系统中使用rm命令,在windows系统中需要使用DEL命令(这里使用系统命令而不是CPU指令来举例,目的是为了更容易理解)
同一行代码,在windows平台运行,JVM会调用windows的CPU指令去执行;在linux平台运行,JVM会调用linux的CPU指令去执行
JVM就是对不同的操作系统的指令集进行封装,使得程序开发时不需要关心操作系统的类型,在程序执行时再由JVM根据不同的操作系统执行不同的指令集,从而达到一次编译到处运行的目的
从操作系统的角度来看,JAVA并不算是一次编译到处运行的,要不JRE为什么要区分不同的操作系统版本呢
JAVA把面向操作系统运行,变成了面向JVM运行。如果能让其他编程语言运行时基于某一个中间件,而不是直接面向操作系统,是不是所有的编程语言都可以号称一次编译到处运行了
其实,JVM是支持运行JAVA以外的其他编程语言的。JVM的运行源是字节码文件,符合规范的的字节码文件都能被JVM运行
JAVA虚拟机规范对字节码文件的格式进行了统一约定,比如在十六进制的字节码文件中,前四位必须是ca fe ba be 开头,也就是我们常说的咖啡baby
除此之外,虚拟机规范还要求字节码文件中还要包含JDK版本信息 ,常量池中的常量数量,类的访问权限,该类和父类在常量池中的地址信息等,就像下图一样
理论上,所有的编程语言,只要能够遵守JAVA虚拟机规范,将程序编译成字节码文件,都能在JVM上运行,就看其他编程语言愿不愿意对接JVM了
因此,JVM也被称为跨语言的平台
提到JVM就不得不说JDK和JRE,它们之间的关系是可以理解为包含关系,范围是JDK>JRE>JVM
JVM负责执行编译过后的字节码文件
JRE包含JVM运行程序所必需的基础类库
JDK包含JRE全部功能外,还提供了编译工具、调试工具、API工具等
程序经过jdk编译后形成字节码文件,由JVM来运行,JVM运行字节码文件时需要用到JRE类库中的基础类
类加载
JAVA程序编译成字节码文件之后就放在那里,JVM怎么知道该去执行它,这就要依靠类加载机制
运行时数据区
尼古拉斯·赵四说过:程序=数据机构+算法
JVM是怎么执行算法的,JVM又是怎么保存数据的?没错,就是运行时数据区
运行时数据区主要用来保存JAVA程序运行时,所需要的数据信息,主要包含以下5个区域
本地方法栈
JDK类库中有许多被native关键字修饰的方法,比如Object类
native翻译过来是本国的、土著的,被native修饰的方法也叫本地方法
用来存储本地方法的栈,就是本地方法栈。当JVM需要执行这些方法时,会从本地方法栈中获取这些方法的数据去执行
需要注意的是,native修饰的方法不一定是用JAVA语言实现的,它可能调用底层的C或C++的代码,俗称C堆栈
程序计数器
程序计数器用来记录当前线程正在执行的字节码的地址或行号
需要强调的是,程序计数器记录的是字节码文件的行号,并不是java源文件的行号。java源文件的一行代码,在字节码文件中对应很多行
程序执行的最小单位是线程,当前线程已经在执行了,为什么还要记录行号呢?因为,线程会被CPU时间调度器挂起呀
当前线程被挂起时,程序计数器记录程序执行到了哪一行。线程被唤起时,程序计数器会告诉JVM应该从哪一行继续执行
程序计数器是运行时数据区中唯一一个不会抛出内存溢出的区域,因为它只记录当前程序执行的地址或行号,一个地址或行号能占用多少内存?
虚拟机栈
虚拟机栈存放当前线程正在运行的方法的数据、指令和返回地址,它会为每一个方法分配一个栈帧,用来存放数据
每个栈帧包含局部变量表、操作数栈、动态地址、方法出口等
虚拟机栈大概就是这个样子
操作数栈用来操作数据,主要就是对数据进行计算、准备要传递给其他方法的参数和接受其他方法的参数。
它通过虚拟机指令加载常量或者变量的值并对这些值进行计算,将计算的结果赋值后放入局部变量表中
局部变量表用来存放当前线程正在执行的方法的变量,它的内部是多个长度为32字节的独立内存区域。因为32位放不下long和double类型的变量,所以它们会占用两个连续的独立内存区域
局部变量表中第0个位置默认存储的是this,如果是静态方法第0个位置存储的是空
下面来看一段代码在虚拟机栈中的执行过程
public int add() {
int i = 10;
int j = 5;
int add = i + j;
return add;
}
使用javap命令把字节码文件反编译过来,可以看到字节码文件中包含的指令,JVM就是通过这些指令去执行方法的
顺便提一下,下面代码中每一行指令前面的编号就是字节码文件的行号,程序技术器记录的就是这个行号
public int add();
Code:
0: bipush 10 // 将10压入操作数栈顶
2: istore_1 // 将变量i赋值后放入局部变量表的第一个位置
3: iconst_5 // 将5压入操作数栈顶
4: istore_2 // 将变量j赋值后放入局部变量表的第二个位置
5: iload_1 // 从局部变量表中获取第一个位置的变量的值,压入操作数栈顶
6: iload_2 // 从局部变量表中获取第二个位置的变量的值,压入操作数栈顶
7: iadd // 将操作数栈中的两个int类型变量相加
8: istore_3 // 将变量add赋值后放入局部变量表中的第三个位置
9: iload_3 // 从局部变量表中获取第三个位置的变量add,压入操作数栈顶
10: ireturn // 方法返回,清空操作数栈和局部变量表
从代码中看不是很直观,我们来分步拆解一下
第一步,将10压入操作数栈顶,此时局部变量表中第0个位置存储的是this
第二步,将变量i赋值后放入局部变量表的第一个位置
第三步,将5压入操作数栈顶
第四步,将变量j赋值后放入局部变量表的第二个位置
第五步,从局部变量表中获取第一个位置的变量的值,也就是10,压入操作数栈顶
第六步,从局部变量表中获取第二个位置的变量的值,也就是5,压入操作数栈顶
第七步,将操作数栈中的两个int类型变量相加
第八步,将变量add赋值后放入局部变量表中的第三个位置
第九步,从局部变量表中获取第三个位置的变量add,也就是15,压入操作数栈顶。
这一步可能会有疑问,已经不需要对add变量进行计算了,为什么还要把add变量压入操作数栈?
这是因为操作数栈还要准备传递给其他方法的参数,也就是该方法的返回值。该方法renturn的是add变量,操作数栈要把add变量对应的值解析出来进行return
第十步,方法返回,清空操作数栈和局部变量表。同时,该方法的栈帧也会从虚拟机栈中被弹出
这里演示了一个简单的方法在虚拟机栈中的执行过程,业务逻辑复杂的方法执行的过程也相对复杂,但是都大同小异,无非就是JVM根据字节码指令进行一步步运算而已
我也梳理了一份JVM字节码的指令集,有需要的小伙伴可以在赫连小伍公众号后台回复JVM获取
这里有一个值得思考的地方,如果一个方法m1调用了方法m2。那么,在虚拟机栈中,哪个方法位于栈顶?
public void m1(){
m2() ;
return ;
}
public void m2(){
return ;
}
答案是方法2位于栈顶,因为方法m1调用了m2,说明m1执行需要依赖于m2的结果,也就是说只有等m2执行完毕得到结果后,m1才能继续执行
执行完的方法会被弹出虚拟机栈,根据栈的先进后出的特性,位于栈顶的应该先被弹出,所以方法m2位于栈顶
虚拟机栈的栈帧中还有一块动态链接的区域,它的主要作用是将符号引用转变为直接引用。
在字节码文件中,一切的方法调用都只是符号引用,而不是被调用方在内存中的实际地址,动态链接区域就是将字节码中的符号引用转换为指向内存中具体位置的直接引用
比如,我们对上面的m1方法的字节码文件进行反编译查看。在m1方法中对m2进行调用,在字节码中的指令是invokevirtual #2,其中#2就是引用地址
在字节码文件的常量池中,通过注释可以看到#2对应的就是方法m2
在上图中,#2还分别引用了#3和#17,动态链接区域的作用就是根据这些引用地址找到这些地址在常量池中实际对应的位置
-- 微信公众号 赫连小伍,关注获取更多技术干货