Zohar's blog

Java - String 类

JavaJava

Java 提供了默认的字符串类 (String),本文主要记述了其基本用法和需要注意的细节,本文忽略已过时或有关 Unicode 的方法

1. “String对象” 不是 “字符串”

  1. 事实上 String 对象并不等于字符串,真正代表字符串的是 String 对象中的私有常数组: private final char value[]);由于 String 类是一个值类(一个实例对象仅能代表一个值),加上使用习惯,我们通常将 String 对象称为 字符串

     /** The value is used for character storage. */
     private final char value[];
    
  2. 由于 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
    

    strnewStr 其实只是两个引用,最初指向同一片地址空间;str 进行拼接操作后,strnewStr 指向了不同地址,而 newStr 指向的对象却没变;表明在拼接过后,str 不再是原来那个对象了,而是 JVM 重新在常量池中创建 String 对象(”Hello World! Man”),再将 str 指向新的对象,原本的对象(”Hello World!”)是不变的。

  3. 由于字符串名是个引用,因此当引用没指向字符串对象时,调用其相关方法将发生空指针异常,如:

     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 这个引用创建时,分两步:

  1. JVM 在常量池中检查是否已经有 “abc” 这一对象,发现没有(之前没创建过),就在常量池中创建了一个 “abc” 对象。

  2. 由于 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 XXXX
    2
    0800 ~ FFFF 12~16 1 110X XXXX
    10XX XXXX
    10XX XXXX
    3
    01 0000 ~ 10 FFFF 17~21 2~n 110X XXXX
    10XX XXXX
    10XX XXXX
    10XX XXXX
    4

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

至于… 最后一个是怎么回事,我至今没搞懂…

奇怪的emoji

竟然是五???

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 的原因:

  1. 哈希算法所采用的模或者是乘子采用质数能够有效地减小碰撞,参考 哈希表的大小为什么最好是素数

  2. 由于它的第二个性质,JVM 自动对它进行了优化:

     // 31 = 2^5 - 1
     31 * i == i * (2^5 - 1) == i << 5 - i
    

    移位运算大大提高了循环运算的效率

  3. value[n] 是字符串中字符所在的 Unicode 平面的编号,范围从 0 到 65536;太小的质数进行哈希运算碰撞率会提高,而太大的质数进行哈希运算导致运算成本陡增,因此必须选用适中的质数,而由于 31 存在优化且碰撞率并不算高,因此选用 31 作为乘子

    奇怪的emoji2

参考: