1. “String对象” 不是 “字符串”
-
事实上 String 对象并不等于字符串,真正代表字符串的是 String 对象中的私有常数组:
private final char value[]
);由于 String 类是一个值类(一个实例对象仅能代表一个值),加上使用习惯,我们通常将 String 对象称为 字符串/** The value is used for character storage. */ private final char value[];
-
由于 String 使用私有的常字符数组来保存字符串内容,因此每个 String 对象在初始化后都无法改变
value[]
这个字符串,String 对象是不可变的;
那你或许会疑惑:那我之前可以对字符串进行拼接、删减工作呀?字符串怎么会是不可变的呢?
发出这样疑问的原因是你对于 java 变量认识不够深刻,看一个例子:String str = "Hello World!"; // hashCode:-969099747 String newStr = str; // hashCode:-969099747 System.out.println(str == newStr); // true str += " Man"; // hashCode:1318721719 newStr.hashCode(); // hashCode:-969099747 System.out.println(str == newStr); // false
str
与newStr
其实只是两个引用,最初指向同一片地址空间;str
进行拼接操作后,str
与newStr
指向了不同地址,而newStr
指向的对象却没变;表明在拼接过后,str
不再是原来那个对象了,而是 JVM 重新在常量池中创建 String 对象(”Hello World! Man”),再将str
指向新的对象,原本的对象(”Hello World!”)是不变的。 -
由于字符串名是个引用,因此当引用没指向字符串对象时,调用其相关方法将发生空指针异常,如:
String str = null; System.out.printf(str.hashCode()); // NullPointerException!
2. String的唯一性判断
-
使用
==
判断两个字符串引用是否指向同一对象,即对象的 内存地址 是否相等 -
String 重写
equals()
方法:逐个字符对比判断字符串对象的 内容 是否相等 -
String 重写
hashCode()
方法:相同内容的字符串算出来的结果是一样的,而非 String 对象的地址空间
看完了上文,你或许又会有疑惑了:既然 String 对象是不可变的,那么同一个字符串不是会指向同一个对象吗?那判断字符串内容是否相等,直接用 ==
判断是不是同一个对象就行了?干嘛要重写 equals()
方法呢?
再看一个例子:
String str0 = new String("abc");
String str1 = "abc";
String str2 = String.valueOf("abc");
System.out.println(str0.equals(str1)); // true
System.out.println(str2.equals(str1)); // true
System.out.println(str0 == str1); // false
System.out.println(str2 == str1); // true
别急,客官听我道来:
str0
这个引用创建时,分两步:
-
JVM 在常量池中检查是否已经有 “abc” 这一对象,发现没有(之前没创建过),就在常量池中创建了一个 “abc” 对象。
-
由于
str0
使用构造函数显示创建对象,因此 JVM 根据构造函数在堆中 new 了一个新的字符串对象,其内容与 “abc” 对象相同(想不到吧,我说它不可变又没说他不能有重复的)
str1
这个引用创建时:JVM 发现常量池中已经有 “abc” 这个对象,就直接将 str1
指向了它。
str2
这个引用创建过程得看一看 String.valueOf(String str)
方法:
// String.java
public static String valueOf(Object obj) {
return (obj == null) ? "null" : obj.toString();
}
public String toString() {
return this;
}
String.valueOf("abc")
时调用了 "abc".toString()
方法作为返回值,而 String 重写了 toString()
方法直接返回自身,所以
String str2 = String.valueOf("abc"); // 等同于 String str2 = "abc";
因此,str2
同样指向常量池的 “abc” 对象,因此 str1 == str2
;
相同的字符串可能会落在不同的对象中,所以,编写 String 类的开发者希望我们使用 ==
是否为同一个对象,使用 equals()
和 hashCode()
来判断字符串内容是否相等。
同时,由于字符串对象是不可变的,我们应该照他的指示,将相同的字符串引用指向同一个对象(这是安全的,因为你改变了字符串的值,就会自动指向其他对象,不会改变到原来的对象),而千万不要用构造方法的方式来创建字符串对象!
String str1 = new String("Hello world"); // 禁止 ❌!!!
String str2 = "Hello world"; // 推荐 ✔
String str3 = String.valueOf("Hello world"); // 不推荐
String str4 = String.valueOf(1315.520); // 推荐 ✔
3. 连接字符串
-
使用
+
连接二字符串使用此操作符进行字符串连接,无论连接的右操作数是否为空,都将返回一个新的字符串(即使该新字符串与原字符串一样)
-
使用
concat(String str)
方法连接二字符串使用
concat(String str)
方法进行字符串连接时,若传入的是 null,则将抛出空指针异常,因为内部调用了该参数的 length 方法;同时,若传入的是""
,则将返回原对象,这一点与+
截然不同:// Source Code 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); }
4. 字符串的长度
4.1 字符串的编码
/**
* ...The Java
* platform uses the UTF-16 representation in {@code char} arrays and
* in the {@code String} and {@code StringBuffer} classes...
*/
Java 使用的字符集是 Unicode,文档中声名默认使用 UTF-16 编码方式解析 Unicode,但由于 UTF-16 字符固定占 2 字节长度,对于英文字符(ASCII 字符占 1 字节)而言过于占用空间,因此 Java 会使用操作系统默认的编码方式对 Unicode 进行解析,使用非英文的系统大多使用 UTF-8 编码:
// Test Code
System.out.println(Charset.defaultCharset());
// Output:
UTF-8
4.2 Unicode
通称万国码,通过数字编码的方式来存储字符,理论长度从 000000 到 10FFFF 共 4180000 (2^20 + 2^16)个编码;
4.3 UTF
(Unicode Transformation Format)是 Unicode 从数字转化为字符的方案,而 UTF-8 是其中一套可变长度的转化方案,UTF-16 是双字节定长的转化方案;这就表明了,对于英文字符,UTF-8 将仅占用单字节长度来表示,大大节省了空间。
4.4 UTF-8 转换规则
-
如果只有一个字节则其最高二进制位为0;
-
如果是多字节,其第一个字节从最高位开始,连续的二进制位值为1的个数决定了其编码的字节数,其余各字节均以10开头。
-
UTF-8 转换表
Unicode bit Unit UTF-8 byte 0000 ~ 007F 00~07 1 0XXX XXXX 1 0080 ~ 07FF 08~11 1 110X XXXX
10XX XXXX2 0800 ~ FFFF 12~16 1 110X XXXX
10XX XXXX
10XX XXXX3 01 0000 ~ 10 FFFF 17~21 2~n 110X XXXX
10XX XXXX
10XX XXXX
10XX XXXX4
4.5 字符长度与字符串长度
如果系统采用的编码方式是 UTF-8,那么恭喜你,你的字符串的长度会变来变去;这是由于 UTF-8 是可变长的编码方式所决定的:
// Test Code
String string;
string = "ab";
getStringInfo(string);
string = "你好";
getStringInfo(string);
string = "𡃁𡃁";
getStringInfo(string);
string = "👦👩";
getStringInfo(string);
string = "👽👽";
getStringInfo(string);
...
private static void getStringInfo(String string) {
System.out.println(string + ".length\t\t\t\t= " + string.length());
System.out.println(string + ".toCharArray.length\t= " + string.toCharArray().length);
System.out.println(string + ".getBytes.length\t= " + string.getBytes().length);
}
// Output:
ab.length = 2
ab.toCharArray.length = 2
ab.getBytes.length = 2
你好.length = 2
你好.toCharArray.length = 2
你好.getBytes.lengt = 6
𡃁𡃁.length = 4
𡃁𡃁.toCharArray.length = 4
𡃁𡃁.getBytes.length = 8
👦👩.length = 4
👦👩.toCharArray.length = 4
👦👩.getBytes.length = 8
👽👽.length = 5
👽👽.toCharArray.length = 5
👽👽.getBytes.length = 11
-
String.length()
方法返回的是 Unicode 单元的长度 -
String.toCharArray.length()
String 本来就是 Char 数组,同样是 Unicode 单元的长度 -
String.getBytes.length()
方法将返回 UTF-8 编码的字节数组长度
目前的 Unicode 字符分为17组编排,0x0000 至 0x10FFFF,每组称为平面(Plane),而每平面拥有FFFF(65536) 个码位。我们平时使用的字符基本上都在 BMP(Basic Multilingual Plane,基本多语言平面) 中,即 Plane 0,范围为:0000 ~ FFFF 共 65536 个字符,占 1 个 Unicode 单元,从转换表中可以看出,其 UTF-8 编码占 1~3 个 bit 长度。
因此,上例子中的 𡃁𡃁
、👦👩
、👽👽
由于是 Unicode 3.0 后加入的字符,早已超出 BMP 所能表示的字符,因此需要占用 2 个及以上的 Unicode 单元,因此所求得的字符串长度会和显示的字符长度不一致,这是一个坑!
而由于 UTF-8 是变长编码,Byte 数组长度不一则是很正常的表现了。
而如果需要获取与实际相比较精准的字符数量,则需要使用 String.codePotinCount
方法:
// Test Code
string = "ab";
System.out.println(string.codePointCount(0, string.length()));
string = "你好";
System.out.println(string.codePointCount(0, string.length()));
string = "𡃁𡃁";
System.out.println(string.codePointCount(0, string.length()));
string = "👦👩";
System.out.println(string.codePointCount(0, string.length()));
string = "👽👽";
System.out.println(string.codePointCount(0, string.length()));
// Output:
2
2
2
2
3
至于… 最后一个是怎么回事,我至今没搞懂…
竟然是五???
5. hashCode() 的魔法值
在上文 String的唯一性判断 中,我们通过 hashCode() 方法判断字符串内容是否相同,但在哈希函数中,有一个很奇怪的乘子:31
/** The value is used for character storage. */
private final char value[];
/** Cache the hash code for the string */
private int hash; // Default to 0
/**
* Returns a hash code for this string. The hash code for a
* {@code String} object is computed as
* <blockquote><pre>
* s[0]*31^(n-1) + s[1]*31^(n-2) + ... + s[n-1]
* </pre></blockquote>
* using {@code int} arithmetic,where {@code s[i]} is the
* <i>i</i>th character of the string,{@code n} is the length of
* the string,and {@code ^} indicates exponentiation.
* (The hash value of the empty string is zero.)
*
* @return a hash code value for this object.
*/
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;
}
文档中给出了这个哈希函数的算法,通过推导检验算法:
// s = val; n = length
s[0]*31^(n-1) + s[1]*31^(n-2) + ... + s[n-1]
// Check
length=1 -> h = 31 * 0 + val[0]
= val[0]
length=2 -> h = 31 * (31 * 0 + val[0]) + val[1]
= 31 * val[0] + val[1]
= val[0] * 31^(2-1) + val[1]
length=3 -> h = 31 * (31 * (31 * 0 + val[0]) + val[1]) + val[2]
= 31 * 31 * val[0] + 31 * val[1] + val[2]
= val[0] * 31^(3-1) + val[1] * 31^(3-2) + val[2]
length=n -> h = val[0] * 31^(n-1) + val[1] * 31^(n-2) + ... + val[n-1]
将哈希算法写成数学公式为:
而由于整型的数值边界是 ,所以实际上是:
31 有以下几个性质:
-
31 是一个质数
-
31 =
由于 31 所具备的性质,以下解释乘子采用 31 的原因:
-
哈希算法所采用的模或者是乘子采用质数能够有效地减小碰撞,参考 哈希表的大小为什么最好是素数
-
由于它的第二个性质,JVM 自动对它进行了优化:
// 31 = 2^5 - 1 31 * i == i * (2^5 - 1) == i << 5 - i
移位运算大大提高了循环运算的效率
-
value[n] 是字符串中字符所在的 Unicode 平面的编号,范围从 0 到 65536;太小的质数进行哈希运算碰撞率会提高,而太大的质数进行哈希运算导致运算成本陡增,因此必须选用适中的质数,而由于 31 存在优化且碰撞率并不算高,因此选用 31 作为乘子
参考: