大家好,我是此林。

前面我们讲述了 class 文件结构:

Java 字节码进阶1:深入解析 class 文件结构-CSDN博客

知道了10 个部分(魔数、版本号、常量池、类/超类索引、接口表、字段表、方法表、属性表)是 class 文件的整体结构,它是 JVM 规范规定的二进制格式。

换句话说,class 文件就像一个「容器」,它把类的所有信息都组织起来:类的元数据、常量池、字段、方法、以及额外的属性。

那字节码指令在哪?

字节码指令是在「方法表」里的。

  • 在 class 文件的 方法表(Method Table)里,每个方法都有一个 attributes 表。

  • 其中最重要的属性就是 Code 属性(Code Attribute)

  • Code 属性 里才真正保存了该方法的字节码指令。

这是之前文章的截图:

那么,今天我们来更深入地学习具体的字节码指令。

1. 字节码简述

Java 虚拟机字节码指令一般由 操作码 + 操作数 组成,这些操作数通常是索引(index)、常量池引用、偏移量。

比如:把整形常量 100 压栈到栈顶的指令是 bipush 100,其中 bipush 是操作码,100 是操作数。

大部分字节码指令是和类型相关的,比如 ireturn 指令用于返回一个 int 类型的数据,dreturn 指令用于返回一个 double 类型的数据,freturn 指令用于返回一个 float 类型的数据。

