简单认识 java 字节码 --- bytecode

简单认识 java 字节码 --- bytecode-我的技术分享

我们在最初学习java的时候,都会使用记事本写一个hello world,然后使用javac编译成class文件,最后使用java命令执行编译后的文件。

进一步学习我们了解到java代码和java虚拟机是两回事,虚拟机能接受的是字节码,java代码编译后的东西是字节码,但字节码也可以是别的语言编译出来的。

通过对字节码的学习,我们可以进一步了解java代码的执行原理,了解各种语法糖的实现机制。当然实际我们常用的spring框架本身也是通过字节码增强技术实现各种功能。

现在我们进一步了解字节码相关的内容,编写了如下测试代码

public class Main {
    public static void main(String[] args) {
        System.out.println("Hello world!");
        Test hello = new Test("hello");
        hello.method();
        Test.staticMethod();
    }
    public static class Test {
        private String text;
        public Test(String text) {
            this.text = text;
        }
        {
            System.out.println("{}");
        }
        static {
            System.out.println("static");
        }
        public static void staticMethod() {
            System.out.println("staticMethod");
        }
        public void method() {
            System.out.println(text);
        }
    }
}

此代码包含了入口点函数main,静态内部类Test,实例方法调用method`,静态方法调用`staticMethod,代码块,静态代码块,构造函数,默认构造函数。

我们使用javac编译源码,得到了2个类的class文件
简单认识 java 字节码 --- bytecode-我的技术分享

我们先使用javap查看内部类Test的字节码,因为内容比较长,我将分段贴出来并解释内容的含义。

PS E:\Data\Desktop\tencent2\untitled\out\production\untitled> javap -v '.\Main$Test.class'
Classfile /E:/Data/Desktop/tencent2/untitled/out/production/untitled/Main$Test.class
  Last modified 2024年2月2日; size 757 bytes
  SHA-256 checksum 4b8c20d77e222d8522b84daf68753d7c1786e78fc45b80bb2b5a3e4ebe93c7f4
  Compiled from "Main.java"

此段输出了文件的基本内容,包含了文件的完整路径,修改日期,SHA-256值,对应的源文件名。

类声明信息

public class Main$Test
  minor version: 0
  major version: 61
  flags: (0x0021) ACC_PUBLIC, ACC_SUPER
  this_class: #22                         // Main$Test
  super_class: #2                         // java/lang/Object
  interfaces: 0, fields: 1, methods: 4, attributes: 3

此段输出了这个类的基本属性信息。

属性 说明
minor version 参与编译的java次版本号
major version 参与编译的java主版本号,值61表示这是jdk17编译的
flags 类的访问修饰符,是public类型的,后面的ACC_SUPER就涉及到java沉重的历史包袱了,可以忽略。
this_class 类索引
super_class 父类索引
interfaces 接口计数
fields 字段集合数
methods 方法计数
attributes 附加属性数

常量池

Constant pool:
   #1 = Methodref          #2.#3          // java/lang/Object."<init>":()V
   #2 = Class              #4             // java/lang/Object
   #3 = NameAndType        #5:#6          // "<init>":()V
   #4 = Utf8               java/lang/Object
   #5 = Utf8               <init>
   #6 = Utf8               ()V
   #7 = Fieldref           #8.#9          // java/lang/System.out:Ljava/io/PrintStream;
   #8 = Class              #10            // java/lang/System
   #9 = NameAndType        #11:#12        // out:Ljava/io/PrintStream;
  #10 = Utf8               java/lang/System
  #11 = Utf8               out
  #12 = Utf8               Ljava/io/PrintStream;
  #13 = String             #14            // {}
  #14 = Utf8               {}
  #15 = Methodref          #16.#17        // java/io/PrintStream.println:(Ljava/lang/String;)V
  #16 = Class              #18            // java/io/PrintStream
  #17 = NameAndType        #19:#20        // println:(Ljava/lang/String;)V
  #18 = Utf8               java/io/PrintStream
  #19 = Utf8               println
  #20 = Utf8               (Ljava/lang/String;)V
  #21 = Fieldref           #22.#23        // Main$Test.text:Ljava/lang/String;
  #22 = Class              #24            // Main$Test
  #23 = NameAndType        #25:#26        // text:Ljava/lang/String;
  #24 = Utf8               Main$Test
  #25 = Utf8               text
  #26 = Utf8               Ljava/lang/String;
  #27 = String             #28            // staticMethod
  #28 = Utf8               staticMethod
  #29 = String             #30            // static
  #30 = Utf8               static
  #31 = Utf8               Code
  #32 = Utf8               LineNumberTable
  #33 = Utf8               LocalVariableTable
  #34 = Utf8               this
  #35 = Utf8               LMain$Test;
  #36 = Utf8               method
  #37 = Utf8               <clinit>
  #38 = Utf8               SourceFile
  #39 = Utf8               Main.java
  #40 = Utf8               NestHost
  #41 = Class              #42            // Main
  #42 = Utf8               Main
  #43 = Utf8               InnerClasses
  #44 = Utf8               Test

