版本号: 1.0.0
声明: 为简洁起见,本文示例未包含详细代码与注释,敬请谅解。
提示:为保证文章质量,文章采用的术语均来自于参考文献,详细解释请参考相应文献。
“见贤思齐焉,见不贤而内自省也。”
—孔子《论语•里仁》
“Objects of the String class are immutable. If you examine the JDK documentation for the String class, you’ll see that every method in the class that appears to modify a String actually creates and returns a brand new String object containing the modification. The original String is left untouched.“(《Think in Java (Fourth Edition)》)
字符串是不可变的对象。每次修改字符串的方法都会创建一个全新的String对象,而最初的String对象却并未改变。
不可变性允许变量之间共享字符串,这种理念自然来源于Java设计者认为共享带来的高效率远远胜过提前、拼接字符串所带来的低效率。
Java的设计者认为C++重载操作符的设计理念很糟糕,用于String的“+”和“+=”是Java中仅有的两个重载操作符,而且Java不允许程序员重载任何操作符。
《Thinking in Java》对字符串使用“+”操作符生成的JVM字节码进行了分析,发现Java对该操作的优化采用了首先创建一个StringBuilder对象,然后调用append方法,最后调用toString的方式来实现。因此类似“a”+“b”的字符串操作与新建StringBuilder对象进行连接并没有性能上的区别。
然而,如果“+”或者“+=”操作符出现在循环结构中时:
String result=“”;
for(int i =0;i<=24;i++){
result += i;
}
return result;
分析JVM字节码可以发现,每一次循环都会创建一个新的StringBuilder对象,使得性能大为下降,因此应该避免在循环中使用字符串连接操作符。
值得说明的是:字符串对象和非字符串对象使用操作符连接时,非字符串对象会首先调用toString方法,然后再进行连接。
《Effective Java》书中在第51条Beware the performance of string concatenation指出:
don’t use the string concatenation operator to combine more than a few strings unless performance is irrelevant. Use StringBuilder’s append method instead.
因此,上述代码可以用以下代码优化:
StringBuilder result = new StringBuilder;
for(int i =0;i<=24;i++){
result.append(i);
}
return result.toString();
当然预先制定StringBuilder的大小避免多次分配缓冲可以进一步优化性能。StringBuilder是线程非安全的,与之类似的StringBuffer对象则是线程安全的。相应StringBuffer的开销也比StringBuilder要高,因此单线程下进行大量字符串连接操作应该选用StringBuilder,而多线程下则应该采用StringBuffer。
观察String源码可以发现,String类内部是采用char型数组对字符串进行存储的。char数据类型是一个采用UTF-16编码表示Unicode代码点的代码单元。大多数的常见Unicode字符采用一个代码单元就可以表示,而辅助字符则需要一对代码单元表示。《Java核心技术(卷一)》指出:
“强烈建议不要在程序中使用char类型”。
具体关于代码点和代码单元的解释请参考该书3.3.3节和3.6.4节。
String类分别提供了以byte数组、char数组、StringBuffer、StringBuilder和String自身为传参的构造器。
public int length()
返回此字符串的长度。长度等于字符串中Unicode代码单元的数量。
public boolean isEmpty()
当且仅当 length() 为 0 时返回 true。由于String中isEmpty只对字符串长度进行了判断,因此空串调用该方法的结果是True,第三方类库org.apache.commons.lang提供了相应的isEmpty的Util方法。同时该文件提供了isBlank方法对空串进行同样的检查。
public boolean equals(Object anObject)
将此字符串与指定的对象比较。当且仅当该参数不为 null,并且是与此对象表示相同字符序列的 String 对象时,结果才为 true。
equalsIgnoreCase则忽略了大小写,但参数仅限为字符串类型。
public int compareTo(String anotherString)
按照字典顺序,如果字符串位于参数字符串anotherString之前,则返回负数,如果字符串位于参数字符串anotherString之后,则返回正数。同样忽略大小写的字符串比较方法为compareToIgnoreCase。
public boolean contains(CharSequence s)
当且仅当此字符串包含指定的 char 值序列时,返回 true。
public boolean startsWith(String prefix)
测试此字符串是否以指定的前缀开始。同时String类提供了startsWith(String prefix, int toffset)函数对从指定索引开始的子字符串是否以指定前缀开始提供了支持。
public boolean endsWith(String suffix)
测试此字符串是否以指定的后缀结束。
public static String valueOf(Object obj)
valueOf对基本类型、字符数组和Object实现了重载,并返回传入参数的字符串表达式。对int,long,float,double和Object的valueOf操作均调用了其toString的方法。
public String replace(char oldChar, char newChar)
返回一个新的字符串,它是通过用 newChar 替换此字符串中出现的所有 oldChar 得到的。
public String toUpperCase()
使用默认语言环境的规则将此 String 中的所有字符都转换为大写。
public String toLowerCase()
使用默认语言环境的规则将此 String 中的所有字符都转换为小写。
public String trim()
返回字符串的副本,忽略前导空白和尾部空白。
分析以上需要改变字符串的方法可以发现,当内容没有改变时,String的方法只是返回指向原对象的引用而已。而当内容改变时,则会返回一个新的String对象。当然,如果函数内部调用了Java SE 7之前的subString函数,则会引用一个共享字符串。
由于String为不可变对象,下面对修改字符串内容的相关函数实现进行源码分析:
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);
}
分析源码可以发现concat函数首先计算链接后新字符序列所需存储空间,然后分别复制了源字符串和目标字符串的字符序列。最后构造新的字符串将其返回。
public String substring(int beginIndex) {
if (beginIndex < 0) {
throw new StringIndexOutOfBoundsException(beginIndex);
}
int subLen = value.length - beginIndex;
if (subLen < 0) {
throw new StringIndexOutOfBoundsException(subLen);
}
return (beginIndex == 0) ? this : new String(value, beginIndex, subLen);
}
分析可以发现subString方法在beginIndex !=0的条件下会调用构造函数 String(int paramInt1, int paramInt2, char[] paramArrayOfChar),该构造函数是包内可见的,其源码在Java6之前为:
String(int paramInt1, int paramInt2, char[] paramArrayOfChar)
{
this.value = paramArrayOfChar;
this.offset = paramInt1;
this.count = paramInt2;
}
分析以上代码可以发现,该构造器并未重新拷贝传入的字符序列,而是直接对其进行了引用。而调用该构造器的subString方法也返回了该引用。当对一个长度很长的字符串进行subString函数调用后,由于源字符串和目标字符串都持有对同一字符序列的引用,因此在垃圾回收时,只有当两个字符串都被释放时,所引用的字符序列才会被回收。因此,对大容量字符串反复调用subString操作最终会导致内存溢出的问题.
该问题的解决方法是用String的公有构造器代替包内构造器:
public String(String paramString)
{
int i = paramString.count;
char[] arrayOfChar1 = paramString.value;
char[] arrayOfChar2;
if (arrayOfChar1.length > i)
{
int j = paramString.offset;
arrayOfChar2 = Arrays.copyOfRange(arrayOfChar1, j, j + i);
}
else
{
arrayOfChar2 = arrayOfChar1;
}
this.offset = 0;
this.count = i;
this.value = arrayOfChar2;
}
以上公有构造方法对字符序列进行了保护性拷贝,因此调用该方法处理subString返回的引用能够很好地解决这一问题:
String result = newString(sourceStr.subString(1));
该问题在Java SE 7中得到修正,同时引起了很大争议。下面先看一下subString的新版实现:
public String substring(int beginIndex) {
if (beginIndex < 0) {
throw new StringIndexOutOfBoundsException(beginIndex);
}
int subLen = value.length - beginIndex;
if (subLen < 0) {
throw new StringIndexOutOfBoundsException(subLen);
}
return (beginIndex == 0) ? this : new String(value, beginIndex, subLen);
}
从源码最后一行可以看出subString不再调用原来的包内构造器,而是调用了公有构造器:
public String(char value[], int offset, int count) {
if (offset < 0) {
throw new StringIndexOutOfBoundsException(offset);
}
if (count <= 0) {
if (count < 0) {
throw new StringIndexOutOfBoundsException(count);
}
if (offset <= value.length) {
this.value = "".value;
return;
}
}
// Note: offset or count might be near -1>>>1.
if (offset > value.length - count) {
throw new StringIndexOutOfBoundsException(offset + count);
}
this.value = Arrays.copyOfRange(value, offset, offset+count);
}
该公有构造器对字符序列进行了保护性拷贝,便不会存在之前的内存溢出的问题。同时Java SE 7的String类删除了之前版本的包内构造器。
对于subString函数改动的争论点在于性能和健壮性的平衡上
String类之所以是不可变的(不考虑反射的情况下)依赖于以下几点:
1、String类禁止继承;
2、String类中的字符序列是私有且恒定不变的;
3、String类的所有公有构造器对传入的字符串参数进行了保护性拷贝。
4、未提供任何改变对象状态的方法。
同时由于之前的subString内的操作都是私有操作或者包私有操作,并未对外暴露这些接口。因此是对外安全的。但由于上文中所阐述的原因,Java SE 6之前版本的subString方法存在内存溢出的风险。而且该风险很容易被程序员忽略。
Java 7之后的subString方法采用了保护性拷贝的措施来防止内存溢出。但是由于拷贝过程带来新的资源开销,导致性能下降。因此,有些观点认为之前在subString中采用共享的方式与String不可变性的设计初衷是一致的。抛开可能存在的内存溢出问题,性能上的降低可能不为一部分人所接收。
System.out.println(Arrays.toString("Monday,,,".split(",")));
System.out.println(Arrays.toString(",,,Monday".split(",")));
//[Monday]
//[, , , Monday]
直接调用String类的split方法可以发现实例中最后的空串(” “)直接被忽略了,而非末尾空串却被保留。分析源码:
public String[] split(String regex) {
return split(regex, 0);
}
可以看到其内部实现为调用了重载方法split(String regex, int limit)(重载方法源码可读性略差,文中不再展示),并将limit设置为0,在split注释文档中可以看到:
“The limit parameter controls the number of times the pattern is applied and therefore affects the length of the resulting array.
If the limit n is greater than zero then the pattern will be applied at most n - 1 times, the array’s length will be no greater than n, and the array’s last entry will contain all input beyond the last matched delimiter.
If n is non-positive then the pattern will be applied as many times as possible and the array can have any length.
If n is zero then the pattern will be applied as many times as possible, the array can have any length, and trailing empty strings will be discarded.”
以上注释表明:
如果 limit 为 0,那么模式将被应用尽可能多的次数,数组可以是任何长度,并且结尾空字符串将被丢弃。
采用limit作为split执行次数的参数是合理的,但采用零值时对末尾空串的忽略并不合适。而不含limit传参的重载split函数直接默认使用0作为limit参数值的处理方式也容易为使用者所忽略。
在apache的第三方jar包commons-lang-2.6中的StringUtils类也提供了字符串分割相应的静态方法,分析源码可以发现:StringUtils类split方法同样采用了过程化的设计思路。
System.out.println(Arrays.toString(StringUtils.split("Monday,,,", ","))); System.out.println(Arrays.toString(StringUtils.split("Monday,,,Tuesday", ',')));
//[Monday]
//[Monday, Tuesday]
测试可以看到其对split方法进行了优化,选择直接过滤掉分割后所有的空串,这样虽然在一定程度上避免了歧义,却剥夺了用户是都过滤空串的选择权。
Guava中Splitter类采用了链式操作的实现方式对字符串进行分割:
Iterator iterator = Splitter.on(',').omitEmptyStrings().trimResults().split(" Monday ,,Tuesday,,").iterator();
List splitList= Splitter.on(',').omitEmptyStrings().trimResults().splitToList(" Monday ,,Tuesday,,");
while (iterator.hasNext()) {
System.out.println(iterator.next());
}
System.out.println(splitList);
//Monday
//Tuesday
//[Monday, Tuesday]
Splitter类提供了split和splitToList两个方法对String进行分割:前者返回一个实现了Iterable接口的类,后者则返回了分割后的List。Splitter类同时包含了omitEmptyStrings(忽略空字符串)、trimResults(删除生成字符串前后空格)、limit(限制生成字符串总数)、fixedLength(指定分割后字符串长度)等方法。除此之外,Splitter类存在静态内部类MapSplitter来专门处理字符串转换为Map的操作:
Splitter.MapSplitter mapSplitter = Splitter.on("#").withKeyValueSeparator("=");
Map splitterMap = mapSplitter.split("Washington D.C=Redskins#New York City=Giants#Philadelphia=Eagles#Dallas=Cowboys");
for (Map.Entry item : splitterMap.entrySet()) {
System.out.println(item.getKey() + "=" + item.getValue());
}
//Washington D.C=Redskins
//New York City=Giants
//Philadelphia=Eagles
//Dallas=Cowboys
Java SE 8增加了字符串连接的新方法join,包含两个重载版本:
基于字符序列接口CharSequence的重载版本:
public static String join(CharSequence delimiter, CharSequence... elements)
基于迭代器接口Iterable的重载版本:
public static String join(CharSequence delimiter,
Iterable extends CharSequence> elements)
测试代码:
System.out.println(String.join(" ", "Java", "is", "cool"));
System.out.println(String.join(" ", "Java", "is", null));
System.out.println(String.join("-", "Java", " ", null));
System.out.println(String.join(" ", Lists.newArrayList("Java", "is", "cool")));
System.out.println(String.join(" ", Lists.newArrayList("Java", "is", null)));
System.out.println(String.join("-", Lists.newArrayList("Java", " ", "cool")));
// Java is cool
// Java is null
// Java- -null
// Java is cool
// Java is null
// Java- -cool
注:Lists.newArrayList()方法时Guava集合工具类Lists中所提供的静态工厂方法。
以上测试结构表明String类的join方法将传入的null值转换为字符串“null”,同时并未忽略空串。
与split方法不同,String的join方法直到Java SE 8才被正式引入,而apache在StringUtils类中也早已重载了分别以Object[]、Iterator和Collection为参数的join方法,对于传入参数中包含的null值均被处理成空串表示,详细范例请参考apache common-lang jar包的参考文档,这里不再赘述。
同样作为对照,下面将简要介绍Guava中的Joiner类:
Joiner类除了与Splitter类一样采用了链式操作的设计思路之外,也提供了以Object[]、Iterable、Iterator和Object不确定参数为传入参数的重载join方法,示例如下:
System.out.println(Joiner.on(",").skipNulls().join("1", "2", " ", null, "3"));
System.out.println(Joiner.on(",").useForNull("no values").join("1", "2", " ", null, "3"));
//1,2, ,3
//1,2, ,no values,3
Joiner类给工具类用户提供了skipNulls(跳过null值)和useForNull(用其他字符串代替null值)的方法设置。相对于String类和StringUtils类所实现的join方法拥有了更多的选择。当然其对于空字符串仍然采用默认保留的处理方式,如果想要对空字符串进行与null值同样的代替或跳过处理,可以采用skipNulls和useForNull类似匿名内部类的方式对Joiner类进行扩展,但由于Guava设计者对外来contributors持不欢迎态度,所以相关功能只能等待Guava的更新了。
除此之外,Joiner类还为实现了Appendable接口的子类提供了appendTo方法:
System.out.println(Joiner.on("|").skipNulls().appendTo(new StringBuilder(), "foo", "bar", "baz"));
//foo|bar|baz
appendTo允许Object类对象以字符串的表示方式添加到实现了Appendable接口的子类末尾。与Splitter类相似,Joiner类存在静态内部类MapJoiner来专门处理Map转换为字符串的操作:
Map testMap = Maps.newLinkedHashMap();
testMap.put("Washington D.C", "Redskins");
testMap.put("New York City", "Giants");
testMap.put("Philadelphia", "Eagles");
testMap.put("Dallas", "Cowboys");
System.out.println(Joiner.on("#").withKeyValueSeparator("=").join(testMap));
//Washington D.C=Redskins#New York City=Giants#Philadelphia=Eagles#Dallas=Cowboys
本文并非一次写就便再不改动的文章,如文章开头所示,文章目前版本号为1.0.0,以后会随时纠正错误的内容并增加新的内容。
值得一提的是:Java SE 8中引入的函数化编程和流(Stream)操作无疑是一次革命性的进步,如以上Guava中的Joiner类所具有的功能利用实现了Stream接口的类同样可以完成。但Java SE 8仍然借鉴了Guava中Joiner类的设计新加了StringJoiner类(个人猜测)。从其他方面的蛛丝马迹也能发现Java SE 8吸收了很多Guava的设计思想,例如Java SE 8中Objects类的requireNonNull方法基本是照搬Guava中Preconditions类的checkNotNull。因此,不得不说如Guava一样的优秀开源项目在促进Java发展方面起了非常重要的积极作用。
1、《Think in Java, Fourth Edition》
2、《Java编程思想(第四版)》
3、《Core Java, Ninth Edition》
4、《Java核心技术(第九版)》
5、《Effective Java, Second Edition》
6、《Effective Java 中文版(第二版)》
7、《Java程序设计语言(第四版)》
8、《The Java™ Programming Language, Fourth Edition》
9、《Getting Started with Google Guava》