7.2 剖析String

7.2 剖析String

字符串操作是计算机程序中最常见的操作之一。Java中处理字符串的主要类是String和StringBuilder,本节介绍String。先介绍基本用法,然后介绍实现原理,随后介绍编码转换,分析String的不可变性、常量字符串、hashCode和正则表达式。

7.2.1 基本用法

字符串的基本使用是比较简单直接的。可以通过常量定义String变量:

1
String name = "老马说编程";

也可以通过new创建String变量:

1
String name = new String("老马说编程");

String可以直接使用+和+=运算符,如:

1
2
3
4
String name = "老马";
name+= "说编程";
String descritpion = ",探索编程本质";
System.out.println(name+descritpion);

输出为:

1
老马说编程,探索编程本质

String类包括很多方法,以方便操作字符串,比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public boolean isEmpty() //判断字符串是否为空
public int length() //获取字符串长度
public String substring(int beginIndex) //取子字符串
public String substring(int beginIndex, int endIndex) //取子字符串
public int indexOf(int ch) //查找字符,返回第一个找到的索引位置,没找到返回-1
public int indexOf(String str) //查找子串,返回第一个找到的索引位置,没找到返回-1
public int lastIndexOf(int ch) //从后面查找字符
public int lastIndexOf(String str) //从后面查找子字符串
public boolean contains(CharSequence s) //判断字符串中是否包含指定的字符序列
public boolean startsWith(String prefix) //判断字符串是否以给定子字符串开头
public boolean endsWith(String suffix) //判断字符串是否以给定子字符串结尾
public boolean equals(Object anObject) //与其他字符串比较,看内容是否相同
public boolean equalsIgnoreCase(String anotherString) //忽略大小写比较是否相同
public int compareTo(String anotherString) //比较字符串大小
public int compareToIgnoreCase(String str) //忽略大小写比较
public String toUpperCase() //所有字符转换为大写字符,返回新字符串,原字符串不变
public String toLowerCase() //所有字符转换为小写字符,返回新字符串,原字符串不变
public String concat(String str) //字符串连接,返回当前字符串和参数字符串合并结果
public String replace(char oldChar, char newChar) //字符串替换,替换单个字符
//字符串替换,替换字符序列,返回新字符串,原字符串不变
public String replace(CharSequence target, CharSequence replacement)
public String trim() //删掉开头和结尾的空格,返回新字符串,原字符串不变
public String[] split(String regex) //分隔字符串,返回分隔后的子字符串数组

看个String的简单例子,按逗号分隔”hello, world”:

1
2
String str = "hello, world";
String[] arr = str.split(", ");

arr[0]为”hello”, arr[1]为”world”。

String的操作大多简单直接,不再赘述。从调用者的角度了解了String的基本用法,下面我们进一步来理解String的内部(代码基于Java 7)。

7.2.2 走进String内部

String类内部用一个字符数组表示字符串,实例变量定义为:

1
private final char value[];

String有两个构造方法,可以根据char数组创建String变量:

1
2
public String(char value[])
public String(char value[], int offset, int count)

需要说明的是,String会根据参数新创建一个数组,并复制内容,而不会直接用参数中的字符数组。String中的大部分方法内部也都是操作的这个字符数组。比如:
1)length()方法返回的是这个数组的长度。
2)substring()方法是根据参数,调用构造方法String(char value[], int offset, intcount)新建了一个字符串。
3)indexOf()方法查找字符或子字符串时是在这个数组中进行查找。

这些方法的实现大多比较直接,不再赘述。

String中还有一些方法,与这个char数组有关:

1
2
3
4
5
public char charAt(int index) //返回指定索引位置的char
//返回字符串对应的char数组, 注意,返回的是一个复制后的数组,而不是原数组
public char[] toCharArray()
//将char数组中指定范围的字符复制入目标数组指定位置
public void getChars(int srcBegin, int srcEnd, char dst[], int dstBegin)

与Character类似,String也提供了一些方法,按代码点对字符串进行处理,具体不再赘述。

1
2
3
4
public int codePointAt(int index)
public int codePointBefore(int index)
public int codePointCount(int beginIndex, int endIndex)
public int offsetByCodePoints(int index, int codePointOffset)

7.2.3 编码转换

String内部是按UTF-16BE处理字符的,对BMP字符,使用一个char,两个字节,对于增补字符,使用两个char,四个字节。我们在第2.3节介绍过各种编码,不同编码可能用于不同的字符集,使用不同的字节数目,以及不同的二进制表示。如何处理这些不同的编码呢?这些编码与Java内部表示之间如何相互转换呢?

Java使用Charset类表示各种编码,它有两个常用静态方法:

1
2
public static Charset defaultCharset()
public static Charset forName(String charsetName)

第一个方法返回系统的默认编码,比如,在笔者的计算机中,执行如下语句:

1
System.out.println(Charset.defaultCharset().name());

输出为UTF-8。

第二个方法返回给定编码名称的Charset对象,与我们在2.3节介绍的编码相对应,其charset名称可以是US-ASCII、ISO-8859-1、windows-1252、GB2312、GBK、GB18030、Big5、UTF-8等,比如:

1
Charset charset = Charset.forName("GB18030");

String类提供了如下方法,返回字符串按给定编码的字节表示:

1
2
3
public byte[] getBytes()
public byte[] getBytes(String charsetName)
public byte[] getBytes(Charset charset)

第一个方法没有编码参数,使用系统默认编码;第二个方法参数为编码名称;第三个方法参数为Charset。

String类有如下构造方法,可以根据字节和编码创建字符串,也就是说,根据给定编码的字节表示,创建Java的内部表示。

1
2
public String(byte bytes[], int offset, int length, String charsetName)
public String(byte bytes[], Charset charset)

除了通过String中的方法进行编码转换,Charset类中也有一些方法进行编码/解码,本书就不介绍了。重要的是认识到,Java的内部表示与各种编码是不同的,但可以相互转换。

7.2.4 不可变性

与包装类类似,String类也是不可变类,即对象一旦创建,就没有办法修改了。String类也声明为了final,不能被继承,内部char数组value也是final的,初始化后就不能再变了。

String类中提供了很多看似修改的方法,其实是通过创建新的String对象来实现的,原来的String对象不会被修改。比如,concat()方法的代码:

1
2
3
4
5
6
7
8
9
10
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);
}

通过Arrays.copyOf方法创建了一块新的字符数组,复制原内容,然后通过new创建了一个新的String,最后一行调用的是String的另一个构造方法,其定义为:

1
2
3
4
String(char[] value, boolean share) {
//assert share : "unshared not supported";
this.value = value;
}

这是一个非公开的构造方法,直接使用传递过来的数组作为内部数组。关于Arrays类,我们在7.4节介绍。

与包装类类似,定义为不可变类,程序可以更为简单、安全、容易理解。但如果频繁修改字符串,而每次修改都新建一个字符串,那么性能太低,这时,应该考虑Java中的另两个类StringBuilder和StringBuffer。

7.2.5 常量字符串

Java中的字符串常量是非常特殊的,除了可以直接赋值给String变量外,它自己就像一个String类型的对象,可以直接调用String的各种方法。我们来看代码:

1
2
3
System.out.println("老马说编程".length());
System.out.println("老马说编程".contains("老马"));
System.out.println("老马说编程".indexOf("编程"));

实际上,这些常量就是String类型的对象,在内存中,它们被放在一个共享的地方,这个地方称为字符串常量池,它保存所有的常量字符串,每个常量只会保存一份,被所有使用者共享。当通过常量的形式使用一个字符串的时候,使用的就是常量池中的那个对应的String类型的对象

比如以下代码:

1
2
3
String name1 = "老马说编程";
String name2 = "老马说编程";
System.out.println(name1==name2);

