浅谈StringBuilder

作者: 美团Java | 来源:发表于2016-08-30 19:05 被阅读5938次

    简书 占小狼
    转载请注明原创出处,谢谢!

    连接符号 "+" 本质

    浅谈Java String内幕(1) 中,字符串变量(非final修饰)通过 "+" 进行拼接,在编译过程中会转化为StringBuilder对象的append操作,注意是编译过程,而不是在JVM中。

    public class StringTest {
        public static void main(String[] args) {
            String str1 = "hello ";
            String str2 = "java";
            String str3 = str1 + str2 + "!";
            String str4 = new StringBuilder().append(str1).append(str2).append("!").toString();
        }
    }
    

    上述 str3 和 str4 的执行效果其实是一样的,不过在for循环中,千万不要使用 "+" 进行字符串拼接。

    public class test {
        public static void main(String[] args) {
            run1();
            run2();
        }   
    
        public static void run1() {
            long start = System.currentTimeMillis();
            String result = "";
            for (int i = 0; i < 10000; i++) {
                result += i;
            }
            System.out.println(System.currentTimeMillis() - start);
        }
        
        public static void run2() {
             long start = System.currentTimeMillis();
            StringBuilder builder = new StringBuilder();
            for (int i = 0; i < 10000; i++) {
                builder.append(i);
            }
            System.out.println(System.currentTimeMillis() - start);
        }
    }
    

    在for循环中使用 "+" 和StringBuilder进行1万次字符串拼接,耗时情况如下:
    1、使用 "+" 拼接,平均耗时 250ms;
    2、使用StringBuilder拼接,平均耗时 1ms;

    for循环中使用 "+" 拼接为什么这么慢?下面是run1方法的字节码指令



    5 ~ 34 行对应for循环的代码,可以发现,每次循环都会重新初始化StringBuilder对象,导致性能问题的出现。

    性能问题

    StringBuilder内部维护了一个char[]类型的value,用来保存通过append方法添加的内容,通过 new StringBuilder() 初始化时,char[]的默认长度为16,如果append第17个字符,会发生什么?

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

    如果value的剩余容量,无法添加全部内容,则通过expandCapacity(int minimumCapacity)方法对value进行扩容,其中minimumCapacity = 原value长度 + append添加的内容长度。
    1、扩大容量为原来的两倍 + 2,为什么要 + 2,而不是刚好两倍?
    2、如果扩容之后,还是无法添加全部内容,则将 minimumCapacity 作为最终的容量大小;
    3、利用 System.arraycopy 方法对原value数据进行复制;

    在使用StringBuilder时,如果给定一个合适的初始值,可以避免由于char[]数组多次复制而导致的性能问题。

    不同初始容量的性能测试:

    public class StringBuilderTest {
        public static void main(String[] args) {
            int sum = 0;
            final int capacity = 40000000;
            for (int i = 0; i < 100; i++) {
                sum += cost(capacity);
            }
            System.out.println(sum / 100);
        }
    
        public static long cost(int capacity) {
            long start = System.currentTimeMillis();
            StringBuilder builder = new StringBuilder(capacity);
            for (int i = 0; i < 10000000; i++) {
                builder.append("java");
            }
            return System.currentTimeMillis() - start;
        }
    }
    

    执行一千万次append操作,不同初始容量的耗时情况如下:
    1、容量为默认16时,平均耗时110ms;
    2、容量为40000000时,不会发生复制操作,平均耗时85ms;

    通过以上数据可以发现,性能损耗不是很严重。

    内存问题

    1、StringBuilder内部进行扩容时,会新建一个大小为原来两倍+2的char数组,并复制原char数组到新数组,导致内存的消耗,增加GC的压力。
    2、StringBuilder的toString方法,也会造成char数组的浪费。

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

    String的构造方法中,会新建一个大小相等的char数组,并使用 System.arraycopy() 复制StringBuilder中char数组的数据,这样StringBuilder的char数组就白白浪费了。

    重用StringBuilder

    public class StringBuilderHolder {
        private final StringBuilder sb;
        public StringBuilderHolder(int capacity) {
            sb = new StringBuilder(capacity);
        }
    
        public StringBuilder resetAndGet() {
            sb.setLength(0);
            return sb;
        }
    }
    

    通过 sb.setLength(0) 方法可以把char数组的内存区域设置为0,这样char数组重复使用,为了避免并发访问,可以在ThreadLocal中使用StringBuilderHolder,使用方式如下:

    private static final ThreadLocal<StringBuilderHolder> stringBuilder= new ThreadLocal<StringBuilderHolder>() {
        @Override
        protected StringBuilderHolder initialValue() {
            return new StringBuilderHolder(256);
        }
    };
     
    StringBuilder sb = stringBuilder.get().resetAndGet();
    

    不过这种方式也存在一个问题,该StringBuilder实例的内存空间一直不会被GC回收,如果char数组在某次操作中被扩容到一个很大的值,可能之后很长一段时间都不会用到如此大的空间,就会造成内存的浪费。

    总结

    虽然使用默认的StringBuilder进行字符串拼接操作,性能消耗不是很严重,但在高性能场景下,还是推荐使用ThreadLocal下可重用的StringBuilder方案。

    参考资料:
    StringBuilder在高性能场景下的正确用法

    END。
    我是占小狼。
    在魔都艰苦奋斗,白天是上班族,晚上是知识服务工作者。
    如果读完觉得有收获的话,记得关注和点赞哦。
    非要打赏的话,我也是不会拒绝的。

    相关文章

      网友评论

      • 黄云斌huangyunbin:resetAndGet 这个方法
        有个问题:如果有次构建了一个很大的StringBuilder,那么他对应的数组一直很大,可以理解为轻微的内存泄露。
        解决方式:判断如果超过了你设置的一个大小(比如1024),setLength(1024),再trimToSize。最后setLength(0)
        美团Java:这种确实可以
      • come_true:学习了
      • 208_1263:长知识了
      • listen2code:扩大容量为原来的两倍 + 2,为什么要 + 2,而不是刚好两倍??这个好像没说
      • 捡淑:马克
      • alighters:关于 “for循环中使用 "+" 拼接为什么这么慢” ,为什么 run1 方法字节码指令中,看到的是 StringBuilder 对象?
        美团Java:@alighters 恩,这是编译器javac实现的,也算是语法糖的一类的吧
        alighters:@alighters 哦哦,明白了。对这个加号自动转 StringBuilder 颇感神奇。赞一个
        美团Java:@alighters 用 "+" 进行拼接,都会转化成 StringBuilder 对象,但是在for循环中,每循环一次,就创建一个StringBuilder 对象,如果循环1千万次,就创建了1千万个StringBuilder 对象,性能会差很多

      本文标题:浅谈StringBuilder

      本文链接:https://www.haomeiwen.com/subject/dshfettx.html