【JavaSE】String的那些坑

169 阅读16分钟

1. 参考博客

博客地址:深入理解Java String类

2. 内容补充

2.1 基础特性

public final class String implements java.io.Serializable, Comparable<String>, CharSequence {
    private final char value[];

    /** Cache the hash code for the string */
    private int hash; // Default to 0

    /** use serialVersionUID from JDK 1.0.2 for interoperability */
    private static final long serialVersionUID = -6849794470754667710L;
    
    ...
}

由String类的部分源码,可以初探String的一些基础特性:

  • String类被final关键字修饰,表示String类不能被继承
  • String类实现Serializable,Comparable,CharSequence接口,表示String类支持这些接口定义的规范。
    • String类实现Serializable接口,表示String实例是支持序列化的,可以在网络中传输。
    • String类实现Comparable接口,表示String类定义了String实例的比较规则。
    • String类实现CharSequence接口,表示String实例中的字符序列是可读的。
  • value作为String类的成员变量,表示String实例中存储的字符串是由一个char[]进行底层的实现
  • value被private和final关键字修饰,表示String实例中存储的字符串是一个不可变的字符序列,也就是说String实例中存储的字符内容一旦初始化后不能被修改。

2.2 字面量定义

在Java中,只有基本数据类型,null类型和String类型支持字面量定义。

使用字面量定义的方式给一个String类型的变量赋值,该String实例存储在JVM堆空间中的字符串常量池中

定义示例:

String str = "abc";

2.3 new+构造器定义

2.3.1 new String()

定义示例:

String str = new String();

构造器源码:

public String() {
    this.value = "".value;
}

2.3.2 new String(String)

定义示例:

// 字符串常量作为参数
String str1 = new String("abc");
// String类型的引用作为参数
String str2 = "Hello world!";
String str3 = new String(str2);

构造器源码:

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

2.3.3 new String(char[])

定义示例:

char[] chars = {'a', 'b', 'c'};
String str = new String(chars);

构造器源码:

public String(char value[]) {
    // 建立一个value.length长度的新数组,将value中的内容拷贝进新数组后将新数组返回
    this.value = Arrays.copyOf(value, value.length);
}

// Arrays.copyOf源码
public static char[] copyOf(char[] original, int newLength) {
    // 建立一个newLength长度的新数组copy
    char[] copy = new char[newLength];
    // 将original数组中的内容全部拷贝到copy中
    System.arraycopy(original, 0, copy, 0, Math.min(original.length, newLength));
    return copy;
}

// System.arraycopy源码
public static native void arraycopy(Object src, int srcPos, Object dest, int destPos, int length);

2.3.4 总结

由上述三种构造器的源码,我们可以发现使用new+String构造器定义字符串实例时,都会有这样一行代码:this.value = ???;

这一行代码的意思就是:使用new+String构造器定义字符串实例时,该字符串实例的value是通过其他同value的字符串实例赋值得来的。那么其他同value的字符串实例是怎么来的呢?

在编译的时候,编译器就知道了你使用new+String构造器定义字符串实例的value是什么,比如String()就是固定的"",String(String)就是作为参数传入的字符串实例的value。在运行的时候,JVM会去字符串常量池中查找是否有和待构造器创建的字符串实例相同value的字符串实例。

  • 如果有,就会将字符串常量池中的实例的value赋值给构造器,从而在堆空间中定义字符串实例。
  • 如果没有,JVM会在字符串常量池中创建与待构造器创建的字符串实例相同value的字符串实例,然后通过字符串常量池中的实例的value赋值给构造器从而在堆空间中定义字符串实例。

例题:

以下语句创建了几个对象?

String str = new String("abc");

将该条语句拆分成四个部分:

  1. String str :在栈空间定义了一个名为str的变量存储字符串实例的地址,并没有创建对象。
  2. = :字符串实例的地址赋值给str变量的操作,没有创建对象。
  3. "abc" :JVM在执行new String(String)之前会先去字符串常量池查找有没有value="abc"的字符串实例,如果有则不创建字符串实例对象,如果没有则创建。
  4. new String(String) :将value="abc"的字符串实例作为参数传入构造器,参数的value给构造器中this.value赋值,从而在堆空间中创建value="abc"的字符串实例对象。

