Java字符串拼接效率分析及最佳实践

简介:


本文来源于问题 Java字符串连接最佳实践?

  1. java连接字符串有多种方式,比如+操作符,StringBuilder.append方法,这些方法各有什么优劣(可以适当说明各种方式的实现细节)?
  2. 按照高效的原则,那么java中字符串连接的最佳实践是什么?
  3. 有关字符串处理,都有哪些其他的最佳实践?

废话不多说,直接开始, 环境如下:

  • JDK版本: 1.8.0_65
  • CPU: i7 4790
  • 内存: 16G

直接使用+拼接

看下面的代码:


 
 
  1. @Test 
  2.     public void test() { 
  3.         String str1 = "abc"
  4.         String str2 = "def"
  5.         logger.debug(str1 + str2); 
  6.     }  

在上面的代码中,我们使用加号来连接四个字符串,这种字符串拼接的方式优点很明显: 代码简单直观,但是对比StringBuilder和StringBuffer在大部分情况下比后者都低,这里说是大部分情况下,我们用javap工具对上面代码生成的字节码进行反编译看看在编译器对这段代码做了什么。


 
 
  1. public void test(); 
  2.     Code: 
  3.        0: ldc           #5                  // String abc 
  4.        2: astore_1 
  5.        3: ldc           #6                  // String def 
  6.        5: astore_2 
  7.        6: aload_0 
  8.        7: getfield      #4                  // Field logger:Lorg/slf4j/Logger; 
  9.       10: new           #7                  // class java/lang/StringBuilder 
  10.       13: dup 
  11.       14: invokespecial #8                  // Method java/lang/StringBuilder."<init>":()V 
  12.       17: aload_1 
  13.       18: invokevirtual #9                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder; 
  14.       21: aload_2 
  15.       22: invokevirtual #9                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder; 
  16.       25: invokevirtual #10                 // Method java/lang/StringBuilder.toString:()Ljava/lang/String; 
  17.       28: invokeinterface #11,  2           // InterfaceMethod org/slf4j/Logger.debug:(Ljava/lang/String;)V 
  18.       33: return  

从反编译的结果来看,实际上对字符串使用+操作符进行拼接,编译器会在编译阶段把代码优化成使用StringBuilder类,并调用append方法进行字符串拼接,最后调用toString方法,这样看来是否可以认为在一般情况下其实直接使用+,反正编译器也会帮我优化为使用StringBuilder?

StringBuilder源码分析

答案自然是不可以的,原因就在于StringBuilder这个类它内部做了些什么时。

我们看一看StringBuilder类的构造器


 
 
  1. public StringBuilder() { 
  2.         super(16); 
  3.     } 
  4.  
  5.     public StringBuilder(int capacity) { 
  6.         super(capacity); 
  7.     } 
  8.  
  9.     public StringBuilder(String str) { 
  10.         super(str.length() + 16); 
  11.         append(str); 
  12.     } 
  13.  
  14.     public StringBuilder(CharSequence seq) { 
  15.         this(seq.length() + 16); 
  16.         append(seq); 
  17.     }  

StringBuilder提供了4个默认的构造器, 除了无参构造函数外,还提供了另外3个重载版本,而内部都调用父类的super(int capacity)构造方法,它的父类是AbstractStringBuilder,构造方法如下:


 
 
  1. AbstractStringBuilder(int capacity) { 
  2.         value = new char[capacity]; 
  3.     }  

可以看到实际上StringBuilder内部使用的是char数组来存储数据(String、StringBuffer也是),这里capacity的值指定了数组的大小。结合StringBuilder的无参构造函数,可以知道默认的大小是16个字符。

也就是说如果待拼接的字符串总长度不小于16的字符的话,那么其实直接拼接和我们手动写StringBuilder区别不大,但是我们自己构造StringBuilder类可以指定数组的大小,避免分配过多的内存。

现在我们再看看StringBuilder.append方法内部做了什么事:


 
 
  1. @Override 
  2.    public StringBuilder append(String str) { 
  3.        super.append(str); 
  4.        return this; 
  5.    }  

