1. 字符串

1.1. 字符串的创建(JDK8)

1.1.1. char[]数组创建

  1. String s = new String(new char[]{'a', 'b', 'c'});

字符串与StringTable(字符串常量池) - 图1

1.1.2. byte[]数组创建

  1. String s = new String(new byte[]{97, 98, 99});

字符串与StringTable(字符串常量池) - 图2

byte[] 会在构造时转换成 char[],区别在于字符集不一样(大小不一样)

按 GBK 字符集转换时,两个 byte 类型的 0xD50xC5 被转换成了一个 char 类型的 0x5F20 (汉字【张】)。

  1. String s = new String(new byte[]{(byte) 0xD5, (byte) 0xC5}, Charset.forName("gbk"));

按 UTF-8 字符集转换时,三个 byte 类型的 0xE50xBC0xA0 被转换成了一个 char 类型的 0x5F20 (汉字【张】)。

  1. String s = new String(new byte[]{(byte) 0xE5, (byte) 0xBC, (byte) 0xA0}, Charset.forName("utf-8"));

1.1.3. int[]数组创建

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

  1. String s = new String(new int[]{0x1F602}, 0, 1);

一个 int 类型的 0x1F602 被转换成了两个 char 类型的 0xD83D0xDE02 (emoji【😂】)。

1.1.4. 从已有字符串创建

传入一个源字符串,创建一个新字符串。

  1. public String(String original) {
  2. this.value = original.value;
  3. this.coder = original.coder;
  4. this.hash = original.hash;
  5. }

使用方法

  1. String s1 = new String(new char[]{'a', 'b', 'c'});
  2. String s2 = new String(s1);

根据构造方法,两个 String 对象中的 value 引用同一个 char[] 数组。

字符串与StringTable(字符串常量池) - 图3

1.1.5. 字面量创建

最常用的创建方式。

  1. int i = 10; // 10是字面量
  2. String s = "abc"; // "abc"是字符串字面量

【非对象】

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

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

【懒加载】

执行到字面量代码时,才会创建对象。

  1. // 假设现在String类对象有100个
  2. System.out.println("a"); // 执行完后有String类对象有101个
  3. System.out.println("b"); // 执行完后有String类对象有102个
  4. System.out.println("c"); // 执行完后有String类对象有103个

【不重复】

相同类中的相同字面量,编译时【类文件常量池】中只有一个字面量,运行时只创建一个字符串对象

  1. String s1 = "abc";
  2. String s2 = "abc";
  3. System.out.println(s1 == s2); // true

不同类中的相同字面量,编译时【类文件常量池】中有多个字面量,运行时只创建一个字符串对象

  1. public class StringTest {
  2. public static void main(String[] args) {
  3. String s1 = "abc";
  4. String s2 = "abc";
  5. StringTest2.main(new String[]{s1, s2});
  6. }
  7. }
  8. public class StringTest2 {
  9. public static void main(String[] args) {
  10. String s = "abc";
  11. System.out.println(s == args[0]); // true
  12. System.out.println(s == args[1]); // true
  13. }
  14. }

1.1.6. 拼接创建

使用加号运算符 + 将两个字符串拼接为一个新的字符串。

  1. // ① 字面量 + 字面量
  2. String s1 = "a" + "b";
  3. // ② 字面量 + 常量
  4. final String x = "b";
  5. String s2 = "a" + x;
  6. // ③ 字面量 + 变量
  7. String x = "b";
  8. String s3 = "a" + x;
  9. // ④ 字面量 + 数字
  10. String s4 = "a" + 1;

从原理上看,①和②原理相同,③和④原理相同。

①:没有真正的拼接操作。在编译阶段,就已经把 "a""b" 串在一起了。

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

②:没有真正的拼接操作。final 代表 x 的值不可改变,在编译阶段,其它引用 x 的地方都安全地被替换为 "b"

  1. Constant pool:
  2. #1 = Methodref #5.#22 // java/lang/0bject. "<init>":()V
  3. #2 = String #23 // b
  4. #3 = String #24 // ab

