String应该是每一个Java程序员在工作和学习中使用频率最高的一个类了,正是因为它的使用频率是如此之高,所以Java的设计者对String类型做了一定程度的优化,下面我们来探讨一下String类的几个比较有趣的性质。

1. String类的不变性(Immutable)

在Java中,String对象的值是不可能发生改变的。

和C语言中的字符串表示方式一样,String类在其内部也是通过一个字符数组来保存字符串的所有的字符的(这应该已经算是一个常识了,大家都知道)。观察String类的源码就可以发现:

  • 字符数组 value 被关键字 privatefinal 所修饰;
  • String类本身也只可能在执行构造方法的时候才会去修改value的值;
  • String类本身也被声明为final的,意味着String类无法被继承;

以上几个条件保证了String类只会在执行构造方法(即初始化)的时候才会修改字符数组的值,SUN的工程师通过这一系列的技巧来保证String对象的值绝对不会被修改。

写到这里就会有同学跳出来说了:你胡说,我明明就修改了String的值啊,而且修改的很频繁,比如下面这个例子。

1
2
3
// 关于为什么不使用 new 也能创建一个 String 对象会在下一小结谈到,这里不需要管
String s1 = "1";
s1 = s1 + "2"; // 或 s1 += "2";

如果我们这里打印s1的值,会发现s1的值变成了12,这是不是就意味着s1所指向的String对象被修改了呢?当然没有,下面这个例子可以证明String对象并没有被修改:

1
2
3
4
5
String s1 = "1";
String s2 = s1;
s1 = s1 + "2";
System.out.println(s1); // 结果为 12
System.out.println(s2); // 结果为 1

我们可以发现,s2所指向的对象”1”并没有被修改,说明了String是不变的。

我们隐约感觉到上面的操作可能和 ++= 这两个操作有关,那么String类型的 ++= 操作是怎么实现的呢?
答:++= 是Java中的唯一两个重载的操作符,他们只适用于String对象的操作(不是String类型的对象如果使用了这个操作符,则会调用其 toString() 方法来返回一个String对象)。在使用 + 或者 += 操作符的时候,实际上是使用了 StringBuilder 这个类来实现了所有的操作。String s = "1" + "2"; 的真实过程如下所示:

1
2
3
StringBuilder sb1 = new StringBuilder("1");
StringBuilder sb2 = new StringBuilder("2");
String s = sb1.append(sb2).toString();

这样我们就明白了,String对象确实是不可变的,而且String对象在执行 ++= 操作的时候原来是通过StringBuilder来协助完成操作的。

2. 为什么不使用new也能创建String对象

String类型和Java中的其他类型(不包括基本类型)的最大区别大概就在于他不需要 new 就可以创建一个对象了,这种实现方法很类似于基本类型的创建方式,难道说String类型和基本类型之间存在着什么联系吗?答案是否定的。

