Java编程优化之旅(二) String类型知多少

___________________________________________________________________________

    String 类大概是Java编程中用到最多的类。一段文本,一个URL,一个Email,甚至一串电话号码都是字符串。然而正是由于如此常用,所以关于String类中的一些低效率的使用方法更应该引起大家的重视。String类型博大精深,优化的方式有很多,本文也只是惊鸿一瞥,简单介绍几个小Tip。以后若想到了其他技巧,会继续撰文补充。

    本文中参照的Jdk 源码是1.7。虽然String类已经稳定,但不同版本的 Jdk 中 String 类的定义还是有变化的。比如我看过的《Java性能优化》一书,其中讲到String 的某些源码已经和如今的源码不同了。

___________________________________________________________________________

 

String空串的判断

 

    通过看String的源码,可以发现String类中实际在存储字符串的是一个字符数组,源码中有这么一句:

private final char value[];


    空串一般有两种含义,一种是“”,另一种是null。null表示不指向任何东西,如果进行比较等操作会出现“空指针异常”。而“”是一个没有字符的字符串,在内存中确有所指。通常我我们比较一个字符串str是否为空串的常用写法为:

if(str != null && !str.equals("")) { }

    也就是说要把两种情况都要考虑进去。【请注意】:不能写成  !str.equals("")&&str!=null  因为根据语句执行的顺序,如果str真的是null的话,那么执行str的equals方法必然会报 “空指针异常”的错误。所以要先判断是否为null 不为null ,才能进去比较。

    然而,str.equals("")是有进行优化的潜力的。因为equas方法,着实是个比较费力的操作。请看源码:

public boolean equals(Object anObject) {
    if (this == anObject) {
        return true;
    }
    if (anObject instanceof String) {
        String anotherString = (String) anObject;
        int n = value.length;
        if (n == anotherString.value.length) {
            char v1[] = value;
            char v2[] = anotherString.value;
            int i = 0;
            while (n-- != 0) {
                if (v1[i] != v2[i])
                        return false;
                i++;
            }
            return true;
        }
    }
    return false;
}

    注意,即使是空字符串 "",也是一个Object(对象)。所以以上语句都会执行。所以无形之中降低了效率。更为高效的比较空串的方法是:

str.length() != 0

    String的length() 方法直接返回 字符数组value的长度:

//源码
public int length() {
    return value.length;
}

    直接来判断 String 对象持有的字符数组的大小是否为0,是不是更高效呢?此外,在Jdk 1.6 开始出现了一个新的方法 isEmpty()

public boolean isEmpty() {
	return value.length == 0;	
}

    显然是一种语法糖了。所以最后,我们判断一个字符串是否为空串的高效表达为:

if(str != null && str.length() != 0) { }
//或(jdk >=1.6)
if(str != null && !str.isEmpty()) { }


字符串连接


少吃语法糖“+”


初学Java你一定会为String类中中实现了用加号“+”来连接两个字符串而沾沾自喜。我认为这是Java中最甜的语法糖,甚至甜到掉牙。两个字符串str1和str2。如果你使用:
str1 + str2;
这样的语法并不会改变str1的字符串内容。要写作:
str1 = str1 + str2;//str1 += str2;
你或许很清楚这个语法。而这背后所做的操作其实在内存中新建了一个新的String对象来存储str1和str2中value数组中的字符,然后再把这个新对象的引用赋给str1。你可能也觉得没有什么关系。然而:
str1 = str1 + str2 +str3 +str4 +str5;
这背后所做的事情就不是新建一个String对象这么简单了。而是从左向右依次两两连接。先新建一个对象来存储str1和str2的连接之后的字符串,再使用这个新字符串和str3进行上面的“+”操作,以此类推,“+”完所有的字符串。期间新建的字符串绝不止一个。
所以请牢记一点:千万不要在循环之中使用“+”来连接字符串。

String的contact()方法


上面已经提到了直接进行“+”的效率之低(不信你可以测试一下)。String类型中提供了contact()方法来进行连接操作。使用的语法是:
str1 = str1.contact(str2);

