Java String 类

#String 的不可变性

在 Java 8 源码中,对于 String 的定义为:

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

在 String 类中,使用一个 char 类型的数组来保存字符串,final 修饰符使对象引用 value 的值不可再变,即数组的地址是不变的,但数组中的值是可以改变的。因此,String 不变性的原因是 value 的访问控制符 private 和String类的 final 修饰符,前者使字符数组是私有的且没有在类中暴露任何方法修改数组的值,后者保证了 String 类不可被继承,避免了子类破坏字符串的不变性。

#字符串的内部存储

在 Java 8 中,String 类内使用 char 类型的数组存储字符串,自 Java 9 开始改为使用 byte 数组存储字符串。将字符存储在一个 char 数组中,每个字符使用两个字节(十六位)。从许多不同应用程序收集的数据表明,字符串是堆使用的主要组成部分,而且大多数 String 对象仅包含 Latin-1 字符。此类字符仅需要一个字节的存储空间,因此此类对象的内部 char 数组中的一半空间未使用。因此可以采用更节省空间的内部表示。我们建议将 String 类的内部表示从 UTF-16 char 数组更改为 byte 数组加上编码标志字段。新 String 类将根据字符串的内容存储编码为 ISO-8859-1/Latin-1(每个字符一个字节)或 UTF-16(每个字符两个字节)的字符。编码标志将指示使用哪种编码。

在 Java 9 源码中,对于 String 的定义为:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
public final class String
    implements java.io.Serializable, Comparable<String>, CharSequence {

    @Stable
    private final byte[] value;

    /**
     * The identifier of the encoding used to encode the bytes in
     * {@code value}. The supported values in this implementation are
     *
     * LATIN1
     * UTF16
     */
    private final byte coder;
    ...
}

#字符串常量池

为了提高性能并减少内存的开销,在JVM层面为字符串提供字符串常量池,避免了字符串的重复创建。创建一个新的字符串时,会先在字符串常量池中检查是否存在这个字符串,如果存在,则直接返回引用;如果不存在,则会在堆中实例化该字符串,并将该字符串放入到字符串常量池中,以便于之后的使用。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
String str1 = "123";
String str2 = "123";
String str3 = "123";
String str4 = new String("123");
String str5 = new String("123");
String str6 = new String("123");

System.out.println(str1 == str2);  // true
System.out.println(str2 == str3);  // true
System.out.println(str3 == str4);  // false
System.out.println(str4 == str5);  // false
System.out.println(str5 == str6);  // false

// intern()返回字符串在常量池中的引用
System.out.println(str1 == str4.intern());  // true

String str = new String("123) 中,若常量池里没有 "123" 字符串,则创建了 2 个对象;若有该字符串,则创建了一个对象及对应的引用。

#字符串常量池位置的变化

  • 在 JDK1.7 前,运行时常量池和字符串常量池是存放在方法区中,HotSpot VM 对方法区的实现称为永久代。
  • 在 JDK1.7 中,字符串常量池从方法区移到堆中,运行时常量池保留在方法区中。
  • 在 JDK1.8 中,HotSpot 移除永久代,使用元空间(Meta Space)代替,此时字符串常量池保留在堆中,运行时常量池保留在方法区中,只是实现不一样了,JVM 内存变成了直接内存。

#字符串的比较

使用 == 运算符只会检测两个字符串是否放置在同一个位置,有时候两个内容相同的字符串其地址并不在同一个位置。因此要比较字符串的内容是否相同需要使用 equals()。String 中的 equals 方法是被重写过的,比较的是 String 字符串的值是否相等;Object 的 equals 方法是比较的对象的内存地址。

#==equals() 以及 hashCode()

  • == 运算符比较的是值,对于基本数据类型,比较的是值;对于对象引用比较的也是值,只不过对象引用的值是对象的地址。
  • equals() 是类中的方法,因此不能用于基本数据类型的比较。在 Object 类中,equals() 是通过 == 实现的,因此如果没有重写 equals(),那么 equals() 等价于 ==
  • 重写的 equals() 一般是比较对象的内容,即实例域来判断两个对象是否相同。
  • hashCode() 用来获取对象的哈希码,从而确定其在哈希表中的位置。Object 的 hashCode() 是本地方法,也就是用 C 语言或 C++ 实现的,该方法通常用来将对象的内存地址转换为整数之后返回。

重写 equals() 时必须重写 hashCode(),因为 hashCode 的常规协定声明相等的两个对象必须拥有相同的 hashCode。

  • 如果两个对象的 hashCode 值不相等,就可以直接认为这两个对象不相等。
  • 如果两个对象的 hashCode 值相等,那这两个对象不一定相等(哈希碰撞)。
  • 如果两个对象的 hashCode 值相等并且 equals() 也返回 true,才认为这两个对象相等。

当你把对象加入 HashSet 时,HashSet 会先计算对象的 hashCode 值来判断对象加入的位置,同时也会与其他已经加入的对象的 hashCode 值作比较,如果没有相符的 hashCode,HashSet 会假设对象没有重复出现。但是如果发现有相同 hashCode 值的对象,这时会调用 equals() 方法来检查 hashCode 相等的对象是否真的相同。如果两者相同,HashSet 就不会让其加入操作成功。如果不同的话,就会重新散列到其他位置。这样我们就大大减少了 equals() 的次数,相应就大大提高了执行速度。

如果重写了 equals() 方法但没有重写 hashCode() 方法,那么相等的两个对象在加入哈希表时因为 hashCode 不同而都被存储了,从而造成混淆。

#StringBuilder 与 StringBuffer

  • String 适⽤于字符串不经常变化的场景中,例如常量的声明、少量的变量声明运算等。
  • StringBuffer 适⽤于在频繁的进⾏字符串的操作(如拼接、替换、删除等),并且是运⾏在多线程的环境(线程安全)下,例如 XML 解析、HTTP 参数解析和封装等。
  • StringBulider 适⽤于频繁的对字符串进⾏操作但是在单线程的环境(线程不安全)下,如 SQL 语句拼接、JSON 封装等。

#字符串的拼接

Java 语言本身并不支持运算符重载,“+” 和 “+=” 是专门为 String 类重载过的运算符,也是 Java 中仅有的两个重载过的元素符。使用 “+” 运算符进行字符串拼接实际上是使用 StringBuilder 的 append() 和 toString() 实现的。在循环中使用 “+” 运算符会重复创建 StringBuilder,而不会重用同一个 StringBuilder,因此在循环中应当避免使用 “+” 运算符。

#参考资料

如何理解 String 类型值的不可变? - 知乎提问

JEP 254: Compact Strings

JVM——字符串常量池详解

hashCode与equals

updatedupdated2022-05-272022-05-27