String 的不可变性
📝 String 不可变的原理
我们先来看一下 String 的源码是怎么存储数据的
public final class String
implements java.io.Serializable, Comparable<String>, CharSequence {
/**
* The value is used for character storage.
*/
private final char[] value;
从 String 的源码我们可以看出:
-
1、String 是一个 final 类,这就意味着 String 是不能被继承的,通过这种方式防止出现:程序员通过继承重写 String 类的方法的手段来使得String类成为“可变的”的情况。
-
2、数组 value 是String的底层数组,用于存储字符串的内容,而且是 private final
但数组是引用类型,只能限制引用不改变而已,也就是说数组元素的值是可以改变的,而且String 有一个可以传入数组的构造方法,那么我们可不可以通过修改外部 char 数组元素的方式来“修改” String 的内容呢?比如下面的例子:
public static void main(String[] args) {
char[] arr = new char[]{'a','b','c','d'};
String str = new String(arr);
arr[3]='x';
System.out.println("str:" + str);
System.out.println("arr[]:" + Arrays.toString(arr));
}
>>>>>>>>
str: abcd
arr[]: [a, b, c, x]
结果好像与我们所想不一样:字符串 str 使用数组 arr 来构造一个对象,当数组 arr 修改其元素值后,字符串 str 并没有跟着改变。那我们就去源码里看一下这个构造方法是怎么处理的:
public String(char value[]) {
this.value = Arrays.copyOf(value, value.length);
}
原来 String 在使用外部 char 数组构造对象时,是重新复制了一份外部 char 数组,从而不会让外部 char 数组的改变影响到 String 对象。
📝 String 的改变即创建
从上面的分析我们知道,我们是无法从外部修改String对象的,那么可不可能使用String提供的方法,因为有不少方法看起来是可以改变String对象的,如 replace()、replaceAll()、substring() 等。我们以 substring() 为例,看一下源码:
public String substring(int beginIndex, int endIndex) {
//........
return ((beginIndex == 0) && (endIndex == value.length)) ? this
: new String(value, beginIndex, subLen);
}
从源码可以看出,如果不是切割整个字符串的话,就会新建一个对象。也就是说,只要与原字符串不相等,就会新建一个String对象。
📝 缓存 Hashcode
Java中经常会用到字符串的哈希码(hashcode)。例如,在HashMap中,字符串的不可变能保证其hashcode永远保持一致,这样就可以避免一些不必要的麻烦。这也就意味着每次在使用一个字符串的hashcode的时候不用重新计算一次,这样更加高效。
在 String 类中,有以下代码:
private int hash; //this is used to cache hash code.
以上代码中hash变量中就保存了一个String对象的hashcode,因为String类不可变,所以一旦对象被创建,该hash值也无法改变。所以,每次想要使用该对象的hashcode的时候,直接返回即可.
字符串拼接
其实,所有的所谓字符串拼接,都是重新生成了一个新的字符串,那么,在Java中,到底如何进行字符串拼接呢?字符串拼接有很多种方式,这里简单介绍几种比较常用的
📖 使用 “+” 拼接字符串
我们先来看一个例子:
public class Test {
public static void main(String[] args) {
// 创建4个字符串:创建方式不同但字符串内容一样
String s = "Hello Java";
String s2 = "Hello" + " Java";
String s3 = s2 + "";
String s4 = new String("Hello Java");
// 对这4个字符串使用相等比较
System.out.println("s == s2: " + (s == s2));
System.out.println("s == s3: " + (s == s3));
System.out.println("s == s4: " + (s == s4));
}
}
>>>>>
s == s2: true
s == s3: false
s == s4: false
为什么结果是这样子的?我们来慢慢分析一下,首先,我们要知道编译器有个优点:在编译期间会尽可能地优化代码,所以能由编译器完成的计算,就不会等到运行时计算,如常量表达式的计算就是在编译期间完成的。所以,s2 的结果其实在编译期间就已经计算出来了,与 s 的值是一样,所以两者相等,即都属于字面常量,在类加载时创建并维护在字符串常量池中。但 s3 的表达式中含有变量 s2,只能是运行时才能执行计算,也就是说,在运行时才计算结果,在堆中创建对象,自然与 s 不相等。而 s4 使用new直接在堆中创建对象,更不可能相等。
那在运行期间,是如何完成String的+号拼接操作的呢,要知道String对象可是不可改变的对象。我们反编译上面例子的 class 文件来看一下原因:
public class Test
{
public Test()
{
}
public static void main(String args[])
{
String s = "Hello Java";
String s2 = "Hello Java"; //已经得到计算结果
String s3 = (new StringBuilder(String.valueOf(s2))).toString();
String s4 = new String("Hello Java");
// + 号处理成了 StringBuilder.append() 方法
System.out.println((new StringBuilder("s == s2 ")).append(s == s2).toString());
System.out.println((new StringBuilder("s == s3 ")).append(s == s3).toString());
System.out.println((new StringBuilder("s == s4 ")).append(s == s4).toString());
}
}
可以看出,编译器将 + 号处理成了StringBuilder.append()方法。也就是说,在运行期间,链接字符串的计算都是通过 创建 StringBuilder 对象,调用 append() 方法来完成的
📖 使用 concat 拼接字符串
除了使用+拼接字符串之外,还可以使用String类中的方法concat方法来拼接字符串。如:
String s1 = "Hello";
String s2 = "Java";
String s3 = s1.concat(" ").concat(s2);
我们再来看一下 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);
}
从源码可以看出,concat 的原理是创建了一个字符数组,长度是已有字符串的长度和待拼接字符串的长度之和,再把两个字符串的值复制到新的字符数组中,并使用这个字符数组创建一个新的 String 对象并返回。所以,经过 concat 方法,其实是 new 了一个新的 String 对象,这也验证了前面我们说的字符串的不变性原理。
StringBuffer 和 StringBuilder
接下来我们看看 StringBuilder 和 StringBuffer 的实现原理。和 String 类似,StringBuilder 类也封装了一个字符数组,定义如下:
char[] value; // The value is used for character storage.
但与 String 不同的是,它并不是 final 的,所以他是可以修改的,并且 StringBuilder 的字符数组不一定所有位置都已经被使用,因为它有一个实例变量,用来表示数组中已经使用的字符个数,定义如下:
int count; // The count is the number of characters used.
其append源码如下:
public StringBuilder append(String str) {
super.append(str);
return this;
}
该类继承了 AbstractStringBuilder 类,我们进去看下其 append 方法的源码:
public AbstractStringBuilder append(String str) {
if (str == null) {
return appendNull();
}
int len = str.length();
ensureCapacityInternal(count + len);
putStringAt(count, str);
count += len;
return this;
}
append 会直接拷贝字符到内部的字符数组中,并且使用 ensureCapacityInternal() 来检查字符数组容量是否足够,如果字符数组容量不够,则会进行扩展。
StringBuffer 和 StringBuilder 类似,都是继承于 AbstractStringBuilder,最大的区别就是 StringBuffer 是线程安全的,我们可以看一下 StringBuffer 的 append 方法的源码:
public synchronized StringBuffer append(String str) {
toStringCache = null;
super.append(str);
return this;
}
该方法使用 synchronized 进行声明,说明是一个线程安全的方法,而 StringBuilder 则不是线程安全的。
字符串拼接效率比较
字符串的拼接方式比较多:使用+号拼接、使用 concat() 方法拼接、使用 StringBuilder 或 StringBuffer 拼接、使用 StringUtils.join 拼接等等。当同一时刻频繁进行大量拼接时(比如 for 循环中循环次数很大),他们的拼接效率从高到低排序为:
StringBuilder >> StringBuffer >> concat >> + >> StringUtils.join
StringBuffer 是在 StringBuilder 的基础上做了同步处理,所以会在耗时上比 StringBuilder 多一点,这个很好理解。但问题是,使用 + 进行拼接字符串的原理也是使用 StringBuilder 的呀,为什么效率会差这么远的?来看一下下面的例子:
String str = "";
for (int i = 0; i < 500; i++) {
String temp = String.valueOf(i);
str += temp;
}
反编译后代码如下
String str = "";
for (int i = 0; i < 500; i++) {
String temp = String.valueOf(i);
str = (new StringBuilder()).append(str).sppend(temp).toString();
}
可以看到使用 + 来拼接,每次都是 new 了一个 StringBuilder,然后再把 String 转成 StringBuilder,再进行 append 操作,如果频繁的新建对象当然要耗费很多时间了,不仅仅会耗费时间,频繁的创建对象,还会造成内存资源的浪费。
所以:循环体内,字符串的连接方式,使用StringBuilder 的 append 方法进行扩展。而不要使用 +
总结
下面总结一下这篇文章的主要内容
- String 是不可变的,一旦创建将不可修改,所有看似修改 String 的方法都是通过创建返回新的 String 来处理的
- 常用的字符串拼接方式有五种,分别是使用 +、使用concat、使用 StringBuilder、使用 StringBuffer 以及使用 StringUtils.join
- 由于字符串拼接过程中会创建新的对象,所以如果要在一个循环体中进行字符串拼接,就要考虑内存问题和效率问题
- 使用 StringBuilder 的方式是效率最高的。因为 StringBuilder 天生就是设计来定义可变字符串和字符串的变化操作的
使用的基本原则是:
- 如果不是在循环体中进行字符串拼接的话,直接使用 + 就可以了
- 如果在并发场景中进行字符串拼接的话,要使用 StringBuffer 来代替 StringBuilder