综上所述,一般情况下,需要创建两个对象。一个在字符串常量池中,一个在堆空间中。

JVM内存分配:

image.png

2.4 两种定义方式的异同

  • 使用字面量定义的字符串实例存储在字符串常量池中。
  • 使用字面量定义的字符串实例如果value相同,可以共用一个实例。
  • 使用new+构造器定义的字符串实例存储在堆空间中。
  • 使用new+构造器定义的字符串实例即使value相同,也必须独立创建。
  • 使用new+构造器定义的字符串实例需要字符串常量区中的字符串实例辅助创建。
  • 无论是使用字面量定义还是new+构造器定义的字符串实例都遵循不可变性。
String str1 = "abc";
String str2 = "abc";
// true,常量池中共用一个实例
System.out.println(str1 == str2);

String str3 = new String("abc");
String str4 = new String("abc");
// false,堆空间中各自创建实例
System.out.println(str3 == str4);

2.5 不可变性

String类型的变量重新赋值时,需要重新指定或新建内存区域中的其他String实例进行赋值。而不能在保持内存区域不变的情况下,修改原有的String实例中的value属性达到重新赋值的目的。

例题:

String str = "abc";
str = "abcd";

"abc"字符串实例的value被初始化为['a','b','c'],value.length = 3。已知该实例的value成员变量永远不能被改变,所以str = "abcd"的语句在编译后,JVM会保留"abc"字符串实例,并在字符串常量池中寻找是否有值为"abcd"的字符串实例。如果有直接返回该实例的地址。如果没有,就在字符串常量池中新创建一个值为"abcd"的字符串实例,并把该实例的地址返回。

2.6 "+" 字符串拼接

  • 字符串常量与常量使用"+"的拼接结果存储在字符串常量池中,如果常量池中有和拼接结果的value相同的字符串实例,直接返回该实例的地址。
  • 字符串常量与变量使用"+"的拼接结果存储在堆空间中。
  • 字符串变量与变量使用"+"的拼接结果存储在堆空间中。

例题:

String str1 = "abc";
String str2 = "def";
String str3 = "abcdef";
String str4 = "abc" + "def";
String str5 = str1 + "def";
String str6 = "abc" + str2;
String str7 = str1 + str2;
String str8 = str7.intern();

// true
System.out.println(str3 == str4);
// 以下三个全是false
System.out.println(str3 == str5);
System.out.println(str3 == str6);
System.out.println(str3 == str7);
// true
System.out.println(str3 == str8);

JVM内存分配:

image.png

2.7 StringBuilder和StringBuffer

2.7.1 概述

StringBuffer与StringBuilder均代表可变的字符序列。

与String实例不同的是,StringBuffer实例和StringBuilder实例能够被多次的修改,并且不产生新的未使用对象

StringBuffer与StringBuilder中的方法和功能完全是等价的,只是StringBuffer中的方法大都采用了synchronized关键字进行修饰,因此是线程安全的,而StringBuilder没有这个修饰,可以被认为是线程不安全的。从而导致StringBuilder相较于StringBuffer有速度优势,所以多数情况下建议使用StringBuilder类。然而在应用程序要求线程安全的情况下,则必须使用StringBuffer类。

在具体场景中,如果是多线程环境下涉及到共享变量的插入和删除操作,StringBuffer则是首选。如果是非多线程操作并且有大量的字符串拼接,插入,删除操作则StringBuilder是首选。

2.7.2 继承结构

20210410180244.png

2.7.3 源码分析

