深入理解 Java String

本贴最后更新于 2298 天前,其中的信息可能已经天翻地覆

以下代码或分析来源于 jdk8

简介

String 类由 final 标记,实现了 java.io.Serializable,Comparable<String>,CharSequence(字符序列)三个接口。

String/StringBuilder/StringBuffer 本质上都是通过字符数组实现的。

String 是字符串__常量__,对 String 值的操作是产生新的 String 对象。

StringBuilder/StringBuffer 都是字符串__变量__,即可变的字符序列,都继承自 AbstractStringBuilder,实现 CharSequence 接口。对 StringBuffer/StringBuilder 类操作是对字符数组进行扩容。

StringBuffer 是线程安全的,StringBuilder 是非线程安全的。

源码浅析

String 类包含 char[] value 属性,StringBuffer/StringBuilder 的父类 AbstractStringBuilder 包含 char[] value 属性。同时注意此 value 在 String 中的声明为 private final char value[],对于字符串的处理都是在处理 value 属性。

String 类做如此多的限定是一种 Immutable 设计模式的典型应用,String 变量一旦初始化后就不能更改,禁止改变对象的状态,从而增加共享对象的坚固性、减少对象访问的错误,同时还避免了在多线程共享时进行同步的需要。

另外一点对与 String 类如此设计的猜测,设计到 java 对于 String 类型本身的设计,字符串都存储在常量池中用于共享从而提高性能的,但是如果 String 值是可变的,那么就可能引起冲突,所以需要设计成不可变的,以此来避免字符串共享所引起的数据冲突。

常用功能实现

hashcode 方法实现:

public int hashCode() {
    int h = hash;//hash默认是0
    if (h == 0 && value.length > 0) { //只算一次
        char val[] = value;

        for (int i = 0; i < value.length; i++) {
            h = 31 * h + val[i];
        }
        hash = h; 
    }
    return h;
}

indexOf 方法:

public int indexOf(int ch, int fromIndex) {
    final int max = value.length;
    if (fromIndex < 0) {
        fromIndex = 0;
    } else if (fromIndex >= max) {
        // Note: fromIndex might be near -1>>>1.
        return -1;
    }
    //Character.MIN_SUPPLEMENTARY_CODE_POINT = 65536 也就是两个字节所能存储的最大值
    if (ch < Character.MIN_SUPPLEMENTARY_CODE_POINT) {
        // handle most cases here (ch is a BMP code point or a
        // negative value (invalid code point))
        final char[] value = this.value;
        for (int i = fromIndex; i < max; i++) {
            if (value[i] == ch) {
                return i;
            }
        }
        return -1;
    } else {
        return indexOfSupplementary(ch, fromIndex);
    }
}

/**
  * Handles (rare) calls of indexOf with a supplementary character.
  */
//java中特意对超过两个字节的字符进行了处理
private int indexOfSupplementary(int ch, int fromIndex) {
    if (Character.isValidCodePoint(ch)) {
        final char[] value = this.value;
        final char hi = Character.highSurrogate(ch);
        final char lo = Character.lowSurrogate(ch);
        final int max = value.length - 1;
        for (int i = fromIndex; i < max; i++) {
            if (value[i] == hi && value[i + 1] == lo) {
                return i;
            }
        }
    }
    return -1;
}

java 特意对多余两个字节的字符例如一些 emoji 做了特殊的处理。

在 lastIndexOf 方法中,有这样一段对于多重循环的处理值得注意以下:

static int lastIndexOf(char[] source, int sourceOffset, int sourceCount,
            char[] target, int targetOffset, int targetCount,
            int fromIndex) {
    ...
    startSearchForLastChar:
    while (true) {
        while (i >= min && source[i] != strLastChar) {
            i--;
        }
        if (i < min) {
            return -1;
        }
        int j = i - 1;
        int start = j - (targetCount - 1);
        int k = strLastIndex - 1;

        while (j > start) {
            if (source[j--] != target[k--]) {
                i--;
                continue startSearchForLastChar;
            }
        }
        return start - sourceOffset + 1;
    }

使用了 continue label 的方式来处理多重循环下的跳出,这是 java 中存在但是很少被使用的方式,类似 c 语言中的 goto 语句,java 可以在循环体前设置跳转标志,然后配合 continue/break 关键字更好的控制多重循环。

重点讲解以下 split 方法:


public String[] split(String regex, int limit) {
    char ch = 0;
    if (((regex.value.length == 1 &&
          ".$|()[{^?*+\\".indexOf(ch = regex.charAt(0)) == -1) ||
          (regex.length() == 2 &&
          regex.charAt(0) == '\\' &&
          (((ch = regex.charAt(1))-'0')|('9'-ch)) < 0 &&
          ((ch-'a')|('z'-ch)) < 0 &&
          ((ch-'A')|('Z'-ch)) < 0)) &&
        (ch < Character.MIN_HIGH_SURROGATE ||
          ch > Character.MAX_LOW_SURROGATE))
    {
        int off = 0;
        int next = 0;
        boolean limited = limit > 0;
        ArrayList<String> list = new ArrayList<>();
        while ((next = indexOf(ch, off)) != -1) {
            if (!limited || list.size() < limit - 1) {
                list.add(substring(off, next));
                off = next + 1;
            } else {    // last one
                //assert (list.size() == limit - 1);
                list.add(substring(off, value.length));
                off = value.length;
                break;
            }
        }
        // If no match was found, return this
        if (off == 0)
            return new String[]{this};

        // Add remaining segment
        if (!limited || list.size() < limit)
            list.add(substring(off, value.length));

        // Construct result
        int resultSize = list.size();
        if (limit == 0) {
            while (resultSize > 0 && list.get(resultSize - 1).length() == 0) {
                resultSize--;
            }
        }
        String[] result = new String[resultSize];
        return list.subList(0, resultSize).toArray(result);
    }
    return Pattern.compile(regex).split(this, limit);
}

其中的第一个 if 有一段非常复杂的判断,且体现了 jdk 源码中极致的优化。考虑这样的情况,如果传入的仅仅只有一个字符,并且这个字符不是任何特殊的正则表达式,明显我们不需要经过正则表达式编译一次。同样,如果是两个字符,并且这两个字符是以 \ 开头,并且不是字母或者数字的时候,例如'|',为什么会有这种情况呢?
用实际代码试一试:

String s = "a|b|c";
String[] split = s.split("|");
for (String s1 : split) {
    System.out.println(s1);
}
---------输出---------
a
|
b
|
c

很奇怪吧?这是出于第一个判断 ".$|()[{^?*+\\".indexOf(ch = regex.charAt(0)) == -1),判断的是是否是特殊的正则表达式,而 | 符号正好位列其中,| 在正则中表示__或__,而 "|" 就等同于 ""。所以就相当于把字符串用空格分开。于是就有了这一个考虑,通过转移,让 '|' 字符生效,里面又包含了一个有意思的代码片段:(((ch = regex.charAt(1))-'0')|('9'-ch)) < 0 &&((ch-'a')|('z'-ch)) < 0 && ((ch-'A')|('Z'-ch)) < 0))。判断字符是否是数字或者字母,使用或运算减少代码量提高效率。之后的第三个判断是用于判断是否是两个字节的 unicode 字符。

还有一条很优雅的代码片段值得注意:

boolean limited = limit > 0;
ArrayList<String> list = new ArrayList<>();
while ((next = indexOf(ch, off)) != -1) {
    if (!limited || list.size() < limit - 1) {
        list.add(substring(off, next));
        off = next + 1;
    } else {    // last one
        //assert (list.size() == limit - 1);
        list.add(substring(off, value.length));
        off = value.length;
        break;
    }
}
...
// 将最后剩余的一段连接上
if (!limited || list.size() < limit)
    list.add(substring(off, value.length));
// 构造结果集合
int resultSize = list.size();
if (limit == 0) {
    while (resultSize > 0 && list.get(resultSize - 1).length() == 0) {
        resultSize--;
    }
}
String[] result = new String[resultSize];
return list.subList(0, resultSize).toArray(result);

将判断 limit 和分配剩余字符串两者巧妙的结合。

Immutable 设计模式

String 类有两个构造方法值得注意:

public String(char value[]) {
    this.value = Arrays.copyOf(value, value.length);
}
public String(char value[], int offset, int count) {
    ...
    if (count <= 0) {
        ...
        if (offset <= value.length) {
            this.value = "".value;
            return;
        }
    }
    ...
    this.value = Arrays.copyOfRange(value, offset, offset+count);
}

这两个构造方法都调用了 Arrays.copy 方法,经过查看源码,都是复制数组,也就是说传入的 char[]数组在外部的变化并不会影响 String 本身的值,体现不可变的原则,其他类似的传入数组的构造器都是类似的行为。也因此,String 所有能够返回值的方法也同样是采用类似的复制数组的方式,例如:

public void getChars(int srcBegin, int srcEnd, char dst[], int dstBegin) {
    ...
    System.arraycopy(value, srcBegin, dst, dstBegin, srcEnd - srcBegin);
}

在源码中到处充斥着这样的代码,3000 多行代码(包含注释),都在严谨的遵循着这个原则。

String 的本质

String 类中所有修改值的方法都是返回新的对象,例如 String.substring 方法:

//截取字符串
public String substring(int beginIndex, int endIndex) {
  ...
    int subLen = endIndex - beginIndex;
  ...
    return ((beginIndex == 0) && (endIndex == value.length)) ? this
    : new String(value, beginIndex, subLen);
}

处理 value 返回新的字符串对象,new String(value,beginIndex,subLen)构造函数会截取数组并复制给新 String 对象的 value。

但是我们是否能够通过反射来修改呢?做一个尝试:

String s1 = "abcd";
String s2 = "abcd";
String s3 = new String("abcd");
String s4 = new String(s1);
System.out.println("s1==s2:" + (s1 == s2));
System.out.println("s1==s3:" + (s1 == s3));
System.out.println("s3==s4:" + (s3 == s4));
Field field = String.class.getDeclaredField("value");
field.setAccessible(true);
char[] value = (char[]) field.get(s1);
value[1] = 'p';
System.out.println("s1 = " + s1);
System.out.println("s2 = " + s2);
System.out.println("s3 = " + s3);
System.out.println("s4 = " + s4);

System.out.println("s1==s2:" + (s1 == s2));
System.out.println("s1==s3:" + (s1 == s3));
System.out.println("s3==s4:" + (s3 == s4));

输出如下:

s1==s2:true
s1==s3:false
s3==s4:false
s1 = apcd
s2 = apcd
s3 = apcd
s4 = apcd
s1==s2:true
s1==s3:false
s3==s4:false

由此可以看出,反射能够修改 String 的值,但是另外出现了一个有趣的点,在修改了 s1 的值之后,s2/s3/s4 的值都经过了修改。一个一个的看,先看最简单的 s4。

通过 s1 来 new 一个新的 String 对象,翻看构造器源码:

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

很简单,是通过传递引用 value 来构造的新的 String 对象,也就是说 s1 和 s4 享有同一个 char 数组。所以无论我们修改 s1 还是 s4 的值,都是修改的这个 char 数组。所以 s4 随着 s1 变化,可以理解。

接下来看 s2/s3 为什么变化:

表面上看,s1 与 s2 的赋值虽然都是“相等”的字符串,但是无论声明还是使用看起来都确实是分开的。这里从 jdk 源码上我们无从得知具体原因。这牵扯出了我们对于 String 类型的最大疑问,字符串的本质到底是怎样的,jvm 是如何对待一特殊的类型的。探究出 s2 自然而然就能了解 s3 变化的始末。

其实从前面的源码查看中,我们已经能够看出来,字符串的本质就是__字符数组__。但是按照不同的方式来初始化出来的字符串却并不是被一视同仁的。

String 的定义方法归纳出来大致分为三种方式:

  • 双引号直接赋值:String str = "str";
  • 使用 new 关键字:String str = new String("str");
  • 连接字符串:String str = "s" + "tr";

在此,需要先了解一下 jvm 中的堆栈、常量池的概念。

常量池

常量池指的是在编译期就被确定,并且保存在已编译的.class 文件中的一些数据,包含代码中所定义的各种基本类型(如 int、long 等等)和对象型(如 String 及数组)的常量值(final)还包含一些以文本形式出现的符号引用。比如:

  • 类和接口的全限定名
  • 字段的名称和描述符
  • 方法的名称和描述符

一个运行时的数据区域,类的对象就是在这里分配空间的。这些对象通过 new、newarray、 anewarray 和 multianewarray 等指令建立,它们不需要程序代码来显式的释放。堆是由垃圾回收来负责的,堆的优势是可以动态地分配内存 大小,生存期也不必事先告诉编译器,因为它是在运行时动态分配内存的,Java 的垃圾收集器会自动收走这些不再使用的数据,也因此大小和声明周期并不确定。但缺点是,由于要在运行时动态 分配内存,存取速度较慢。堆中的对象不可以共享(注意,这里的共享不是指我们在 java 代码中的赋值给另外一个引用,而是指:Object a = new Object();Object b = new Object()a == b 的情况)

存取速度比堆要快,仅次于寄存器,栈数据可以共享(int a = 1;int b = 1a == b),存放基本类型的变量数据和对象的引用,但对象本身不存放在栈中,而是存放在堆(new 出来的对象)

ps:网上有说对象不一定存储在堆中,这句话是对的,但是举出来的反例是字符串对象存储在常量池中,这个反例是错误的,大胆举证一个最简单的例子,String a = new String("a");String b = new String("a"),如果按照这种说法,那么 a == b,因为常量池中的数据是共享的,然后明显不对,至于更深层次的原因下面讲到。正确的反例应当是《深入理解 java 虚拟机 》一书中所说到的 “随着 JIT 编译期的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化,所有的对象都分配到堆上也渐渐变得不那么“绝对”了”。通熟点就是 java 底层优化越来越变态导致的。

解析 String 的定义

在编译期就被确定的(以双引号定义的)字符串就被存储在常量池中,如果运行期(new)才能确定的就存储在堆中。

这里了解以下 String.intern 方法。String 的 intern()方法会查找在常量池中是否存在一份 equal 相等的字符串,如果有则返回该字符串的引用,如果没有则添加自己的字符串进入常量池。

运行时常量池相对于 CLass 文件常量池的另外一个重要特征是具备动态性,Java 语言并不要求常量一定只有编译期才能产生,也就是并非预置入 CLass 文件中常量池的内容才能进入方法区运行时常量池,运行期间也可能将新的常量放入池中,这种特性被开发人员利用比较多的就是 String 类的 intern()方法。

  • 第一种定义方法执行过程(String str = "str"):在程序编译期间,编译程序先去字符串常量池检查,是否由"str"存在,如果不存在,则在常量池中开辟一个内存空间存放"str",如果存在,则不开辟。接着,会在堆中创建一个 String 对象,注意这里和网上有些说法不一样,下面我们通过代码证明这里的正确性,之后在栈中开辟一块空间,命名为 str,存放对象的__地址__。
String s = new String("str");
        String s2 = s.intern();
        System.out.println(s == s2);

-------输出---------------
false

明显看出 s2 并不是指向的 s,而是指向的"str"字符串所指向的对象。

  • 第二种定义方法执行过程(String str = new String("str")):在程序编译期间,编译程序先在字符串常量池检查,是否存在"str",如果不存在,则在常量池中开辟一个内存空间存放"str"。如果不存在,则开辟一个内存空间,否则不开辟。然后在内存堆中开辟一个空间,存放 new 出来的 String 实例,之后在栈中开辟一个空间,命名为 str,存放堆中 String 实例的内存 地址 ,这个过程就是将引用 str 指向 new 出来的 Sring 实例。

  • 第三种定义方法执行过程:对于第三个方法的过程会稍显复杂,在这个举例中 jvm 会进行小技巧优化。我们将定义的形式变化一下,绕过这个优化,然后根据字节码来判断,当我们进行字符串拼接初始化字符串时会发生什么。

