一小篇文章教你看懂Java字节码

1.认识JVM内存模型

public class Test {
    public static void main(String[] args) {
        System.out.println("Hello World!");
    }
}

Java HelloWord 代码到底是如何执行的呢,要了解代码的执行过程,必须要了解一下JVM的内存模型,这里,我说一下我对JVM的理解,先上一张好图

img

​ JVM是这样定义的,虚拟机(VM: Virtual Machine)是通过软件模拟物理机器执行程序的执行器。

​ 这里,我们其实我们可以把JVM的执行过程类比成你想要制作某样东西的过程,例如玩具车,首先,得有原料,然后进行加工,而加工过程中,你得按步骤来是吧,每一步要做什么,用到什么,你得心理清楚吧,这样你才能一步步的进行,加工完成你最初想要的东西。这里,Class文件看做是生产原料,放在方法区,这里是最初的开始,即所有线程共享的地方,然后中的东西可以看做是单独加工好的一个个成型的零件,待组装的零件,这里也是所有线程共享的,而线程可以看做我们一次次制作的过程,因为每次我们加工是互不影响的,所以线程所使用到的内存数据都是相互独立的,一切准备就绪,好了你准备加工了,可是你怎么知道原料()具体有什么呢,所以有了一个清单,即常量池,运行的时候就会变成运行时常量池,这里列出了原料()的详细信息,以便让你方便查询使用,而程序计数器其实是记录了我们的加工步骤,如果你加工的时候突然有事,你可以先去办别的事,然后再回来继续,可以通过程序计数器,就可以定位到你上次执行到哪一步,看起来很妙不是吗。这里虚拟机栈就是核心步骤了,即开始进行制作,在虚拟机栈操作这一步,极其关键,这里会来回的操作数据,即入栈出栈,对,搞半天代码执行就是入栈出栈操作,这里我们有一块区域叫做局部变量表,在虚拟机栈这里当做临时存储区域,在进行虚拟机栈入栈和出栈的时候,会来回在局部变量表里取数据和存数据。

​ 另外,上图提到的执行引擎,这个其实是相当复杂的,执行引擎包含了解释器、即时编译器和垃圾回收器,解释器和编译器是为了跨平台使用的,因为代码最终执行是要经过操作系统,然后又进行CPU指令调度,但是我们的操作系统又有不同的版本,所有为了适配所有的操作系统,用解释器来进行翻译,将java字节码翻译成不同操作系统要执行的机器语言,进行CPU调度,这玩意就是个胖翻译啊!另外,垃圾回收器主要就是负责GC回收的,这里先有个大概了解吧,还有本地方法,是java直接调的操作系统底层的方法。对JVM的内存模型有了初步认识后,下面我们来看看JVM是怎么去执行Java代码的。

2.读懂JVM字节码(助记符)

​ 一段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 只显示 protectedpublic 类及成员。
  -package 只显示包、protectedpublic 类及成员。这是缺省设置。
  -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

  • 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和dup
  • pop用于栈顶数值出栈操作;
  • dup用于赋值栈顶的指定个数的数值,并将其压入栈顶指定次数;
栈顶操作 含义
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
对象和数组
  • 创建类实例: new
  • 创建数组:newarray、anewarray、multianewarray
  • 数组元素 加载到 操作数栈:xaload (x可为b,c,s,i,l,f,d,a)
  • 操作数栈的值 存储到数组元素: xastore (x可为b,c,s,i,l,f,d,a)
  • 数组长度:arraylength
  • 类实例类型:instanceof、checkcast
运算指令

运算指令是用于对操作数栈上的两个数值进行某种运算,并把结果重新存入到操作栈顶。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

其他运算:

  • 位移:ishl,ishr,iushr,lshl,lshr,lushr
  • 按位或: ior,lor
  • 按位与: iand, land
  • 按位异或: ixor, lxor
  • 自增:iin
  • 比较:dcmpg,dcmpl,fcmpg,fcmpl,lcmp

类型转换

类型转换用于将两种不同类型的数值进行转换。

  • 对于宽化类型转换(小范围向大范围转换),无需显式的转换指令,并且是安全的操作。各种范围从小到大依次排序: int, long, float, double。

  • 对于窄化类型转换,必须显式地调用类型转换指令,并且该过程很可能导致精度丢失。转换规则中需要特别注意的是当浮点值为NaN, 则转换结果为int或long的0。虽然窄化运算可能会发生上/下限溢出和精度丢失等情况,但虚拟机规范明确规定窄化转换U不可能导致虚拟机抛出异常。

  • 类型转换指令:i2b, i2c,f2i等等。

流程控制

控制指令是指有条件或无条件地修改PC寄存器的值,从而达到控制流程的目标

  • 条件分支:ifeq、iflt、ifnull、ifnonnull等
  • 复合分支:tableswitch、lookupswitch
  • 无条件分支:goto、goto_w、jsr、jsr_w、ret

同步与异常

异常:

Java程序显式抛出异常: athrow指令。在Java虚拟机中,处理异常(catch语句)不是由字节码指令来实现,而是采用异常表来完成。

同步:

方法级的同步和方法内部分代码的同步,都是依靠管程(Monitor)来实现的。

Java语言使用synchronized语句块,那么Java虚拟机的指令集中通过monitorenter和monitorexit两条指令来完成synchronized的功能。为了保证monitorenter和monitorexit指令一定能成对的调用(不管方法正常结束还是异常结束),编译器会自动生成一个异常处理器,该异常处理器的主要目的是用于执行monitorexit指令。


​ 以上只是包含我对JVM的一点认识,后面我会深入了解JVM更多的相关知识,但是我知道,通过本文学习至少能看懂JVM的字节码指令了,能通过Javap 的命令进行字节码分析,从而对java代码的执行原理有了进一步的理解,我觉得这是每一个Java程序员都应该具备的能力,如果有什么写的不对的,欢迎留言指出,大家共同进步!

革命尚未成功,猿某人还需努力啊!

参考资料:
图片来源

指令总结摘抄自

你可能感兴趣的:(Java,java)