StringTable

327 阅读22分钟

一、字符串前生今世

1.1 如何出生

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

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

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

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

  • 使用 char[] 数组配合 new 来创建

  • 使用 byte[] 数组配合 new 来创建

  • 使用 int[] 数组配合 new 来创建

  • 使用 已有字符串配合 new 来创建

  • 使用字面量创建(不使用 new )

  • 合二为一,使用 + 运算符来拼接创建

可以看到,至少从表面上讲,后两种都没有用到 new 关键字

1.2 char[] 数组创建

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

String s = new String(new char[]{'a', 'b', 'c'});
String s = new String(new byte[]{97, 98, 99}); // abc
new String(    new byte[]{(byte) 0xD5, (byte) 0xC5},     Charset.forName("gbk"));
new String(    new byte[]{(byte) 0xE5, (byte) 0xBC, (byte) 0xA0},     Charset.forName("utf-8"));
String s = new String(new int[]{0x1F602}, 0, 1);

参考

unicode 9.0 说明

unicode 中的 emoji 表情

1.5 从已有字符串创建

直接看源码

public String(String original) {    this.value = original.value;    this.hash = original.hash;}
String s1 = new String(new char[]{'a', 'b', 'c'});String s2 = new String(s1);
public static void main(String[] args) {    String s = "abc";}

一粥一饭,当思来之不易,半丝半缕,恒念物力维艰
- 《朱子家训》

/**
 * 演示 intern 减少内存占用
 */
public class Demo1 {
    public static void main(String[] args) throws IOException {
        List<String> address = new ArrayList<>();
        System.in.read();
        for (int i = 0; i < 10; i++) {
            try (BufferedReader reader = new BufferedReader(new InputStreamReader(new FileInputStream("linux.words"), "utf-8"))) {
                String line = null;
                long start = System.nanoTime();
                while (true) {
                    line = reader.readLine();
                    if(line == null) {
                        break;
                    }
                    address.add(line.intern());
                }
                System.out.println("cost:" +(System.nanoTime()-start)/1000000);
            }
        }
        System.in.read();
    }
}

2.4 家的位置

沉舟侧畔千帆过,病树前头万木春
刘禹锡

StringTable 的位置(1.6)

StringTable 的位置(1.8)