原代码如下:

String s = "1"+"2"+"3";
System.out.println(s);
String s2 = "2";
for (int i = 0; i < 10; i++){
  s2 += s2;
}
System.out.println(s2);

字节码输出如下:

Code:
0: ldc           #2                  // String 123
2: astore_1
3: getstatic     #3                  // Field java/lang/System.out:Ljava/io/PrintStream;
6: aload_1
7: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
10: ldc           #5                  // String 2
12: astore_2
13: iconst_0
14: istore_3
15: iload_3
16: bipush        10
18: if_icmpge     46
21: new           #6                  // class java/lang/StringBuilder
24: dup
25: invokespecial #7                  // Method java/lang/StringBuilder."<init>":()V
28: aload_2
29: invokevirtual #8                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
32: aload_2
33: invokevirtual #8                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
36: invokevirtual #9                  // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
39: astore_2
40: iinc          3, 1
43: goto          15
46: getstatic     #3                  // Field java/lang/System.out:Ljava/io/PrintStream;
49: aload_2
50: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
53: return

简单描述一下关键:

  • 0: ldc #2 // String 123 代表引用常量池里的值,这里代码经过编译器优化之后,"1"+"2"+"3" 被优化成了 "123"

  • astore_2 代表将 ldc 的值“2”存到__局部变量表__(用于存放方法参数和方法内部定义的局部变量)中的第 2 个槽中

  • 13-43 行是一个循环。也就是我们的 0-10 的循环,可以看到在这个循环中在不停的 new StringBuilder(21 行)。这种没有经过编译器优化的拼接是采用 StringBuidler 实现的。

  • 最后会调用 StringBuilder 的 toString 方法,然后 jvm 会拿到字符序列,并按照 String 对象的处理方式进行处理。

简单对比我们主动采用 StringBuider 类拼接字符串。
源码:

StringBuilder s2 = new StringBuilder("2");
for (int i = 0; i < 10; i++){
  s2.append("2");
}
System.out.println(s2);

字节码:

0: new           #2                  // class java/lang/StringBuilder
3: dup
4: ldc           #3                  // String 2
6: invokespecial #4                  // Method java/lang/StringBuilder."<init>":(Ljava/lang/String;)V
9: astore_1
10: iconst_0
11: istore_2
12: iload_2
13: bipush        10
15: if_icmpge     31
18: aload_1
19: ldc           #3                  // String 2
21: invokevirtual #5                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
24: pop
25: iinc          2, 1
28: goto          12

明显看到在 0 处 new 了一次 StringBuilder 之后,就一直在重用此对象。显然提高了很多的效率。

回到定义方式继续探讨,三种定义方式都抛出了一个非常重要的问题,new 出来的 String 对象实例和在常量池中的字符串是什么关系?有什么区别?为什么直接定义的字符串同样可以调用 String 对象的各种方法呢?

以”String s1 = new String("somestring")“为例,在字符串常量池中存储"somestring"字符序列,在堆中开辟 String 对象空间,在栈中存储这个地址命名为 s。首先就需要弄清楚,怎么通过对象找到常量池中的字符串?

在解析阶段,虚拟机发现字符串常量 somestring,它会在一个内部字符串常量列表【各种虚拟机实现方式可能不一样,例如 hotspot 使用 hashtable 实现】中查找,如果没有找到,那么会在堆中创建一个包含字符序列[somestring]的 string 对象_s_,然后把这个字符序列和对应的 String 对象做为名值对([somestring],s)保存到内部字符串常量列表中。

弄懂了这些原理,我们就能够清楚的知道前面 s1/s2/s3/s4 发生变化的原因了,很简单,因为都是操作的同样的一个字符序列,这也是 java 废了很大力气去优化的一点,让所有的想同字符串字面值只存储一次,以实现性能、空间利用的最大提升。