这种连接的方法效率要高于直接使用“+”来连接。在连接操作不是特别多的情况下,鼓励使用。若是我们有一个循环要执行10000次字符串连接操作,这两种连接方法都不鼓励大家使用了。那么该如何解决呢,其实Sun公司早就为大家提供了神兵利器。

使用StringBuffer来连接


类名中的Buffer顾名思义就是缓冲了,在Java中有许多使用Buffer来提高效率的例子。这是效率特别特别高的字符串连接的方法。比如上面我们提到的情况:
StringBuffer sb = new StringBuffer() ;
for(int i=0;i<10000;i++)
	sb.append("x");

String s= sb.toString();
在我电脑里的执行时间几乎每次都在10ms以下,多数情况下只有1,2 ms的样子。
而是用 contact()方法:
String s1 = "";
System.out.println(s1);
for(int i=0;i<10000;i++)
	s1 = s1.concat("x");
在我的电脑中执行时间大概100多ms。
最后看看,直接使用“+”来操作:
String s = "";
for(int i=0;i<10000;i++)
	s = s + "x";
在我电脑里的执行时间是超过200ms接近300ms。可见,这最甜的语法糖很可能是你的性能毒药。
-------------------------------------------------------------------------------
Java除了StringBuffer以外还有一个与String相关的StringBuilder。StringBuffer和StringBuilder一母同胞,有相同的继承实现关系,都实现了相同的接口。比如这个字符串的连接操作。StringBuilder的append方法甚至比StringBuffer还要高,然而StringBuilder属于非线程安全的,这里不做具体细节做过多谈论。所以我的观点是:除非你十分了解什么时候需要线程安全什么时候不需要线程安全,否则请统一使用StringBuffer。
-------------------------------------------------------------------------------

charAt()方法的小讨论


    charAt() VS toCharArray()


    前段时间看了一篇国外的论文《Thirty Ways to Improve the Performance of Your Java™ Programs》(提升Java程序性能的30种方法)。其中第4.6节提到 使用 toCharArray()方法来代替charAt()方法,据说可以提高性能,然而我做了测试,结果却有些出乎意料。(所以说,实践是检验真理的唯一标准)

    作者的观点是这样的:在比较一个字符串的每个字符(或者要比较大量字符)的时候。可以分两步走,①可以先使用toCharArray方法获得这个String对象的字符数组。②对获得的字符数组进行字符的比较。诚然,比较Char数组中字符的开销要小于使用字符串对象一次次调用charAt方法来比较字符的开销,然而,作者可能忽略了一点,那就是在开始的时候获得字符数组的 toCharArray方法的开销也是不容小觑的。为此,我做了几次测试:

int size = 10000;
StringBuffer sb = new StringBuffer();
for (int i = 0; i < size; i++)
	sb.append('x');
String s = sb.toString();
long time = System.currentTimeMillis();
for (int i = 0; i < s.length(); i++)
	if (s.charAt(i) == 'x') {
	}

System.out.println(System.currentTimeMillis() - time);
time = System.currentTimeMillis();

char ss[] = s.toCharArray();

for (int i = 0; i < ss.length; i++)
	if (ss[i] == 'x') {
	}
System.out.println(System.currentTimeMillis() - time);
运行10次的结果:


    目前来看,上面作者的结论貌似是对的。然而接下来。我改变上面代码中size的大小,也就是String对象所包含的的字符的个数。

int size = 10000000;//比上一次代码多了三个0,也就是扩大1000呗
然后运行10次的结果:


    可以看出charAt方法以绝对优势胜出了。这是字符串的长度扩大1000倍后的结果。实际上在我的电脑上,在第一次size的基础上扩大100倍后,charAt方法的优势也并不明显,然而1000倍后就凸显出来了。大概的性能曲线可能是这样:在字符串的长度不是很大的情况下,使用toCharArray方法是高效的,但若字符串长度很大的时候,使用toCharArray的性能就会降低,因为花了高额的代价来获取字符数组(也就是前面分析中的第一步)。

—————————————————题外话,性能测试的小Tip————————————————————

    为此,我和原作者往来了两封Emil。他也说是那篇论文写得时间比较早了(1999)。所以Java的编译器和运行环境有了很大改进。使得chatAt方法的性能得到了很大的提升。

    由此可见,Java性能低下的观念已经有些过时,除了硬件的进步带来的性能提高以外,Java语言本身(源码,编译器,运行环境等)也在不断进步着。

    此外,作者还给我提了个测试性能时,定时的小建议:避免使用空循环。比如我上面的语句:

long time = System.currentTimeMillis();
for (int i = 0; i < s.length(); i++)
    if (s.charAt(i) == 'x') {
}
System.out.println(System.currentTimeMillis() - time);
    我的  if 语句体是空的。他建议的写法是:

 long sum1 = 0;
 long time = System.currentTimeMillis();
 for (int i = 0; i < s.length(); i++) {
     if (s.charAt(i) >= 'a' && s.charAt(i) <= 'z')
      sum1 += s.charAt(i);
 }
 System.out.println(System.currentTimeMillis() - time);
    我想其原因大概是因为实际我们写代码的时候不会出现我上面那样的空语句,所以结果可能不够客观。因为编译器也会做优化,可能会认为是无用的语句所以根本不执行那个if括号里的语句。虽然我上面测试两个方法的时候都使用的空循环体,貌似对于性能的定性比较没有影响,但实际我们不清楚编译器到底会不会做出不同的优化来,所以我们以后尽量避免使用空语句测试。

————————————————————————————————————————

与startWith()的比较

    
        另外一处比较,《Java性能优化》里面有一节“高效charAt()方法”,里面提到使用charAt()方法来替代startsWith()/endsWith()方法。原书代码:
int len = orgStr.length();
if (orgStr.charAt(0) == 'a' && orgStr.charAt(1) == 'b'
		&& orgStr.charAt(2) == 'c')
	;
if (orgStr.charAt(len - 1) == 'a' && orgStr.charAt(len - 2) == 'b'
		&& orgStr.charAt(len - 3) == 'c')
	;
//等价的startsWith()和endsWith()实现
orgStr.startsWith("abc");
orgStr.endsWith("abc");
但从这段代码来看,测试也很容易发现charAt方法的效率确实高些,然而却很难推广开charAt代替startsWith这个观点。因为在这里使用charAt 方法去替代startWiths有两个大前提:
  • 要比较的前缀(或后缀)字符串要是在编码期已知的,也就是不能是动态字符串,比如用户输入的
  • 要比较的前缀(或后缀)字符串包含的字符个数不能太多。否则 if 可不好看。
如果,不满足这两个前提呢?比如是一个用户输入的字符串,或者字符串很长。那么就要用循环来改写了,下面我写了个简单的循环实现来测试性能
String str = "abcdefghigk";//目标串
String pre = "abcdefghig";//前缀串
long time = System.currentTimeMillis();
for (int j = 0; j < 1000000; j++)//增加循环次数,用于放大差异
	if (!str.startsWith("abc"))
		;//可以相应的添加不匹配的提示语句
System.out.println(System.currentTimeMillis() - time);
time = System.currentTimeMillis();
int len = pre.length();
int len2 = str.length();
if (len2 < len)//目标串字符个数小于前缀串
	;//可以相应的添加不匹配的提示语句
else {
	for (int j = 0; j < 1000000; j++)//增加循环次数,用于放大差异
		for (int i = 0; i < len; i++)
			if (str.charAt(i) != pre.charAt(i)) {
				;//可以相应的添加不匹配的提示语句
				break;
			}
}
System.out.println(System.currentTimeMillis() - time);
10次运行的结果:
可见,charAt不可取了。所以只有在前缀串不长的情况下,直接使用 if 语句,才可以。而字符串过长,或者是前缀串在编码期间未知(比如用户输入的前缀串)那么还是乖乖的使用 官方的 startsWith 吧!
-----------------------------------------------------------------------------------
总结,这里讨论了 charAt 的两处用法,对两个原作者的观点都造成了冲击。我认为charAt方法本身并不能称作高效或低效,具体要看应用的地方以及和什么方法做的比较。

posted on 2014-04-05 20:03  果冻虾仁  阅读(230)  评论(0编辑  收藏  举报

导航