本文为2010年左右编写,关于String的介绍还是相对较为初步,如果需要进步一了解String的细节,请参考2012年编写的文章:
如果只是先初步了解String和一些细节,本文还是可以阅读的,本文一些过细的东西因为时间较早,有一些说法上不是很准确,请不要完全参考。
从大学毕业后开始自己研习部分源码后,我逐渐认识到,对于第三方提供的包或者工具需要深入本质认识方能应用自如,而不是所有的事情第三方都处理好了,因为有些东西还不是那么智能,我们以一下很多人做过的简单试验一个试验来推导开思路吧:
//使用StringBuffer进行append进行添加
private static void StringBufferAppend() {
StringBuilder str = new StringBuilder("a");
long start = System.currentTimeMillis();
for (int i = 0; i < 10000; i++) {
str.append(",").append(i);
}
System.out.println(System.currentTimeMillis() - start);
}
//使用String的一万次拼接
private static void StringAdd() {
String a = "a";
long start = System.currentTimeMillis();
for (int i = 0; i < 10000; i++) {
a += "," + i;
}
System.out.println(System.currentTimeMillis() - start);
}
通过调用下面两段代码可以看到两者的速度差距非常大,继续增加次数,可以看到差距会更加大,差距会在5000倍左右,在一般的拼接字符串中一般只有几十次一百多次的拼接字符串,貌似这个试验没什么意义,但是我们的系统也不是给一个人用的,用的同一块内存的系统,同一个服务器上的CPU,所以我们用循环来模拟高并发也是一种手段,那么两者为什么会有速度上如此大的差距呢?至于SUN内部的一些数组转换我们暂时不多说,我们只需要首先知道,对于高频繁使用的代码段内部只要超过3个以上的拼接,一定要是使用StringBuffer去做append操作,不要使用+,这是极度影响整体性能的,接下来我简单说下为什么吧。
对于StringBuffer属于变长数组,而String是定长的,以两轮循环下来对比:
第一次循环时候,需要创建一个 "a" 对象,"," 对象和一个 i转换为的字符串对象("0"),需要先由"a"+","变成"a,"对象,然后由","+0变成,变成 ",0",而其余四个("a",",","a,","0")对象完全是临时对象,作为垃圾存在,而且创建这些临时对象也需要很多时间;
第二次循环事"a,0"+","+i 其中i是新创建的由数字转换后的对象:"a,0,1",中间四个临时对象为:"a,0",",","a,0,","1"(其中","这个字符串第二次是在内部管理池中直接获取,"a,0"也是可以通过上次循环的结果得到,但是本次循环后就再无用处)。
在对比一次浪费空间的字节数:
1、浪费了5个字节的堆空间(不算管理空间),只得到3个字节的需要空间。
2、浪费了9个字节的堆空间(去掉第一次已经申请的","对象,也有8个),只得到5个字节需要的空间(可以以此类推)。
上述为我们没有看到源码的理论值,但是其实StringBuffer也是会重新申请空间的(初始化默认是16个长度),只是这个过程中,他不像String一般和实际值一致,他是预留了很大的空间,若不够了,再重新申请更大的空间,根据实际情况,所以在我们能够估计大致拼接后的字符串空间大小的时候,我们可以给一个预定值,以便于让其更加少的去申请数组了(假如我们知道大致在1024个长度):
StringBuffer strBuf = new StringBuffer(1024);
而相对StringBuffer是在数组后追加内容,所以浪费的空间只有追加的内容,每次只会浪费一个与添加数字相关的字符串空间(这也是不可避免的),无论在空间和时间上无益我们都要优先考虑StringBuffer的,不过在StringBuffer后就不要再使用+来作为字符串连接符号了,不然会辜负StringBuffer所提供的性能优势,我曾经见过下面的代码:
strBuf.append("a"+"b"+"c");
这样写其实还不如直接加,因为StringBuffer此时就成了空架子,没有太大的意义,呵呵,当然没有错误而已,类似于这样,想要写到一行也是很简答的:
strBuf.append("a").append("b").append("c");
付:在JDK1.5后,局部变量推荐使用StringBuilder(类变量和静态变量不推荐使用,但是静态匿名块可以使用,因为他只会执行一次),这在性能测试基础上效率更加高于StringBuffer,其基本原理就是他是异步的,而StringBuffer是同步的,等会我们引开话题后可以简单看一些源码。
/*以JDK1.5为例,大部分源码在/Java/jdk1.5.0_06/src.zip,解压后,即为大部分源码所在*/
本文不能将所有源码拿出来看,只能将一些常用的拿出说明,其余可以自行推敲和源码查看,然后在使用上更加自如了,上面提到字符串,本文就主要只说关于字符串的信息(本文所涉及1.4版本向高版本跨越的语法会相继说明,不过源码是以1.5的为准,算法思想基本没有区别)。
首先看一个String带参数的构造方法,不能一一列举,以一个String参数说明吧。
public String(String original) {
int size = original.count;
char[] originalValue = original.value;
char[] v;
if (originalValue.length > size) {
v = new char[size];
System.arraycopy(originalValue, original.offset, v, 0, size);
} else {
v = originalValue;
}
this.offset = 0;
this.count = size;
this.value = v;
}
在构造方法头两行,original.count相当于数组的长度(这个只能在String内部使用,外部只能通过.length()的方式获取,以保证封装性),original.value其实就是字符串数组(可以看出,内部使用字符串来完成)
看if (originalValue.length > size) 这个其实一般情况下是不成立的,因为一般操作下的数组长度和count值是相等的,在某些情况下将char数组指定位置强制转换为String可能会造成数组的长度大于count的值,此时就会执行if成立的情况,申请一个新数组,并将size长度的部分进行拷贝,若不然,则指向同一个数组。
这里非常推荐使用:System.arraycopy作为数组拷贝方法,这也是系统最快速的数组拷贝方法,在实际应用中若能得到善用是非常好的,比起使用for循环一个一个去拷贝快了很多。
此时我们看几个比较常用的方法:
首先看非常常用的equals方法的源码:
public boolean equals(Object anObject) {
if (this == anObject) {
return true;
}
if (anObject instanceof String) {
String anotherString = (String)anObject;
int n = count;
if (n == anotherString.count) {
char v1[] = value;
char v2[] = anotherString.value;
int i = offset;
int j = anotherString.offset;
while (n-- != 0) {
if (v1[i++] != v2[j++])
return false;
}
return true;
}
}
return false;
}
翻译伪代码:
1、通过if (this == anObject) 判定是否为同一块内存空间,即内存地址是否一致,若一致,这是最快返回TRUE的办法。
2、通过if (anObject instanceof String) 判定传入字符串是否为String类型或者集成于String类型,若不是,则直接返回false。
3、强制类型转换传入值为String类型,并获取其长度与当前字符串的长度对比是否一样,若不一样则直接返回false,若一致,进入第四步.
4、第四步骤为逐步循环,逐个字符串查找是否不一致,若找到返回false,知道循环结束,则返回true。
可以看出:equals的效率是极为低下的,尤其是在字符串较长的情况下,两个字符串相等的比较,我们不愿意去采用,那么还有其他的办法吗?答案是肯定的,前面说过了,如果使用内存地址来比较无益是最快速的,但是问到的就是他们本来就是不同的空间,怎么可能有相同的内存地址呢,SUN给我们提供了一个办法,其实这个JAVA的内部管理池,对String、Double、Integer、Float等相应的都有一个本地化管理池的概念,方法为:
public native String intern();
这是本地化的一个方法,看不到源码的,你只需要知道的是,通过一个非空字符串.intern()方法,就可以获取到在池中的唯一对象地址,而且获取的速度是非常快的,此时若为同样对象,获取到的是同一个地址,那么实用==来比较对于CPU的开销就是一个COMPARE的过程,而不是一大堆的判定过程。所以对于大字符串的比较我们推荐使用intern()去获取本地池的对象。
为了理解共享池的概念,我们来做几个实验,首先我们做一个大家都知道的实验,也是JAVA老师上课都会将的一个试验,看看运行结果:
String str1 = new String("str");
String str2 = new String("str");
System.out.println(str1 == str2);
System.out.println(str1.equals(str2));
这个试验我相信JAVA只要在大学学过基本都知道结果是(主要讲解实体和句柄空间的区别,这里的实验以JDK1.5为标准):
false
true
此时我们将代码稍微修改,再看结果:
String str1 = "str";
String str2 = "str";
System.out.println(str1 == str2);
System.out.println(str1.equals(str2));
此时可以做一下试验,可以发现一个很多人意想不到的效果,输出结果是:
true
true
为什么呢?这就是池的功能了,第一个String str1 = "str";已经将其字符串在池中注册,因为没有使用new String,所以这个句柄直接指向了池中的空间,此时第二次使用str2 发现池中已经存在该对象,直接返回句柄,也就是str1和str2此时指向同一块内存堆中,当使用str1==str2中时,此时需要肯定就是true了,那么我们继续下面的实验:
String str1 = new String("str");
String str2 = "str";
System.out.println(str1.intern() == str2);//JDK1.4需要使用str1.intern()==str2.inetrn()
System.out.println(str1.equals(str2));
此时也会输出两个true,这个推敲过程和上述基本一致,第一个使用了new String对象,池中也会注册,此时使用intern()对象时是获取了池中对应的对象,我们发现,new String时会成对出现对象的镜像,所以如果是常量,我们建议使用直接等价,而不要再去new 了。
有关String更为详细的介绍在我后面写的一篇文章:Java基础小技巧回顾--关于String点点滴滴中有更为详细的说明。
付(一些小技巧):
在equals中,经常看到这样的代码:
if(a.equals("cc"))
这段代码我们没有判定a是否为空的情况,若a为空,就抛空指针了,但是若加上a!=null的判定,就显得很重复很雍容的代码,我们这里推荐将代码倒过来写,就不会出错了:
if("cc".equals(a))这样既不会抛空指针,也不用判定空,也会对比出是否相等的值出来,当然若两者都可能为空就只有用其他办法了,此时就像在引申intern()的一样,首先要保证两者都不为空,但是这些判定过程显得我们的代码非常的重复,所以我们将其抽象到静态组件中,提供调用即可,如,我们对于字符串的的处理可以写一个类,而判定字符串是否相等可以写一个或几个静态方法,如下:
public boolean equalstr(String str1,String str2) {
if(str1==null || str2 == null) {
return false;
}
return str1.intern() == str2.intern();
}
若这个类叫做:StringUtils名字,我们外部调用的时候就这样一行代码即可:
String result = StringUtils.equalstr(a,b)?"两者相等":"两者不相等";
即可,我们根本就不用关心a和b是否为空导致的空指针的问题,也不用关心对比的性能问题,代码也同时显得非常的简单。
关于concat操作,其实其等价于+操作,我们看下源码:
public String concat(String str) {
int otherLen = str.length();
if (otherLen == 0) {
return this;
}
char buf[] = new char[count + otherLen];
getChars(0, count, buf, 0);
str.getChars(0, otherLen, buf, count);
return new String(0, count + otherLen, buf);
}
其得到当前字符串的长度和传入字符串的长度,若传入字符串长度只要不是空串,就会创建一个新的数组空间来存储这些数组,并将其拷贝到新数组中,这无疑是好不留余地的申请和临时堆空间。关于getChars()的源码其实就是做了一些异常判定基础上然后做了一个如前所述的System.arraycopy方法。我们再看下StringBuffer的append方法是怎么写的:
public synchronized StringBuffer append(String str) {
super.append(str);
return this;
}
此时说明将append写到了其父亲类内部,而且对于调用同一个实体的此方法,是同步的,印证了前序的说法,那就看父亲类内部是如何写的:
public AbstractStringBuilder append(String str) {
if (str == null) str = "null";
int len = str.length();
if (len == 0) return this;
int newCount = count + len;
if (newCount > value.length)
expandCapacity(newCount);
str.getChars(0, len, value, count);
count = newCount;
return this;
}
其实可以看到,此时需要对比当前长度加上传入字符串的长度是否已经大于数组的总长度,若大于,则要调用expandCapacity方法去扩展空间,其实进入这个方法,可以看到:
void expandCapacity(int minimumCapacity) {
int newCapacity = (value.length + 1) * 2;
if (newCapacity < 0) {
newCapacity = Integer.MAX_VALUE;
} else if (minimumCapacity > newCapacity) {
newCapacity = minimumCapacity;
}
char newValue[] = new char[newCapacity];
System.arraycopy(value, 0, newValue, 0, count);
value = newValue;
}
其实所谓的扩展空间就是所谓的新申请一个空间来存储的,而以前的数组就将成为垃圾空间,所以我们要尽量避免这样的过程,所以我们需要尽量提前考虑到所需要空间的大小,这样是的整个append尽量不产生垃圾空间,否则最坏的情况将和concat一样。
至于其他的方法如:startsWith、endsWith、hashCode、indexOf、lastIndexOf、substring、replace、contains、replaceFirst、replaceAll、split、toLowerCase、toUpperCase、trim、format、toString、length、charAt等等其他方法,请自行查看和分析,对于JDK1.5相对的字符串分隔迭代器,还有一个StringTokenizer,在java.util包内部,也可以研究研究。对于JDK1.5增强循环,这里也可以简单提及一下,一些简单小技巧吧,呵呵!
在JDK1.5中就拿一个简单的字符串遍历来说吧,假如这个字符串是以逗号分隔,我们可以使用一下两种方式遍历。
一种是StringTokenizer来做:
String tempString = "aa,bb,dd,cc";
StringTokenizer string = new StringTokenizer(tempString , ",");//以逗号为分隔
while (string.hasMoreTokens()) {
System.out.println(string.nextToken());
}
二种增强循环:
String []strs = tempString.split(",");
for(String str : strs) {
System.out.println(str);
}
今天最后再说一个小技巧,如上字符串,若需要判定逗号出现的次数,我们很多人第一想法就是循环的次数或者使用split后的数组长度,显然前者较为笨,后者浪费空间,因为就只需要知道一个次数,何必要大费周章去那么多数组对象,然后去提取数组的长度,其实很简单一个办法就可以办到,虽然不能说多快,但是代码很简单。
int length = tempString.length() - tempString.replace(",","").length();
就字符串处理技巧方面,还有很做知识,这里若一一列举就没完了,只是抛砖引玉,给大致说一些,如:加密和解密算法、中文比较、中文排序、大量重复拼串的预组装过程、相应涉及JDK1.5基本类Interger相关的自动拆装箱、JDK1.5以后的泛型、字符集等等暂时就不一一列举,若遇到相关问题,这里再进行研究,不过关于JDK1.5和JDK1.4的主要区别后续会专门写一篇相关文章。