完成以上问题之后,就可以完美解决面试经常遇到的以下问题:

  • 得出以下输出
String s1 = "str";
String s2 = "str";
String s3 = new String("str");
String s4 = new String(s1);

System.out.println(s1 == s2);
System.out.println(s1 == s3);
System.out.println(s3 == s4);
System.out.println(s1 == s4);
  • 每一行代码((每行代码分开运行,不考虑前置因素)),各自产生了多少个对象,字符串常量池里面有哪几个值。
String s2 = "str";
String s3 = new String("str");
String s5 = new String("str")+"str"; 

StringBuilder/StringBuffer 源码解析

弄清楚了 String,那么 StringBuilder/StringBuffer 其实已经相当清楚了,本质上讲 StringBuilder/StringBuffer 是对字符数组进行扩容的对象,都继承字 AbstractStringBuilder。查看 AbstractStringBuilder 的源码,发现与 String 一样,包含 char[] value 和 int count。但是与 String 不同的是,它们没有 final 修饰符。因此得出结论:String、StringBuffer 和 StringBuilder 在本质上都是字符数组,不同的是,在进行连接操作时,String 每次返回一个新的 String 实例,而 StringBuffer 和 StringBuilder 的 append 方法直接返回 this,所以这就是为什么在进行大量字符串连接运算时,不推荐使用 String,而推荐 StringBuffer 和 StringBuilder。

StringBuilder 和 StringBuffer 的源码

查看两个类的 append 和 toString 方法:

//StringBuilder
@Override
public StringBuilder append(String str) {
    super.append(str);
    return this;
}
@Override
public String toString() {
    // Create a copy, don't share the array
    return new String(value, 0, count);
}
//StringBuffer
@Override
public synchronized StringBuffer append(String str) {
    toStringCache = null;
    super.append(str);
    return this;
}
@Override
public synchronized String toString() {
    if (toStringCache == null) {
        toStringCache = Arrays.copyOfRange(value, 0, count);
    }
    return new String(toStringCache, true);
}

可以看出第一个区别,StringBuffer 采用 synchronized 修饰,是线程安全的,而 StringBuilder 不是。

另外一点,在 StringBuffer 中有这样一个属性 private transient char[] toStringCache;。它缓存了字符数组,StringBuffer 下所有的修改方法都会清空掉这个缓存,只有在执行 toString 时才会赋值,而在 StringBuilder 中完全没有这种机制。这是为什么呢?首先要指明的是,其实这个缓存意义不算很大,因为两次 toString 而没有改变的情况是真的少见。但是从设计上来说,这种缓存对于真正遇到这种情况的性能是有提升的,而 StringBuffer 因为线程安全,所以可以设置这样一个缓存,但是 StringBuilder 类并不是线程安全的,如果这样设计会导致可能产生不一致的情况,对照我们平时写的代码来看,缓存应该在同步安全的条件下才被设置以用来提升性能,否则这将导致产生 toString 结果与预期不一致。

所以在使用选择上来说,如果在多线程环境可以使用 StringBuffer 进行字符串连接操作,单线程环境使用 StringBuilder,它的效率更高。

答案

true,false,false,false

对象:1,2,3。常量池:"str","str",["str","strstr"]

参考链接

  • Java

    Java 是一种可以撰写跨平台应用软件的面向对象的程序设计语言,是由 Sun Microsystems 公司于 1995 年 5 月推出的。Java 技术具有卓越的通用性、高效性、平台移植性和安全性。

    3187 引用 • 8213 回帖
  • 字符串
    30 引用 • 57 回帖

相关帖子

欢迎来到这里!

我们正在构建一个小众社区,大家在这里相互信任,以平等 • 自由 • 奔放的价值观进行分享交流。最终,希望大家能够找到与自己志同道合的伙伴,共同成长。

注册 关于
请输入回帖内容 ...
请输入回帖内容 ...