字节码使用大端序(Big-Endian)表示,即高位在前,低位在后的方式。比如字节码 getfield 00 02,表示的是 getfield 0x00<<8 | 0x02 (getfield #2)

getfield 00 02 到底什么意思呢?

  • getfield 是一条指令,用来从对象里取字段。

  • 它的操作数是一个 常量池索引(占 2 个字节)。

  • 这两个字节是:0002

因为是 大端序

  • 第一个字节 (00) → 高位

  • 第二个字节 (02) → 低位

组合方式就是:

索引 = (0x00 << 8) | 0x02
     = (0x00 * 256) + 0x02
     = 0x0002
     = 2

所以 getfield 00 02 实际含义是:

getfield #2

也就是从常量池(constant pool)里取第 2 个常量,去解析要访问的字段。

字节码并不是某种虚拟 CPU 机器码,而是一种介于源码和机器码的中间抽象表示,不过字节码通过 JIT(Just In Time)技术可以被进一步编译成机器码。

根据字节码的不用用途,字节码指令可以分为下面几类:

1. 加载和存储指令,比如 iload 将一个 int 从局部变量表加载到操作数栈。

2. 控制转义指令,比如条件分支 ifeq。

3. 对象操作,比如创建类实例的指令 new。

4. 方法调用,比如 invokevirtual 指令用于调用实例对象的方法。

5. 运算指令和类型转换,比如加法指令 iadd。

6. 线程同步,比如 monitorenter 和 monitorexit 这两条指令用于支持 synchronized 关键字语义

7. 异常处理,比如 athrow 显式抛出异常。

2. 字节码如何在 JVM 上执行?

2.1. JVM 虚拟机实现方式

字节码是运行在 JVM 上的,接下来以 Java 虚拟机栈为切入点讲解字节码在 JVM 上的执行细节。

虚拟机常见的实现方式有两种:基于栈(Stack based)和 基于寄存器(Register based)。

基于栈的虚拟机:Hotspot JVM、.net CLR

基于寄存器的虚拟机: Lua JVM、Android DalvikVM

基于栈的虚拟机 通过操作栈顶元素执行指令,指令格式简洁,字节码体积小,便于跨平台移植和解释执行,但每次操作都需频繁压栈和出栈,导致指令数量多,执行效率相对较低。

基于寄存器的虚拟机 直接使用寄存器存储操作数,指令可直接访问寄存器,减少了栈操作开销,执行效率更高,适合 JIT 即时编译优化,但指令长度长,指令集复杂,编译器实现难度大,可移植性较差。

Java 虚拟机的实现方式是:基于栈。

2.2. 栈帧

HotSpot JVM 是一个基于栈的虚拟机。我们都知道 JVM 内存结构有五大部分:

线程私有:虚拟机栈、程序计数器、本地方法栈

线程共享:堆内存、元空间

所以每个线程都一个私有的虚拟机栈来存储栈帧,多个线程就会有多个虚拟栈。

每次方法调用都会伴随着栈帧的创建和销毁,所以栈帧是用于支持虚拟机进行方法调用和执行的数据结构。

如果我们递归不写终止条件,就会报 StackOverFlowError 异常,也就是栈溢出,我们可以通过 JVM 参数 -Xss 来制定线程栈的大小,比如 -Xss:256k 用于将栈大小设置为 256 KB 

栈帧的组成

局部变量表、操作数栈、指向常量池的引用。

局部变量表

局部变量表其实保存的就是局部变量,大小在编译器就已经确定,对应 class 文件中方法 atrributes 表里的 Code 属性中的 max_locals 字段,这个在文章的最开头已经说过。

案例分析:

public class Hello {
    public static void main(String[] args) {
        String tmp = "A";
    }
}

我们使用 javac -g Hello.java 命令进行编译,然后执行 javap -c -v -l Hello 查看字节码。

比较长,我们来关注重点。

可以看到,因为我们方法参数为 String[] args,所以 args_size = 1。

locals 为局部变量的个数,有两个,分别是:String[] args 和 String tmp。

不过需要注意,如果当前方法不是 static,也就是为实例方法时, args_size 需要加1,因为其中一个隐藏的局部变量为 this,即当前实例对象。

操作数栈

这个其实就是栈帧内部一个后进先出的栈,栈的大小同样在编译期就被确认,对应 class 文件中方法 atrributes 表里的 Code 属性中的 max_stack 字段。

案例分析:执行 1 + 2 的过程

实际上,整个 JVM 指令执行的过程就是局部变量表 和 操作数栈 之间不断加载存储的过程。

3. switch-case 实现的字节码原理

if-else 语句为什么执行效率比较低?原因就是我们输入的条件会逐层经过每一个 if-else 语句去判断是否为 true,那么当 if-else 分支特别多的时候,最坏情况时间复杂度就是 O(n)。

问:不过,难道 switch-case 就不是逐个去比较的吗?

答:还真不是!

我们来看一段代码:

public class Hello {
    public int test(int i) {
        switch (i) {
            case 1: return 1;
            case 3: return 0;
            case 5: return -1;
            default: return 9;
        }
    }
}

我们使用 javac Hello.java 命令把它编译为 Hello.class,

然后使用 javap -v Hello 命令去查看 Hello.class 的字节码。

重点看 tableswitch 指令,我们源代码中 case 只有整数 1、3、5,但是编译后字节码中却自动出现了整数 2 和 4。

原因分析:

1. javac 编译器会对 case 值做分析,如果 case 值比较紧凑,中间只有少量断层,那么会使用 tableswitch 指令生成一些假的 case 帮忙补齐。

2. 由于 case 值已经被补齐为连续的值,那么后续就可以通过下标直接找到,时间复杂度为 O(1)。

我们再来看一个 case 严重断层的例子:

public class Hello {
    public int test(int i) {
        switch (i) {
            case 10: return 1;
            case 1: return 0;
            case 100: return -1;
            default: return 9;
        }
    }
}

显然,如果还是用之前的 tableswitch 去生成虚假的 case,会生成上百个虚假 case,class 文件的大小会爆炸式增长,显然不合理。

但是细心的同学会发现,编译器使用了 lookupswitch 指令,并且所有的 case 已经排好序了。

所以在查找上,他可以使用二分查找,时间复杂度为 O(logn),虽然不如 tableswitch 的 O(1),但是相比于 if-else 的 O(n) 已经优化很多了。

4. String 的 switch-case 字节码原理

刚刚我们介绍了 int 的 switch-case 字节码原理:编译器会根据 case 值的 “稀疏” 程度,分别由 tableswitch 和 lookupswitch 指令来实现。

那编译器是如何让 String 也支持 switch-case 的呢?下面我们来说说 String 的。

public class Hello {
    public int test(String name) {
        switch (name) {
            case "Java": return 1;
            case "Python": return 0;
            default: return 9;
        }
    }
}

这个字节码就比较多了。

         0: aload_1
         1: astore_2
         2: iconst_m1
         3: istore_3
         4: aload_2
         5: invokevirtual #7                  // Method java/lang/String.hashCode:()I
         8: lookupswitch  { // 2
             -1889329924: 50
                 2301506: 36
                 default: 61
            }
        36: aload_2
        37: ldc           #13                 // String Java
        39: invokevirtual #15                 // Method java/lang/String.equals:(Ljava/lang/Object;)Z
        42: ifeq          61
        45: iconst_0
        46: istore_3
        47: goto          61
        50: aload_2
        51: ldc           #19                 // String Python
        53: invokevirtual #15                 // Method java/lang/String.equals:(Ljava/lang/Object;)Z
        56: ifeq          61
        59: iconst_1
        60: istore_3
        61: iload_3
        62: lookupswitch  { // 2
                       0: 88
                       1: 90
                 default: 92
            }
        88: iconst_1
        89: ireturn
        90: iconst_0
        91: ireturn
        92: bipush        9
        94: ireturn

不过没关系,看这里:

这里实际上调用了 hashcode 方法,把字符串用 hashcode 表示,-1889329924 对应 Python,2301506 对应 Java。

因为一般 hash 值比较离散,所以字符串会使用 lookupswitch 来作为 switch-case 的实现。

如果我们传入的 name 的 hashcode 和 Java 字符串相等,那么会跳到 36 行用 equals 方法比较是否相等。

比较是否相等的命令是 ifeq,相等时 matchIndex = 0,然后跳到第 61 行做最后的 switch-case 比较。

显然,如果 matchIndex = 0,即我们传入的 name 为 Java,那么就 return 1。

注意这里 88 和 88 行对应,iconst_1,就是我们源代码里 return 的 1。

同样,90 也和 90 行对应,iconst_0,就是我们源代码里 return 的 0。

同样,92 也和 92 行对应,bipush 9,就是我们源代码里 return 的 9。

总结来说,我们的源代码等价于下面的伪代码:


public int test(String name) {
    String tmpName = name;
    int matchIndex = -1;

    switch (tmpName.hashCode()) {
        case -1889329924: 
            if (tmpName.equals("Python")) {
                matchIndex = 1;
            }
            break;
        case 2301506:
            if (tmpName.equals("Java")) {
                matchIndex = 0;
            }
            break;
        default: 
            break;
    }

    switch (matchIndex) {
        case 0:
            return 1;
        case 1:
            return 0;
        default:
            return 9;
    }
}

问:看到这里,还有个小问题,如果字符串的 hashcode 冲突了怎么办,比如:Aa 和 BB 的 hashcode 都是 2112?

答:根据上面的伪代码,实际上类似如下:

    switch (tmpName.hashCode()) {
        case 2112: 
            if (tmpName.equals("BB")) {
                matchIndex = 1;
            } else if (tmpName.equlas("Aa") {
                matchIndex = 0;
            }
            break;
        default: 
            break;
    }

只不过在 case 2112 里新增加了一个 else-if 做条件而已。

有兴趣的同学可以自己去看下字节码。

今天的分享就到这里了,相信你一定有所收获!

我是此林,关注我吧,带你看不一样的世界!

更多推荐