字符串(一)——String类(String No. 1)

版本号: 1.0.0

声明: 为简洁起见,本文示例未包含详细代码与注释,敬请谅解。

提示:为保证文章质量,文章采用的术语均来自于参考文献,详细解释请参考相应文献。

说明:
楷体:专有名词
标红:重要提示
加粗:重点内容

“见贤思齐焉,见不贤而内自省也。”
—孔子《论语•里仁》

字符串(一)——String类(String No. 1)

基本概念

不可变对象

“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源码可以发现,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为不可变对象,下面对修改字符串内容的相关函数实现进行源码分析:

concat

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函数首先计算链接后新字符序列所需存储空间,然后分别复制了源字符串和目标字符串的字符序列。最后构造新的字符串将其返回。

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方法在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不可变性的设计初衷是一致的。抛开可能存在的内存溢出问题,性能上的降低可能不为一部分人所接收。

split

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

join

Java SE 8增加了字符串连接的新方法join,包含两个重载版本:

基于字符序列接口CharSequence的重载版本:

public static String join(CharSequence delimiter, CharSequence... elements)

基于迭代器接口Iterable的重载版本:

public static String join(CharSequence delimiter,
            Iterable 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

值得一提的是: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》

你可能感兴趣的:(Java)