我们先了解一下Java中是如何对字符串进行操作的:

  1. 在Java文件被编译为class文件之后,在class文件中(即字节码文件中),有一个区域叫做常量池(Constant Pool Table),常量池中主要保存两个东西:字面量(Literal)和符号引用(Symbolic References)。顾名思义,字面量就是能显示出值的常量,而引用就是对另外一个地方的值进行了引用的常量,我们所关注的字符串就被保存为字面量。

  2. 当class被加载到JVM中时,原来的class文件常量池中的内容会被加载到运行时常量池中,运行时常量池位于方法区。但是字符串并没有被加载到运行时常量池,而是会根据字符串的内容进行判断:如果字符串常量区中还没有一个引用所指向的堆内存中的对象与这个字符串中的内容一样,就会在堆内存中创建一个对应的String对象,然后会在字符串常量池(String Literal Pool中创建一个指向这个对象的引用。字符串常量池是所有类共享,位于方法区外。注意,以上的String对象创建和字符串常量池中创建引用的操作在类加载时就会执行,并且执行完毕。

之后,如果新建的字符串是 String s = "1"; 的这种格式,虚拟机会在字符串常量区中进行查找,当找到了一个指向堆内存中值为”1”的的对象的引用时,就把这个引用复制给s,也就是说让s也指向堆内存中的那个字符串对象,s的赋值完成。

如果是 String s = new String("1"); 这种格式,虚拟机会在堆内存中直接创建一个值为1的字符串对象,然后让s指向这个对象,显然这个新创建的对象和上面的那种String s = "1";的操作方式所指向的不是同一个对象。但是,在这里有个必须要强调的地方,当使用new方式来创建String对象的时候,会执行String的构造方法,我们看一下String对应的这个构造方法

1
2
3
4
public String(String original) {
this.value = original.value;
this.hash = original.hash;
}

我们发现这个新建的对象中的内容实际上是来自于 original 的内容(注意是内容,即那个保存字符串的数组),也就是说,这个新的对象的内容等于 original 的内容。original 其实是一个在class加载的时候就保存在堆内存中的对象,其在字符串常量区中会有一个指向它的引用。所以我们可以说这种通过new来创建字符串对象的方式会在堆内存中创建两个对象,一个是在加载时创建的被所有方法区都可以使用的在字符创常量区有一个指向其的引用的对象;另一个就是new出来的字符串对象,不过它的value是前面的那个 original 对象赋给它的。更进一步,这两个字符串其实指向的是同一个字符数组,如果能把这两个对象的value变量比较一下的话(不过做不到,因为value是private的),它们肯定是相等的。

看到这里我们就明白了,之所以不使用new也能创建一个String对象,是因为这个String对象在class被载入到方法区的时候就已经被创建在堆内存中了。之后在执行 String s = "xxx"; 语句的时候,其实是把字符串常量区中的对应的引用复制给s。相反的,当使用 String s = new String("xxx"); 的时候,和其他的对象的创建方式就一样了,是直接在堆内存中创建了一个全新的对象。

1
2
3
4
5
6
String s1 = "s"; // 在字符串常量区拥有同一个引用
String s2 = "s";
String s3 = new String("s");
String s4 = new String("s");
System.out.println(s1 == s2); // 打印 true
System.out.println(s3 == s4); // 打印 false

上面这个例子证实了我们的理论,s1和s2之所以相等,就是因为它们都是复制于字符串常量区中的同一个引用,所以它们指向的就是同一个对象,自然也就相等;而s3和s4因为它们指向的是堆内存中的不同的对象(它们都分别创建了自己对象),所以自然不会相等。那么s1和s3比较呢,是否相等?如果你已经理解了上面的过程,那么应该已经知道答案了。

了解了上面两种创建String对象的方式的区别之后,我再提一下String对象的 intern 方法,SUN对intern方法的功能介绍如下:

When the intern method is invoked, if the pool already contains a string equal to this String object as determined by the equals(Object) method, then the string from the pool is returned. Otherwise, this String object is added to the pool and a reference to this String object is returned.

简单说来当一个String对象调用 intern 方法的时候,分为以下两种情况

  1. 如果字符串常量区存在一个和这个String对象equals的string,那么就返回这个string
  2. 如果不存在,就在字符串常量区创建一个指向这个String Object的引用,然后返回

通过intern方法,能够把通过new关键字创建的string也和字符串常量区联系起来了,下面这两个例子就很好的说明这一点
例子1:

1
2
3
4
String s1 = "1";
String s2 = new String("1");
System.out.println(s1 == s2.intern()); // 打印结果为 true
System.out.println(s2 == s2.intern()); // 打印结果为 false

在例子1中,因为s1是在字符串常量区中的,又因为s2的intern方法会直接返回"1"这个对象在字符串常量区中引用,所以它们自然相等,所以打印结果1为true;又因为s2所指向的对象和s1所指向的对象(也就是字符串常量区中的引用所指向的对象)不相等,所以打印结果2为false。
例子2:

1
2
3
4
String s1 = "1";
String s2 = "2";
String s3 = new String(s1 + s2);
System.out.println(s3 == s3.intern()); // 打印结果为 true

在例子2中,为了避免在字符串常量区中创建”12”,我们把s3通过两个字符串分开创建然后再组合起来。因为s3在调用intern方法的时候,其返回的字符串常量区中的引用也是指向了堆内存中的同样一个对象,所以他们自然相等。

3. String和StringBuilder和StringBuffer的适用场景

  • 我们已经知道了String是不可变的了,所以String一般用于字符串不会产生变化的时候;
  • StringBuilder一般用于字符串会变化的情况,因为其线程不安全,所以用于单线程下字符串会变化的情况;
  • StringBUffer是线程安全的,一般用于多线程下字符串会变化的情况

下面是一个比较这三个类的效率的小demo

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
long time1 = System.nanoTime();
String s = "a"; // 固定字符串
s = s + "b";
s = s + "c";
long time2 = System.nanoTime();
System.out.println("String\t\t" + (time2 - time1));
long time3 = System.nanoTime();
StringBuilder sb = new StringBuilder("a"); // 变化的字符串,非线程安全,单线程下使用
sb = sb.append("b");
sb = sb.append("c");
long time4 = System.nanoTime();
System.out.println("StringBuilder\t" + (time4 - time3));
long time5 = System.nanoTime();
StringBuffer sb1 = new StringBuffer("a"); // 变化的字符串,线程安全,多线程下使用
sb1 = sb1.append("b");
sb1 = sb1.append("c");
long time6 = System.nanoTime();
System.out.println("StringBuffer\t" + (time6 - time5));

在我的电脑上测试打印结果如下:

String          3422
StringBuilder   933
StringBuffer    25503

由此可见,StringBuilder的变化效率最高,String其次,StringBuffer最慢。因此如果字符串经常变化,而且是在单线程情况下,请使用StringBuilder来替代String和StringBuffer吧。

参考

  1. Strings, Literally
  2. 请别再拿“String s = new String(“xyz”);创建了多少个String实例”来面试了吧
  3. String str=new String(“Hello”)到底创建了几个对象?
  4. 《Java编程思想》第13章