一、字符串前生今世

1.1 如何出生

话说 Java 大家族中有一类对象称为字符串,它的地位举足轻重,就让我们从它的出生开始说起 :smile:

这里说的出生,就是指对象被创建,那有同学就会说直接 new 呗,所有对象不都是使用 new 来创建吗?

对于字符串,还真有点特殊。

字符串有六种基本的创建(出生)方式

使用 char[] 数组配合 new 来创建使用 byte[] 数组配合 new 来创建使用 int[] 数组配合 new 来创建使用 已有字符串配合 new 来创建使用字面量创建(不使用 new )合二为一,使用 + 运算符来拼接创建可以看到,至少从表面上讲,后两种都没有用到 new 关键字

1.2 char[] 数组创建

这种是最基本的,因为字符串、字符串、就是将字符串起来,结果呢,也就是多个字符的 char[] 数组,例如

String s =newString(newchar[]{'a','b','c'});

它的内部结构如下(1.8)

java 字符串初始化字段默认值_字符串

其中 97 其实就是 'a' ,98 其实就是 'b' ,99 其实就是 'c'

1.3 byte[] 数组创建

有同学会问,什么时候会根据 byte[] 数组来创建字符串呢?

答案是,从网络传递过来的数据,或是 I/O 读取到的数据,都有从 byte[] 转为字符串的需求

例如

String s =newString(newbyte[]{97,98,99});// abc

其中 new byte[]{97, 98, 99} 就可以是

从网络(例如一个浏览器的 http 请求)传递过来的字节数据也可以是从 I/O(例如从一个文本文件)读取到的数据它的内部结构其实也是

java 字符串初始化字段默认值_字符串_02

这时 byte[] 会在构造时被转换为 char[]

其中 byte[] 和 char [] 的结构如下

java 字符串初始化字段默认值_java的前世今生文章_03

看到上幅图有同学会说,对于 byte[] 转换为 char[],97 还是对应 97,98 还是对应 98,99 还是对应 99 啊,看不出 byte[] 和 char[] 的任何区别啊?你要知道,首先他们的大小不一样,其次上面的 char[] 中的 97(a),98(b),99(c) 都属于拉丁字符集,如果用到其它字符集,那么结果就不一样了,看下面的例子

例1,按 gbk 字符集转换

newString(newbyte[]{(byte)0xD5,(byte)0xC5}, Charset.forName("gbk"));

这时

java 字符串初始化字段默认值_java 字符串初始化字段默认值_04

其中两个 byte 0xD5 和 0xC5 被转换成了一个 char 0x5F20(汉字【张】)

例2,按 utf-8 字符集转换

newString(newbyte[]{(byte)0xE5,(byte)0xBC,(byte)0xA0}, Charset.forName("utf-8"));

java 字符串初始化字段默认值_bc_05

其中三个 byte 0xE5,0xBC 和 0xA0 被转换成了一个 char 0x5F20(汉字【张】)

其实 java 中的 char 字符都是以 unicode 编码的,从外界不同的编码(如 gbk,utf-8)传过来的 byte[] 最终到 java 中的 char 都统一了

1.4 int[] 数组创建

有时候我们还需要用两个 char 表示一个字符,比如 这个笑哭的字符,它用 unicode 编码表示为 0x1F602,存储范围已经超过了 char 能表示的最大值 0xFFFF,因此需要使用 int[] 来构造这样的字符串,如下

String s =newString(newint[]{0x1F602},0,1);

转换过程如图所示

java 字符串初始化字段默认值_bc_06

1.5 从已有字符串创建

直接看源码

publicString(String original){this.value = original.value;this.hash = original.hash;}

这种最为简单,但要注意是两个字符串对象引用同一个 char[] 对象

String s1 =newString(newchar[]{'a','b','c'});String s2 =newString(s1);

内存结构如下

java 字符串初始化字段默认值_java的前世今生文章_07

1.6 字面量创建

以上四种创建方式,大家用的实际上相对少一点,最熟悉的是这种字面量的方式:

publicstaticvoidmain(String[] args){ String s ="abc";}