③:有真正的拼接操作。在编译阶段,加号运算符 + 会被替换成 StringBuilder 来进行字符串拼接。

  1. Constant pool:
  2. #1 = Methodref #9.#26 // fava/1ang/0bject. "<init>":()V
  3. #2 = String #27 // b
  4. #3 = Class #28 // java/lang/StringBuilder
  5. #4 = Methodref #3.#26 // java/lang/StringBuilder. "<init>":()V
  6. #5 = String #29 // a

由于并没有真正意义上的字符串拼接操作,因此,源代码会被编译成 StringBuilder 执行。

  1. String x = "b";
  2. String s3 = new StringBuilder().append("a").append(x).toString();

实际上, toString() 方法就是根据自身维护的 value 数组,创建一个新的 String 对象。

  1. public final class StringBuilder extends AbstractStringBuilder
  2. implements java.io.Serializable, Comparable<StringBuilder>, CharSequence {
  3. // 从AbstractStringBuilder继承的属性,阅读方便放在这里
  4. char[] value; // JDK 9 以后换成了 byte[] value;
  5. public String toString() {
  6. return new String(value, 0, count);
  7. }
  8. }

④:原理同③完全一样。

1.2. JDK 9 的变化

1.2.1. 内存结构的变化

为了节约内存,不再使用 char[] 存储字符,改为了 byte[] 存储。

① 如果字符串只有拉丁字符,使用 byte 表示只占用1个字节,而使用 char 表示会占用2个字节,因此能节约内存空间。

  1. String s = new String(new byte[]{97, 98, 99});

字符串与StringTable(字符串常量池) - 图4

② 如果字符串只有中文字符,不能节约内存空间。

  1. String s = new String(new byte[]{(byte) 0xD5, (byte) 0xC5}, Charset.forName("gbk"));

字符串与StringTable(字符串常量池) - 图5

③ 如果字符串既有拉丁字符又有中文字符,拉丁字符也会被当做Unicode字符占用两个字节,因此不能节约内存空间。

  1. String s = new String(new byte[]{(byte) 0xD5, (byte) 0xC5, 97}, Charset.forName("gbk"));

字符串与StringTable(字符串常量池) - 图6

1.2.2. 拼接方式的变化

JDK 8 会把加号运算符 + 替换成 StringBuilder 来进行字符串拼接。而 JDK 9 则默认使用字节码指令 invokedynamic ,反射执行拼接方法来实现字符串拼接。

  1. public static void main(String[] args) throws Throwable {
  2. String x = "b";
  3. // String s = "a"+ x;
  4. // 会生成如下等价的字节码
  5. // 编译器会提供lookup,用来查找MethodHandle
  6. MethodHandles.Lookup lookup = MethodHandles.lookup();
  7. CallSite callSite = StringConcatFactory.makeConcatWithConstants(
  8. lookup,
  9. // 方法名,不重要,编译器会自动生成
  10. "arbitrary",
  11. // 方法的签名,第一个String为返回值类型,之后是入参类型
  12. MethodType.methodType(String.class, String.class),
  13. // 具体处方格式,其中\1意思是变量的占位符,将来被x代替
  14. "a\1"
  15. );
  16. // callSite.getTarget()返回的是MethodHandle对象,用来反射执行拼接方法
  17. String s = (String) callSite.getTarget().invoke(x);
  18. }

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

策略名 内部调用 解释
BC_SB 字节码拼接生成 StringBuilder 代码 等价于new StringBuilder()
BC_SB_SIZED 字节码拼接生成 StringBuilder 代码 等价于new StringBuilder(n),n为预估大小
BC_SB_SIZED_EXACT 字节码拼接生成 StringBuilder 代码 等价于new StringBuilder(n),n为准确大小
MH_SB_SIZED MethodHandle 生成 StringBuilder 代码 等价于new StringBuilder(n),n为预估大小
MH_SB_SIZED_EXACT MethodHandle 生成 StringBuilder 代码 等价于new StringBuilder(n),n为准确大小
MH_NLINE_SIZED_EXACT MethodHandle 内部使用字节数组直接构造 String 默认策略

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

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

