这是一个比较常见的关于字符串的面试题,如下:
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文件的结构:

简单说明:
- 图二中的u4,u2一般是指无符号整数,u4就是把4个字节看成一个无符号整数。
-
魔数(magic):如果是一个java.class文件,那么这4个字节是固定的,0xCAFEBABE。图一是一个java.class文件,1个16进制数4位,所以两个16进制为一位,图一的前4个字节为CAFEBABE。
-
版本号(小版本号(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的对应表。

-
constant_pool_count:2个字节表示常量池的长度,继续图一顺序读2个字节为00 47,转换成十进制为71。也就是说常量池的长度为71,值得注意的是常量池的角标是从1开始的,但是保留了一个0,通俗点说就是0位有,但是目前不能用,所以长度71要减1。这样计算下来,常量池内有70个常量。
-
constant_pool:是一个长度为constant_pool_count-1的表。表里面的东西很多,很复杂,包括了关于类、方法、接口等中的常量,还有一些引用等等。
-
其余的组成部分为:
-
access_flags(class文件的修饰符 2个字节)
-
this_class(当前类的名字 2个字节):这两个字节指的是一个
constant_pool表中的有效索引。 -
super_class(父类的名字 2个字节):这两个字节指的是一个
constant_pool表中的有效索引或者0。 -
interfaces_count(实现的接口个数 2个字节)
-
interfaces(2个字节):长度为interfaces_count-1的一个表,实现的具体接口名,表中的每个值都是
constant_pool表中的一个有效的索引。 -
fields_count(属性个数 2个字节)
-
fields:具体有哪些属性。其中包括了属性的修饰符、名字索引等等。
-
methods_count:(方法的个数 2个字节)
-
methods:具体到哪些方法,每个方法的修饰符、名字索引、附加属性等等。
-
attributes_count:附加属性个数
-
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的复制操作就是为了这个,至此,该实例初始化完成。此时的栈,如图所示:

这样我们可以批量读一下这几条指令:创建了一个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拼接。
补充:
- 以上是JDK8的实现过程,JDK9并不是这样的。下图是JDK9中s4的赋值过程,不难发现取消了StringBuilder的创建,因为每做一次+操作, 就会产生个StringBuilder实例,调用append后就扔掉了。取而代之的是产生了一个内部类StringConcatFactory,调用了makeConcatWithConstants方法,这个方法功能是字符拼接的,是JDK9中新增的。

- invokedynamic :调用一些动态产生的类和方法的时候使用。从JDK8开始,引入了lambda表达式,这说明java开始支持动态语言了,如果使用lambda表达式创建或者调用方法的时候,就用这条指令。
- 关于class文件结构的详细介绍,可以参考JVMS的4.1。关于指令的详细解释可以参考JVMS的6.5。下面是JDK14版本的JVMS地址:https://docs.oracle.com/javase/specs/jvms/se14/jvms14.pdf
本文深入解析Java字节码,揭示字符串拼接的底层实现,对比JDK8与JDK9中字符串拼接的不同处理方式,强调字符串常量与引用相加的区别。
&spm=1001.2101.3001.5002&articleId=108274665&d=1&t=3&u=f1412819fe3d4be88b2899779475f03d)
328

被折叠的 条评论
为什么被折叠?