"abc" 被叫做字符串字面量(英文 Literal),但恰恰是这种方式其实奥妙最多,我总结了三点:非对象、懒加载、不重复。来逐一看一下

非对象

严格地说,字面量在代码运行到它所在语句之前,它还不是字符串对象

要理解从字面量变成字符串对象的过程,需要从字节码的角度来分析

在上面的 java 代码被编译为 class 文件后,"abc" 存储于【类文件常量池】中

Constant pool: // 常量池 #1 = Methodref #19.#41 // java/lang/Object."":()V #2 = String #42 // abc ...

当 class 完成类加载之后,"abc" 这个字面量被存储于【运行时常量池】(归属于方法区)中,其中 #1 #2 都会被翻译为运行时真正的内存地址

再看一下 class 中 main 方法的字节码

public static void main(java.lang.String[]); // 字节码指令 descriptor: ([Ljava/lang/String;)V flags: ACC_PUBLIC, ACC_STATIC Code: stack=1, locals=2, args_size=1 0: ldc #2 // String abc 2: astore_1 3: return ...

将来 main 方法被调用时,就会执行里面的字节码指令

0: ldc #2 // String abc2: astore_13: return

ldc #2 就是到运行时常量池中找到 #2 的内存地址,找到 "abc" 这个字面量,再根据它创建一个 String 对象。

java 字符串初始化字段默认值_java的前世今生文章_08

懒加载

当第一次用到 "abc" 字面量时(也就是执行到 ldc #2 时) ,才会创建对应的字符串对象

如何验证呢?

例如有如下代码

System.out.println();System.out.println("1");// 断点1 2411System.out.println("2");// 断点2 2412System.out.println("3");// 断点3

可以给每行语句加上断点,然后用 idea 的 debug 界面中的 memory 工具来查看字符串对象的数量

刚开始在断点1 处,其它类中创建的字符串对象有 2411 个

java 字符串初始化字段默认值_java_09

执行到断点2 处,这时新创建了 "1" 对应的字符串对象,个数为 2412

java 字符串初始化字段默认值_java_10

执行到断点3 处,这时新创建了 "2" 对应的字符串对象,个数为 2413

java 字符串初始化字段默认值_字符串_11

不重复

同一个类中的值相同字面量,其实只有一份

publicclassTestString1{publicstaticvoidmain(String[] args){ String s1 ="abc"; String s2 ="abc";}}

常量池为

Constant pool: #1 = Methodref #25.#48 // java/lang/Object."":()V #2 = String #49 // abc ...

对应的字节码为

public static void main(java.lang.String[]); descriptor: ([Ljava/lang/String;)V flags: ACC_PUBLIC, ACC_STATIC Code: stack=1, locals=3, args_size=1 0: ldc #2 // String abc 2: astore_1 3: ldc #2 // String abc 5: astore_2 6: return ...

可以看到 "abc" 这个字面量虽然出现了 2 次,但实际上都是对应着常量池中 #2 这个地址

如果是不同类中的 "abc" 呢?【类文件常量池】包括【运行时常量池】都是以类为单位的

例如,另一个类中

publicclassTestString2{publicstaticvoidmain(String[] args){ String s1 ="a"; String s2 ="abc";}}

对应的常量池

Constant pool: #1= Methodref #5.#22// java/lang/Object."":()V #2= String #23// a #3= String #24// abc

可以看到在这个类中,"abc" 对应的常量池的编号是 #3,与 TestString1 中的已经不同

这时候【字面量】是两份,而【字符串对象】会有几个呢?

我们来做个实验,把刚才的代码做个改写

publicclassTestString1{publicstaticvoidmain(String[] args){ String s1 ="abc";// 字符串对象 "abc" String s2 ="abc";// 字符串对象 "abc" TestString2.main(newString[]{s1, s2});}}publicclassTestString2{publicstaticvoidmain(String[] args){// args[0] "abc", args[1] "abc" String s1 ="a"; String s2 ="abc"; System.out.println(args[0]== s2); System.out.println(args[1]== s2);}}

运行结果

truetrue

1.7 拼接创建

最后还可以通过 + 运算符将两个字符串(其中一个也可以是其它类型)拼接为一个新字符串,例如

例1

String s ="a"+"b";

例2

final String x ="b";String s ="a"+ x;

例3

String x ="b";String s ="a"+ x;

例4

String s ="a"+1;

有同学会问,例1与例2与例3 不同吗?还别说,真就不同,其中例1 与例2 原理是一样的,例3 与例4 原理是一样的,反编译一下

例1

String s ="a"+"b";

常量池

Constant pool: #1 = Methodref #4.#20 // java/lang/Object."":()V #2 = String #21 // ab ...

主方法

public static void main(java.lang.String[]); descriptor: ([Ljava/lang/String;)V flags: ACC_PUBLIC, ACC_STATIC Code: stack=1, locals=2, args_size=1 0: ldc #2 // String ab 2: astore_1 3: return ...

可以看到,其实并没有真正的【拼接】操作发生,从源码编译为字节码时,javac 就已经把 "a" 和 "b" 串在一起了,这是一种编译期的优化处理

例2

final String x ="b";String s ="a"+ x;

常量池

Constant pool: #1 = Methodref #5.#22 // java/lang/Object."":()V #2 = String #23 // b #3 = String #24 // ab ...

主方法

public static void main(java.lang.String[]); descriptor: ([Ljava/lang/String;)V flags: ACC_PUBLIC, ACC_STATIC Code: stack=1, locals=3, args_size=1 0: ldc #2 // String b final b 2: astore_1 3: ldc #3 // String ab 5: astore_2 6: return ...

可以看到,还是没有真正的【拼接】操作发生,final 意味着 x 的值不可改变,因此其它引用 x 的地方都可以安全地被替换为 "b",而不用担心 x 被改变,从源码编译为字节码时,javac 就也进行了优化,把所有出现 x 的地方都替换成为了 "b"

那么,什么是真正的【拼接】操作呢?看一下例3 反编译后的结果

String x ="b";String s ="a"+ x;

常量池

Constant pool: #1 = Methodref #9.#26 // java/lang/Object."":()V #2 = String #27 // b #3 = Class #28 // java/lang/StringBuilder #4 = Methodref #3.#26 // java/lang/StringBuilder."":()V #5 = String #29 // a ...

可以看到常量池中并没有 ab 字面量

主方法

public static void main(java.lang.String[]); descriptor: ([Ljava/lang/String;)V flags: ACC_PUBLIC, ACC_STATIC Code: stack=2, locals=3, args_size=1 0: ldc #2 // String b 2: astore_1 3: new #3 // class java/lang/StringBuilder 6: dup 7: invokespecial #4 // Method java/lang/StringBuilder."":()V 10: ldc #5 // String a 12: invokevirtual #6 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder; 15: aload_1 16: invokevirtual #6 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder; 19: invokevirtual #7 // Method java/lang/StringBuilder.toString:()Ljava/lang/String; 22: astore_2 23: return

翻译成人能读懂的就是

String x ="b";String s ="a"+ x;String x ="b";String s =newStringBuilder().append("a").append(x).toString();

StringBuilder 的 toString() 方法又是怎么实现的呢?

publicfinalclassStringBuilderextendsAbstractStringBuilderimplementsjava.io.Serializable, CharSequence {// 从 AbstractStringBuilder 继承的属性,方便阅读加在此处char[] value;@Overridepublic String toString(){// Create a copy, don't share the arrayreturnnewString(value,0, count);}}

可以看到,本质上就是根据 StringBuilder 维护的 char[] 创建了新的 String 对象

1.8 JDK 9 之后的改变

前面我们讲的是 JDK 8 中的字符串,但从 JDK 9 开始,String 的内部存储方式、以及拼接方式又发生了较大的改变

不再用 char[] 存储字符,改为了 byte[],目的是更节约内存使用 invokedynamic 指令扩展了字符串的拼接的实现方式内存结构改变

例如,字符串中仅有拉丁字符

String s =newString(newbyte[]{97,98,99});

java 字符串初始化字段默认值_bc_12

例如,字符串中有中文字符

String s =newString(newbyte[]{(byte)0xd5,(byte)0xc5}, Charset.forName("gbk"));

java 字符串初始化字段默认值_字符串_13

例如,既有中文字符也有拉丁字符

String s =newString(newbyte[]{(byte)0xd5,(byte)0xc5,97}, Charset.forName("gbk"));

java 字符串初始化字段默认值_bc_14

拼接方式改变

例如

publicstaticvoidmain(String[] args){ String x ="b"; String s ="a"+ x;}

常量池

Constant pool: #1 = Methodref #5.#22 // java/lang/Object."":()V #2 = String #23 // b ...

主方法

java 字符串初始化字段默认值_字符串_15

直接跟 invokedynamic 对应的字节码比较难,我直接翻译成人能看懂的代码

java 字符串初始化字段默认值_java_16

为什么搞这么麻烦!!!主要是为了对字符串的拼接做各种扩展优化,多了扩展途径。其中最为重要的是 MethodHandle ,它使用了策略模式生成,JDK 提供的所有的策略可以在 StringConcatFactory.Strategy 中找到:

java 字符串初始化字段默认值_java 字符串初始化字段默认值_17

如果想改变策略,可以在运行时添加 JVM 参数,例如将策略改为 BC_SB

-Djava.lang.invoke.stringConcat=BC_SB-Djava.lang.invoke.stringConcat.debug=true-Djava.lang.invoke.stringConcat.dumpClasses=匿名类导出路径

还有一种选择,是在 javac 编译时仍使用 1.5 的办法拼接字符串,而不是采用 invokedynamic,就是在 javac 时加上参数

-XDstringConcat=inline

默认拼接策略

默认策略为 MH_INLINE_SIZED_EXACT,使用字节数组直接构造出 String

例如有下面的字符串拼接代码

String x ="b";String s ="a"+ x +"c"+"d";

使用了 MH_INLINE_SIZED_EXACT 策略后,内部会执行如下等价调用

java 字符串初始化字段默认值_java的前世今生文章_18

注意

StringConcatHelper 对外是不可见的,因此无法直接测试,只能反射测试prepend 可以直接修改字符串中的 bytes 属性值,他们都是 java.lang 包下

模仿 BC_SB 策略

接下来我模拟其中一种策略的实现过程:以字节码指令生成拼接方法为例

先说明一下我的目的

String x ="hello,";String y ="world";String s = x + y;

其中 + 可以被 invokedynamic 优化为多种实现策略,如果让我自己来实现,我仅会用 StringBuilder 来拼接,因此我希望 x+y 能够被翻译为对下面方法的调用

publicstatic String concat(String x, String y){returnnewStringBuilder().append(x).append(y).toString();}

1. 方法手动生成

提供一个拼接方法

java 字符串初始化字段默认值_字符串_19

用 MethodHandle 反射调用

java 字符串初始化字段默认值_java_20

输出

hello,world

但这样需要自己提供 concat 方法,而且其参数个数都固定死了,能否动态生成这么一个方法呢,答案是肯定的,为了简化生成逻辑,这里我仍然以固定参数为例

2. 字节码生成方法

Unsafe 对象访问类

java 字符串初始化字段默认值_java_21

可以使用 asm 生成匿名类字节码

java 字符串初始化字段默认值_java 字符串初始化字段默认值_22

java 字符串初始化字段默认值_java 字符串初始化字段默认值_23

这么多字节码主要目的仅仅是生成一个匿名类的字节码,其中包括了拼接方法

publicstatic String concat(String x, String y){returnnewStringBuilder().append(x).append(y).toString();}

接下来就可以生成匿名类,供 MethodHandler 反射调用

java 字符串初始化字段默认值_java 字符串初始化字段默认值_24

最终就可以使用该 MethodHandle 反射完成字符串拼接了

String x ="hello,";String y ="world";String s =(String) mh.invoke(x, y);

输出

hello,world

JDK 9 当然做的更为专业,可以适配生成不同的参数个数、类型的 MethodHandle,但原理就是这样。