1.2.3. 默认的拼接策略

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

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

  1. String x = "b";
  2. String s = "a"+ x + "c"+ "d";

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

  1. String x = "b";
  2. // 预先分配字符串需要的字节数组
  3. byte[] buf = new byte[4];
  4. // 创建新字符串,这时内部字节数组值为 [0,0,0,0]
  5. String s = StringConcatHelper.newString(buf, 0);
  6. // 执行【拼接】,字符串内部字节数组值为 [97,0,0,0]
  7. StringConcatHelper.prepend(1, buf, "a");
  8. // 执行【拼接】,字符串内部字节数组值为 [97,98,0,0]
  9. StringConcatHelper.prepend(2, buf, x);
  10. // 执行【拼接】,字符串内部字节数组值为 [97,98,99,100]
  11. StringConcatHelper.prepend(4, buf, "cd");
  12. // 到此【拼接完毕】

2. StringTable

2.1. 家养与野生

String 的六种创建方式中,除了字面量方式创建的字符串是家养的以外,其它方法创建的字符串都是野生的。

  • 家养:字面量方式创建的字符串,会放入 StringTable 中,StringTable 管理的字符串,具有不重复的特性。
  • 野生:而 char[]byte[]int[]String,以及 + 方式本质上都是使用 new 在堆中创建新的字符串对象,不会考虑字符串重复,对内存占用严重。

JDK 使用了 StringTable 来解决(数据结构上就是一个hash表),字符串对象就充当hash表中的key,key 的不重复性,是hash表的基本特性。

【示例代码】

  1. String s1 = "abc"; // 家养
  2. String s2 = "abc"; // 家养
  3. String s3 = new String(new char[]{'a', 'b', 'c'}); // 野生
  4. String s4 = "a" + "bc"; // 家养
  5. String x = "a";
  6. String s5 = x + "bc"; // 野生
  7. System.out.println(s1 == s2); // true
  8. System.out.println(s1 == s3); // false
  9. System.out.println(s1 == s4); // true
  10. System.out.println(s1 == s5); // false

2.2. StringTable 的存储位置

JDK1.6及以前:StringTable 在方法区中

JDK1.8及以后:StringTable 在堆内存中

2.3. intern() 方法

字符串提供了 intern() 方法来实现去重,让字符串对象有机会受到 StringTable 的管理。

  1. // 尝试将调用者放入StringTable
  2. public native String intern();

2.3.1. StringTable 中已存在

intern() 总会返回 StringTable 中已存在的字符串对象。

  1. String x = new String(new char[]{'a', 'b', 'c'}); // 野生
  2. String y = "abc"; // 家养,将"abc"加入到StringTable中
  3. String z = x.intern(); // StringTable中有"abc",则返回StringTable中的"abc"
  4. System.out.println(z == x); // false
  5. System.out.println(z == y); // true

2.3.2. StringTable 中不存在(≥JDK7)

intern() 先把调用者加入到 StringTable 中,再返回 StringTable 中已存在的字符串对象。

  1. String x = new String(new char[]{'a', 'b', 'c'}); // 野生
  2. String z = x.intern(); // StringTable没有"abc",则先加入x,再返回"abc"
  3. String y = "abc"; // StringTable中有"abc",则直接使用
  4. System.out.println(z == x); // true
  5. System.out.println(z == y); // true

2.3.3. StringTable 中不存在(≤JDK6)

intern() 先把调用者复制一份,再加入到 StringTable 中,再返回 StringTable 中已存在的字符串对象。

  1. String x = new String(new char[]{'a', 'b', 'c'}); // 野生
  2. String z = x.intern(); // StringTable没有"abc",则先加入"abc",再返回"abc"
  3. String y = "abc"; // StringTable中有"abc",则直接使用
  4. System.out.println(z == x); // false
  5. System.out.println(z == y); // true

2.3.4. 原理

  1. 根据 char[] 和长度,计算 hash值。
  2. 根据 hash 值计算 hashtable 的桶下标。
  3. 检查字符串在 hashtable 中是否已存在:
    • 如果已存在:直接返回。
    • 如果不存在:创建一个新的字符串对象,并加入到 hashtable 中,最后返回。

2.4. G1 去重

在 JDK 8u20及以后,可以开启 G1 垃圾回收器并开启字符串去重功能。

  1. -XX:+UseG1GC -XX:+UseStringDeduplication

原理是让多个字符串对象引用同一个 char[] 来达到节省内存的目的。

字符串与StringTable(字符串常量池) - 图7

与调用 intern 去重相比,G1去重好处在于自动,但缺点是即使 char[] 不重复,但字符串对象本身还要占用一定内存(对象头、value引用、 hash),而 intern 去重是字符串对象只存一份,更省内存。


3. 面试题

① 判断输出

  1. String str1 = "string"; // 家养
  2. String str2 = new String("string"); // 野生
  3. String str3 = str2.intern(); // 家养
  4. System.out.println(str1 == str2); // false
  5. System.out.println(str1 == str3); // true

② 判断输出

  1. String baseStr = "baseStr";
  2. final String baseFinalStr = "baseStr";
  3. String str1 = "baseStr01"; // 家养
  4. String str2 = "baseStr" + "01"; // 家养
  5. String str3 = baseStr + "01"; // 野生
  6. String str4 = baseFinalStr + "01"; // 家养
  7. String str5 = new String("baseStr01").intern(); // 野生变家养
  8. System.out.println(str1 == str2); // true
  9. System.out.println(str1 == str3); // false
  10. System.out.println(str1 == str4); // true
  11. System.out.println(str1 == str5); // true

③ 判断输出(区分版本)

  1. String str2 = new String("str") + new String("01");
  2. str2.intern(); // 1.6 复制一个副本添加;1.7 直接添加
  3. String str1 = "str01";
  4. System.out.println(str1 == str2); // 【1.6】false,【1.7】true

④ 判断输出

  1. String str1 = "str01";
  2. String str2 = new String("str") + new String("01");
  3. str2.intern(); // str2本身不会变化,当用str3来接收返回值后,则str1==str3
  4. System.out.println(str1 == str2); // false

**String s = new String("xyz");** 创建了几个字符串对象?

2个。"xyz" 创建在 StringTable 中,new String() 创建在堆内存中。但是两者引用同一个 char[] 数组。

⑥ 判断输出

  1. String str1 = "abc"; // 家养
  2. String str2 = "abc"; // 家养
  3. System.out.println(str1 == str2); // true

⑦ 判断输出

  1. String str1 = new String("abc"); // 野生
  2. String str2 = new String("abc"); // 野生
  3. System.out.println(str1 == str2); // false

⑧ 判断输出

  1. String str1 = "abc"; // 家养
  2. String str2 = "a";
  3. String str3 = "bc";
  4. String str4 = str2 + str3; // 野生,变量+变量,StringBuilder拼接
  5. System.out.println(str1 == str4); // false

⑨ 判断输出

  1. String str1 = "abc"; // 家养
  2. final String str2 = "a";
  3. final String str3 = "bc";
  4. String str4 = str2 + str3; // 家养,常量+常量,编译阶段拼接
  5. System.out.println(str1 == str4); // true

⑩ 判断输出

  1. String s = new String("abc");
  2. String str1 = "abc";
  3. String str2 = new String("abc");
  4. System.out.println(s == str1.intern()); // false
  5. System.out.println(s == str2.intern()); // false
  6. System.out.println(str1 == str2.intern()); // true