7.3 剖析StringBuilder

7.3 剖析StringBuilder

7.2.4节提到,如果字符串修改操作比较频繁,应该采用StringBuilder和StringBuffer类,这两个类的方法基本是完全一样的,它们的实现代码也几乎一样,唯一的不同就在于StringBuffer类是线程安全的,而StringBuilder类不是。

关于线程的概念,我们到第15章再介绍。这里需要知道的就是,线程安全是有成本的,影响性能,而字符串对象及操作大部分情况下不存在线程安全问题,适合使用String-Builder类。所以,本节就只讨论StringBuilder类,包括基本用法和基本原理。

7.3.1 基本用法

StringBuilder的基本用法很简单。创建StringBuilder对象:

1
StringBuilder sb = new StringBuilder();

通过append方法添加字符串:

1
2
sb.append("老马说编程");
sb.append(",探索编程本质");

通过toString方法获取构建后的字符串:

1
System.out.println(sb.toString());

输出为:

1
老马说编程,探索编程本质

大部分情况,使用就这么简单,通过new新建StringBuilder对象,通过append方法添加字符串,然后通过toString方法获取构建完成的字符串。

7.3.2 基本实现原理

StringBuilder类是怎么实现的呢?我们来看下它的内部组成,以及一些主要方法的实现,代码基于Java 7。与String类似,StringBuilder类也封装了一个字符数组,定义如下:

1
char[] value;

与String不同,它不是final的,可以修改。另外,与String不同,字符数组中不一定所有位置都已经被使用,它有一个实例变量,表示数组中已经使用的字符个数,定义如下:

1
int count;

StringBuilder继承自AbstractStringBuilder,它的默认构造方法是:

1
2
3
public StringBuilder() {
super(16);
}

调用父类的构造方法,父类对应的构造方法是:

1
2
3
AbstractStringBuilder(int capacity) {
value = new char[capacity];
}

也就是说,new StringBuilder()代码内部会创建一个长度为16的字符数组,count的默认值为0。来看append方法的代码:

1
2
3
4
5
6
7
8
public AbstractStringBuilder append(String str) {
if(str == null) str = "null";
int len = str.length();
ensureCapacityInternal(count + len);
str.getChars(0, len, value, count);
count += len;
return this;
}

append会直接复制字符到内部的字符数组中,如果字符数组长度不够,会进行扩展,实际使用的长度用count体现。具体来说,ensureCapacityInternal(count+len)会确保数组的长度足以容纳新添加的字符,str.getChars会复制新添加的字符到字符数组中,count+=len会增加实际使用的长度。

ensureCapacityInternal的代码如下:

1
2
3
4
5
private void ensureCapacityInternal(int minimumCapacity) {
//overflow-conscious code
if(minimumCapacity - value.length > 0)
expandCapacity(minimumCapacity);
}

如果字符数组的长度小于需要的长度,则调用expandCapacity进行扩展,其代码为:

1
2
3
4
5
6
7
8
9
10
11
void expandCapacity(int minimumCapacity) {
int newCapacity = value.length * 2 + 2;
if(newCapacity - minimumCapacity < 0)
newCapacity = minimumCapacity;
if(newCapacity < 0) {
if (minimumCapacity < 0) //overflow
throw new OutOfMemoryError();
newCapacity = Integer.MAX_VALUE;
}
value = Arrays.copyOf(value, newCapacity);
}

扩展的逻辑是:分配一个足够长度的新数组,然后将原内容复制到这个新数组中,最后让内部的字符数组指向这个新数组,这个逻辑主要靠下面的代码实现:

1
value = Arrays.copyOf(value, newCapacity);

关于类Arrays,我们下一节介绍,这里主要看下newCapacity是怎么算出来的。参数minimumCapacity表示需要的最小长度,需要多少分配多少不就行了吗?不行,因为那就跟String一样了,每append一次,都会进行一次内存分配,效率低下。这里的扩展策略是跟当前长度相关的,当前长度乘以2,再加上2,如果这个长度不够最小需要的长度,才用minimumCapacity。

比如,默认长度为16,长度不够时,会先扩展到16*2+2即34,然后扩展到34*2+2即70,然后是70*2+2即142,这是一种指数扩展策略。为什么要加2?这样,在原长度为0时也可以一样工作。

