Java 基础教程

Java 面向对象

Java 高级教程

Java 笔记

java 字符串拼接的几种方式详解(执行效率及内存占用等对比)

Java 笔记 Java 笔记


java 的字符串拼接是开发中最常遇到的场景,开发者往往会忽略字符串拼接的执行速度与内存消耗,本文详细介绍字符串连接的几种方式及其背后的原理,以及运行效率和内存占用的比较。这里我们先道出实验对比后的结果,对于在保证线程安全的情况下,推荐使用 StringBuilder的方式,如果多线程操作就应该用 StringBuffer,它的所有操作方式都是带有 synchronized 的同步方法,可以确保多线程操作时的线程安全。

拼接几种方式

java 的字符串拼接主要有 4 种方式,具体如下:

  1. 程序员最常见的操作方式是通过加号(+),将多个离散的字符串连接起来;

    String s = "know" + "ledge" + "dict";
  2. java String 类有一个实例方法 concat(String str),它其实就是英文单词连接(concatenates)的简写,该方法将当前字符串后面追加指定其它字符串;

    "know".concat("ledge").concat("dict");
  3. 从 JDK 1.0 开始的字符串可变操作的 StringBuffer 类,它是使用缓冲区的,内容可以改变,且所有操作都是线程安全的;

    new StringBuffer("know").append("ledge").append("dict");
  4. 在 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 的使用呢

答案是这种认知是错误的,主要是以下两点原因:

  1. 如果加号拼接是多次分开操作的,其实相当于多次实例化了 StringBuilder 对象
  2. 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。

实验结果如下曲线图:

字符串拼接,加号、concat 方法与 StringBuilder 对比

从上图可以看出,StringBuilder 的表现远远大于其它两个,其次是 concat 方法,最差的是加号拼接的方式。