StringTable(二)

简介: StringTable(二)

StringTable(一):https://developer.aliyun.com/article/1535759

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_SIZED MethodHandle 生成 StringBuilder 代码 等价于 new StringBuilder(n) n为预估大小
MH_SB_SIZED_EXACT MethodHandle 生成 StringBuilder 代码 等价于 new StringBuilder(n) n为准确大小
MH_INLINE_SIZED_EXACT MethodHandle 内部使用字节数组直接构造出 String 默认策略
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 的内部存储方式、以及拼接方式又发生了较大的改变


StringTable(三):https://developer.aliyun.com/article/1535762

目录
相关文章
|
6月前
|
存储 Java
StringTable(三)
StringTable(三)
27 1
|
6月前
|
Java 程序员
StringTable(一)
StringTable(一)
42 1
|
7月前
为对象分配内存TLAB
为对象分配内存TLAB
|
存储 Java API
jvm之StringTable解读(二)
jvm之StringTable解读(二)
|
7月前
|
存储 缓存 算法
深入理解JVM - 对象分配内存
深入理解JVM - 对象分配内存
70 1
|
7月前
|
存储 缓存 算法
对象和数组并不是都是在堆上分配内存的
对象和数组并不是都是在堆上分配内存的
50 0
|
存储 缓存 Oracle
|
Java C++
jvm之StringTable解读(三)
jvm之StringTable解读(三)
|
存储 缓存 Java
JVM - 深入剖析字符串常量池
JVM - 深入剖析字符串常量池
133 0
|
存储 机器学习/深度学习 Java
StringTable(3)
StringTable
86 0