此博客基于JDK1.8。
我们先用一张表格来回顾一下Java中String,StringBuilder,StringBuffer关于值可变性、线程安全性、时间性能排序、常用场景等主要区别。见下表:
数据类型 | 值可变性 | 线程安全性 | 时间性能排名 | 常用场景 |
---|---|---|---|---|
String | 不可变 | 安全 | 3 | 值不常修改 |
StringBuilder | 可变 | 不安全 | 1 | 单线程、值常修改 |
StringBuffer | 可变 | 安全 | 2 | 多线程、值常修改 |
接着我们由源码可以看到,Java中String类,StringBuilder类,StringBuffer类实现的继承和接口如下:
我们可以看到String,StringBuilder,StringBuffer都实现了CharSequence接口(字符序列接口),也就是说它们三者的本质都是字符数组。但StringBuilder类和StringBuffer类都继承自AbstractStringBuilder抽象类。
接下来我们分析一下它们之间的奥秘 ^ _ ^
String作为我们在Java中最常使用的数据类型之一,它与StringBuilder,StringBuffer最大的区别是它的值不可改变的。
注意:String并不是基本数据类型
但是我们不是经常给一个String类型重新赋值或者追加字符内容吗?例如:
String string = "A";
System.out.println("string对象地址->" + System.identityHashCode(string));
string = "B";
System.out.println("string对象地址->" + System.identityHashCode(string));
或者
String string = "A";
System.out.println("string对象地址->" + System.identityHashCode(string));
string = string + "B";
System.out.println("string对象地址->" + System.identityHashCode(string));
其中System.identityHashCode()是根据内存地址获取到hash值。
以上两种方式打印结果都一样,见下图:
结论:由此可见,当对String重新赋值或者追加字符内容时,修改前和修改后的String已经不再是同一个对象,因为内存地址已经发生变化。
我们并没有重新new一个字符串对象,为什么内存地址会发生改变呢?
我们看到String类源码中有这么一个类属性:
可以看到String类中的char[] value(存放的值)是final的,也就是不可变的。
所以在修改字符串后,并不是在原来的String中直接修改,实质上是重新new了一个新的String。
那么StringBuilder,StringBuffer呢?当看它们的源码会发现并没有类似 char[] vuale(存放的值)的类属性,那么值存放在哪里呢?
我们前面提到StringBuilder,StringBuffer都继承自AbstractStringBuilder这个抽象类,AbstractStringBuilder源码中有这么一个类属性char[] value,可以知道这就是存放值的一个变量(没有用final修饰),也就是可以改变的。
线程安全性通俗来说就是多个线程同时运行某段程序时,其最终的结果是不是正确的,是不是我们所预期的。
由前面的表格可知,String和StringBuffer是线程安全的,StringBuilder是线程不安全的。
String线程安全是因为String的值是不可改变的,即无法对其字符内容进行追加或删除。
如果我们在子线程中对String的值进行重新赋值:
以上代码相当于创建N个子线程同时执行相同的操作,其中cachedThreadPool是缓存线程池。
发现编译不能通过,提示内部类无法访问非final的局部变量,而一旦我们将string这个局部变量声明为final,则提示不能为final的变量重新赋值。
(其实这种情况下,重新赋值这种操作对讨论线程是否安全没有什么意义)
接着我们看到StringBuilder类和StringBuffer类都提供了一个追加字符内容的方法(append),如下:
这里举例参数为String类型的方法,其他数据类型相似。我们可以看到StringBuffer和StringBuilder区别有两个:
1.StringBuffer的append()方法中多了synchronized关键字(即同步锁)。
2.StringBuffer的append()方法会将toStringCache(字符串缓存)清空。
关于toStringCache属性的解释是:StringBuffer为了提高性能,使用了字符串缓存,返回的是最后一次toString的缓存值,如果StringBuffer的值被修改就会清空这个缓存。
而对于区别1来说,就是判断StringBuffer和StringBuilder是否线程安全的关键点。对于这两者来说,在多线程环境下,加了synchronized关键字(即同步锁)的StringBuffer是线程安全的,没有加的StringBuffer则是线程不安全的。
为什么说加了synchronized关键字就是线程安全的呢?我们接着往下看。
我们看到它们的父类都是AbstractStringBuilder类,因此它们父类的append()方法的操作都是一样的。
2.然后确保追加字符内容后的字符数组的容量是足够的,如果不够则进行扩容。如下图:
扩容方法为数组拷贝的方式(浅拷贝,内存地址不变)。
3.再然后是str.getChars()方法,我们继续追踪其源码,如下图:
前面都是一些异常抛出,直接看到最后一行可知,这一个方法其实也是做了字符串拷贝的操作(同样是浅拷贝,内存地址不变)。
4.最后让字符数组的元素数量 = 原来的字符数组的元素数量 + 追加的字符串长度。
结论:所以如果不在最外层的方法添加synchronized关键字,则多个线程可能会同时执行内层的方法某段代码,导致字符数组内容还未来得及被修改就被另一个线程所覆盖,造成数组长度缺失,追加操作被覆盖。
我们通过测试用例也可证明上面的结论,如下:
final static int N = 100000000;
static CountDownLatch countDownLatch = null;
public static void main(String[] args) throws InterruptedException {
StringBuilder stringBuilder = new StringBuilder();
StringBuffer stringBuffer = new StringBuffer();
System.out.println("stringBuilder对象地址->" + System.identityHashCode(stringBuilder));
ExecutorService cachedThreadPool = Executors.newCachedThreadPool();
countDownLatch = new CountDownLatch(N);
long startTime = System.currentTimeMillis();
for (int i = 0; i < N; i++) {
cachedThreadPool.execute(new Runnable() {
public void run() {
stringBuilder.append("A");
countDownLatch.countDown();
}
});
}
countDownLatch.await();
long endTime = System.currentTimeMillis();
long expendTime = endTime - startTime;
System.out.println("stringBuilder花费时间(ms)->" + expendTime);
System.out.println("stringBuilder元素数量->" + stringBuilder.length());
System.out.println("stringBuilder对象地址->" + System.identityHashCode(stringBuilder));
System.out.println(".................................");
System.out.println("stringBuffer对象地址->" + System.identityHashCode(stringBuffer));
cachedThreadPool = Executors.newCachedThreadPool();
countDownLatch = new CountDownLatch(N);
long startTime1 = System.currentTimeMillis();
for (int i = 0; i < N; i++) {
cachedThreadPool.execute(new Runnable() {
public void run() {
stringBuffer.append("A");
countDownLatch.countDown();
}
});
}
countDownLatch.await();
long endTime1 = System.currentTimeMillis();
long expendTime1 = endTime1 - startTime1;
System.out.println("stringBuffer花费时间(ms)->" + expendTime1);
System.out.println("stringBuffer元素数量->" + stringBuffer.length());
System.out.println("stringBuffer对象地址->" + System.identityHashCode(stringBuffer));
System.out.println(".................................");
}
通过线程池创建线程的方式,对StringBuilder和StringBuffer分别循环追加100000000次相同的值。打印结果如下图:
打印结果也证实了StringBuilder和StringBuffer修改前和修改后内存地址都不会发生改变,我们特别关注一下StringBuilder和StringBuffer元素数量,就会发现StringBuilder的元素数量是少于循环追加的次数的,而StringBuffer则是一样的。这也证实了在多线程环境下,StringBuilder是线程不安全的,StringBuffer是线程安全的。
时间性能排序:String < StringBuffer < StringBuilder(理想状态下)
我们通过前面那种图:(此处不对String时间性能进行测试)
在循环追加100000000次的操作后,StringBuilder大约花费时间65531ms,而StringBuffer大约花费时间68900ms。同样证实时间性能上StringBuffer < StringBuilder的结论。
原因:由于String是不可变的,每次修改值都会重新new一个新的String,所以时间性能原则上要比StringBuilder和StringBuffer要低,而StringBuffer为了保证线程安全,加了synchronized同步锁关键字,其余子线程只能等待占用同步代码块的线程完成操作并释放锁后才能进入,从而牺牲了线程排队等待时所消耗的时间性能。