本文只分析StringBuilder源码,StringBuffer类比即可。

  1. 已知StringBuilder继承AbstractStringBuilder,由AbstractStringBuilder源码可知,StringBuilder使用了一个可变的char[]来存储字符序列,使用count来记录字符序列中字符的个数。

    abstract class AbstractStringBuilder implements Appendable, CharSequence {
        /**
         * The value is used for character storage.
         */
        char[] value;
        
        
        /**
         * The count is the number of characters used.
         */
        int count;
        
        ...
    }
    
  2. 由StringBuilder构造器源码可知,StringBuilder默认初始化长度为16。如果传入一个字符串,那么初始化长度为字符串的长度+16。

    public StringBuilder() {  
        super(16);            
    }             
    
    public StringBuilder(String str) {  
        super(str.length() + 16);       
        append(str);                    
    }                                   
    
  3. 由StringBuilder的append源码可知,StringBuilder拼接字符串底层也是char[]的创建与拷贝。

    @Override                                 
    public StringBuilder append(String str) { 
        super.append(str);                    
        return this;                          
    }                                         
    
    public AbstractStringBuilder append(String str) {
        if (str == null)
            // 在value的最后加上null
            return appendNull();
        // 新增字符序列中的字符数
        int len = str.length();
        // 判断新增的str的长度(len)和原value的长度(count)和是否超出value的容量(capacity),
        // 如果超出就创建一个新value,扩容成原来的(capacity*2+2)长度,并将原value内容拷贝到新value中。
        ensureCapacityInternal(count + len);
        // 将str从0到len的所有内容拷贝到value的count的后面
        str.getChars(0, len, value, count);
        // 刷新count
        count += len;
        return this;
    }
    
    private void ensureCapacityInternal(int minimumCapacity) {
        if (minimumCapacity - value.length > 0) {
            // int newCapacity = (value.length << 1) + 2;
            value = Arrays.copyOf(value, newCapacity(minimumCapacity));
        }
    }
    
    public static char[] copyOf(char[] original, int newLength) {
        char[] copy = new char[newLength];
        // void arraycopy(Object src, int srcPos, Object dest, int destPos, int length);
        System.arraycopy(original, 0, copy, 0, Math.min(original.length, newLength));
        return copy;
    }
    
    public void getChars(int srcBegin, int srcEnd, char dst[], int dstBegin) {
        if (srcBegin < 0) {
            throw new StringIndexOutOfBoundsException(srcBegin);
        }
        if (srcEnd > value.length) {
            throw new StringIndexOutOfBoundsException(srcEnd);
        }
        if (srcBegin > srcEnd) {
            throw new StringIndexOutOfBoundsException(srcEnd - srcBegin);
        }
        // void arraycopy(Object src, int srcPos, Object dest, int destPos, int length);
        System.arraycopy(value, srcBegin, dst, dstBegin, srcEnd - srcBegin);
    }
    

3. 面试题

3.1 第一题

:执行如下代码,最后会输出什么?

public class Test {

    private String str = "abc";
    private char[] chars = {'t', 'e', 's', 't'};

    public void change(String str, char[] chars) {
        str = "Hello world!";
        chars[0] = 'b';
    }

    public static void main(String[] args) {
        Test test = new Test();
        test.change(test.str, test.chars);
        System.out.println(test.str);
        System.out.println(test.chars);
    }
}

:"abc"和"best"。

考点:方法的参数传递,字符串实例的不可变性。

分析

已知,堆中有一个引用str,该引用是Test类的成员变量str。栈中有一个引用str,该引用是change方法的形参str。两个引用同时指向堆空间中的"abc"实例。在change方法执行时,操作栈中的str给"abc"实例重新赋值。由于字符串实例的不可变性,"abc"实例不能再被修改,因此只能在堆空间中重新创建一个"Hello world!"实例让栈中的str重新指向。在change方法结束后,栈中的str就被销毁,这时main方法输出的str是堆中的str,由于堆中的"abc"实例没有改变,所以输出的还是"abc"。

至于chars为什么输出"best",是因为char[]没有不可变性。在change方法执行过程中,堆中的char[]实例的内容就被栈中的chars修改了。

3.2 第二题

:执行如下代码,JVM中会生成几个对象?

String str1 = "abc";
String str2 = new String("def");
String str3 = str1 + str2;

:5个对象。

分析

反编译字节码后得到:

String str1 = "abc";
String str2 = new String("def");
(new StringBuilder()).append(str1).append(str2).toString();

