一.引言
很多人觉得C/C++难,Java则相对简单,其中有一个原因就是,C/C++处理字符串那真的是会让很多人头疼,比如在C/C++中对字符串的初始化定义为:
char str[10] = "java";
char *str = "java";
char str[10]={'j','a','v','a','\0'};
一看到数组、指针,就让很多人犯愁了。而又例如字符串的拼接,在C/C++中是通过strcat(str1,str2)实现的,但是使用这个方法,必须得清楚知道str1拥有足够的空间容纳str2,否则会造成不能完整将str2拼接到str1上。总之,挺麻烦的,不是?而Java则对字符串相关的处理方法进行了很高级的封装,Java使用者也能很轻松地对字符串进行一系列操作,相比于C/C++,简直是如鱼得水。
当然,本人在此并不是比较C/C++和Java谁好谁不好。本篇文章主要讲讲Java中涉及到字符串的String类和StringBuilder类。
二.String类
1.String类的定义
如下代码所示:
public final class String
implements java.io.Serializable, Comparable<String>, CharSequence {
/** The value is used for character storage. */
private final char value[];
//其他成员变量和方法
}
第一,可以注意到final修饰符,说明String类不能被继承。
第二,成员变量char value[]用于存储字符串中的每一个字符。
2.String对象的只读特性
String对象是不可变的,具有只读特性。
这句话看似无关痛痒,其实在实际的工程项目中,这一特性对性能必然有很大的影响,只是在大多数的开发过程中,我们并不在意。
那如何说明String对象的只读特性呢?又如何说明这一特性对性能的影响呢?我们One by one的回答。
2.1证明只读特性
在《Thinking in Java》第13章《字符串》中,作者举例说明:
package String;
public class Immutable {
public static String upcase(String s) {
return s.toUpperCase();
}
public static void main(String[] args) {
String q = "howdy";
System.out.println(q);
String qq = upcase(q);
System.out.println(qq);
System.out.println(q);
}
}
输出:
howdy
HOWDY
howdy
作者的解释:当把q传给upcase()方法时,实际传递的是引用的一个拷贝。其实,每当把String对象作为方法的参数时,都会复制一份引用,而该引用所指的对象其实一直待在单一的物理位置上,从未动过。回到upcase()的定义,传入其中的引用有了名字s,只有upcase()运行的时候,局部引用s才存在。一旦upcase()运行结束,s就消失了。当然了,upcase()的返回值,其实只是最终结果的引用。这足以说明,upcase()返回的引用已经指向了一个新的引用,而原本的q则还在原地。
个人认为这个例子并不能完整地说明String对象的只读特性。我的例子如下:
public static void main(String[] args) {
String s = "abc";
String t = "JAVA";
System.out.println(s);
System.out.println(t);
String ss = s.toUpperCase();
String tt = t.toUpperCase();
System.out.println(ss);
System.out.println(s);
System.out.println(tt);
System.out.println(t);
System.out.println(ss == s);
System.out.println(tt == t);
}
输出:
abc
JAVA
ABC
abc
JAVA
JAVA
false
true
大家都知道在Java中“==”比较的是两个对象的内存地址,从上面的例子可以看出来,如果原String对象的值未被修改,则返回的就是原来的对象,如果原对象被修改了,就会返回一个新的String对象。可参考String类中所有修改String值的方法,比如toUpperCase()方法中大致结构就是:
if(不需要修改) return this; //返回本身
else return new String(修改后的值);//返回一个新的String对象
2.2只读特性的影响
如各种资料可见,最好的例子是字符串拼接。
最常用的方式就是重载“+”和StringBuilder.append()方法。可能一般情况下,大多数Java开发人员都喜欢用“+”,
因为最简单,最方便,比如:
public static void main(String[] args) {
String a = "喜欢";
String b = "我" + a + "Java" + 31;
}
由于String对象的不可变性,那么上面的代码会执行多次“+”:“我”和a相连,产生一个新的String对象,然后再和”Java”相连,再产生一个新的String对象,以此类推。实际开发过程中,一定会遇到拼接多个String对象的时候,那如此可见,这样一行代码,就会产生很多的String类型的中间变量。那对性能的影响体现在何处?这里就要提到Java对象在内存中的真正大小=对象头+实例数据+对齐填充(可参考http://www.cnblogs.com/zhanjindong/p/3757767.html)。我们在多线程开发中经常使用synchronized给对象加锁,那一个对象的锁状态在哪里?就在对象头里。32位HotSpot虚拟机中,对象头的结构如下:
图片来源:http://blog.csdn.net/zhoufanyang_china/article/details/54601311。
也意味着,每创建一个String中间变量,都会占用一定内存,比我们想象的还要多。如果可以避免产生这么多的中间变量,岂不是更好?
2.3编译器的优化
首先我们编译一下上面的代码,然后再反编码看一下编译器都干嘛了:
从这段反编译后的字节码中可以看出,编译器自动引入了StringBuilder类,因为StringBuilder更高效(为何更高效,请看第三部分)。编译器创建了一个StringBuilder对象用于构造最终的String,然后调用了四次append()方法。也意味着上面的代码等价于:
String b = new StringBuilder().append("我").append(a).append("Java").append(31).toString();
如此看来,编译器会自动优化性能,那我们便可以随意使用重载“+”用于字符串拼接吗?非也,例如:
//String“+”:循环拼接字符串
public static String connectStr(String[] str) {
String result = "";
for (String s : str) {
result += s;
}
return result;
}
//StringBuilder:循环拼接字符串
public static String connectStrBuilder(String[] str) {
StringBuilder stringBuilder = new StringBuilder();
for (String s : str) {
stringBuilder.append(s);
}
return stringBuilder.toString();
}
同样通过反编译看看编译器都干嘛了:
首先是重载“+”:
StringBuilder.append():
由此可见,编译器的确对重载“+”方法进行了优化,但是在循环中使用重载“+”,每一次循环都会都产生一个StringBuilder类型的中间变量。OMG,我们不是一直在尽力避免产生不必要的中间变量吗?而使用StringBuilder的append()方法,则简单多了,因为只会在循环之前产生一个StringBuilder对象用于构造最终的String,在循环中,只需要调用append()方法即可。所以一般在处理字符串拼接时,为了性能达到最优,推荐使用StringBuilder的append()方法,然后再通过toString()方法将结果转为String类型,同时StringBuilder也提供了其他一些方法。
三.StringBuilder类
前面已经提到在对字符串的某些处理上,StringBuilder类相比于String类更加高效,比较常见的就是通过append()方法进行字符串拼接,那么为何StringBuilder会更加高效呢?我们来分析一下StringBuilder的定义和append()方法。
//StringBuilder.java
public final class StringBuilder
extends AbstractStringBuilder
implements java.io.Serializable, CharSequence
{
public StringBuilder() {
super(16);//调用父类的构造函数,并且参数为16,这个参数的意义是初始容量
}
public StringBuilder(int capacity) {//指定初始容量
super(capacity);
//其他构造函数等等
}
@Override
public StringBuilder append(String str) {
super.append(str);//调用父类AbstractStringBuilder的append()方法
return this;//返回的是本身
}
}
再看看AbstractStringBuilder的定义和append()方法
//AbstractStringBuilder.java
abstract class AbstractStringBuilder implements Appendable, CharSequence {
char[] value;
int count;
AbstractStringBuilder() {
}
AbstractStringBuilder(int capacity) {
value = new char[capacity];//初始化value数组
}
//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;
}
private void ensureCapacityInternal(int minimumCapacity) {
// overflow-conscious code
//如果需要的最小容量(minimumCapacity = 原来的字符串长度count+需要拼接的字符串长度)已经超过了总的容量(数组value的长度),则进行扩容
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;
}
所以,StringBuilder的拼接过程如下,用默认的初始容量16举例:
总结起来就是:
(1)StringBuilder初始化时,既可以指定初始化容量(如果你已经大概知道最终的字符串大小,那这样就可以省去扩容过程),也可以按照默认的初始化容量进行初始化。
(2)无论拼接多少次、是否会循环,只会生成一个StringBuilder对象(当然如果你把StringBuilder s = new StringBuilder()这样的语句写在了循环内,那就另说,而且这样写,也不符合一般情况下的逻辑)。
文章内容参考了《Thinking in java》和几篇网上的资料,已在文章给出链接。由于水平有限,文中出现错误与不妥之处在所难免,恳请读者批评指正。