为什么要这么扩展呢?这是一种折中策略,一方面要减少内存分配的次数,另一方面要避免空间浪费。在不知道最终需要多长的情况下,指数扩展是一种常见的策略,广泛应用于各种内存分配相关的计算机程序中。不过,如果预先就知道需要多长,那么可以调用StringBuilder的另外一个构造方法:

1
public StringBuilder(int capacity)

字符串构建完后,我们来看toString方法的代码:

1
2
3
4
public String toString() {
//Create a copy, don't share the array
return new String(value, 0, count);
}

基于内部数组新建了一个String。注意,这个String构造方法不会直接用value数组,而会新建一个,以保证String的不可变性。

除了append和toString方法, StringBuilder还有很多其他方法,包括更多构造方法、更多append方法、插入、删除、替换、翻转、长度有关的方法,限于篇幅,就不一一列举了。主要看下插入方法。在指定索引offset处插入字符串str:

1
public StringBuilder insert(int offset, String str)

原来的字符后移,offset为0表示在开头插,为length()表示在结尾插,比如:

1
2
3
4
5
6
StringBuilder sb = new StringBuilder();
sb.append("老马说编程");
sb.insert(0, "关注");
sb.insert(sb.length(), "老马和你一起探索编程本质");
sb.insert(7, ", ");
System.out.println(sb.toString());

输出为:

1
关注老马说编程,老马和你一起探索编程本质

了解了用法,下面来看insert的实现代码:

1
2
3
4
5
6
7
8
9
10
11
12
public AbstractStringBuilder insert(int offset, String str) {
if((offset < 0) || (offset > length()))
throw new StringIndexOutOfBoundsException(offset);
if(str == null)
str = "null";
int len = str.length();
ensureCapacityInternal(count + len);
System.arraycopy(value, offset, value, offset + len, count - offset);
str.getChars(value, offset);
count += len;
return this;
}

这个实现思路是:在确保有足够长度后,首先将原数组中offset开始的内容向后挪动n个位置,n为待插入字符串的长度,然后将待插入字符串复制进offset位置

挪动位置调用了System.arraycopy()方法,这是个比较常用的方法,它的声明如下:

1
public static native void arraycopy(Object src,int srcPos,Object dest, int destPos, int length);

将数组src中srcPos开始的length个元素复制到数组dest中destPos处。这个方法有个优点:即使src和dest是同一个数组,它也可以正确处理。比如下面的代码:

1
2
3
int[] arr = new int[]{1,2,3,4};
System.arraycopy(arr, 1, arr, 0, 3);
System.out.println(arr[0]+", "+arr[1]+", "+arr[2]);

这里,src和dest都是arr, srcPos为1, destPos为0, length为3,表示将第二个元素开始的三个元素移到开头,所以输出为:

1
2,3,4

arraycopy的声明有个修饰符native,表示它的实现是通过Java本地接口实现的。Java本地接口是Java提供的一种技术,用于在Java中调用非Java实现的代码,实际上,array-copy是用C++语言实现的。为什么要用C++语言实现呢?因为这个功能非常常用,而C++的实现效率要远高于Java。

7.3.3 String的+和+=运算符

Java中,String可以直接使用+和+=运算符,这是Java编译器提供的支持,背后,Java编译器一般会生成StringBuilder, +和+=操作会转换为append。比如,如下代码:

1
2
3
String hello = "hello";
hello+=", world";
System.out.println(hello);

背后,Java编译器一般会转换为:

1
2
3
StringBuilder hello = new StringBuilder("hello");
hello.append(", world");
System.out.println(hello.toString());

既然直接使用+和+=就相当于使用StringBuilder和append,那还有什么必要直接使用StringBuilder呢?在简单的情况下,确实没必要。不过,在稍微复杂的情况下,Java编译器可能没有那么智能,它可能会生成过多的StringBuilder,尤其是在有循环的情况下,比如,如下代码:

1
2
3
4
5
String hello = "hello";
for(int i=0; i<3; i++){
hello+=", world";
}
System.out.println(hello);

Java编译器转换后的代码大致如下所示:

1
2
3
4
5
6
7
String hello = "hello";
for(int i=0; i<3; i++){
StringBuilder sb = new StringBuilder(hello);
sb.append(", world");
hello = sb.toString();
}
System.out.println(hello);

在循环内部,每一次+=操作,都会生成一个StringBuilder。

所以,对于简单的情况,可以直接使用String的+和+=,对于复杂的情况,尤其是有循环的时候,应该直接使用StringBuilder。