深入String源码分析(包含字符串常量池底层实现)

本文主要分析jdk8中的String,关于jdk9之后的变化,会在文中说明。

String类为什么是不可被继承的?

可以避免歧义。假设String可以被继承,那么我们可以创建其子类MyString,在下面代码中分别创建这两个类的对象

String s = "monkey1024";
MyString ms = "monkey1024";
System.out.println(s.equals(ms));//true or false

上面代码打印的结果是返回true还是false?这里就有歧义了。

返回true:因为两个对象的都是字符串,且内容一致,这样应该返回true,但是注意,String和MyString不是同一种类型。

返回false:两个字符串的内容一致,返回false似乎有些不妥。

因此,String类被设计为是不可被继承的原因是避免类似上述的歧义。

String的不可变性

String创建好之后在堆中的数组引用是不可以被改变的。先看下面代码

public static void main(String[] args) {
    String s = "monkey";
    change(s);
    System.out.println(s);//monkey
}

public static void change(String s) {
    s = "good";
}

上面代码在运行的之后,会打印出monkey,String是引用数据类型,根据以往对引用数据的理解,在main方法中的s和change方法中的s指向的是同一个String对象,按理说在change中将s修改“good”,对应的main中也应该打印出“good”,但是现在的打印结果却仍然是monkey。这个就是String的不可变性了。

通过String的构造方法得知其底层是一个数组(jdk8之前是char数组,jdk9之后是byte数组,后面来分析9的变化),源码如下:

private final char value[];

可以看到该数组是private final修饰的,final关键字导致该数组所指向的地址不可变,这就是String不可变的原因了。如下图所示(这里先不讨论字符串常量池),虚线1是可以改变的,实线2不能改变,因此不能将2的引用修改为指向其他对象。

如果我们要修改s值的时候,只能是将引用1修改为指向新的数组。

String s = "monkey";
s = "banana";

上面对面对应的图如下,jvm会在堆中开辟一块新的内存地址,将字符串对象banana放入,之后将栈中的s指向修改为0x456。即没有直接在monkey的基础上进行修改,而是新开辟了一块内存空间,将新的数据放入。

String为什么被设计为不可变?

线程安全,String在实际开发中会频繁的用到,String不可变有效的避免了线程的安全问题。

双引号中的字符串对象会放入到一个叫做字符串常量池(StringTable)的地方,这样能够达到字符串对象的复用性,倘若String是可以被修改的,这样一处修改多地变化,很可能会引发程序的逻辑问题。

使用反射打破String的不可变

虽然不能修改String中数组引用指向的内容,但是我们可以通过反射的方式来直接修改数组中的内容,这样就可以打破String 的不可变。

下面代码是利用反射获取到了String中的value数组对象,然后直接修改该数组中的内容。

public class BreakImmutable {
    public static void main(String[] args) throws Exception {
        String name = "aaa";
        change(name);
        System.out.println(name);//打破String不可变之后,这里会打印bbb
    }

    public static void change(String name) throws Exception {
        //利用反射获取String中的value数组
        Class<?> clazz = Class.forName("java.lang.String");
        Field field = clazz.getDeclaredField("value");
        field.setAccessible(true);
        char[] value = (char[]) field.get(name);

        //直接修改value数组中的值
        for (int i = 0; i < value.length ; i++) {
            value[i] = 'b';
        }
    }
}

字符串常量池

在openJDK中,有一个全局表叫做StringTable(存储于native memory中),相当于是一个HashTable。这个StringTable在有些地方被叫做StringPool,即字符串常量池,它管理了interned String,我们直接通过双引号创建出的字符串对象会存储在interned String中,HashTable的key存储根据字符串和长度计算出的hash值,value存储字符串对象的引用,注意这里是存储的是字符串的引用,对象是在interned String中。interned String在jdk7版本之前处于永久代中,之后被移动到了java堆里面。

下面是openjdk8-b120中关于字符串常量池中的部分源码,倒数第三行是将hashValue和String()传入:

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<oop, mtSymbol>* entry = new_entry(hashValue, string());
  add_entry(index, entry);
  return string();
}

下面代码利用==比较了两个引用数据类型s1和s2,此时的结果是true,说明两者指向的是同一个内容地址中的字符串对象。

String s1 = "monkey1024";
String s2 = "monkey1024";

System.out.println(s1 == s2);

如下图所示(这里StringTable只画出了引用),在执行s1赋值操作的时候,会向堆中放入字符串对象,然后再字符串常量池中存储该对象的引用,在执行s2赋值的时候,发现字符串常量池中已经有指向该字符串的引用,此时会直接将引用赋值给s2,因此s1和s2都指向同一个内存地址。

再来看下面的代码,通过之前分析得知直接使用双引号的字符串会利用字符串常量池,通过new创建的字符串会直接放入到堆中,不会利用常量池。

String s1 = "monkey1024";
String s2 = new String("monkey1024");

System.out.println(s1 == s2);

