深入理解Java中的String

String类

先看一下源码(jdk1.8.0_144)中的对于类的定义

public final class String implements java.io.Serializable, Comparable<String>, CharSequence {
    ...
}
  1. 首先可以看到String类是被final修饰的,即意味着String类不能被继承,并且它的成员方法都默认为final方法
  2. String类实现了三个接口,Serializable接口用于序列化,Comparable接口用于字符串比较,CharSequence用于实现字符数组操作

继续看成员变量

/** The value is used for character storage. */
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;

/**
 * Class String is special cased within the Serialization Stream Protocol.
 *
 * A String instance is written into an ObjectOutputStream according to
 * <a href="{@docRoot}/../platform/serialization/spec/output.html">
 * Object Serialization Specification, Section 6.2, "Stream Elements"</a>
 */
private static final ObjectStreamField[] serialPersistentFields =
    new ObjectStreamField[0];
  1. String类中使用字符数组(value[])用来存放数据,value同样被final修饰的,这意味着value初始化后将不能指向其它引用,但是数组内容是可以更改的
  2. 成员变量hash用于缓存hashcode,String重写了hashCode方法并重新定义了hashcode的生成规则;成员变量serialVersionUID与serialPersistentFields都是用于对象序列化

然后看String类中部分方法的实现

public String substring(int beginIndex, int endIndex) {
    if (beginIndex < 0) {
        throw new StringIndexOutOfBoundsException(beginIndex);
    }
    if (endIndex > value.length) {
        throw new StringIndexOutOfBoundsException(endIndex);
    }
    int subLen = endIndex - beginIndex;
    if (subLen < 0) {
        throw new StringIndexOutOfBoundsException(subLen);
    }
    return ((beginIndex == 0) && (endIndex == value.length)) ? this
            : new String(value, beginIndex, subLen);
}

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);
}

public String replace(char oldChar, char newChar) {
    if (oldChar != newChar) {
        int len = value.length;
        int i = -1;
        char[] val = value; /* avoid getfield opcode */

        while (++i < len) {
            if (val[i] == oldChar) {
                break;
            }
        }
        if (i < len) {
            char buf[] = new char[len];
            for (int j = 0; j < i; j++) {
                buf[j] = val[j];
            }
            while (i < len) {
                char c = val[i];
                buf[i] = (c == oldChar) ? newChar : c;
                i++;
            }
            return new String(buf, true);
        }
    }
    return this;
}
  1. 与假想不同的是,正常思路下对于String对象内容的更改应该是直接修改value字符数组就可以了,但发现并不是这样实现的,无论sub、concat还是replace操作,都不会对对象中的value字符数组进行更改,而是重新生成了一个新的String对象
  2. 也就是说:String对象一旦被创建就是固定不变的了,对String对象的任何改变都不影响到原对象,相关的任何change操作都会生成新的对象。这样设计的原因我们将在下一节讨论

初始化与字符串常量池

String是一个引用类型,这意味着String类型的实例化与其它对象一样,相较于基本数据类型,时间和空间的消耗都是较大的,但是由于String的使用频率非常高,JVM为了提高性能和减少内存的开销,在实例化字符串的时候进行了一些优化,引入了字符串常量池。

每当我们创建字符串常量时,JVM会首先检查字符串常量池,如果该字符串已经存在常量池中,那么就直接返回常量池中的实例引用。如果字符串不存在常量池中,就会实例化该字符串并且将其放到常量池中。由于String字符串的不可变性我们可以十分肯定常量池中一定不存在两个相同的字符串

String对象的初始化方式将会影响对象内存分配的方式,整体来说String对象的初始化分为两种:

  1. 字面量初始化
String s = "test";
  1. new 初始化
String s = new String("test");

当String对象通过第一种方式初始化的时候,*.class文件中的常量池将包含String字面量,在jvm进行类装载过程中,class文件中的常量池将被载入内存,此时便形成了所谓的字符串常量池,字符串常量池中的所有String对象内容上都是唯一的,不会出现重复的,因为每当一个String对象需要添加进入字符串常量池中时都会先检查池中是否有相同内容的对象了,若存在则直接返回该对象的引用,否则才会创建新对象。

