深入解析一道关于String面试题(字节码/ByteCode)

本文深入解析Java字节码,揭示字符串拼接的底层实现,对比JDK8与JDK9中字符串拼接的不同处理方式,强调字符串常量与引用相加的区别。

这是一个比较常见的关于字符串的面试题,如下:

    public static void main(String[] args) {
        String s1 = "Hello";
        String s2 = "Hello";
        String s3 = "Hel" + "lo";
        String s4 = "Hel" + new String("lo");
        String s5 = new String("Hello");
        String s7 = "H";
        String s8 = "ello";
        String s9 = s7 + s8;

        System.out.println(s1 == s2);  // true
        System.out.println(s1 == s3);  // true
        System.out.println(s1 == s4);  // false
        System.out.println(s1 == s5);  // false
        System.out.println(s4 == s5);  // false
        System.out.println(s1 == s9);  // false
    }

得到这个结果是很容易的,但是产生这个结果的原因是什么呢?下面从字节码层面做一下解释。

一 简单介绍一下Class文件

下图是用16进制打开的一个Class文件:

下图是一个Class文件的结构:

简单说明:

  1. 图二中的u4,u2一般是指无符号整数,u4就是把4个字节看成一个无符号整数。
  2. 魔数(magic):如果是一个java.class文件,那么这4个字节是固定的,0xCAFEBABE。图一是一个java.class文件,1个16进制数4位,所以两个16进制为一位,图一的前4个字节为CAFEBABE。

  3. 版本号(小版本号(minor_version)、主版本号(major_version)):minor_version是2个字节,从图一中的第5个字节开始读,读2个字节,得到minor_version是00 00,没有小版本号。major_version也是2个字节,再读2个字节,得出了major_version是00 34,转换成十进制为52,52代表的是JDK8。附一张版本号和JDK的对应表。

  4. constant_pool_count:2个字节表示常量池的长度,继续图一顺序读2个字节为00  47,转换成十进制为71。也就是说常量池的长度为71,值得注意的是常量池的角标是从1开始的,但是保留了一个0,通俗点说就是0位有,但是目前不能用,所以长度71要减1。这样计算下来,常量池内有70个常量。

  5. constant_pool:是一个长度为constant_pool_count-1的表。表里面的东西很多,很复杂,包括了关于类、方法、接口等中的常量,还有一些引用等等。

  6. 其余的组成部分为:

    1. access_flags(class文件的修饰符 2个字节)

    2. this_class(当前类的名字 2个字节):这两个字节指的是一个constant_pool表中的有效索引。

    3. super_class(父类的名字 2个字节):这两个字节指的是一个constant_pool表中的有效索引或者0。

    4. interfaces_count(实现的接口个数 2个字节)

    5. interfaces(2个字节):长度为interfaces_count-1的一个表,实现的具体接口名,表中的每个值都是constant_pool表中的一个有效的索引。

    6. fields_count(属性个数 2个字节)

    7. fields:具体有哪些属性。其中包括了属性的修饰符、名字索引等等。

    8. methods_count:(方法的个数 2个字节)

    9. methods:具体到哪些方法,每个方法的修饰符、名字索引、附加属性等等。

    10. attributes_count:附加属性个数

    11. attributes:附加属性信息,下图是当前测试文件的一个附加属性信息。

二 读一个字节码文件

    简单了解了组成,还不能读一个字节码文件。下面借助工具,简单说明一下如何读。IDEA,JDK8,插件是jclasslib。

图中有三个部分:main方法、class文件的组成部分以及main方法的字节码。

从字节码这个部分开始解释:

1.第一条指令是ldc ,用这个插件,有一个好处,就是命令这里可以点,点击之后会跳转到JVMS关于这条命令的解释。

这条指令的解释从常量池取出指定索引处的常量压栈。#2的意思是角标为2,这个插件会直接显示角标为2的常量是Hello。也就是将Hello压栈(放入操作数据栈顶)。

 下图说明,角标为2的常量内容指向constant_pool表中的第44个索引,索引为44的常量是一个字符串Hello

2.第二条指令是,这条指令的意思是把栈顶的内容赋值给本地变量表角标为1的变量,此时这个变量是s1。这条指令翻译过来就是把刚刚压栈的Hello赋值给s1。到此 String s1 = "Hello";命令结束。

关于本地变量表,在这里指的是main方法的所有局部变量。如下图:如果这里是非静态方法,还会有一个this,可以自己试看一下。

到此为止,前6条指令都可以读,分别是把Hello赋值给s1、Hello赋值给s2、Hello赋值给s3。值得注意的是关于Hel+lo,这是两个字符串常量的相加,这种情况下的+会在预编译的时候被优化,相当于把两个或者两个以上字符串常量自动合成一个字符串常量。

3.下面几条命令放在一起解释一下

  • new #3:创建一个常量池角标为3的类的实例,把该实例的引用压栈。需要注意的是这个时候的操作是在堆中分配内存,赋默认值,实例的引用压栈。此时是默认值,并不是初始值。
  • dup:将刚刚压栈的引用在栈中赋值一份。此时的栈,如图所示:

         

  • invokespecial :调用的那些可以直接定位,不需要多态的方法后面也显示出来了,实际上调用的是StringBuilder的构造方法。这个方法的调用需要从栈顶拿一个引用出来,dup的复制操作就是为了这个,至此,该实例初始化完成。此时的栈,如图所示:

      

  • invokevirtual 多数方法的调用,自带多态的,如果创建的是子类,就调用的是子类的该方法。

这样我们可以批量读一下这几条指令:创建了一个StringBuilder实例,拼接了一个Hel,创建一个初始值为lo的字符串,StringBuilder实例拼接了字符串,然后调用toString(),将结果集赋值给s4。

4.有了上面的指令解读,可以依次读出来s5(创建一个初始值为Hello的字符串)、s6、s7、s8(创建StringBuilder实例,拼接H再拼接ello,然后执行toString(),赋值)

 

小结:通过读字节码可以了解,s1、s2、s3都是常量池的角标为2的常量,s4是创建StringBuilder然后拼接字符得到,s5是创建一个值为Hello的新字符串,值得注意的是s3和s8的加号是不同的,s3是字符串常量相加,在预编译的时候可以优化,但是s8是引用相加,预编译时不能做优化,所以在字节码层,还是使用StringBuilder拼接。

 

补充:

  1. 以上是JDK8的实现过程,JDK9并不是这样的。下图是JDK9中s4的赋值过程,不难发现取消了StringBuilder的创建,因为每做一次+操作, 就会产生个StringBuilder实例,调用append后就扔掉了。取而代之的是产生了一个内部类StringConcatFactory,调用了makeConcatWithConstants方法,这个方法功能是字符拼接的,是JDK9中新增的。
  2. invokedynamic :调用一些动态产生的类和方法的时候使用。从JDK8开始,引入了lambda表达式,这说明java开始支持动态语言了,如果使用lambda表达式创建或者调用方法的时候,就用这条指令。
  3. 关于class文件结构的详细介绍,可以参考JVMS的4.1。关于指令的详细解释可以参考JVMS的6.5。下面是JDK14版本的JVMS地址:https://docs.oracle.com/javase/specs/jvms/se14/jvms14.pdf
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值