Java 字符串拼接效率分析及最佳实践
soch7638
8年前
<ol> <li> <p>java连接字符串有多种方式,比如+操作符, StringBuilder.append 方法,这些方法各有什么优劣(可以适当说明各种方式的实现细节)?</p> </li> <li> <p>按照高效的原则,那么java中字符串连接的最佳实践是什么?</p> </li> <li> <p>有关字符串处理,都有哪些其他的最佳实践?</p> </li> </ol> <p>废话不多说,直接开始, 环境如下:</p> <p>JDK版本: 1.8.0_65</p> <p>CPU: i7 4790`</p> <p>内存: 16G</p> <h3><strong>直接使用 + 拼接</strong></h3> <p>看下面的代码:</p> <pre> <code class="language-java">@Test public void test() { String str1 = "abc"; String str2 = "def"; logger.debug(str1 + str2); }</code></pre> <p>在上面的代码中,我们使用加号来连接四个字符串,这种字符串拼接的方式优点很明显: 代码简单直观,但是对比 StringBuilder 和 StringBuffer 在 大部分情况下 比后者都低,这里说是 大部分情况下 ,我们用javap工具对上面代码生成的字节码进行反编译看看在编译器对这段代码做了什么。</p> <pre> <code class="language-java">public void test(); Code: 0: ldc #5 // String abc 2: astore_1 3: ldc #6 // String def 5: astore_2 6: aload_0 7: getfield #4 // Field logger:Lorg/slf4j/Logger; 10: new #7 // class java/lang/StringBuilder 13: dup 14: invokespecial #8 // Method java/lang/StringBuilder."<init>":()V 17: aload_1 18: invokevirtual #9 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder; 21: aload_2 22: invokevirtual #9 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder; 25: invokevirtual #10 // Method java/lang/StringBuilder.toString:()Ljava/lang/String; 28: invokeinterface #11, 2 // InterfaceMethod org/slf4j/Logger.debug:(Ljava/lang/String;)V 33: return</code></pre> <p>从反编译的结果来看,实际上对字符串使用 + 操作符进行拼接,编译器会在编译阶段把代码优化成使用 StringBuilder 类,并调用 append 方法进行字符串拼接,最后调用 toString 方法,这样看来是否可以认为在一般情况下 其实直接使用+,反正编译器也会帮我优化为使用StringBuilder ?</p> <h3><strong>StringBuilder 源码分析</strong></h3> <p>答案自然是 不可以 的,原因就在于 StringBuilder 这个类它内部做了些什么时。</p> <p>我们看一看 StringBuilder 类的构造器</p> <pre> <code class="language-java">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); }</code></pre> <p>StringBuilder 提供了4个默认的构造器, 除了无参构造函数外,还提供了另外3个重载版本,而内部都调用父类的 super(int capacity) 构造方法,它的父类是 AbstractStringBuilder ,构造方法如下:</p> <pre> <code class="language-java">AbstractStringBuilder(int capacity) { value = new char[capacity]; }</code></pre> <p>可以看到实际上StringBuilder内部使用的是 char数组 来存储数据(String、StringBuffer也是),这里 capacity 的值指定了数组的大小。结合 StringBuilder 的无参构造函数,可以知道默认的大小是 16 个字符。</p> <p>也就是说如果待拼接的字符串总长度不小于16的字符的话,那么其实直接拼接和我们手动写StringBuilder区别不大,但是我们自己构造StringBuilder类可以指定数组的大小,避免分配过多的内存。</p> <p>现在我们再看看 StringBuilder.append 方法内部做了什么事:</p> <pre> <code class="language-java">@Override public StringBuilder append(String str) { super.append(str); return this; }</code></pre> <p>直接调用的父类的 append方法 :</p> <pre> <code class="language-java">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; }</code></pre> <p>在这个方法内部调用了 ensureCapacityInternal 方法,当拼接后的字符串总大小大于内部数组 value 的大小时,就必须先扩容才能拼接,扩容的代码如下:</p> <pre> <code class="language-java">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); }</code></pre> <p>StringBuilder 在扩容时把容量增大到 当前容量的两倍+2 ,这是很可怕的,如果在构造的时候没有指定容量,那么很有可能在扩容之后占用了浪费大量的内存空间。其次扩容后还调用了 Arrays.copyOf 方法,这个方法把扩容前的数据复制到扩容后的空间内,这样做的原因是: StringBuilder 内部使用 char数组 存放数据,java的数组是不可扩容的,所以只能重新申请一片内存空间,并把已有的数据复制到新的空间去,这里它最终调用了 System.arraycopy 方法来复制,这是一个native方法,底层直接操作内存,所以比我们用循环来复制要块的多,即便如此,大量申请内存空间和复制数据带来的影响也不可忽视。</p> <h3><strong>使用 + 拼接和使用 StringBuilder 比较</strong></h3> <pre> <code class="language-java">@Test public void test() { String str = ""; for (int i = 0; i < 10000; i++) { str += "asjdkla"; } }</code></pre> <p>上面这段代码经过优化后相当于:</p> <pre> <code class="language-java">@Test public void test() { String str = null; for (int i = 0; i < 10000; i++) { str = new StringBuilder().append(str).append("asjdkla").toString(); } }</code></pre> <p>一眼就能看出 创建了太多的StringBuilder对象 ,而且在每次循环过后str越来越大,导致每次申请的内存空间越来越大,并且当str长度大于16时,每次都要扩容两次!而实际上 toString 方法在创建 String 对象时,调用了 Arrays.copyOfRange 方法来复制数据,此时相当于每执行一次,扩容了两次,复制了3次数据,这样的代价是相当高的。</p> <pre> <code class="language-java">public void test() { StringBuilder sb = new StringBuilder("asjdkla".length() * 10000); for (int i = 0; i < 10000; i++) { sb.append("asjdkla"); } String str = sb.toString(); }</code></pre> <p>这段代码的执行时间在我的机器上都是0ms(小于1ms)和1ms,而上面那段代码则大约在380ms!效率的差距相当明显。</p> <p>同样是上面的代码,将循环次数调整为 1000000 时,在我的机器上,有指定 capacity 时耗时大约20ms,没有指定 capacity 时耗时大约29ms,这个差距虽然和直接使用 + 操作符有了很大的提升(且循环次数增大了100倍),但是它依旧会触发多次扩容和复制。</p> <p>将上面的代码改成使用 StringBuffer ,在我的机器上,耗时大约为33ms,这是因为 StringBuffer 在大部分方法上都加上了 synchronized 关键字来保证线程安全,执行效率有一定程度上的降低。</p> <h3><strong>使用 String.concat 拼接</strong></h3> <p>现在再看这段代码:</p> <pre> <code class="language-java">@Test public void test() { String str = ""; for (int i = 0; i < 10000; i++) { str.concat("asjdkla"); } }</code></pre> <p>这段代码使用了 String.concat 方法,在我的机器上,执行时间大约为130ms,虽然直接相加要好的多,但是比起使用 StringBuilder 还要太多了,似乎没什么用。其实并不是,在很多时候,我们只需要连接两个字符串,而不是多个字符串的拼接,这个时候使用 String.concat 方法比 StringBuilder 要简洁且效率要高。</p> <pre> <code class="language-java">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); }</code></pre> <p>上面这段是 String.concat 的源码,在这个方法中,调用了一次Arrays.copyOf,并且指定了 len + otherLen ,相当于分配了一次内存空间,并分别从str1和str2各复制一次数据。而如果使用 StringBuilder 并指定 capacity ,相当于分配一次内存空间,并分别从str1和str2各复制一次数据,最后因为调用了 toString 方法,又复制了一次数据。</p> <h3><strong>结论</strong></h3> <p>现在根据上面的分析和测试可以知道:</p> <ol> <li> <p>Java中字符串拼接不要直接使用 + 拼接。</p> </li> <li> <p>使用StringBuilder或者StringBuffer时,尽可能准确地估算capacity,并在构造时指定,避免内存浪费和频繁的扩容及复制。</p> </li> <li> <p>在没有线程安全问题时使用 StringBuilder , 否则使用 StringBuffer 。</p> </li> <li> <p>两个字符串拼接直接调用 String.concat 性能最好。</p> </li> </ol> <h3>关于 String 的其他最佳实践:</h3> <ol> <li> <p>用 equals 时总是把能确定不为空的变量写在左边,如使用 "".equals(str) 判断空串,避免空指针异常。</p> </li> <li> <p>第二点是用来排挤第一点的.. 使用 str != null && str.length() != 0 来判断空串,效率比第一点高。</p> </li> <li> <p>在需要把其他对象转换为字符串对象时,使用 String.valueOf(obj) 而不是直接调用 obj.toString() 方法,因为前者已经对空值进行检测了,不会抛出空指针异常。</p> </li> <li> <p>使用 String.format() 方法对字符串进行格式化输出。</p> </li> <li> <p>在JDK 7及以上版本,可以在 switch 结构中使用字符串了,所以对于较多的比较,使用 switch 代替 if-else 。</p> </li> <li> <p>我暂时想的起来的就这么几个了.. 请大家帮忙补充补充...</p> </li> </ol> <p> </p> <p>来自:https://segmentfault.com/a/1190000007099818</p> <p> </p>