如何证明

  • 1.6 不断将字符串用 intern 加入 StringTable,最后撑爆的是永久代内存,为了让错误快速出现,将永久代内存设置的小一些:-XX:MaxPermSize=10m,最终会出现 java.lang.OutOfMemoryError: PermGen space

  • 1.8 不断将字符串用 intern 加入 StringTable,最后撑爆的是堆内存,为了让错误快速出现,将堆内存设置的小一些:-Xmx10m -XX:-UseGCOverheadLimit 后一个虚拟机参数是避免 GC 频繁引起其他错误而不是我们期望的 java.lang.OutOfMemoryError: Java heap space

    代码

    /**
     * 演示 StringTable 位置
     * 在jdk8下设置 -Xmx10m -XX:-UseGCOverheadLimit
     * 在jdk6下设置 -XX:MaxPermSize=10m
     */
    public class Demo2 {
    
        public static void main(String[] args) throws InterruptedException {
            List<String> list = new ArrayList<String>();
            int i = 0;
            try {
                for (int j = 0; j < 260000; j++) {
                    list.add(String.valueOf(j).intern());
                    i++;
                }
            } catch (Throwable e) {
                e.printStackTrace();
            } finally {
                System.out.println(i);
            }
        }
    }
    

    2.5 intern 去重原理

    致知在格物,物格而后知至
    - 《礼记·大学》

    查阅一下 jdk 的源码

    hg.openjdk.java.net/jdk8u/jdk8u…

    // string_or_null 字符串对象
    // name 字符串原始指针
    // len 字符串长度
    oop StringTable::intern(Handle string_or_null, jchar* name,
                            int len, TRAPS) {
      // 获取字符串的 hash 值
      unsigned int hashValue = hash_string(name, len);
      // 算出 hash table 桶下标  
      int index = the_table()->hash_to_index(hashValue);
      // 看字符串在 hash table 中有没有 
      oop found_string = the_table()->lookup(index, name, len, hashValue);
    
      // 如果有,直接返回(避免重复加入)
      if (found_string != NULL) {
        // 确保该字符串对象没有被垃圾回收  
        ensure_string_alive(found_string);
        return found_string;
      }
    
      debug_only(StableMemoryChecker smc(name, len * sizeof(name[0])));
      assert(!Universe::heap()->is_in_reserved(name),
             "proposed name of symbol must be stable");
    
      Handle string;
      // try to reuse the string if possible
      if (!string_or_null.is_null()) {
        string = string_or_null;
      } else {
        // 根据 unicode 创建【字符串对象 string】 
        string = java_lang_String::create_from_unicode(name, len, CHECK_NULL);
      }
    
    #if INCLUDE_ALL_GCS
      if (G1StringDedup::is_enabled()) {
        // Deduplicate the string before it is interned. Note that we should never
        // deduplicate a string after it has been interned. Doing so will counteract
        // compiler optimizations done on e.g. interned string literals.
        G1StringDedup::deduplicate(string());
      }
    #endif
    
      // Grab the StringTable_lock before getting the_table() because it could
      // change at safepoint.
      oop added_or_found;
      {
        MutexLocker ml(StringTable_lock, THREAD);
        // 将【字符串对象 string】加入 hash table
        added_or_found = the_table()->basic_add(index, string, name, len,
                                      hashValue, CHECK_NULL);
      }
    
      ensure_string_alive(added_or_found);
    
      return added_or_found;
    }

    其中 lookup 的定义为

    // index 桶下标
    // name 字符串原始指针
    // len 字符串长度
    // hash 哈希码
    oop StringTable::lookup(int index, jchar* name,
                            int len, unsigned int hash) {
      int count = 0;
      for (HashtableEntry<oop, mtSymbol>* l = bucket(index); l != NULL; l = l->next()) {
        count++;
        if (l->hash() == hash) {
          if (java_lang_String::equals(l->literal(), name, len)) {
            return l->literal();
          }
        }
      }
      // 如果链表过长,需要 rehash
      if (count >= rehash_count && !needs_rehashing()) {
        _needs_rehashing = check_rehash_table(count);
      }
      return NULL;
    }

    其中 basic_add 的定义为

    // index_arg 桶下标
    // string 字符串对象
    // name 字符串原始指针
    // len 字符串长度
    oop StringTable::basic_add(int index_arg, Handle string, jchar* name,
                               int len, unsigned int hashValue_arg, TRAPS) {
    
      assert(java_lang_String::equals(string(), name, len),
             "string must be properly initialized");
      // Cannot hit a safepoint in this function because the "this" pointer can move.
      No_Safepoint_Verifier nsv;
    
      // Check if the symbol table has been rehashed, if so, need to recalculate
      // the hash value and index before second lookup.
      unsigned int hashValue;
      int index;
      if (use_alternate_hashcode()) {
        hashValue = hash_string(name, len);
        index = hash_to_index(hashValue);
      } else {
        hashValue = hashValue_arg;
        index = index_arg;
      }
    
      // Since look-up was done lock-free, we need to check if another
      // thread beat us in the race to insert the symbol.
    
      oop test = lookup(index, name, len, hashValue); // calls lookup(u1*, int)
      if (test != NULL) {
        // Entry already added
        return test;
      }
        
      // 构造新的 HashtableEntry 节点
      HashtableEntry<oop, mtSymbol>* entry = new_entry(hashValue, string());
      // 加入链表  
      add_entry(index, entry);
      // 返回字符串对象
      return string();
    }

    2.6 G1 去重

    懒云窝,醒时诗酒醉时歌。瑶琴不理抛书卧,无梦南柯
    - 阿里西瑛

    懒惰是程序员的一大美德,不追求懒惰的程序员不是好程序员

    如果你使用的 JDK 8u20,那么可以使用下面的 JVM 参数开启 G1 垃圾回收器,并开启字符串去重功能

    -XX:+UseG1GC -XX:+UseStringDeduplication

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

    特点

    • 由 G1 垃圾回收器在 minor gc 阶段自动分析优化,不需要程序员自己干预

    • 只有针对那些多次回收还不死的字符串对象,才会进行去重优化,可以通过 -XX:StringDeduplicationAgeThreshold=n 来调整

    • 可以通过 -XX:+PrintStringDeduplicationStatistics 查看 G1 去重的统计信息

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

    2.7 家的大小

    安得广厦千万间,大庇天下寒士俱欢颜,风雨不动安如山
    - 杜甫

    StringTable 足够大,才能发挥性能优势,大意味着 String 在 hash 表中冲突减少,链表短,性能高。

    可以通过 -XX:+PrintStringTableStatistics 来查看 StringTable 的大小,JDK 8 中它的默认大小为 60013

    要注意 StringTable 底层的 hash 表在 JVM 启动后大小就固定不变了

    这个 hash 表可以在链表长度太长时进行 rehash,但不是利用扩容实现的 rehash,而是通过重新计算字符串的 hash 值来让它们分布均匀

    如果想在启动前调整 StringTable 的大小,可以通过 -XX:StringTableSize=n 来指定

    代码

    /**
     * 演示串池大小对性能的影响
     * -XX:+PrintStringTableStatistics -XX:StringTableSize=1009
     */
    public class Demo3 {
    
        public static void main(String[] args) throws IOException {
            try (BufferedReader reader = new BufferedReader(new InputStreamReader(new FileInputStream("linux.words"), "utf-8"))) {
                String line = null;
                long start = System.nanoTime();
                while (true) {
                    line = reader.readLine();
                    if (line == null) {
                        break;
                    }
                    line.intern();
                }
                System.out.println("cost:" + (System.nanoTime() - start) / 1000000);
            }
        }
    }

    2.8 字符串之死

    All Men Must Die - 凡人皆有一死
    冰与火之歌:权力的游戏

    字符串也是一个对象,只要是对象,终究逃不过死亡的命运。字符串对象与其它 Java 对象一样,只要失去了利用价值,就会被垃圾回收,无论是野生字符串,还是家养字符串

    怎么证明家养的字符串也能被垃圾回收呢,可以用以下 JVM 参数来查看

    -XX:+PrintStringTableStatistics -XX:+PrintGCDetails -verbose:gc

    代码

    /**
     * 演示 StringTable 垃圾回收
     * -Xmx10m -XX:+PrintStringTableStatistics -XX:+PrintGCDetails -verbose:gc
     */
    public class Demo4 {
        public static void main(String[] args) throws InterruptedException {
            int i = 0;
            try {
                for (int j = 0; j < 100000; j++) { // j=100, j=10000
                    String.valueOf(j).intern();
                    i++;
                }
            } catch (Throwable e) {
                e.printStackTrace();
            } finally {
                System.out.println(i);
            }
    
        }
    }

    三、面试题讲解

    1. 判断输出

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

    2. 判断输出

    String baseStr = "baseStr";
    final String baseFinalStr = "baseStr";
    
    String str1 = "baseStr01"; // 家
    String str2 = "baseStr"+"01"; // 家
    String str3 = baseStr + "01"; // 野生
    String str4 = baseFinalStr+"01";// 家
    String str5 = new String("baseStr01").intern(); // 家
    
    System.out.println(str1 == str2);//#3 true
    System.out.println(str1 == str3);//#4 false 
    System.out.println(str1 == str4);//#5 true
    System.out.println(str1 == str5);//#6 true
    

    3. 判断输出(注意版本)

    String str2 = new String("str")+new String("01");
    str2.intern(); //1.6
    String str1 = "str01";
    System.out.println(str2==str1);//#7 1.7 true, 1.6 false

    4. 判断输出

    String str1 = "str01";
    String str2 = new String("str")+new String("01");
    str2.intern();
    System.out.println(str2 == str1);//#8 false

    5. String s = new String("xyz"),创建了几个String Object?

    6. 判断输出

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

    7. 判断输出

    String s1 = new String("abc");
    String s2 = new String("abc");
    System.out.println(s1 == s2); //false

    8. 判断输出

    String s1 = "abc";String s2 = "a";String s3 = "bc";String s4 = s2 + s3;System.out.println(s1 == s4); //false
    String s1 = "abc";final String s2 = "a";final String s3 = "bc";String s4 = s2 + s3;System.out.println(s1 == s4);//true
    String s = new String("abc"); // 野生String s1 = "abc"; // 家String s2 = new String("abc"); // 野生System.out.println(s == s1.intern()); // falseSystem.out.println(s == s2.intern()); // falseSystem.out.println(s1 == s2.intern()); // true

    10. 判断输出

    9. 判断输出

2.3 去重的好处

false
true

输出

String x = new String(new char[]{'a', 'b', 'c'}); // 野生的
String z = x.intern(); // 野生的 x 被复制后加入 StringTable,StringTable 中有了 "abc"
String y = "abc"; // 已有,不会产生新的对象,用的是 StringTable 中 "abc"
System.out.println(z == x);
System.out.println(z == y);

例子,代码同上面 1.7 相同

xsStringTableintern()如果没有将x引用的对象复制将复制后的对象加入返回 StringTable 对象xsStringTable

String x = ...;
String s = x.intern();

如果 StringTable 中没有(1.6 JDK 的做法)

true
true

输出

String x = new String(new char[]{'a', 'b', 'c'}); // 野生的
String z = x.intern(); // 野生的 x 加入 StringTable,StringTable 中有了 "abc"
String y = "abc"; // 已有,不会产生新的对象,用的是 StringTable 中 "abc"
System.out.println(z == x);
System.out.println(z == y);

例子

xsStringTableintern()如果没有将x引用的对象加入返回 StringTable 对象xsStringTable

String x = ...;
String s = x.intern();

如果 StringTable 中没有(1.7 以上 JDK 的做法)

true
false

输出

String x = new String(new char[]{'a', 'b', 'c'}); // 野生的
String y = "abc"; // 将 "abc" 加入 StringTable
String z = x.intern(); // 已有,返回 StringTable 中 "abc",即 y
System.out.println(z == y);
System.out.println(z == x);