图示,在使用new创建字符串对象的时候,会在堆中开辟一块空间来存储,字符串常量池不会存储这个对象的引用。

字符串连接运算符的本质

下面代码运算结果是false,s4中的字符串会利用字符串常量池,而s3是使用两个变量进行的拼接,因此不会利用字符串常量池。

String s1 = "Hello";
String s2 = "World";
String s3 = s1 + s2;//不会利用字符串常量池
String s4 = "Hello" + "World";//会利用字符串常量池

System.out.println(s3 == s4);

为了方便查看字节码指令,我们将上面代码分块,首先观察下面代码的字节码指令:

String s4 = "Hello" + "World";//会利用字符串常量池

对应的字节码指令如下,可以看到是加载了HelloWorld:

   L0
    LINENUMBER 5 L0
    LDC "HelloWorld"  //加载常量
    ASTORE 1
   L1
    LINENUMBER 6 L1
    RETURN
   L2
    LOCALVARIABLE args [Ljava/lang/String; L0 L2 0
    LOCALVARIABLE s4 Ljava/lang/String; L1 L2 1
    MAXSTACK = 1
    MAXLOCALS = 2

接下来查看下面代码对应的字节码指令:

String s1 = "Hello";
String s2 = "World";
String s3 = s1 + s2;//不会利用字符串常量池

对应的字节码指令如下,可以通过指令看到这种通过变量名进行字符串拼接的时候,会创建一个StringBuilder对象,通过调用该对象的append方法完成的拼接。

 L0
    LINENUMBER 5 L0
    LDC "Hello"
    ASTORE 1
   L1
    LINENUMBER 6 L1
    LDC "World"
    ASTORE 2
   L2
    LINENUMBER 7 L2
    NEW java/lang/StringBuilder  //创建了StringBuilder对象
    DUP
    INVOKESPECIAL java/lang/StringBuilder.<init> ()V
    ALOAD 1
    INVOKEVIRTUAL java/lang/StringBuilder.append (Ljava/lang/String;)Ljava/lang/StringBuilder;
    ALOAD 2
    INVOKEVIRTUAL java/lang/StringBuilder.append (Ljava/lang/String;)Ljava/lang/StringBuilder;
    INVOKEVIRTUAL java/lang/StringBuilder.toString ()Ljava/lang/String;
    ASTORE 3
   L3
    LINENUMBER 8 L3
    RETURN
   L4
    LOCALVARIABLE args [Ljava/lang/String; L0 L4 0
    LOCALVARIABLE s1 Ljava/lang/String; L1 L4 1
    LOCALVARIABLE s2 Ljava/lang/String; L2 L4 2
    LOCALVARIABLE s3 Ljava/lang/String; L3 L4 3
    MAXSTACK = 2
    MAXLOCALS = 4

不要频繁的对字符串进行拼接

我们已经知道字符串是不可变的,对一个字符串进行修改的时候,会开辟一块新的空间来存储,倘若对一个字符串频繁的修改会在堆中开辟很多内存空间,造成内存的浪费并且会增加GC的工作量。倘若要频繁的对字符串进行拼接修改操作的话最好使用StringBuilder或者StringBuffer,其内部本质也是数组,只不过该数组并未使用final修饰。需要注意的是,在使用StringBuilder或者StringBuffer的无参构造创建对象的时候底层会创建一个长度是16的数组,在向其内部添加数据时,会自动的进行数组扩容,我们可以通过有参构造传入一个整数来减少数组扩容的操作。

new StringBuilder();//会创建一个长度是16的数组

对数据的长度进行预估,利用有参构造传入预估长度可以减少或避免数组的扩容

new StringBuuilder(64);//会创建长度是64的数组

jdk9的变化

在jdk9中将String,StringBuffer,StringBuilder中的char[]改成了byte[],对于存储英文和数字等内容的字符串来说,使用byte完全足够了,因此这个变化使内存空间节省了1倍并且可以减少GC的次数,当然,对于存储中文的字符来说占用的内存空间跟之前一样。通过源码(下面以jdk11的源码进行分析)可以看到在String中有两种字符集:LATIN1和UTF16。在存储英文等数据的时候,会只用LATIN1,存储中文的时候使用UTF16,这是通过String中的编码标志位coder来确定编码的。

/**
     * The identifier of the encoding used to encode the bytes in
     * {@code value}. The supported values in this implementation are
     *
     * LATIN1
     * UTF16
     *
     * @implNote This field is trusted by the VM, and is a subject to
     * constant folding if String instance is constant. Overwriting this
     * field after construction will cause problems.
     */
    private final byte coder;

通过debug查看存储英文和中文字符串对应的byte数组,可以看到对于中文来说1个字符,占用了2个长度。

由于1中文占2个长度,所以在String的length()方法中有进行右移的操作:

public int length() {
    return value.length >> coder();
}

如果coder()的结果是1,则表示是UTF16,此时需要右移1位,如果coder()结果是0,则表示是LATIN1,此时右移0位,这样就保证了可以正确返回字符串的长度。