直接调用的父类的append方法:


 
 
  1. public AbstractStringBuilder append(String str) { 
  2.         if (str == null
  3.             return appendNull(); 
  4.         int len = str.length(); 
  5.         ensureCapacityInternal(count + len); 
  6.         str.getChars(0, len, value, count); 
  7.         count += len; 
  8.         return this; 
  9.     }  

在这个方法内部调用了ensureCapacityInternal方法,当拼接后的字符串总大小大于内部数组value的大小时,就必须先扩容才能拼接,扩容的代码如下:


 
 
  1. void expandCapacity(int minimumCapacity) { 
  2.         int newCapacity = value.length * 2 + 2; 
  3.         if (newCapacity - minimumCapacity < 0) 
  4.             newCapacity = minimumCapacity; 
  5.         if (newCapacity < 0) { 
  6.             if (minimumCapacity < 0) // overflow 
  7.                 throw new OutOfMemoryError(); 
  8.             newCapacity = Integer.MAX_VALUE; 
  9.         } 
  10.         value = Arrays.copyOf(value, newCapacity); 
  11.     }  

StringBuilder在扩容时把容量增大到当前容量的两倍+2,这是很可怕的,如果在构造的时候没有指定容量,那么很有可能在扩容之后占用了浪费大量的内存空间。其次扩容后还调用了Arrays.copyOf方法,这个方法把扩容前的数据复制到扩容后的空间内,这样做的原因是:StringBuilder内部使用char数组存放数据,java的数组是不可扩容的,所以只能重新申请一片内存空间,并把已有的数据复制到新的空间去,这里它最终调用了System.arraycopy方法来复制,这是一个native方法,底层直接操作内存,所以比我们用循环来复制要块的多,即便如此,大量申请内存空间和复制数据带来的影响也不可忽视。

使用+拼接和使用StringBuilder比较


 
 
  1. @Test 
  2. public void test() { 
  3.     String str = ""
  4.     for (int i = 0; i < 10000; i++) { 
  5.         str += "asjdkla"
  6.     } 
  7. }  

上面这段代码经过优化后相当于:


 
 
  1. @Test 
  2.    public void test() { 
  3.        String str = null
  4.        for (int i = 0; i < 10000; i++) { 
  5.            str = new StringBuilder().append(str).append("asjdkla").toString(); 
  6.        } 
  7.    } 

一眼就能看出创建了太多的StringBuilder对象,而且在每次循环过后str越来越大,导致每次申请的内存空间越来越大,并且当str长度大于16时,每次都要扩容两次!而实际上toString方法在创建String对象时,调用了Arrays.copyOfRange方法来复制数据,此时相当于每执行一次,扩容了两次,复制了3次数据,这样的代价是相当高的。


 
 
  1. public void test() { 
  2.         StringBuilder sb = new StringBuilder("asjdkla".length() * 10000); 
  3.         for (int i = 0; i < 10000; i++) { 
  4.             sb.append("asjdkla"); 
  5.         } 
  6.         String str = sb.toString(); 
  7.     }  

这段代码的执行时间在我的机器上都是0ms(小于1ms)和1ms,而上面那段代码则大约在380ms!效率的差距相当明显。

同样是上面的代码,将循环次数调整为1000000时,在我的机器上,有指定capacity时耗时大约20ms,没有指定capacity时耗时大约29ms,这个差距虽然和直接使用+操作符有了很大的提升(且循环次数增大了100倍),但是它依旧会触发多次扩容和复制。

将上面的代码改成使用StringBuffer,在我的机器上,耗时大约为33ms,这是因为StringBuffer在大部分方法上都加上了synchronized关键字来保证线程安全,执行效率有一定程度上的降低。

使用String.concat拼接

现在再看这段代码:


 
 
  1. @Test 
  2.    public void test() { 
  3.        String str = ""
  4.        for (int i = 0; i < 10000; i++) { 
  5.            str.concat("asjdkla"); 
  6.        } 
  7.    }  

这段代码使用了String.concat方法,在我的机器上,执行时间大约为130ms,虽然直接相加要好的多,但是比起使用StringBuilder还要太多了,似乎没什么用。其实并不是,在很多时候,我们只需要连接两个字符串,而不是多个字符串的拼接,这个时候使用String.concat方法比StringBuilder要简洁且效率要高。


 
 
  1. public String concat(String str) { 
  2.         int otherLen = str.length(); 
  3.         if (otherLen == 0) { 
  4.             return this; 
  5.         } 
  6.         int len = value.length; 
  7.         char buf[] = Arrays.copyOf(value, len + otherLen); 
  8.         str.getChars(buf, len); 
  9.         return new String(buf, true); 
  10.     } 

上面这段是String.concat的源码,在这个方法中,调用了一次Arrays.copyOf,并且指定了len + otherLen,相当于分配了一次内存空间,并分别从str1和str2各复制一次数据。而如果使用StringBuilder并指定capacity,相当于分配一次内存空间,并分别从str1和str2各复制一次数据,最后因为调用了toString方法,又复制了一次数据。

结论

现在根据上面的分析和测试可以知道:

  1. Java中字符串拼接不要直接使用+拼接。
  2. 使用StringBuilder或者StringBuffer时,尽可能准确地估算capacity,并在构造时指定,避免内存浪费和频繁的扩容及复制。
  3. 在没有线程安全问题时使用StringBuilder, 否则使用StringBuffer。
  4. 两个字符串拼接直接调用String.concat性能最好。

关于String的其他最佳实践

  1. 用equals时总是把能确定不为空的变量写在左边,如使用"".equals(str)判断空串,避免空指针异常。
  2. 第二点是用来排挤第一点的.. 使用str != null && str.length() != 0来判断空串,效率比第一点高。
  3. 在需要把其他对象转换为字符串对象时,使用String.valueOf(obj)而不是直接调用obj.toString()方法,因为前者已经对空值进行检测了,不会抛出空指针异常。
  4. 使用String.format()方法对字符串进行格式化输出。
  5. 在JDK 7及以上版本,可以在switch结构中使用字符串了,所以对于较多的比较,使用switch代替if-else。

我暂时想的起来的就这么几个了.. 请大家帮忙补充补充...


作者:疯狂的爱因斯坦

来源:51CTO

相关文章
|
10月前
|
Java Go 开发工具
【Java】(9)抽象类、接口、内部的运用与作用分析,枚举类型的使用
抽象类必须使用abstract修饰符来修饰,抽象方法也必须使用abstract修饰符来修饰,抽象方法不能有方法体。抽象类不能被实例化,无法使用new关键字来调用抽象类的构造器创建抽象类的实例。抽象类可以包含成员变量、方法(普通方法和抽象方法都可以)、构造器、初始化块、内部类(接 口、枚举)5种成分。抽象类的构造器不能用于创建实例,主要是用于被其子类调用。抽象类中不一定包含抽象方法,但是有抽象方法的类必定是抽象类abstract static不能同时修饰一个方法。
372 1
|
10月前
|
存储 Java Go
【Java】(3)8种基本数据类型的分析、数据类型转换规则、转义字符的列举
牢记类型转换规则在脑海中将编译和运行两个阶段分开,这是两个不同的阶段,不要弄混!
403 2
|
11月前
|
SQL JSON Java
告别字符串拼接:用Java文本块优雅处理多行字符串
告别字符串拼接:用Java文本块优雅处理多行字符串
635 108
|
11月前
|
数据采集 存储 弹性计算
高并发Java爬虫的瓶颈分析与动态线程优化方案
高并发Java爬虫的瓶颈分析与动态线程优化方案
|
12月前
|
安全 Java 编译器
new出来的对象,不一定在堆上?聊聊Java虚拟机的优化技术:逃逸分析
逃逸分析是一种静态程序分析技术,用于判断对象的可见性与生命周期。它帮助即时编译器优化内存使用、降低同步开销。根据对象是否逃逸出方法或线程,分析结果分为未逃逸、方法逃逸和线程逃逸三种。基于分析结果,编译器可进行同步锁消除、标量替换和栈上分配等优化,从而提升程序性能。尽管逃逸分析计算复杂度较高,但其在热点代码中的应用为Java虚拟机带来了显著的优化效果。
364 4
|
12月前
|
机器学习/深度学习 安全 Java
Java 大视界 -- Java 大数据在智能金融反洗钱监测与交易异常分析中的应用(224)
本文探讨 Java 大数据在智能金融反洗钱监测与交易异常分析中的应用,介绍其在数据处理、机器学习建模、实战案例及安全隐私等方面的技术方案与挑战,展现 Java 在金融风控中的强大能力。
Java 大视界 -- Java 大数据在智能家居能源消耗模式分析与节能策略制定中的应用(198)
简介:本文探讨Java大数据技术在智能家居能源消耗分析与节能策略中的应用。通过数据采集、存储与智能分析,构建能耗模型,挖掘用电模式,制定设备调度策略,实现节能目标。结合实际案例,展示Java大数据在智能家居节能中的关键作用。
|
10月前
|
JSON 网络协议 安全
【Java】(10)进程与线程的关系、Tread类;讲解基本线程安全、网络编程内容;JSON序列化与反序列化
几乎所有的操作系统都支持进程的概念,进程是处于运行过程中的程序,并且具有一定的独立功能,进程是系统进行资源分配和调度的一个独立单位一般而言,进程包含如下三个特征。独立性动态性并发性。
441 1
|
10月前
|
JSON 网络协议 安全
【Java基础】(1)进程与线程的关系、Tread类;讲解基本线程安全、网络编程内容;JSON序列化与反序列化
几乎所有的操作系统都支持进程的概念,进程是处于运行过程中的程序,并且具有一定的独立功能,进程是系统进行资源分配和调度的一个独立单位一般而言,进程包含如下三个特征。独立性动态性并发性。
411 1
Java 数据库 Spring
459 0