《Java面试题系列》:一个长知识又很有意思的专栏。深入挖掘、分析源码、汇总原理、图文结合,打造公众号系列文章,面试与否均可提升Level。欢迎持续关注【程序新视界】。第8篇。
关于字符串的面试题除了内存分布、equals比较,最常见的就是与StringBuffer和StringBuilder之间的区别了。
如果你回答:String类是不可变的,StringBuffer和StringBuilder是可变类,StringBuffer是线程安全的,StringBuilder则不是线程安全的。
就上面的总结而言,好像知道的有点少。本篇文章就带领大家全面的了解一下它们三个的区别与底层实现。
String字符串的拼接
关于String字符串前面多篇文章已经详细描述过,它的不可变性也是因为每当通过“+”操作时,都会在内存中生成新的字符串而导致的。
String a = "hello ";
String b = "world!";
String ab = a + b;
1
2
3
针对上述代码,内存分布图如下:
其中a和b初始化时位于字符串常量池,ab拼接后的对象位于堆中。可以很直观的看出,经过拼接新生成了String对象。如果拼接多次,那么会生成多个中间对象。
上面的结论在Java8之前是成立的,在Java8时JDK对“+”号拼接进行了优化,上面所写的拼接方式会被优化为基于StringBuilder的append方法进行处理。
stack=2, locals=4, args_size=1 0: ldc #2 // String hello 2: astore_1 3: ldc #3 // String world! 5: astore_2 6: new #4 // class java/lang/StringBuilder 9: dup 10: invokespecial #5 // Method java/lang/StringBuilder."<init>":()V 13: aload_1 14: invokevirtual #6 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder; 17: aload_2 18: invokevirtual #6 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder; 21: invokevirtual #7 // Method java/lang/StringBuilder.toString:()Ljava/lang/String; 24: astore_3 25: return
上面是通过javap -verbose命令反编译字节码的结果,很显然可以看到StringBuilder的创建和append方法的调用。
此时,如果再笼统的回答:通过加号拼接字符串会创建多个String对象,因此性能比StringBuilder差,就是错误的了。因为本质上加号拼接的效果最终经过编译器处理之后和StringBuilder是一致的。
如果你在代码中使用如下写法:
StringBuilder sb = new StringBuilder("hello ");
sb.append("world!");
System.out.println(sb.toString());
1
2
3
编译器的插件甚至建议你使用String来代替。
StringBuffer与StringBuilder的对比
StringBuffer和StringBuilder实现的核心代码基本一致,很多代码都是公用的。这两个类均继承自抽象类AbstractStringBuilder。
我们来从构造方法到append方法来逐一看一下它们的区别。先看StringBuilder的构造方法:
public StringBuilder(String str) {
super(str.length() + 16);
append(str);
}
1
2
3
4
其中super方法便是调用的AbstractStringBuilder的构造方法。对应StringBuffer的构造方法中实现也是如此:
public StringBuffer(String str) {
super(str.length() + 16);
append(str);
}
1
2
3
4
从构造方法来说,StringBuffer和StringBuilder是一样的。下面再看看append方法,StringBuilder实现如下:
@Override
public StringBuilder append(String str) {
super.append(str);
return this;
}
1
2
3
4
5
StringBuffer对应的方法如下:
@Override
public synchronized StringBuffer append(String str) {
toStringCache = null;
super.append(str);
return this;
}
1
2
3
4
5
6
很显然,在StringBuffer的append方法实现上除了内部将toStringCache变量赋值为null,唯一的不同就是在方法上使用synchronized进行了同步处理。
toStringCache是用来缓存最后一次调用toString方法时生成的字符串,当StringBuffer内容变动时,改值也会变动。
通过上面的append方法的对比,我们可以很轻易的发现StringBuffer是线程安全的,StringBuilder是非线程安全的。当然,使用synchronized进行同步处理,性能便会降低很多。
StringBuffer与StringBuilder的底层实现
StringBuffer与StringBuilder都调用了父类的构造方法:
AbstractStringBuilder(int capacity) {
value = new char[capacity];
}
1
2
3
通过该构造方法我们可以看到它们用来处理字符串信息的关键属性为value。在初始化时先初始化一个长度为传入字符串长度+16的char[]数组,也就是value值,用来存储实际的字符串。
在调用父类构造方法之后便是调用各自的append方法(见前面的代码),而其中的核心处理又的调用父类的append方法:
public AbstractStringBuilder append(String str) { if (str == null) return appendNull(); int len = str.length(); ensureCapacityInternal(count + len); str.getChars(0, len, value, count); count += len; return this; }
上述代码中其中str.getChars方法用来对传入的str字符串进行拼接,在原有的value数组后面进行填充。而count用来记录当前value数字中已经使用的长度。
那么,当没有使用synchronized进行同步操作时,线程不安全发生在哪里?上面代码中count+=len并不是原子操作。比如当前count为5,两个线程同时执行到++操作,拿到的值都为5,执行完加操作之后赋值给count,两个线程赋值都为6,而不是7。此时便出现了线程不安全的问题。
为什么String要设计成不可变
在Java中将String设计成不可变的是综合考虑到各种因素的结果,有如下原因:
1、字符串常量池的需要,如果字符串可变,改变一个对象会影响到另外一个独立的对象。不变这也是字符串常量池存在的前提条件。
2、Java中String对象的哈希码被频繁地使用,比如在HashMap等容器中。字符串不变保证了hash码的唯一性,可以方向缓存并使用。
3、安全性,确保String在当做参数传递时保持不变,避免安全隐患。比如在数据库用户名、密码、访问路径等传输过程中的保持不变,防止改变字符串指向对象的值被改变。
4、由于字符串变量不可变,在多线程中可以被共享使用。
小结
单纯的死记硬背面试题我们都会,但要在记忆面试题的过程中了解更多底层实现原理,不仅仅有助于理解“为什么”,同时还能学到更多相关的知识和原理。
在本文中简化了StringBuilder和StringBuffer内部数据的copy、数组扩容等步骤的讲解,感兴趣的朋友可以继续对照源码进行深入研究。