此段内容声明了当前类的常量池,常量池中主要两种类型的常量

类型 说明
字面量 字符串,final类型的常量值
符号引用 1. 类和接口的全限定名 2. 字段的名称和描述符 3. 方法的名称和描述符

表中的#开头的数字都是索引,通过索引找到对应的值比如#23是 NameAndType 类型,也就是变量名和类型,值是#25:#26,我们继续找到#25和#26索引对应的值,合并后变成text:Ljava/lang/String;,也就是字符串类型的变量text。#44是utf8类型,也就是个字符串,值是 Test

构造函数

{
  public Main$Test(java.lang.String);
    descriptor: (Ljava/lang/String;)V
    flags: (0x0001) ACC_PUBLIC
    Code:
      stack=2, locals=2, args_size=2
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: getstatic     #7                  // Field java/lang/System.out:Ljava/io/PrintStream;
         7: ldc           #13                 // String {}
         9: invokevirtual #15                 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
        12: aload_0
        13: aload_1
        14: putfield      #21                 // Field text:Ljava/lang/String;
        17: return
      LineNumberTable:
        line 10: 0
        line 14: 4
        line 11: 12
        line 12: 17
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      18     0  this   LMain$Test;
            0      18     1  text   Ljava/lang/String;

再接下来就是方法表了,上面是构造函数的定义.我们可以看到,因为是内部类,构造函数的名字不再是当前类名,而是父类$子类

属性 说明
descriptor 方法描述 包含了入参和出参的定义,但只有类型声明
flags 方法的访问修饰符
Code 方法的代码块
LineNumberTable 行号表,记录字节码和源码的行号关联,一般用于堆栈打印具体报错的源码位置
LocalVariableTable 本地变量表,述栈帧中局部变量表的变量与Java源码中定义的变量之间的关系

代码块

下面我们分析一下 code区域的内容。

代码 说明
stack=2, locals=2, args_size=2 这串内容说明调用栈中变量数量,本地变量数量,和入参的数量
0: aload_0 aload指令是将局部变量表(slot)中的指定变量压入操作数栈中,索引0是为固定的this
1: invokespecial #1 invokespecial指定是用于调用方法,此处是调用Object的init方法,init方法值对象的实例构造方法,还有个client方法是class类的构造器,对应的变量初始化,代码块,构造函数都会整合到这2个方法内
4: getstatic #7 获取静态字段.该指令需要一个操作数,该操作数是常量池中某个CONSTANT_Fieldref_info常量的索引。#7对应System.out
7: ldc #13 将常量从运行时常量池压栈到操作数栈 #13 对应字符串{}
9: invokevirtual #15 用于调用非私有实例方法 #15值println方法,操作数栈置空
aload_0 重新将this压入操作数栈
aload_1 将字符串{} 压入操作数栈
14: putfield #21 用栈顶的值为指定的类的实例域赋值 当前栈顶就是{} #21是变量text
return return void
方法调用指令 作用
invokestatic 用于调用静态方法
invokespecial 用于调用私有实例方法、构造器,以及使用 super 关键字调用父类的实例方法或构造器,和所实现接口的default方法
invokevirtual 用于调用非私有实例方法
invokeinterface 用于调用接口方法
invokedynamic 用于调用动态方法

通过对构造函数的分析,我们发现构造函数的实际内容为

System.out.println("{}");
this.text = text;

编译的时候,把构造函数和代码块整合到了一起,且代码块先于构造函数执行。

静态方法

  public static void staticMethod();
    descriptor: ()V
    flags: (0x0009) ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=0, args_size=0
         0: getstatic     #7                  // Field java/lang/System.out:Ljava/io/PrintStream;
         3: ldc           #27                 // String staticMethod
         5: invokevirtual #15                 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
         8: return
      LineNumberTable:
        line 20: 0
        line 21: 8

这个静态方法相比上一个构造函数并没有新的内容产生,再code段可以看到存在2个操作数栈变量,一个是System.out,一个是字符串staticMethod,但源码里没有单独声明变量,所以locals是0.

  public void method();
    descriptor: ()V
    flags: (0x0001) ACC_PUBLIC
    Code:
      stack=2, locals=1, args_size=1
         0: getstatic     #7                  // Field java/lang/System.out:Ljava/io/PrintStream;
         3: aload_0
         4: getfield      #21                 // Field text:Ljava/lang/String;
         7: invokevirtual #15                 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
        10: return
      LineNumberTable:
        line 23: 0
        line 24: 10
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      11     0  this   LMain$Test;

上述是内部类定义的普通方法,在这里讲一下LineNumberTable为辅助性代码,如下图,报错堆栈打印的类的行号就源自这个表LocalVariableTable.
LineNumberTable为辅助性代码,如下图,报错堆栈打印的类的行号就源自这个表,

简单认识 java 字节码 --- bytecode-我的技术分享

比如line 23: 0这个0值得是字节码指令前面的数字,23是指这个指令对应在方法中的哪一行的代码

简单认识 java 字节码 --- bytecode-我的技术分享

后面的line 24: 10是找不到对应的源码位置的,因为返回值是void的方法可以不写return,所以原本应该是return的第24行在源码里是大括号,这也说明了不写return实际是语法糖,java支持,jvm是不支持的。

LocalVariableTable也是辅助性表,当编译出来的字节码需要被另外的程序使用的使用,或者使用mybatis之类的框架的时候需要使用。说白了就是存变量的名字的,对于jvm而言变量的名字是非必要的,变量名仅仅在程序员编写和阅读代码的时候有意义,为了减少程序的体积,编译的时候会移出所有变量的名称。但有时候我们有需要使用这个变量名,比如mybatis中sql中的参数占位符和方法的参数名是绑定的,如果编译的时候移除了参数名则执行SQL的会提示找不到参数值,打印的错误提示可能会变成 存在参数p1,p2,但找不到参数xxx之类的东西。因为参数没有名字,就可能被指定成p1,p2,arg1,arg2之类的无意义的名字。
或者你的程序引入了一个jar,编写代码调用jar内的方法时,如果方法内不存在LocalVariableTable,则参数名会显示成i1,s1之类的名字。

简单认识 java 字节码 --- bytecode-我的技术分享

又又又所以,我们发布maven仓库的时候,会发布jar和src.jar,方便开发和调试。

静态代码块
  static {};
    descriptor: ()V
    flags: (0x0008) ACC_STATIC
    Code:
      stack=2, locals=0, args_size=0
         0: getstatic     #7                  // Field java/lang/System.out:Ljava/io/PrintStream;
         3: ldc           #29                 // String static
         5: invokevirtual #15                 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
         8: return
      LineNumberTable:
        line 17: 0
        line 18: 8
}

