public class Test {
public static void main(String[] args) {
System.out.println("Hello World!");
}
}
Java HelloWord 代码到底是如何执行的呢,要了解代码的执行过程,必须要了解一下JVM的内存模型,这里,我说一下我对JVM的理解,先上一张好图
JVM是这样定义的,虚拟机(VM: Virtual Machine)是通过软件模拟物理机器执行程序的执行器。
这里,我们其实我们可以把JVM的执行过程类比成你想要制作某样东西的过程,例如玩具车,首先,得有原料,然后进行加工,而加工过程中,你得按步骤来是吧,每一步要做什么,用到什么,你得心理清楚吧,这样你才能一步步的进行,加工完成你最初想要的东西。这里,Class文件看做是生产原料,放在方法区,这里是最初的开始,即所有线程共享的地方,然后堆中的东西可以看做是单独加工好的一个个成型的零件,待组装的零件,这里也是所有线程共享的,而线程可以看做我们一次次制作的过程,因为每次我们加工是互不影响的,所以线程所使用到的内存数据都是相互独立的,一切准备就绪,好了你准备加工了,可是你怎么知道原料(类)具体有什么呢,所以有了一个清单,即常量池,运行的时候就会变成运行时常量池,这里列出了原料(类)的详细信息,以便让你方便查询使用,而程序计数器其实是记录了我们的加工步骤,如果你加工的时候突然有事,你可以先去办别的事,然后再回来继续,可以通过程序计数器,就可以定位到你上次执行到哪一步,看起来很妙不是吗。这里虚拟机栈就是核心步骤了,即开始进行制作,在虚拟机栈操作这一步,极其关键,这里会来回的操作数据,即入栈出栈,对,搞半天代码执行就是入栈出栈操作,这里我们有一块区域叫做局部变量表,在虚拟机栈这里当做临时存储区域,在进行虚拟机栈入栈和出栈的时候,会来回在局部变量表里取数据和存数据。
另外,上图提到的执行引擎,这个其实是相当复杂的,执行引擎包含了解释器、即时编译器和垃圾回收器,解释器和编译器是为了跨平台使用的,因为代码最终执行是要经过操作系统,然后又进行CPU指令调度,但是我们的操作系统又有不同的版本,所有为了适配所有的操作系统,用解释器来进行翻译,将java字节码翻译成不同操作系统要执行的机器语言,进行CPU调度,这玩意就是个胖翻译啊!另外,垃圾回收器主要就是负责GC回收的,这里先有个大概了解吧,还有本地方法,是java直接调的操作系统底层的方法。对JVM的内存模型有了初步认识后,下面我们来看看JVM是怎么去执行Java代码的。
一段Java代码写完后,我们需要编译才能进行运行的,而JVM是将我们的Java代码编译成了Class字节码文件,这个Class字节码只能JVM可以识别,也就是这样,通过安装JVM得以实现跨平台执行,那么JVM是怎么执行Java代码的呢,通过查阅资料,深知,JVM是通过读取字节码文件中的一个一个操作指令,每个操作指令对应着二进制位,JVM通过执行操作指令就可以执行相关代码,而通过二进制位去看操作指令是相当难理解的,为此,JDK给我们提供了一写可用的分析性工具,其中通过javap 这个命令我们可以对字节码反编译成我们好理解的代码,反编译过后,所有的操作指令都变成了我们人类看得懂的助记符,通过它,我们可以直接对字节码进行间接性的认识,从而进一步了解我们的代码是怎么被执行的。
Javap的相关命令选项
-help 输出 javap 的帮助信息。
-l 输出行及局部变量表。
-b 确保与 JDK 1.1 javap 的向后兼容性。
-public 只显示 public 类及成员。
-protected 只显示 protected 和 public 类及成员。
-package 只显示包、protected 和 public 类及成员。这是缺省设置。
-private 显示所有类和成员。
-J[flag] 直接将 flag 传给运行时系统。
-s 输出内部类型签名。
-c 输出类中各方法的未解析的代码,即构成 Java 字节码的指令。
-verbose 输出堆栈大小、各方法的 locals 及 args 数,以及class文件的编译版本
-classpath[路径] 指定 javap 用来查找类的路径。如果设置了该选项,则它将覆盖缺省值或 CLASSPATH 环境变量。目录用冒号分隔。
-bootclasspath[路径] 指定加载自举类所用的路径。缺省情况下,自举类是实现核心 Java 平台的类,位于 jrelib下面。
-extdirs[dirs] 覆盖搜索安装方式扩展的位置。扩展的缺省位置是 jrelibext。
HelloWord的字节码是如何执行的呢?
下面是使用javap -v 反编译HelloWord程序的字节码,我都标注了每一处具体是干啥的
Last modified 2022-5-4; size 544 bytes //修改时间,所需要的的字节大小
MD5 checksum e603dd8515c39e94ee7169795b7cab14 //MD5 检验码
Compiled from "Test.java" //编译的源程序文件
public class com.xlape.jvm.Test //类
minor version: 0
major version: 52//这两处是代表着jdk的版本,50是JDK1.6,51是jDK1.7,52是JDK1.8
flags: ACC_PUBLIC, ACC_SUPER //访问修饰符
Constant pool://编译期常量池,运行时会指向具体的索引地址,类的相关信息会存储在这里,后面指令调用的时候都会来常量池中取对应的东西
#1 = Methodref #6.#20 // java/lang/Object."":()V
#2 = Fieldref #21.#22 // java/lang/System.out:Ljava/io/PrintStream;
#3 = String #23 // Hello World!
#4 = Methodref #24.#25 // java/io/PrintStream.println:(Ljava/lang/String;)V
#5 = Class #26 // com/xlape/jvm/Test
#6 = Class #27 // java/lang/Object
#7 = Utf8 <init>
#8 = Utf8 ()V
#9 = Utf8 Code
#10 = Utf8 LineNumberTable
#11 = Utf8 LocalVariableTable
#12 = Utf8 this
#13 = Utf8 Lcom/xlape/jvm/Test;
#14 = Utf8 main
#15 = Utf8 ([Ljava/lang/String;)V
#16 = Utf8 args
#17 = Utf8 [Ljava/lang/String;
#18 = Utf8 SourceFile
#19 = Utf8 Test.java
#20 = NameAndType #7:#8 // "":()V
#21 = Class #28 // java/lang/System
#22 = NameAndType #29:#30 // out:Ljava/io/PrintStream;
#23 = Utf8 Hello World!
#24 = Class #31 // java/io/PrintStream
#25 = NameAndType #32:#33 // println:(Ljava/lang/String;)V
#26 = Utf8 com/xlape/jvm/Test
#27 = Utf8 java/lang/Object
#28 = Utf8 java/lang/System
#29 = Utf8 out
#30 = Utf8 Ljava/io/PrintStream;
#31 = Utf8 java/io/PrintStream
#32 = Utf8 println
#33 = Utf8 (Ljava/lang/String;)V
{
public com.xlape.jvm.Test();//默认的构造方法
descriptor: ()V //无参构造
flags: ACC_PUBLIC //访问修饰符为PUBLIC
Code://要执行的代码片段
stack=1, locals=1, args_size=1 //栈的深度|本地局部变量表长度|参数个数
0: aload_0 //将本地局部变量表的第0号位置加载到操作数组栈,0号位置是指this
1: invokespecial #1 //调用默认构造函数方法 // Method java/lang/Object."":()V
4: return //返回
LineNumberTable: //行号表,记录源代码和字节码对应的行号
line 3: 0 //源代码行号|字节码行号
LocalVariableTable: //局部变量表
Start Length Slot Name Signature //字节码的偏移量起始位置|字节码的偏移量结束位置|存储的基本单位,槽位,索引位|变量名|方法签名
0 5 0 this Lcom/xlape/jvm/Test;
public static void main(java.lang.String[]); //main方法
descriptor: ([Ljava/lang/String;)V //参数描述,有一个String的参数变量
flags: ACC_PUBLIC, ACC_STATIC //访问修饰符
Code: //代码片段
stack=2, locals=1, args_size=1 //栈的深度|本地局部变量表长度|参数个数
0: getstatic #2 //去常量池中找2号位置,如果还有引用,依次往下找 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #3 //将3号位置Hello World入操作数组栈 // String Hello World!
5: invokevirtual #4 //调用 4号位置中的方法输出 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return //放回
LineNumberTable:
line 5: 0
line 6: 8 //源代码行号|字节码行号
LocalVariableTable: //局部变量表
Start Length Slot Name Signature //字节码的偏移量起始位置|字节码的偏移量结束位置|存储的基本单位,槽位,索引位|变量名|方法签名
0 9 0 args [Ljava/lang/String;
}
通过对HelloWorld程序的反编译分析知道,如果进行方法调用,则会按照相应的代码操作指令去一步步执行,在指令调用的时候,会从常量池中拿取相关信息,压人操作数栈,并且执行相关的方法,在给局部变量赋值的时候,会调用指令将数据存入局部变量表中,相反在使用局部量的时候,会从局部变量表中拿去数压入操作数栈,进行相关方法的调用以及返回。
也通过以上了解了java字节码的执行细节,当然JVM底层要做的远远不止这些,并且以上只是对Hellword这个程序进行了反编译,实际还有更复杂的类和方法体,会出现更多的操作指令,这里摘抄了一篇好文中对于JVM操作指令的总结:便于后面查阅和使用
栈操作相关
load 命令:用于将局部变量表的指定位置的相应类型变量加载到栈顶;
store命令:用于将栈顶的相应类型数据保入局部变量表的指定位置;
变量进栈 | 含义 | 变量保存 | 含义 |
---|---|---|---|
iload | 第1个int型变量进栈 | istore | 栈顶int数值存入第1局部变量 |
iload_0 | 第1个int型变量进栈 | istore_0 | 栈顶int数值存入第1局部变量 |
iload_1 | 第2个int型变量进栈 | istore_1 | 栈顶int数值存入第2局部变量 |
iload_2 | 第3个int型变量进栈 | istore_2 | 栈顶int数值存入第3局部变量 |
iload_3 | 第4个int型变量进栈 | istore_3 | 栈顶int数值存入第4局部变量 |
lload | 第1个long型变量进栈 | lstore | 栈顶long数值存入第1局部变量 |
fload | 第1个float型变量进栈 | fstore | 栈顶float数值存入第1局部变量 |
dload | 第1个double型变量进栈 | dstore | 栈顶double数值存入第1局部变量 |
aload | 第1个ref型变量进栈 | astore | 栈顶ref对象存入第1局部变量 |
const、push和ldc
常量进栈 | 含义 |
---|---|
aconst_null | null进栈 |
iconst_m1 | int型常量-1进栈 |
iconst_0 | int型常量0进栈 |
iconst_1 | int型常量1进栈 |
iconst_2 | int型常量2进栈 |
iconst_3 | int型常量3进栈 |
iconst_4 | int型常量4进栈 |
iconst_5 | int型常量5进栈 |
lconst_0 | long型常量0进栈 |
fconst_0 | float型常量0进栈 |
dconst_0 | double型常量0进栈 |
bipush | byte型常量进栈 |
sipush | short型常量进栈 |
常量池操作 | 含义 |
---|---|
ldc | int、float或String型常量从常量池推送至栈顶 |
ldc_w | int、float或String型常量从常量池推送至栈顶(宽索引) |
ldc2_w | long或double型常量从常量池推送至栈顶(宽索引) |
栈顶操作 | 含义 |
---|---|
pop | 栈顶数值出栈(不能是long/double) |
pop2 | 栈顶数值出栈(long/double型1个,其他2个) |
dup | 复制栈顶数值,并压入栈顶 |
dup_x1 | 复制栈顶数值,并压入栈顶2次 |
dup_x2 | 复制栈顶数值,并压入栈顶3次 |
dup2 | 复制栈顶2个数值,并压入栈顶 |
dup2_x1 | 复制栈顶2个数值,并压入栈顶2次 |
dup2_x2 | 复制栈顶2个数值,并压入栈顶3次 |
swap | 栈顶的两个数值互换,且不能是long/double |
对象相关
字段调用 | 含义 |
---|---|
getstatic | 获取类的静态字段,将其值压入栈顶 |
putstatic | 给类的静态字段赋值 |
getfield | 获取对象的字段,将其值压入栈顶 |
putfield | 给对象的字段赋值 |
方法调用 | 作用 | 解释 |
---|---|---|
invokevirtual | 调用实例方法 | 虚方法分派 |
invokestatic | 调用类方法 | static方法 |
invokeinterface | 调用接口方法 | 运行时搜索合适方法调用 |
invokespecial | 调用特殊实例方法 | 包括实例初始化方法、父类方法 |
invokedynamic | 由用户引导方法决定 | 运行时动态解析出调用点限定符所引用方法 |
方法返回 | 含义 |
---|---|
ireturn | 当前方法返回int |
lreturn | 当前方法返回long |
freturn | 当前方法返回float |
dreturn | 当前方法返回double |
areturn | 当前方法返回ref |
对象和数组
运算指令
运算指令是用于对操作数栈上的两个数值进行某种运算,并把结果重新存入到操作栈顶。Java虚拟机只支持整型和浮点型两类数据的运算指令,所有指令如下:
运算 | int | long | float | double |
---|---|---|---|---|
加法 | iadd | ladd | fadd | dadd |
减法 | isub | lsub | fsub | dsub |
乘法 | imul | lmul | fmul | dmul |
除法 | idiv | ldiv | fdiv | ddiv |
求余 | irem | lrem | frem | drem |
取反 | ineg | lneg | fneg | dneg |
其他运算:
类型转换
类型转换用于将两种不同类型的数值进行转换。
对于宽化类型转换(小范围向大范围转换),无需显式的转换指令,并且是安全的操作。各种范围从小到大依次排序: int, long, float, double。
对于窄化类型转换,必须显式地调用类型转换指令,并且该过程很可能导致精度丢失。转换规则中需要特别注意的是当浮点值为NaN, 则转换结果为int或long的0。虽然窄化运算可能会发生上/下限溢出和精度丢失等情况,但虚拟机规范明确规定窄化转换U不可能导致虚拟机抛出异常。
类型转换指令:i2b, i2c,f2i
等等。
流程控制
控制指令是指有条件或无条件地修改PC寄存器的值,从而达到控制流程的目标
同步与异常
异常:
Java程序显式抛出异常: athrow指令。在Java虚拟机中,处理异常(catch语句)不是由字节码指令来实现,而是采用异常表来完成。
同步:
方法级的同步和方法内部分代码的同步,都是依靠管程(Monitor)来实现的。
Java语言使用synchronized语句块,那么Java虚拟机的指令集中通过monitorenter和monitorexit两条指令来完成synchronized的功能。为了保证monitorenter和monitorexit指令一定能成对的调用(不管方法正常结束还是异常结束),编译器会自动生成一个异常处理器,该异常处理器的主要目的是用于执行monitorexit指令。
以上只是包含我对JVM的一点认识,后面我会深入了解JVM更多的相关知识,但是我知道,通过本文学习至少能看懂JVM的字节码指令了,能通过Javap 的命令进行字节码分析,从而对java代码的执行原理有了进一步的理解,我觉得这是每一个Java程序员都应该具备的能力,如果有什么写的不对的,欢迎留言指出,大家共同进步!
革命尚未成功,猿某人还需努力啊!
参考资料:
图片来源
指令总结摘抄自