无聊的笔记:之一(Java字符串链接方式效率对比和解析)

简介

JAVA开发过程中,经常会遇到字符串操作,对字符串的拼接操作更常见。
拼接字符串主要有以下几种方法:

1. "" + ""
2. "".concat("")
3. new StringBuilder().append()
4. new StringBuffer().append()

还有一个StringUtils工具类的join()方法,这里不做讨论。
时间紧迫的朋友可以跳过分析,直接看结论。

分析

1. 直接使用+拼接字符串

这是最方便的。但是它的性能在大部分情况下也是最低的一个。为什么这么说呢?请看官继续向下:

废话少说,先上个例子:

public class Test1 {
    public static void main(String[] args) {
        String s = "abc";
        String s1 = "123" + s;
        String s2 = "efg" + s1;
    }
}

我们使用javap工具对字节码文件 Test1.class 反编译一下,瞅瞅+操作符干了些啥。

javap是java字节码反编译工具。-c 参数表示显示反汇编命令。

C:\com\demo> javap -c .\Test1.class
Compiled from "Test1.java"
public class com.demo.Test1 {
  public com.demo.Test1();
    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 abc
       2: astore_1
       3: new           #3                  // class java/lang/StringBuilder
       6: dup
       7: invokespecial #4                  // Method java/lang/StringBuilder."<init>":()V
      10: ldc           #5                  // String 123
      12: invokevirtual #6                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
      15: aload_1
      16: invokevirtual #6                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
      19: invokevirtual #7                  // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
      22: astore_2
      23: new           #3                  // class java/lang/StringBuilder
      26: dup
      27: invokespecial #4                  // Method java/lang/StringBuilder."<init>":()V
      30: ldc           #8                  // String efg
      32: invokevirtual #6                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
      35: aload_2
      36: invokevirtual #6                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
      39: invokevirtual #7                  // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
      42: astore_3
      43: return
}

从反编译的代码中可以看出,编译器会把+符号优化成StringBuilder类,并使用 StringBuilder.append() 方法对字符拼接。最后调用 StringBuilder.toString() 方法,返回String。
在上面的代码中,我们对字符串进行了两次+操作,在优化后的代码也创建两次StringBuilder对象和调用两次toString()方法。

既然编译器会把+操作优化成StringBuilder方法,那它们效率会一样?其实不然。

2. 为什么+操作符比StringBuilder效率低?

首先看看StringBuilder怎么拼接字符串。

StringBuffer sb = new StringBuffer();
sb.append("abc");
sb.append("123");
sb.append("efg");
sb.toString();

上面的代码,只 new 了一个 StringBuilder 对象,而且只调用了一次 toString()方法。
我们知道在java中实例化对象,其实是很费时的,还要回收什么的。要不就不会搞出 单例模式 这种东西了。
toString()这个方法更是浪费时间,我们来看下 StringBuildertoString() 方法的源码。

public final class StringBuilder extends AbstractStringBuilder implements java.io.Serializable, CharSequence{
    
    //省略 n 多代码
    private transient char[] toStringCache;     

    @Override
    public synchronized String toString() {
        if (toStringCache == null) {  
            toStringCache = Arrays.copyOfRange(value, 0, count);
        }
        return new String(toStringCache, true);
    }

    //省略 n 多代码    

}

StringBuilderStringBuffer 都是 AbstractStringBuilder 的实现类,其底层是用 char[] 数组来储存数据的。StringBuilder默认初始char[]大小是16,当添加的字符大于16个,就会自动扩容。扩容这个操作本质是对数组的复制,也挺费时的,有兴趣的可以看一下源码。而+操作生成的StringBuilder默认大小为16,如果拼接的字符串过大,会频繁的扩容导致效率低下。

所以如果使用StringBuilder的时候,我们可以给一个参数capacity,初始化char[]的大小,这样可以避免频繁的扩容。

toString() 方法就是对 char[] 数组的复制。这可是个挺费时间的操作。

所以,虽然编译器对 + 操作进行了优化,但是由于频繁的实例化StringBuilder、频繁的调用toString()、在添加的字符串较大的情况下还会频繁的扩容,导致其效率极其低下。

但是,+一定比StringBuilder效率低嘛?答案当然是否定的。

3. +什么时候比StringBuilder高效?

我们再上两段代码:

public static void main(String[] args) {
    // +
    String s1 = "abc"+"123"+"efg";

    // StringBuilder
    StringBuilder sb = new StringBuilder();
    String s2 = sb.append("abc").append("123").append("efg").toString();
}

同样把这段代码反编译一下,你知道,编译器把上面的代码编译成什么样了嘛?

public static void main(String[] args) {
    String s1 = "abc123efg";
    StringBuilder sb = new StringBuilder();
    String s2 = sb.append("abc").append("123").append("efg").toString();
}