StringBuilder类中toString源码:

public String toString() {
    // 返回合并后的字符串实例
    return new String(value, 0, count);
}

第一条语句:在字符串常量池中创建了一个"abc"字符串实例。

第二条语句:在字符串常量池中创建了一个"def"字符串实例,在堆中创建了一个"def"字符串实例。

第三条语句:在堆中创建了一个StringBuilder实例和一个"abcdef"字符串实例。

3.3 第三题

:执行如下代码,JVM中会生成几个对象?

String str1 = "abc";
String str2 = new String("def");
String str3 = str1 + str2;
System.out.println(str3);

:4个对象。

分析

反编译字节码后得到:

String str1 = "abc";
String str2 = new String("def");
String str3 = str1 + str2;
System.out.println(str3);

第一条语句:在字符串常量池中创建了一个"abc"字符串实例。

第二条语句:在字符串常量池中创建了一个"def"字符串实例,在堆中创建了一个"def"字符串实例。

第三条语句:在堆中创建了一个"abcdef"字符串实例。

扩展

参考第二题,为什么str3只定义不使用的情况下会多出一个StringBuilder对象呢?

在字符串实例定义后,如果该字符串被使用,那么编译器会给该字符串实例相关的操作指令进行调整和优化。

3.4 第四题

:以下三种空字符串创建的方式有何不同?

String str1 = "";
String str2 = new String();
String str3 = new String("");

第一个语句:在字符串常量池中创建一个""字符串实例。

第二个语句:在字符串常量池中创建一个""字符串实例,在堆空间中创建了一个""字符串实例。

第三个语句:在字符串常量池中创建一个""字符串实例,在堆空间中创建了一个""字符串实例,并把字符串常量池中""字符串实例的hash赋值给了堆空间的""字符串实例。

扩展

字符串实例hash值存在和不存在有什么区别?

当两个字符串实例存入同一个HashMap的时候,HashMap操作hash已被计算的字符串实例比操作hash未被计算的字符串实例要快。因为如果HashMap操作的字符串实例的hash没有被计算,那么HashMap会先给该字符串实例计算出一个hash并赋值给该字符串实例。而HashMap在操作hash已被计算的字符串实例时可以直接复用该字符串实例的hash。

3.5 第五题

:以下这两种创建字符串的方式有什么区别?

// 第一种方式
String str1 = new String("Hello world!");
// 第二种方式
String str2 = "Hello world!";
String str3 = new String(str2);

在编译过程中,第一种方式编译器需要遍历查找字符串常量池中所有字符串实例,查找是否有 value="Hello world!" 的字符串实例。如果有匹配的,就使用字符串常量池中的字符串实例,如果没有就创建一个放入字符串常量池。第二种方式在字符串构造函数中传入引用,直接告诉编译器 value="Hello world!" 的字符串实例在字符串常量池中的地址,避免了遍历查找的操作。

所以第二种方式在编译时要快于第一种方式。

在运行过程中,第二种方式比第一种方式多在JVM上开辟了一块内存空间str2存放字符串常量池中"Hello world!"实例的地址。

所以第二种方式在内存占用上大于第一种方式。

3.6 第六题

:使用concat方法拼接字符串好吗?

:不好。

分析

相关源码如下:

// concat源码
public String concat(String str) {
    // 获取参数字符串长度,如果是空串,返回自身
    int otherLen = str.length();
    if (otherLen == 0) {
        return this;
    }
    // 获取自身字符数组长度
    int len = value.length;
    // 创建一个长度是自身与参数长度和的新字符数组,将自身的字符数组的内容拷贝进入新字符数组
    char buf[] = Arrays.copyOf(value, len + otherLen);
   	// 将参数的字符数组的内容拷贝进入新字符数组
    str.getChars(buf, len);
    // 将新字符数组转成字符串,并返回
    return new String(buf, true);
}

// Arrays.copyOf源码
public static char[] copyOf(char[] original, int newLength) {
    char[] copy = new char[newLength];
    System.arraycopy(original, 0, copy, 0, Math.min(original.length, newLength));
    return copy;
}

