1.概述
string、stringbuffer、stringbuiler一直是java面试中较为热点的问题,一般作为一个开场问题。那么这三者之间有什么区别,如何根据场景来选择使用,本文将基于三者的源码、性能、以及面试常问点来进行分析。
2.String、StringBuffer、StringBuiler底层源码
2.1 String类
首先看一下String类的类图,它实现了CharSequence接口,也就是说String是CharSequence类型,它的底层是被final修饰的字符数组,意味着一旦String被定义之后就不能够改变。在日常我们做字符串拼接时,其实是一个创建新对象和回收旧对象的过程。以下面代码为例:
String str = "a";
str += "b";
当定义字符串str时并将值“a”赋给它,当执行第二段代码时,即实现str拼接“b”时,此时JAVA虚拟机创建了一个新对象str,将拼接成的字符串“ab”赋给新str,GC同时会回收旧的str对象,所以str并没有被更改,而是直接生成的新对象。若大量字符串拼接,利用上述+=操作将严重影响性能。
public final class String
implements java.io.Serializable, Comparable<String>, CharSequence {
/** The value is used for character storage. */
private final char value[];
//构造函数
public String(String original) {
this.value = original.value;
this.hash = original.hash;
}
//构造函数
public String(char value[]) {
this.value = Arrays.copyOf(value, value.length);
}
//返回一个新的char[]
public char[] toCharArray() {
// Cannot use Arrays.copyOf because of class initialization order issues
char result[] = new char[value.length];
System.arraycopy(value, 0, result, 0, value.length);
return result;
}
}
在上述String类的源码声明中,String类由final对象修饰,被final修饰的类不能被继承、方法不能被重写、属性不能被修改。String是一个典型的Immutable(不可变)类,所谓的不可变类是指这个类的实例一旦创建完成后,就不能改变其成员变量值。与不可变类相对的则是可变类(mutable),可变类创建实例后可以改变其成员变量值,开发中创建的大部分类都属于可变类。跟据上述String源码,分析String保证不可变的原因主要如下:
String类被final修饰,不可被继承; string内部所有成员都设置为私有变量,外部无法访问;
value被final修饰,所以变量的引用不可变,且不提供对外访问value的接口;
在获取value时,不是直接返回value的引用,而是直接通过System.arraycopy()方法来返回一个新的char数组;
char[]为引用类型仍可以通过引用修改实例对象,为此String(char value[])构造函数内部使用的copyOf而不是直接将value[]复制给内部变量。
接下来讲一下这样设计的优势:
1.支持hash映射和缓存,提升效率: 由于字符串不可变性,在其创建时就会缓存其hashCode值,不需要每次重新计算其hash值。这也是HashMap中键值一般会使用字符串的原因,对于字符串的处理速度要快于其它的键对象。
2.保证了线程安全: 由于其不变性,同一个字符串实例可以被多个线程共享。如果是可变对象,很容易被修改而造成不可预估的后果。
3.不可变对象提升了字符串常量池(String Pool)的效率和安全性: 字符串常量池是java堆内存中一个特殊的存储区域,当创建一个String对象,假如此字符串值已经存在于常量池中,则不会创建一个新的对象,而是引用已经存在的对象。在java中内存分为堆内存和栈内存,堆内存存放的是对象,栈内存存储对象的引用,对象是不可变动的,因此拷贝对象时只需要复制它的地址。复制地址(通常一个指针的大小)需要很小的内存,效率也很好。二对于其他引用同一个对象的其他变量也不会造成影响。
2.2 StringBuffer类
由上述类图可知,StringBuffer类继承自类AbstractStringBuilder,使用 value 和 count 分别表示存储的字符数组和字符串使用的计数,AbstractStringBuilder类封装了大量基础方法,包括数组扩容机制、拼接方法等。首先看一下数组初始容量:
/**
* Constructs a string buffer with no characters in it and an
* initial capacity of 16 characters.
*/
public StringBuffer() {
super(16);
}
public StringBuffer(int capacity) {
super(capacity);
}
StringBuffer初始化时默认长度大小为16,StringBuffer是线程安全的,可以查看其append方法(如下所示),由synchronized修饰,因此是线程安全的。
@Override
public synchronized StringBuffer append(Object obj) {
toStringCache = null;
super.append(String.valueOf(obj));
return this;
}
@Override
public synchronized StringBuffer append(String str) {
toStringCache = null;
super.append(str);
return this;
}
public synchronized StringBuffer append(StringBuffer sb) {
toStringCache = null;
super.append(sb);
return this;
}
@Override
synchronized StringBuffer append(AbstractStringBuilder asb) {
toStringCache = null;
super.append(asb);
return this;
}
@Override
public synchronized StringBuffer append(CharSequence s) {
toStringCache = null;
super.append(s);
return this;
}
接下来看一下其扩容部分的源码,具体如下:
private void ensureCapacityInternal(int minimumCapacity) {
// overflow-conscious code
if (minimumCapacity - value.length > 0) {
value = Arrays.copyOf(value,
newCapacity(minimumCapacity));
}
}
private int newCapacity(int minCapacity) {
// overflow-conscious code
int newCapacity = (value.length << 1) + 2;
if (newCapacity - minCapacity < 0) {
newCapacity = minCapacity;
}
return (newCapacity <= 0 || MAX_ARRAY_SIZE - newCapacity < 0)
? hugeCapacity(minCapacity)
: newCapacity;
}
StringBuffer扩容方法继承了类AbstractStringBuilder,上述ensureCapacityInternal方法就是扩容方法,minimumCapacity 就是字符长度 + 要拼接的字符串长度,如果拼接后的字符串要比当前字符长度大的话,会进行数据的复制,真正扩容的方法是在 newCapacity 中,扩容后的字符串长度会是原字符串长度增加一倍 + 2,如果扩容后的长度还比拼接后的字符串长度小的话,那就直接扩容到它需要的长度 newCapacity = minCapacity,然后再进行数组的拷贝。因此,如果我们拼接的字符串长度是可以预计的,那么最好指定合适的capacity,避免多次扩容的开销。
2.3 StringBuiler类
由上述StringBuilder类图可知,StringBuilder与StringBuffer类似,均继承于AbstractStringBuilder,所以其初始空间和扩容机制均相同。区别在于它是被final修饰的,因此是不可被继承的;而且其append方法不是由synchronized修饰,因此不是线程安全的。同时它实现了Serializable 序列化接口,表示对象可以被序列化;还实现了CharSequence 字符序列接口,提供了几个对字符序列进行只读访问的方法,例如 length()、charAt()、subSequence()、toString() 方法等。
public final class StringBuilder
extends AbstractStringBuilder
implements java.io.Serializable, CharSequence {
public StringBuilder() {
super(16);
}
public StringBuilder(int capacity) {
super(capacity);
}
}
abstract class AbstractStringBuilder implements Appendable, CharSequence {
char[] value;
int count;
AbstractStringBuilder() {
}
AbstractStringBuilder(int capacity) {
value = new char[capacity];
}
}
3.String、StringBuffer、StringBuilder性能对比
3.1 测试代码如下:
public class Test {
private static final Integer time = 100000;
public static void main(String[] args) {
testString();
testStringBuffer();
testStringBuilder();
}
public static void testString() {
String str = "";
long start = System.currentTimeMillis();
for (int i = 0;i < time; i++) {
str += "a";
}
long end = System.currentTimeMillis();
System.out.println("string:"+(end - start));
}
public static void testStringBuffer() {
StringBuffer stringBuffer = new StringBuffer();
long start = System.currentTimeMillis();
for (int i = 0;i < time; i++) {
stringBuffer.append("a");
}
long end = System.currentTimeMillis();
System.out.println("stringBuffer:"+(end - start));
}
public static void testStringBuilder() {
StringBuilder stringBuilder = new StringBuilder();
long start = System.currentTimeMillis();
for (int i = 0;i < time; i++) {
stringBuilder.append("a");
}
long end = System.currentTimeMillis();
System.out.println("stringBuilder:"+(end - start));
}
}
3.2 测试结果如下:
上述代码分别测试了在拼接次数为1000次、10000次、100000次和1000000次下,String、StringBuffer、StringBuilder的耗时,具体结果如下表所示:
单位:ms | String | StringBuffer | StringBuilder |
---|---|---|---|
次数:1000 | 3 | 0.1 | 0.1 |
次数:10000 | 72.3 | 0.7 | 0.7 |
次数:100000 | 3534.2 | 4.4 | 3.8 |
次数:1000000 | 130400.4 | 13.2 | 11.2 |
结论:由上表可知,String在进行字符串拼接时,随着拼接次数的增多,耗时也呈指数式增加。相比较而言,StringBuffer和StringBuilder性能相对接近。
注意:上述测试环境基于win10操作系统,jdk 1.8,处理器:Intel® Core™ i7-9700 CPU @ 3.00GHz 3.00 GHz,RAM:16.0 GB。
4.深入理解String案例
1.判断下面程序输出结果
String str1 = "helloworld";
String str2 = "hello" + "world";
System.out.println(str1 == str2); //true
通过反汇编上述代码,得到结果如下:
public class com.eckey.lab.test.StringTest {
public com.eckey.lab.test.StringTest();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
public static void main(java.lang.String[]);
Code:
0: ldc #2 // String helloworld(str1)
2: astore_1
3: ldc #2 // String helloworld(str2)
5: astore_2
6: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream;
9: aload_1
10: aload_2
11: if_acmpne 18
14: iconst_1
15: goto 19
18: iconst_0
19: invokevirtual #4 // Method java/io/PrintStream.println:(Z)V
22: return
}
通过反汇编代码可以看出,编译器对 String str2 = “hello” + “world"做了优化,str2被直接创建成了"helloworld”,而str1在创建时字符串helloworld被写入了常量池。str2在创建时会先从常量池中获取对应字符串,而常量池已经包含了该字符,因此结果为true。
2.判断下面程序输出结果
String str1 = "helloworld";
String str2 = "hello";
String str3 = str2 + "world";
System.out.println(str1 == str3); //false
反汇编上述代码得到结果:
public class com.eckey.lab.test.StringTest {
public com.eckey.lab.test.StringTest();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
public static void main(java.lang.String[]);
Code:
0: ldc #2 // String helloworld
2: astore_1
3: ldc #3 // String hello
5: astore_2
6: new #4 // class java/lang/StringBuilder
9: dup
10: invokespecial #5 // Method java/lang/StringBuilder."<init>":()V
13: aload_2
14: invokevirtual #6 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
17: ldc #7 // String world
19: invokevirtual #6 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
22: invokevirtual #8 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
25: astore_3
26: getstatic #9 // Field java/lang/System.out:Ljava/io/PrintStream;
29: aload_1
30: aload_3
31: if_acmpne 38
34: iconst_1
35: goto 39
38: iconst_0
39: invokevirtual #10 // Method java/io/PrintStream.println:(Z)V
42: return
}
通过上述反汇编代码可知,当执行String str3 = str2 + “world”;操作时,首先生成了一个StringBuilder,其次将其进行了初始化操作,然后调用了append方法,拼接了字符串“world”,最后调用了toString方法,将拼接后的字符串转成了一个新的String,这个过程生成了新的对象,因此str1和str3肯定不是一个对象,结果自然为false。
3.判断下面程序输出结果
String str1 = "helloworld";
final String str2 = "hello";
String str3 = str2 + "world";
System.out.println(str1 == str3); //true
这个结果为true是不是感觉很意外?那我们直接上反汇编代码,一探究竟便知:
public class com.eckey.lab.test.StringTest {
public com.eckey.lab.test.StringTest();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
public static void main(java.lang.String[]);
Code:
0: ldc #2 // String helloworld
2: astore_1
3: ldc #3 // String hello
5: astore_2
6: ldc #2 // String helloworld
8: astore_3
9: getstatic #4 // Field java/lang/System.out:Ljava/io/PrintStream;
12: aload_1
13: aload_3
14: if_acmpne 21
17: iconst_1
18: goto 22
21: iconst_0
22: invokevirtual #5 // Method java/io/PrintStream.println:(Z)V
25: return
}
这个案例与上述2中案例唯一的区别在于变量str2声明前加了一个关键字final,导致在编译时,编译器直接将str3创建成了"helloworld",是不是有很多问号?由于被final修饰,在编译阶段会存入调用类的常量池中,因此str2一开始就存在于常量池中,String str3 = str2 + "world"实际就变成了String str3 = “hello” + “world”,因为String str1 = “helloworld”;这条语句已经将字符串"helloworld"写入常量池,str3首先会获取常量池中的值,所以结果为true。
4.判断下面程序输出结果
public static void main(String[] args) {
String str1 = "helloworld";
final String str2 = returnStr();
String str3 = str2 + "world";
System.out.println(str1 == str3); //false
}
public static String returnStr() {
return "hello";
}
反编译代码如下:
public class com.eckey.lab.test.StringTest {
public com.eckey.lab.test.StringTest();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
public static void main(java.lang.String[]);
Code:
0: ldc #2 // String helloworld
2: astore_1
3: invokestatic #3 // Method returnStr:()Ljava/lang/String;
6: astore_2
7: new #4 // class java/lang/StringBuilder
10: dup
11: invokespecial #5 // Method java/lang/StringBuilder."<init>":()V
14: aload_2
15: invokevirtual #6 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
18: ldc #7 // String world
20: invokevirtual #6 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
23: invokevirtual #8 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
26: astore_3
27: getstatic #9 // Field java/lang/System.out:Ljava/io/PrintStream;
30: aload_1
31: aload_3
32: if_acmpne 39
35: iconst_1
36: goto 40
39: iconst_0
40: invokevirtual #10 // Method java/io/PrintStream.println:(Z)V
43: return
public static java.lang.String returnStr();
Code:
0: ldc #11 // String hello
2: areturn
}
通过上述反汇编代码可知,当执行String str3 = str2 + “world”;操作时,首先生成了一个StringBuilder,其次将其进行了初始化操作,然后调用了append方法,拼接了字符串“world”,最后调用了toString方法,将拼接后的字符串转成了一个新的String,因此结果为false。
5.判断下面程序输出结果
String str1 = "hello";
String str2 = new String("hello");
System.out.println(str1 == str2); //false
在创建这个str2对象时因为使用了 new 关键字,所以肯定会在堆中创建一个对象。然后会在常量池中看有没有 hello这个字符串;如果没有此时还会在常量池中创建一个;如果有则不创建。所以可能是创建一个或者两个对象,但是一定存在两个对象,因此结果为false。
6.判断下面程序输出结果
String str1 = "hello";
String str2 = new String("hello");
System.out.println(str1 == str2.intern()); //true
String.intern()是一个Native方法,它的作用是:如果字符常量池中已经包含一个等于此String对象的字符串,则返回常量池中字符串的引用,否则,将新的字符串放入常量池,并返回新字符串的引用。在上述代码中,在创建str2对象时使用了new,因此肯定会在堆中创建一个对象。由于str1在创建时将字符串hello写入了常量池,因此str2会返回其引用,因此当str2调用intern()方法时,与str1指向的是常量池中同一个地址,则结果为true。
5.小结
1.String适用于字符串拼接较少、字符串变化较小的场合。
2.StringBuffer适用于字符串连接操作比较频繁,且要求线程安全(多线程环境下操作)。
3.StringBuilder适用于字符串连接操作比较频繁,且是单线程的情况。
6.参考文章
1.https://juejin.im/entry/59082ab5a0bb9f006510683a
2.https://juejin.im/entry/580467cfda2f60004fea4673
3.《深入理解JAVA虚拟机》