因为没有静态构造函数,所以静态代码块被单独拿出来了。

SourceFile: "Main.java"
NestHost: class Main
InnerClasses:
  public static #44= #22 of #41;          // Test=class Main$Test of class Main

这段为输出的最后一点内容,描述了class文件对应的源文件名,NestHost表示顶层类,Test内部类,对应顶层类Main,InnerClasses是对这个内部类的扩展说明。

========================

下面我们看下Main类中的内容

PS E:\Data\Desktop\tencent2\untitled\out\production\untitled> javap -v '.\Main.class'
Classfile /E:/Data/Desktop/tencent2/untitled/out/production/untitled/Main.class
  Last modified 2024年2月2日; size 711 bytes
  SHA-256 checksum fcb8f440a309c2c7e3acbaef453486523d279f13ea7263e6fc2bf2a6f847724c
  Compiled from "Main.java"
public class Main
  minor version: 0
  major version: 61
  flags: (0x0021) ACC_PUBLIC, ACC_SUPER
  this_class: #33                         // Main
  super_class: #2                         // java/lang/Object
  interfaces: 0, fields: 0, methods: 2, attributes: 3
Constant pool:
   #1 = Methodref          #2.#3          // java/lang/Object."<init>":()V
   #2 = Class              #4             // java/lang/Object
   #3 = NameAndType        #5:#6          // "<init>":()V
   #4 = Utf8               java/lang/Object
   #5 = Utf8               <init>
   #6 = Utf8               ()V
   #7 = Fieldref           #8.#9          // java/lang/System.out:Ljava/io/PrintStream;
   #8 = Class              #10            // java/lang/System
   #9 = NameAndType        #11:#12        // out:Ljava/io/PrintStream;
  #10 = Utf8               java/lang/System
  #11 = Utf8               out
  #12 = Utf8               Ljava/io/PrintStream;
  #13 = String             #14            // Hello world!
  #14 = Utf8               Hello world!
  #15 = Methodref          #16.#17        // java/io/PrintStream.println:(Ljava/lang/String;)V
  #16 = Class              #18            // java/io/PrintStream
  #17 = NameAndType        #19:#20        // println:(Ljava/lang/String;)V
  #18 = Utf8               java/io/PrintStream
  #19 = Utf8               println
  #20 = Utf8               (Ljava/lang/String;)V
  #21 = Class              #22            // Main$Test
  #22 = Utf8               Main$Test
  #23 = String             #24            // hello
  #24 = Utf8               hello
  #25 = Methodref          #21.#26        // Main$Test."<init>":(Ljava/lang/String;)V
  #26 = NameAndType        #5:#20         // "<init>":(Ljava/lang/String;)V
  #27 = Methodref          #21.#28        // Main$Test.method:()V
  #28 = NameAndType        #29:#6         // method:()V
  #29 = Utf8               method
  #30 = Methodref          #21.#31        // Main$Test.staticMethod:()V
  #31 = NameAndType        #32:#6         // staticMethod:()V
  #32 = Utf8               staticMethod
  #33 = Class              #34            // Main
  #34 = Utf8               Main
  #35 = Utf8               Code
  #36 = Utf8               LineNumberTable
  #37 = Utf8               LocalVariableTable
  #38 = Utf8               this
  #39 = Utf8               LMain;
  #40 = Utf8               main
  #41 = Utf8               ([Ljava/lang/String;)V
  #42 = Utf8               args
  #43 = Utf8               [Ljava/lang/String;
  #44 = Utf8               LMain$Test;
  #45 = Utf8               SourceFile
  #46 = Utf8               Main.java
  #47 = Utf8               NestMembers
  #48 = Utf8               InnerClasses
  #49 = Utf8               Test
{
  public Main();
    descriptor: ()V
    flags: (0x0001) ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: return
      LineNumberTable:
        line 1: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       5     0  this   LMain;

  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: (0x0009) ACC_PUBLIC, ACC_STATIC
    Code:
      stack=3, locals=2, args_size=1
         0: getstatic     #7                  // Field java/lang/System.out:Ljava/io/PrintStream;
         3: ldc           #13                 // String Hello world!
         5: invokevirtual #15                 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
         8: new           #21                 // class Main$Test
        11: dup
        12: ldc           #23                 // String hello
        14: invokespecial #25                 // Method Main$Test."<init>":(Ljava/lang/String;)V
        17: astore_1
        18: aload_1
        19: invokevirtual #27                 // Method Main$Test.method:()V
        22: invokestatic  #30                 // Method Main$Test.staticMethod:()V
        25: return
      LineNumberTable:
        line 3: 0
        line 4: 8
        line 5: 18
        line 6: 22
        line 7: 25
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      26     0  args   [Ljava/lang/String;
           18       8     1 hello   LMain$Test;
}
SourceFile: "Main.java"
NestMembers:
  Main$Test
InnerClasses:
  public static #49= #21 of #33;          // Test=class Main$Test of class Main

下面针对main方法讲一下不一样的内容:

descriptor: ([Ljava/lang/String;)V

此段描述了main方法的入参字符串数组和没有输出参数。其中括号内的内容描述输入参数,参数使用分号分隔,括号后面的输出参数类型。
输入参数[Ljava/lang/String;开头的方括号表示这是数组类型,后面的L指的是class,java/lang/String指的就是String类型的完整包名,使用斜杠分隔。
返回类型V就是void。

接下来讲一下源码中创建实例和调用方法的字节码

源码

        Test hello = new Test("hello");
        hello.method();
        Test.staticMethod();

字节码

         8: new           #21                 // class Main$Test
        11: dup
        12: ldc           #23                 // String hello
        14: invokespecial #25                 // Method Main$Test."<init>":(Ljava/lang/String;)V
        17: astore_1
        18: aload_1
        19: invokevirtual #27                 // Method Main$Test.method:()V
        22: invokestatic  #30                 // Method Main$Test.staticMethod:()V
  1. 首先,new指令创建内部类Test的实例,并将其引用值压入栈顶,
  2. 下一个指令dup用于复制一份栈顶的数据压入栈顶,为什么要复制一份呢? 因为创建对象实例需要调用构造函数啦。invokespecial执行一次消耗一个操作数,这种就只剩一个引用在操作数栈了。
  3. 下一个指指令ldc 读取#23的指压入栈顶,此时操作数栈为 ["hello",this]
  4. astore_1指令 讲栈顶的值存入局部变量1,这个1是谁?看下LocalVariableTable中slot值是1的哪行,发现是刚才new出来的hello
  5. aload_1 从局部变量读取并放入栈顶 也就是把hello读取出来,马上就要操作它了。
  6. invokevirtual 在这里是调用实例非私有方法,#27指的是方法Main$Test.method
  7. invokestatic是调用静态方法 #30对应Main$Test.staticMethod