例子

xsStringTableintern()如果已有返回 StringTable 对象xsStringTable

总会返回家养的 String 对象

String x = ...;
String s = x.intern();

如果 StringTable 中已有

它会尝试将调用者放入 StringTable

public native String intern();

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

野生的字符串也有机会得到教育

子曰:有教无类
- 《论语·卫灵公

2.2 收留野生字符串

当代码运行到一个字面量 "abc" 时,会首先检查 StringTable 中有没有相同的 key,如果没有,创建新字符串对象加入;否则直接返回已有的字符串对象

如何保证家养的字符串对象不重复呢?JDK 使用了 StringTable 来解决,StringTable 是采用 c++ 代码编写的,数据结构上就是一个 hash 表,字符串对象就充当 hash 表中的 key,key 的不重复性,是 hash 表的基本特性

  • 字面量方式创建的字符串,会放入 StringTable 中,StringTable 管理的字符串,才具有不重复的特性,这种就像是家养的

  • 而 char[],byte[],int[],String,以及 + 方式本质上都是使用 new 来创建,它们都是在堆中创建新的字符串对象,不会考虑字符串重不重复,这种就像是野生的,野生字符串的缺点就是如果存在大量值相同的字符串,对内存占用非常严重

前面我们讲解了 String 的六种创建方式,除了字面量方式创建的字符串是家养的以外,其它方法创建的字符串都是野生的。什么意思呢?

其实字符串也一样,分为家养的和野生的。

京城何日多灯火,让星也羞臊。时有弦月清冷,照我无聊
- 米人《夜辍香山》

2.1 家养与野生

二、字符串之家 - StringTable

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

hello,world

输出

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

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

// 生成匿名类所需字节码
byte[] bytes = dump();
// 根据字节码生成匿名类.class
Class<?> innerClass = UnsafeAccessor.UNSAFE
    .defineAnonymousClass(TestString4.class, bytes, null);
// 确保匿名类初始化
UnsafeAccessor.UNSAFE.ensureClassInitialized(innerClass);
// 找到匿名类中 String concat(String x, String y)
MethodHandle mh = MethodHandles.lookup().findStatic(
    innerClass,
    "concat", 
    MethodType.methodType(String.class, String.class, String.class)
);

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

public static String concat(String x, String y) {
    return new StringBuilder().append(x).append(y).toString();
}

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

public static byte[] dump() {

    ClassWriter cw = new ClassWriter(0);
    FieldVisitor fv;
    MethodVisitor mv;
    AnnotationVisitor av0;

    cw.visit(52, ACC_PUBLIC + ACC_SUPER, "cn/itcast/string/TestString4", null, "java/lang/Object", null);

    cw.visitSource("TestString4.java", null);

    {
        mv = cw.visitMethod(ACC_PUBLIC, "<init>", "()V", null, null);
        mv.visitCode();
        Label l0 = new Label();
        mv.visitLabel(l0);
        mv.visitLineNumber(3, l0);
        mv.visitVarInsn(ALOAD, 0);
        mv.visitMethodInsn(INVOKESPECIAL, "java/lang/Object", "<init>", "()V", false);
        mv.visitInsn(RETURN);
        Label l1 = new Label();
        mv.visitLabel(l1);
        mv.visitLocalVariable("this", "Lcn/itcast/string/TestString4;", null, l0, l1, 0);
        mv.visitMaxs(1, 1);
        mv.visitEnd();
    }
    {
        mv = cw.visitMethod(ACC_PUBLIC + ACC_STATIC, "concat", "(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;", null, null);
        mv.visitCode();
        Label l0 = new Label();
        mv.visitLabel(l0);
        mv.visitLineNumber(9, l0);
        mv.visitTypeInsn(NEW, "java/lang/StringBuilder");
        mv.visitInsn(DUP);
        mv.visitMethodInsn(INVOKESPECIAL, "java/lang/StringBuilder", "<init>", "()V", false);
        mv.visitVarInsn(ALOAD, 0);
        mv.visitMethodInsn(INVOKEVIRTUAL, "java/lang/StringBuilder", "append", "(Ljava/lang/String;)Ljava/lang/StringBuilder;", false);
        mv.visitVarInsn(ALOAD, 1);
        mv.visitMethodInsn(INVOKEVIRTUAL, "java/lang/StringBuilder", "append", "(Ljava/lang/String;)Ljava/lang/StringBuilder;", false);
        mv.visitMethodInsn(INVOKEVIRTUAL, "java/lang/StringBuilder", "toString", "()Ljava/lang/String;", false);
        mv.visitInsn(ARETURN);
        Label l1 = new Label();
        mv.visitLabel(l1);
        mv.visitLocalVariable("x", "Ljava/lang/String;", null, l0, l1, 0);
        mv.visitLocalVariable("y", "Ljava/lang/String;", null, l0, l1, 1);
        mv.visitMaxs(2, 2);
        mv.visitEnd();
    }
    cw.visitEnd();

    return cw.toByteArray();
}

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

public class UnsafeAccessor {
    static Unsafe UNSAFE;
    static {
        try {
            Field theUnsafe = Unsafe.class.getDeclaredField("theUnsafe");
            theUnsafe.setAccessible(true);
            UNSAFE = (Unsafe) theUnsafe.get(null);
        } catch (NoSuchFieldException | IllegalAccessException e) {
            e.printStackTrace();
        }
    }
}

Unsafe 对象访问类

2. 字节码生成方法

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

hello,world

输出

String x = "hello,";
String y = "world";
MethodHandle mh = MethodHandles.lookup().findStatic(
    TestString4.class,
    "concat", 
    MethodType.methodType(String.class, String.class, String.class)
);
String s = (String) mh.invoke(x,y);
System.out.println(s);

用 MethodHandle 反射调用

public static String concat(String x, String y) {
    return new StringBuilder().append(x).append(y).toString();
}

提供一个拼接方法

1. 方法手动生成

public static String concat(String x, String y) {
    return new StringBuilder().append(x).append(y).toString();
}

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

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

先说明一下我的目的

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

模仿 BC_SB 策略

注意

  • StringConcatHelper 对外是不可见的,因此无法直接测试,只能反射测试

  • prepend 可以直接修改字符串中的 bytes 属性值,他们都是 java.lang 包下的

String x = "b";

// 预先分配字符串需要的字节数组
byte[] buf = new byte[4];

// 创建新字符串,这时内部字节数组值为 [0,0,0,0]
String s = StringConcatHelper.newString(buf, 0);