当String对象通过第二种方式初始化的时候,可以理解为

String variable = "test"; // variable为匿名变量
String s = new String(variable);

也就是说在第二种初始化中是包含了第一种初始化的,首先进行的是以字面量的形式创建匿名变量,具体流程与第一种方式初始化一致,然后new操作会在堆上创建s指向的String对象,也就是说第二种方式初始化实际上会创建两个String对象,一个存放在字符串常量池,一个存放在堆中;

可以得出以下结论:

  1. 字面量形式的String对象初始化都会被加入字符串常量池;此时当内容一致时,多个引用会同时指向同一对象,这也是为什么String会被设计成immutability(不变性),防止当一个引用更改对象的内容时,其它引用被迫更改。
  2. 使用new操作创建的String对象,一定会在堆上创建对象,但是如果涉及到字面量初始化,则会创建两个对象,分别存放在字符串常量池与堆中。

案例分析

案例一:

String a = "test";
String b = "test";
System.out.println(a.equals(b)); // true
System.out.println(a == b); // true
System.out.println(System.identityHashCode(a)); // 1639705018
System.out.println(System.identityHashCode(b)); // 1639705018

当a初始化时,"test"对象被加入字符串常量池,b初始化时,检查到"test"对象在字符串常量池中,则直接指向该对象;a.equals(b)比较的是对象持有的内容,显然为true,a==b比较的是是否指向的是同一对象,因为指向的同为字符串常量池中"test"对象,结果为true;System.identityHashCode方法的返回值为参数对象object中hashCode()的返回值,无论hashCode()方法是否被覆盖。

案例二:

String a = "test";
String b = new String("test");
System.out.println(a.equals(b)); // true
System.out.println(a == b); // false
System.out.println(System.identityHashCode(a)); // 1639705018
System.out.println(System.identityHashCode(b)); // 1627674070

当a初始化时,"test"对象被加入字符串常量池,b初始化时,首先检查到"test"对象在字符串常量池中,无需在字符串常量池中创建对象,然后在堆中创建"test"对象,b指向堆中"test"对象;可得a.equals(b)为true,a==b为false

案例三:

String a = new String("test");
String b = new String("test");
System.out.println(a.equals(b)); // true
System.out.println(a == b); // false
System.out.println(System.identityHashCode(a)); // 1639705018
System.out.println(System.identityHashCode(b)); // 1627674070

当a初始化时,"test"对象被加入字符串常量池,然后在堆中创建"test"对象,a指向堆中"test"对象;,b初始化同a,但是不像字符串常量池中那样,虽然a与b的内容相同,但是依然会在堆上创建两个对象;所以a.equals(b)为true,a==b为false

案例四:

String a = "test";
String b = "te"+"st";
System.out.println(a.equals(b)); // true
System.out.println(a == b); // true
System.out.println(System.identityHashCode(a)); // 1639705018
System.out.println(System.identityHashCode(b)); // 1639705018

与案例一不同的是,b的值由两个引号相加所得,但是结果和案例一是一样的,a与b指向的都是字符串常量池中的同一对象,这是因为形如"te"+"st"这种由多个字符串常量连接而成的字符串在编译期被认为是一个字符串常量

案例五:

String a = new String("test");
String b = new String("te") + new String("st");
System.out.println(a.equals(b)); // true
System.out.println(a == b); // false
System.out.println(System.identityHashCode(a)); // 1639705018
System.out.println(System.identityHashCode(b)); // 1627674070

根据案例三中的分析可知,a与b内容时一致的,但是指向的不是同一对象,但是问题是这个案例中会在字符串常量池中创建多少个对象?答案应该是三个:"test"、"te"、"st";在堆上会创建多少个对象?答案是四个:a指向的"test"对象,两个匿名对象"te"、"st",b指向的"test"对象

案例六:

String a = "test";
String b = "te";
String c = "st";
String d = b + c;
System.out.println(a.equals(d)); // true
System.out.println(a == d); // false
System.out.println(System.identityHashCode(a)); // 1639705018
System.out.println(System.identityHashCode(d)); // 1627674070

