java 的字符串拼接是开发中最常遇到的场景,开发者往往会忽略字符串拼接的执行速度与内存消耗,本文详细介绍字符串连接的几种方式及其背后的原理,以及运行效率和内存占用的比较。
这里我们先道出实验对比后的结果,对于在保证线程安全的情况下,推荐使用 StringBuilder 类的方式,如果多线程操作就应该用 StringBuffer,它的所有操作方式都是带有 synchronized 的同步方法,可以确保多线程操作时的线程安全。
拼接几种方式
java 的字符串拼接主要有 4 种方式,具体如下:
-
程序员最常见的操作方式是通过加号(+),将多个离散的字符串连接起来;
String s = "know" + "ledge" + "dict";
-
java String 类有一个实例方法 concat(String str),它其实就是英文单词连接(concatenates)的简写,该方法将当前字符串后面追加指定其它字符串;
"know".concat("ledge").concat("dict");
-
从 JDK 1.0 开始的字符串可变操作的 StringBuffer 类,它是使用缓冲区的,内容可以改变,且所有操作都是线程安全的;
new StringBuffer("know").append("ledge").append("dict");
-
在 java 1.5 增加了 StringBuilder 类,它本质也是 StringBuffer 类的实现方式,唯一不同的是线程不安全的,去掉了所有操作方法的 synchronized 锁,这符合实际的绝大多数操作场景;
new StringBuilder("know").append("ledge").append("dict");
加号连接操作
直接通过 + 拼接的字符串其实在编译阶段编译器会优化为使用 StringBuilder 类,并调用 append 方法进行连接操作,我们可以通过字节编译查看工具验证该解释。
如下代码:
public static void main(String[] args) {
String s1 = "know"; // 0 ldc #2 <know>
// 2 astore_1
String s2 = "ledge"; // 3 ldc #3 <ledge>
// 5 astore_2
String s3 = "dict"; // 6 ldc #4 <dict>
// 8 astore_3
String once = s1 + s2 + s3; // 9 new #5 <java/lang/StringBuilder>
// 12 dup
// 13 invokespecial #6 <java/lang/StringBuilder.<init>>
// 16 aload_1
// 17 invokevirtual #7 <java/lang/StringBuilder.append>
// 20 aload_2
// 21 invokevirtual #7 <java/lang/StringBuilder.append>
// 24 aload_3
// 25 invokevirtual #7 <java/lang/StringBuilder.append>
// 28 invokevirtual #8 <java/lang/StringBuilder.toString>
// 31 astore 4
System.out.println(once); // 33 getstatic #9 <java/lang/System.out>
// 36 aload 4
// 38 invokevirtual #10 <java/io/PrintStream.println>
// 41 return
}
为了便于显示,字节编译信息加了 // 作为注释,从上例的编译情况可以看出,三个字符串的相加操作其实是对实例化的 StringBuilder 对象做了三次 append 操作,然后调用 toString 方法转化为 String 类型。
由于编译器会帮助我们优化,那加号的字符串拼接操作可否认为等同 StringBuilder 的使用呢?
答案是这种认知是错误的,主要是以下两点原因:
- 如果加号拼接是多次分开操作的,其实相当于多次实例化了 StringBuilder 对象;
- StringBuilder 的构造方法有 4 个,加号拼接操作优化成调用 StringBuilder 的默认无参构造方法,和实际使用其它构造方法会有区别。
如果使用场景避免如上两个 case,那么其实两种方式是一样的。
首先 check 一下,多次通过加号分开拼接操作的编译 case,如下:
public static void main(String[] args) {
String s1 = "know"; // 0 ldc //2 <know>
// 2 astore_1
String s2 = "ledge"; // 3 ldc //3 <ledge>
// 5 astore_2
String once = s1 + s2; // 6 new #4 <java/lang/StringBuilder>
// 9 dup
// 10 invokespecial #5 <java/lang/StringBuilder.<init>>
// 13 aload_1
// 14 invokevirtual #6 <java/lang/StringBuilder.append>
// 17 aload_2
// 18 invokevirtual #6 <java/lang/StringBuilder.append>
// 21 invokevirtual #7 <java/lang/StringBuilder.toString>
// 24 astore_3
String s3 = "dict"; // 25 ldc #8 <dict>
// 27 astore 4
String twice = once + s3; // 29 new #4 <java/lang/StringBuilder>
// 32 dup
// 33 invokespecial #5 <java/lang/StringBuilder.<init>>
// 36 aload_3
// 37 invokevirtual #6 <java/lang/StringBuilder.append>
// 40 aload 4
// 42 invokevirtual #6 <java/lang/StringBuilder.append>
// 45 invokevirtual #7 <java/lang/StringBuilder.toString>
// 48 astore 5
System.out.println(twice); // 50 getstatic #9 <java/lang/System.out>
// 53 aload 5
// 55 invokevirtual #10 <java/io/PrintStream.println>
// 58 return
}
从上面的编译信息可以看出,分开的加号拼接操作,会实例化多个 StringBuilder 对象,这样明确地会对内存空间会有一定的开销。
若非要进行字符串拼接加号操作,最好一次性拼接完,这样相当于只实例化了一个 StringBuilder 对象。
针对第二个构造函数的问题,我们这里先简单列出代码,后面 StringBuilder 讲解时再详细描述。
StringBuilder 构造方法默认初始化大小 16 的 char 数组,加号拼接的只能使用默认的构造方法,显性调用的实例化方法可以指定数组大小,这给开发者提供了内存优化的方式。
public StringBuilder() {
super(16);
}
public StringBuilder(int capacity) {
super(capacity);
}
public StringBuilder(String str) {
super(str.length() + 16);
append(str);
}
public StringBuilder(CharSequence seq) {
this(seq.length() + 16);
append(seq);
}
StringBuilder 真实的字符串存储在抽象基类的 char[] 数组,如下代码片段:
abstract class AbstractStringBuilder implements Appendable, CharSequence {
/**
* The value is used for character storage.
*/
char[] value;
// other code ..., here ignored
}
String 的 concat 方法
String 类提供了 concat 拼接字符串的方法,直接上源码,如下是 java 8 的:
public String concat(String str) {
int otherLen = str.length();
if (otherLen == 0) {
return this;
}
int len = value.length;
char buf[] = Arrays.copyOf(value, len + otherLen);
str.getChars(buf, len);
return new String(buf, true);
}
从源码可以看出,concat 方法调用了 Arrays.copyOf 方法,即直接内存复制,这和 StringBuilder 底层原理类似,但是它不用实例化 StringBuilder 对象,只是每次 concat 都会创建一个新的 String 对象,所以在有些情况下它可能比 StringBuilder 更快。
如果是两个字符串拼接,直接调用 String.concat 方法性能最好。
StringBuilder 和 StringBuffer
StringBuilder 是一个可变的字符序列。它提供了一个与 StringBuffer 兼容的 API,不一样的是它不保证同步。该类被设计主要用作 StringBuffer 的一个简易替换,并用在字符串缓冲区被单线程使用的时候。
除了上述提到的构造方法外,需要重点要讲到 append 方法:
@Override
public StringBuilder append(String str) {
super.append(str);
return this;
}
它的内部实现是父类 AbstractStringBuilder 的 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;
}
可以看到其内部根据最终字符串的长度,判断是否内存需要扩容(本质上是重新申请新的连续空间,因为是数组),对应方法 ensureCapacityInternal,具体如下:
private void ensureCapacityInternal(int minimumCapacity) {
// overflow-conscious code
if (minimumCapacity - value.length > 0) {
value = Arrays.copyOf(value,
newCapacity(minimumCapacity));
}
}
可以看到,如果当前的数组长度不满足新的字符数组大小,就会调用 Arrays.copyOf 方法进行内存复制。
StringBuilder 与 StringBuffer 的区别
StringBuilder 与 StringBuffer 主要是如下两点区别:
- StringBuffer 是 jdk 1.0 开始就存在的,而 StringBuilder 是 java 5 时,才加入到标准库的;
- StringBuffer 的 append、insert 等所有操作方法都加有 synchronized 同步锁,保证了线程安全,而 StringBuilder 没有加锁,线程不安全,需要在单线程下使用,这样它的效率比 StringBuilder 高。
执行效率对比
下面针对字符串拼接的加号、concat 及 StringBuilder 实现给出如下执行效率实验的代码:
public static void main(String[] args) {
String times = args[0];
int timesInt = Integer.parseInt(times);
long start = System.currentTimeMillis();
String s = "";
for (int i = 0; i < timesInt; i++) {
s = s + "+";
}
System.out.println(System.currentTimeMillis() - start);
}
public static void main(String[] args) {
String times = args[0];
int timesInt = Integer.parseInt(times);
long start = System.currentTimeMillis();
String s = "";
for (int i = 0; i < timesInt; i++) {
s = s.concat("c");
}
System.out.println(System.currentTimeMillis() - start);
}
public static void main(String[] args) {
String times = args[0];
int timesInt = Integer.parseInt(times);
long start = System.currentTimeMillis();
StringBuilder sb = new StringBuilder();
for (int i = 0; i < timesInt; i++) {
sb.append("b");
}
String s = sb.toString();
System.out.println(System.currentTimeMillis() - start);
}
对三个实验代码各自进行轮训次数调整执行,次数为 1000、5000、10000、20000、30000、40000、50000、60000、70000、80000、90000、100000。
实验结果如下曲线图:
从上图可以看出,StringBuilder 的表现远远大于其它两个,其次是 concat 方法,最差的是加号拼接的方式。