输出为true。为什么呢?可以认为,”老马说编程”在常量池中有一个对应的String类型的对象,我们假定名称为laoma,上面的代码实际上就类似于:

1
2
3
4
String laoma = new String(new char[]{'老', '马', '说', '编', '程'});
String name1 = laoma;
String name2 = laoma;
System.out.println(name1==name2);

实际上只有一个String对象,三个变量都指向这个对象,name1==name2也就不言而喻了。

需要注意的是,如果不是通过常量直接赋值,而是通过new创建,==就不会返回true了,看下面的代码:

1
2
3
String name1 = new String("老马说编程");
String name2 = new String("老马说编程");
System.out.println(name1==name2);

输出为false。为什么呢?上面代码类似于:

1
2
3
4
String laoma = new String(new char[]{'老', '马', '说', '编', '程'});
String name1 = new String(laoma);
String name2 = new String(laoma);
System.out.println(name1==name2);

String类中以String为参数的构造方法代码如下:

1
2
3
4
public String(String original) {
this.value = original.value;
this.hash = original.hash;
}

hash是String类中另一个实例变量,表示缓存的hashCode值。

可以看出,name1和name2指向两个不同的String对象,只是这两个对象内部的value值指向相同的char数组。其内存布局如图7-1所示。

epub_923038_53

图7-1 两个String对象的内存布局

所以,name1==name2不成立,但name1.equals(name2)是true。

7.2.6 hashCode

7.2.5节中提到hash这个实例变量,它的定义如下:

1
private int hash; //Default to 0

hash变量缓存了hashCode方法的值,也就是说,第一次调用hashCode方法的时候,会把结果保存在hash这个变量中,以后再调用就直接返回保存的值。

我们来看下String类的hashCode方法,代码如下:

1
2
3
4
5
6
7
8
9
10
11
public int hashCode() {
int h = hash;
if(h == 0 && value.length > 0) {
char val[] = value;
for(int i = 0; i < value.length; i++) {
h = 31 * h + val[i];
}
hash = h;
}
return h;
}

如果缓存的hash不为0,就直接返回了,否则根据字符数组中的内容计算hash,计算方法是:

1
s[0]*31^(n-1) + s[1]*31^(n-2) + ... + s[n-1]

s表示字符串,s[0]表示第一个字符,n表示字符串长度,s[0]*31^(n-1)表示31的(n-1)次方再乘以第一个字符的值。

为什么要用这个计算方法呢?使用这个式子,可以让hash值与每个字符的值有关,也与每个字符的位置有关,位置i(i>=1)的因素通过31的(n-i)次方表示。使用31大致是因为两个原因:一方面可以产生更分散的散列,即不同字符串hash值也一般不同;另一方面计算效率比较高,31h与32h-h即(h<<5)-h等价,可以用更高效率的移位和减法操作代替乘法操作。

在Java中,普遍采用以上思路来实现hashCode。

7.2.7 正则表达式

String类中,有一些方法接受的不是普通的字符串参数,而是正则表达式。什么是正则表达式呢?正则表达式可以理解为一个字符串,但表达的是一个规则,一般用于文本的匹配、查找、替换等。正则表达式具有丰富和强大的功能,是一个比较大的话题,我们在第25章单独介绍。

Java中有专门的类(如Pattern和Matcher)用于正则表达式,但对于简单的情况,String类提供了更为简洁的操作,String中接受正则表达式的方法有:

1
2
3
4
public String[] split(String regex)  //分隔字符串
public boolean matches(String regex) //检查是否匹配
public String replaceFirst(String regex, String replacement) //字符串替换
public String replaceAll(String regex, String replacement) //字符串替换

至此,关于String的用法、原理和特性等基本介绍完了。关于String的实现原理,值得了解的是,Java 9对String的实现进行了优化,它的内部不是char数组,而是byte数组,如果字符都是ASCII字符,它就可以使用一个字节表示一个字符,而不用UTF-16BE编码,节省内存。