对象d的初始化方式似乎跳出了之前所说的两种初始化方式,是由两个存在于字符串常量池中的字符串对象连接生成,但其实此时的d的初始化方式只是复杂了一些,并没有脱离之前所说的两种初始化方式,具体步骤如下:

  1. b、c已经存放在字符串常量池中,将b、c对象复制到堆中;
  2. 在堆中新建d对象,并将b、c连接后的值赋给d;

也就是说在这个案例中字符串常量池中创建了三个对象,堆中创建了三个对象

案例七:

String a = "test";
final String b = "te";
final String c = "st";
String d = b + c;
System.out.println(a.equals(d)); // true
System.out.println(a == d); // true
System.out.println(System.identityHashCode(a)); // 1639705018
System.out.println(System.identityHashCode(d)); // 1639705018

与案例六不同的是b、c都加了final修饰,在这种情况下,与案例四一样,编译器将b+c视为了字符串常量,所以d指向的是在字符串常量池中已存在的"test"对象

总结:

  1. 可以看出不同情况下的String对象初始化,的确会直接影响jvm对于对象的创建以及创建位置,这验证了在之前的结论
  2. 若编译期String对象的内容可确定(案例四、案例七),初始化方式任被认为是字面量初始化

intern方法

在之前的结论中,可以看到String对象被加入进字符串常量池中的条件似乎是必须在编译器可表示为字符串常量,而在运行期间确定的字符串对象时无法加入的,但是也不是绝对的,String类中提供的intern方法就是一种在运行期间加入字符串常量池的一种方法。

当调用intern方法时,如果字符串常量池中已经包含一个内容相同的String对象,则返回池中的对象。否则,将在字符串常量池中添加一个该对象的引用,并返回该对象(在jdk6中,是直接将此String对象添加到池中,并返回此String对象)。

如以下案例:

String a = "test";
System.out.println(System.identityHashCode(a)); // 1639705018
a = a.intern();
System.out.println(System.identityHashCode(a)); // 1639705018

此时a初始化完成后指向的对象的位置是在字符串常量池中,执行a = a.intern()后,指向位置任是在字符串常量池中;所以看到打印的hashcode是一样的;

String a = new String("test");
System.out.println(System.identityHashCode(a)); // 1639705018
a = a.intern();
System.out.println(System.identityHashCode(a)); // 1627674070

此时a初始化完成后指向的对象的位置是在堆上的,同时在字符串常量池中有一个相同内容的String对象,执行a = a.intern()后,指向位置是在字符串常量池中;

String a = new String("te") + new String("st");
System.out.println(System.identityHashCode(a)); // 1639705018
a = a.intern();
System.out.println(System.identityHashCode(a)); // 1639705018

此时a初始化完成后指向的对象的位置是在堆上的,字符串常量池中没有相同内容的String对象,执行a = a.intern()后,字符串常量池中增加一个引用,该引用指向堆上的String对象,所以a依然指向的是堆上的String对象,执行前后,hashcode没有变化;(如果在jdk6 执行此代码,获得结果是不一样的)

String、StringBuilder、StringBuffer

  1. String是不可变字符串,所有的change操作都会创建新对象,StringBuilder与StringBuffer都是可变字符串(字符数组长度可变)
  2. StringBuilder与StringBuffer在实现上基本完全一致,但是StringBuffer中的大部分方法都使用了synchronized进行修饰
  3. String和StringBuffer是线程安全的,StringBuilder不是,因为String是不可变的,显然安全,而StringBuffer中的方法大都采用了synchronized 关键字进行修饰,因此也是线程安全的
  4. 通常情况下执行效率StringBuilder > StringBuffer > String,但不是绝对的,具体情况具体分析
  5. 当字符串基本不改动或改动很少时使用String,频繁改动使用StringBuilder,频繁改动且涉及多线程,则使用StringBuffer
  6. 使用+号连接字符串的操作是通过StringBuilder的append和toString方法实现的

推荐阅读更多精彩内容