// getChars源码
void getChars(char dst[], int dstBegin) {
    System.arraycopy(value, 0, dst, dstBegin, value.length);
}

// System.arraycopy源码
 public static native void arraycopy(Object src, int srcPos, Object dest, int destPos, int length);

由源码分析可得,使用concat连接两个字符串,不仅仅需要创建额外的大数组,还需要对两个数组分别进行循环拷贝。既浪费计算资源,又浪费空间资源。所以不推荐使用concat进行字符串拼接。推荐使用StringBuilder进行字符串拼接。

3.7 第七题

:使用equals方法比较两个较短的字符串好吗?

:不好。

分析

看String类中equals源码:

// equals源码
public boolean equals(Object anObject) {
    // 如果自己和参数地址相同,返回true
    if (this == anObject) {
        return true;
    }
    // 如果自己和参数是同类,可以进行比较
    if (anObject instanceof String) {
        String anotherString = (String) anObject;
        int n = value.length;
        // 如果自己和参数长度相同,则进行按位比较
        if (n == anotherString.value.length) {
            char v1[] = value;
            char v2[] = anotherString.value;
            int i = 0;
            while (n-- != 0) {
                if (v1[i] != v2[i])
                    return false;
                i++;
            }
            // 所有位的内容都相同,返回true
            return true;
        }
    }
    return false;
}

由equals源码可见String类中重写的equals方法比较两个字符串的方法是同时循环遍历两个字符串的内容,这样会损耗大量CPU大量计算资源。

再看String类中hashcode源码:

public int hashCode() {
    // 默认是0,如果hash已被计算,那么直接返回
    int h = hash;
    // 利用value计算hashcode
    if (h == 0 && value.length > 0) {
        char val[] = value;
		// 逐个按照字符的utf-16的编码值求和
        for (int i = 0; i < value.length; i++) {
            h = 31 * h + val[i];
        }
        hash = h;
    }
    return h;
}

由hashCode源码可见,一般情况下,如果两个字符串实例的value相同,那么两个字符串的hashcode的返回值也会相同。

所以可以通过比较两个字符串的hashcode,从而判断两个字符串的内容是否相等。这种方法在效率上要高于使用equals比较的方法。

String str1 = new String("abc");
String str2 = new String("abc");
System.out.println(str1.hashCode() == str2.hashCode());

扩展

为什么比较两个较长的字符串不推荐使用hashcode来比较呢?

因为当两个字符串很长时,在value不一样的情况下,计算出来的hashcode可能会相同。一般建议长度在16以内的字符串使用hashcode来进行比较。

3.8 第八题

:String.intern方法看似减少了对于JVM的内存占用,那么在项目中是否应该大规模使用呢?

:看JDK版本而定。

分析:在JDK6之前,intern方法是将字符串常量存储在了永久代,存储在那里之后的变量是无法被JVM进行回收的,因此会一直占用着内存空间,造成不必要的浪费。到了JDK7之后,将字符串常量池的位置转到了堆空间,可以被JVM自动回收。

3.9 第九题

:为什么String实例要被设计为不可变?

(1)String实例不是线程安全的。如果具备不可变性,那么在多线程访问的环境下,多个线程同时访问一个字符串实例,当其中一个线程修改了字符串变量之后,只会读取到新的引用,而其他线程读取到的值并不会受到影响。相当于即使没有加锁也不会产生线程安全问题。

(2)适合作为HashMap的key。(《HashMap详解》中会给出解释)

3.10 第十题

:String实例的value到底能不能改?

:可以。

分析:final修饰符主要是在编译期间保证变量数据的不可修改,但是在运行期间如果需要对对象实例的数据进行修改,可以通过反射的方式来实现。

如以下代码:

String str = "abc";
// 获取String类中声明的value字段
Field value = String.class.getDeclaredField("value");
// 获得修改private字段的权限
value.setAccessible(true);
// 获取str实例的value
char[] chars = (char[]) value.get(str);
// 修改value数据
chars[0] = 'b';
// 输出:bbc
System.out.println(str);