编译器直接在编译的时候就把 + 给组合完成了,运行时间为 0 。谁效率高谁效率低显而易见。

所以进行大量的字符串拼接操作 StringBuilder 更合适,而进行少量的,可预知的字符串拼接 + 更合适。(一般 for 循环中用 StringBuilder ,而静态变量等用 +

4. StringBuilderStringBuffer的区别。

它们两个的区别主要是 StringBuilder 是线程不安全的,而StringBuffer是线程不安全的。
我们看一下两个类的 append() 方法的源码:
StringBuilder

public final class StringBuilder extends AbstractStringBuilder implements java.io.Serializable, CharSequence{
    // ...
    @Override
    public StringBuilder append(String str) {
        super.append(str);
        return this;
    }
    // ...
}

StringBuffer

public final class StringBuffer extends AbstractStringBuilder implements java.io.Serializable, CharSequence{
    // ...
    @Override
    public synchronized StringBuffer append(String str) {
        toStringCache = null;
        super.append(str);
        return this;
    }
    // ...
}

StringBuilder比StringBuffer少了同步锁。其他基本相同,因为它们都是AbstractStringBuilder的实现类。是双胞胎(其实眼瞎的我莫名感觉他们的名字长得还蛮像的)

所以,在线程安全的情况下,StringBuilder更高效一点(毕竟同步锁也要花时间),而线程不安全就只能用 StringBuffer。

5. String.concat()拼接与StringBuilder的比较

上代码:


String s = "abc".concat("123").concat("efg");

来瞅瞅源码:

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);
}

String.concat()方法调用了一次Arrays.copyOf(value, len + otherLen)方法,一次性分配了两个个字符串长度的内存空间,只执行了一次空间分配,并对拼接的两个字符各复制了一次,复制了两次。
StringBuilderappend()char[]未满的情况下,不会扩容。只会在初始化的时候执行一次空间分配,对两个字符串各复制了一次,复制了两次,但是最后会调用toString(),还会复制一次。

所以,比较之下,在只有两个字符串拼接的情况下,concat的效率要高一点,而大量的字符串拼接,StringBuilder效率会高一点,而且好在初始化的时候,指定char[]容器的大小,这样可以避免过度的扩容

测试环境

  • 系统:windows10 x64
  • 处理器:i7 9700k
  • 内存:16G
  • 台式电脑

测试代码

public class Demo {

    public static void main(String[] args) {
        int number = 10000000; //拼接次数
        long start ;           //开始时间

        // concat
        String s2 = "";
        start = System.currentTimeMillis();
        for(int i = 0 ; i < number ; ++i){
            s2.concat(String.valueOf(i));
        }
        System.out.println(" concat 用时:"+ (System.currentTimeMillis() - start) + " 毫秒");


        // StringBuilder
        StringBuilder stringBuilder = new StringBuilder();
        start = System.currentTimeMillis();
        for(int i = 0 ; i < number ; ++i){
            stringBuilder.append(String.valueOf(i));
        }
        System.out.println("StringBuilder 用时:"+ (System.currentTimeMillis() - start) + " 毫秒");

        //StringBuffer
        StringBuffer stringBuffer = new StringBuffer();
        start = System.currentTimeMillis();
        for(int i = 0 ; i < number ; ++i){
            stringBuffer.append(String.valueOf(i));
        }
        System.out.println(" StringBuffer 用时:"+ (System.currentTimeMillis() - start) + " 毫秒");

        // +
        String s = "";
        start = System.currentTimeMillis();
        for(int i = 0 ; i < number ; ++i){
            s += String.valueOf(i);
        }
        System.out.println(" + 用时:"+ (System.currentTimeMillis() - start) + " 毫秒");


    }
}

测试结果

测试结果取几次或者1次的平均值整数,不同运行环境会有偏差

+ concat StringBuilder StringBuffer
1000次 3ms 1ms 1ms 1ms
100000次 15584ms 10ms 6ms 6ms
1000000次 2212323ms 56ms 34ms 44ms
10000000次 很多很多ms 408ms 353ms 448ms

结论

  1. +最好不要直接大量使用。只有在逻辑较简单的情况下(没有for循环之类)或者字符确定的情况下时候使用。如:String a = "abc"+"123"
  2. 一般情况,在大量字符串拼接操作中。使用StringBuilder和StringBuffer,并尽可能的估算字符串的大小,使用带capacity参数的构造函数,也就是char[]的大小,这样可以避免重复的扩容。
  3. StringBuffer 在线程不安全的情况下使用,其他情况一般使用StringBuilder
  4. 只有两个字符串拼接的时候使用 concat(),这个性能最好。 但是每次拼接操作都会分配内存的操作,而上面两个并不一定每次拼接操作都会分配内存。
posted @ 2020-09-02 23:15  BobCheng  阅读(262)  评论(0编辑  收藏  举报