// 执行【拼接】,字符串内部字节数组值为 [97,0,0,0]
StringConcatHelper.prepend(1, buf, "a");

// 执行【拼接】,字符串内部字节数组值为 [97,98,0,0]
StringConcatHelper.prepend(2, buf, x);

// 执行【拼接】,字符串内部字节数组值为 [97,98,99,100]
StringConcatHelper.prepend(4, buf, "cd");

// 到此【拼接完毕】

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

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

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

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

默认拼接策略

-XDstringConcat=inline

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

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

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

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

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

public static void main(String[] args) throws Throwable {
    String x = "b";
    // String s = "a" + x; 
    // 会生成如下等价的字节码

    // 编译器会提供 lookup,用来查找 MethodHandle
    MethodHandles.Lookup lookup = MethodHandles.lookup();
    CallSite callSite = StringConcatFactory.makeConcatWithConstants(
        lookup,
        // 方法名,不重要,编译器会自动生成
        "arbitrary",
        // 方法的签名,第一个 String 为返回值类型,之后是入参类型
        MethodType.methodType(String.class, String.class),
        // 具体处方格式,其中 \1 意思是变量的占位符,将来被 x 代替
        "a\1"
    );

    // callSite.getTarget() 返回的是 MethodHandle 对象,用来反射执行拼接方法
    String s = (String) callSite.getTarget().invoke(x);
}

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

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
         2: astore_1
         3: aload_1
         4: invokedynamic #3,  0              // InvokeDynamic #0:makeConcatWithConstants:(Ljava/lang/String;)Ljava/lang/String;
         9: astore_2
        10: return
        ...

主方法

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

常量池

public static void main(String[] args) {
    String x = "b";
    String s = "a" + x;
}

例如

拼接方式改变

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

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

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

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

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

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

内存结构改变

  • 不再用 char[] 存储字符,改为了 byte[],目的是更节约内存

  • 使用 invokedynamic 指令扩展了字符串的拼接的实现方式

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

1.8 JDK 9 之后的改变

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

public final class StringBuilder 
    extends AbstractStringBuilder
    implements java.io.Serializable, CharSequence {
    
    // 从 AbstractStringBuilder 继承的属性,方便阅读加在此处
    char[] value;
    
    @Override
    public String toString() {
        // Create a copy, don't share the array
        return new String(value, 0, count);
    }    
}

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

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

String x = "b";
String s = new StringBuilder().append("a").append(x).toString();

翻译成人能读懂的就是

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."<init>":()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

主方法

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

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

常量池

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

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

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

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
         ...

主方法

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

常量池

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

例2

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

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
         ...

主方法

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

常量池

String s = "a" + "b";

例1

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

String s = "a" + 1;

例4

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

例3

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

例2

String s = "a" + "b";

例1

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

1.7 拼接创建

具体原理我们下一个章节再讲

true
true

运行结果

public class TestString1 {
    public static void main(String[] args) {
        String s1 = "abc"; // 字符串对象 "abc"
        String s2 = "abc"; // 字符串对象 "abc"
        TestString2.main(new String[]{s1, s2});
    }
}

public class TestString2 {
    public static void main(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);
    }
}

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

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

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

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

对应的常量池

public class TestString2 {
    public static void main(String[] args) {
        String s1 = "a";
        String s2 = "abc";
    }
}

例如,另一个类中

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

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

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
         ...

对应的字节码为

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

常量池为

public class TestString1 {
    public static void main(String[] args) {
        String s1 = "abc";
        String s2 = "abc";
    }
}

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

不重复

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

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

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

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

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

例如有如下代码

如何验证呢?

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

懒加载

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

0: ldc           #2                  // String abc
2: astore_1
3: return

将来 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
         ...

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

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

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

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

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

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

非对象

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

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

1.6 字面量创建

内存结构如下

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

转换过程如图所示

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

1.4 int[] 数组创建

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

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

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

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

这时

例1,按 gbk 字符集转换

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

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

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

它的内部结构其实也是

  • 从网络(例如一个浏览器的 http 请求)传递过来的字节数据

  • 也可以是从 I/O(例如从一个文本文件)读取到的数据

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

例如

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

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

1.3 byte[] 数组创建

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

它的内部结构如下(1.8)