Java SE基础巩固(二):String类

546 阅读10分钟

String使用频率非常高,无论是在大型还是小型的应用程序都会大量的使用String类。所以,理解并以高性能的方式使用String是非常重要的。

String类提供了很多功能丰富的API,例如substring(),indexOf(),lastIndexOf()等等。String是不可变类,它没有提供任何访问内部状态的方法,即使是substring()这样的看起来是要修改字符串方法也不会真正的修改实例,而是会创建一个新的String对象并返回。String也不能被继承,继承虽然提高了灵活性,但同时也可能会破坏父类的逻辑(因为重写机制),这样会使得String类非常危险,故String的设计者将其设计成不可继承是有很充分的理由的。

下面我们从源码开始慢慢分析上面讲到的特性。

1 String类中丰富的API

String类有很多各种功能的API,无法一一细说,我就挑选了indexOf()方法来详细讨论讨论。

public int indexOf(int ch) {
    return indexOf(ch, 0);
}

public int indexOf(int ch, int fromIndex) {
    final int max = value.length;
    if (fromIndex < 0) {
        fromIndex = 0;
    } else if (fromIndex >= max) {
        // Note: fromIndex might be near -1>>>1.
        return -1;
    }

    if (ch < Character.MIN_SUPPLEMENTARY_CODE_POINT) {
        // handle most cases here (ch is a BMP code point or a
        // negative value (invalid code point))
        final char[] value = this.value;
        for (int i = fromIndex; i < max; i++) {
            if (value[i] == ch) {
                return i;
            }
        }
        return -1;
    } else {
        return indexOfSupplementary(ch, fromIndex);
    }
}

indexOf()除了列出来的两种,还有5种重载形式,不过不是很常用,比较常用就是这俩。只接受一个参数的就不多说了,他没有多余的逻辑,直接就调用了indexOf(int ch, int fromIndex)。indexOf(int ch, int fromIndex)方法的第一个参数是要查找的字符,是int类型,关于该参数,JDK文档是这样描述的:

a character (Unicode code point).

即它是一个Unicode编码的字符(看JDK里注释文档是了解一个方法的最快速的方式),有计算机基础知识的朋友应该不难理解为什么使用整形数值来代替字符,虽然是int类型,但实际上我们使用的时候完全可以直接传递char类型,如下所示:

String s = "hello";
int i = s.indexOf('h',0);

第二个参数fromIndex即从哪个下标开始查找,如果调用的是只有一个参数ch的API,那么fromIndex的值默认就是0,即从字符串开头查找。

接下来就是if-else结构,这里就是对fromIndex做验证,如果fromIndex小于0,即令其等于0,如果fromIndex大于max,那么就直接返回-1,即表示没有找到。

接下来的代码才是indexOf的核心:

final char[] value = this.value;
for (int i = fromIndex; i < max; i++) {
    if (value[i] == ch) {
        return i;
    }
}
return -1;

其实也很简单,就是遍历value数组,然后一个一个比较,如果找到就直接返回,遍历完之后还没找到,就返回-1,表示没有找到。

其他的方法就不多说了,具体使用建议看看JDK API文档,或者使用IDE,直接在IDE里看注释文档。(IDEA的话可以使用Ctrl/cmd + 左键单击进入方法)。

2 String的不可变性

String是不可变的,但为什么要设计成不可变的呢?主要有以下几点考虑:

  1. 便于实现常量池。常量池里的字符串常量如果可变的话,会导致很多安全问题。
  2. 网络安全。网络连接的参数往往都是以字符串的形式出现,如果字符串可变,那就意味着参数可能被篡改,这显然不是我们想看到的。
  3. 线程安全。不可变类肯定是线程安全的,不存在多个线程修改共享变量的情况(因为字符串无法修改)。
  4. 加快字符串处理速度。因为字符串是不可变的,所以hashcode只需要在对象创建的时候计算一次即可,例如将String作为Map的Key。

那String是如何实现不可变的呢?实现不可变至少需要下面几个步骤:

  1. 将类声明成final,这样他就不可被继承了。(继承很有可能会改变类的行为)
  2. 将所有可变的状态声明成final和私有的。
  3. 保证没有任何类似setter可修改状态的方法。
  4. 在getter方法中返回的状态应该返回其拷贝,而不是其本身。
  5. 如果不得不提供一些修改状态的方法,那么这些方法返回值应该是一个新的对象,不能直接在源对象上修改。

我们来看看String是如何实现的。

从源码中我们可以看到,String在类上是有fianl修饰,这样整个类的所有方法就都是final方法,不可被继承重写。

public final class String
    implements java.io.Serializable, Comparable<String>, CharSequence {
    /** The value is used for character storage. */
    private final char value[];
    ......
}

同时,我们可以看到除了hash之外,其他的成员变量都是final修饰的。如下所示:

public static final Comparator<String> CASE_INSENSITIVE_ORDER
                                         = new CaseInsensitiveComparator();

private int hash; // Default to 0
private static final long serialVersionUID = -6849794470754667710L;
private final char value[];
private static final ObjectStreamField[] serialPersistentFields =
        new ObjectStreamField[0];

我们发现,这和我们最开始讲的第二个原则有些不同。但没关系,那只是一个比较强硬的规则,只要能证明即使不将其设置成final,也不会有问题即可。那CASE_INSENSITIVE_ORDER为什么是public的呢?其实这个成员变量不能算是String的内部状态,只能是算是一个常量,即使被外界访问了,也不会有太大影响。

再来看看String有没有提供setter方法,我浏览了一下其API文档,没有发现任何状态的setter方法。接下来看看getter方法,发现有个getBytes()方法,该访问会访问到String的内部状态value数组,并将其编码成字节数组,如下所示:

public byte[] getBytes() {
    return StringCoding.encode(value, 0, value.length);
}

//StringCoding.encode方法
static byte[] encode(char[] ca, int off, int len) {
    String csn = Charset.defaultCharset().name();
    try {
        // use charset name encode() variant which provides caching.
        return encode(csn, ca, off, len);
    } catch (UnsupportedEncodingException x) {
        warnUnsupportedCharset(csn);
    }
    try {
        return encode("ISO-8859-1", ca, off, len);
    } catch (UnsupportedEncodingException x) {
        // If this code is hit during VM initialization, MessageUtils is
        // the only way we will be able to get any kind of error message.
        MessageUtils.err("ISO-8859-1 charset not available: "
                         + x.toString());
        // If we can not find ISO-8859-1 (a required encoding) then things
        // are seriously wrong with the installation.
        System.exit(1);
        return null;
    }
}

encode方法并没有改变value数组的内容,只是获取了内容,并根据内容按照一定的编码方式编码并将结果存入byte数组中,最后返回。从这里可以看出,这个方法没有对String内部状态做修改。

最后看看有没有一些可能修改String的API,大致浏览了一下API 文档,发现有很多方法都可能修改String,例如substring,replace等。但实际上,这些方法最终没有修改String对象的值,而是根据原String的内容生成了新的String对象,如下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);
    }

关键看看最后的返回语句,如果beginIndex为0,那么就不需要重新创建对象了,直接返回本身即可,如果不为0,那就创建一个新的String对象,而不是将对象本身进行修改。replace等方法也是一样的,就不再赘述了。

可见,String类基本上是满足上面提到的5个步骤的,只有少部分代码没有遵循,例如hash不是final修饰的,但没关系,只要能保证没有任何外部途径能修改hash值即可。

3 “+”号重载

我们经常会用“+”号来拼接两个字符串,例如:

String s1 = "hello,";
String s2 = "world";
String res = s1 + s2;
System.out.println(res);

在上一节中,我们说到了String是不可变的,但为什么这里看起来就好像String“可变”了呢?其实这里并没有违反String的不可变性,只是编译器和我们玩了个小“把戏”。我们来看看生成的字节码是怎样的吧,先使用javac将其编译,然后使用javap -verbose xxx.class 命令来展示字节码,结果如下所示(省略了其他内容):

......
Code:
      stack=2, locals=4, args_size=1
         0: ldc           #2                  // String hello,
         2: astore_1
         3: ldc           #3                  // String world
         5: astore_2
         6: new           #4                  // class java/lang/StringBuilder
         9: dup
        10: invokespecial #5                  // Method java/lang/StringBuilder."<init>":()V
        13: aload_1
        14: invokevirtual #6                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
        17: aload_2
        18: invokevirtual #6                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
        21: invokevirtual #7                  // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
        24: astore_3
        25: getstatic     #8                  // Field java/lang/System.out:Ljava/io/PrintStream;
        28: aload_3
        29: invokevirtual #9                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
        32: return
 ....

我们可以看到,“+”号被编译器翻译成了StringBuilder.append()方法(看序号14和18的指令),换句话说,“+”号被重载了,最终拼接完毕后,调用StringBuilder.toString()方法返回新的拼接好的String对象(看序号21的指令)。

所以,String没有被修改,而是调用了StringBuilder.append()方法来辅助拼接原字符串,并生成新的字符串。

运算符重载在C++里是允许的,但是Java并不允许。究竟是出于什么原因,我不是很清楚,但我认为运算符重载是导致C++复杂的一个原因。

4 Integer.toString()和String.valueOf()的关系

我们直接来看源码(不懂的时候直接看源码是最好的方式,比去网上查更加快速、直观):

//String.valueOf(int)
public static String valueOf(int i) {
    return Integer.toString(i);
}

//Integer.toString(int i)
public static String toString(int i) {
    if (i == Integer.MIN_VALUE)
        return "-2147483648";
    int size = (i < 0) ? stringSize(-i) + 1 : stringSize(i);
    char[] buf = new char[size];
    getChars(i, size, buf);
    return new String(buf, true);
}

很明显,String.valueOf(int)方法直接调用了Integre.toString()方法,以此来返回一个用字符串表示的整形数字。而在Integer.toString()方法里,最后返回的是一个新的String对象。

String.valueOf()还有其他重载形式,和 valueOf(int)的方式非常相似,例如valueOf(long)会调用Long.toString(),valueOf(double)会调用Double.toString(),但Boolean就不需要了,直接返回“true”或者“false”即可,具体差别还是建议看看源码。

5 Java9和Java8 String类的区别

Java9对String做了不小的改变,最根本的区别是不再使用char类型数组来存储字符了,而是改用byte类型的数组。char类型占用空间是2个字节,byte占用的是1个字节,这样就节省了很多空间。其他的修改都是基于这个改变而改变的,具体改变建议网上搜索一下,这是我看到的一篇文章:JAVA9 String新特性,说说你不知道的东西

6 小结

String类是非常重要的,Java程序的方方面面都会用到。本文简单的讲了一下String.indexOf()方法、“+”号重载,String的不可变性等,还顺带着讲了一丢丢阅读源码的方式,个人觉得这才是最重要的,因为我们不可能把JDK里所有的类及其API记下来,但只要掌握了看源码、文档的方法,就随时都能快速的了解该类及其API的功能和使用方法,所以掌握方法非常重要!!!