杰瑞科技汇

Java String类底层如何实现?

String 类在 Java 中不仅仅是一个简单的字符序列,它是一个设计精良、功能强大且经过高度优化的类。

Java String类底层如何实现?-图1
(图片来源网络,侵删)

String 类的核心特性

在深入实现细节之前,我们先要理解 String 的几个最核心的特性:

  1. 不可变性:这是 String 类最重要的特性,一旦一个 String 对象被创建,它的内容就不能被改变,任何看起来修改 String 的操作(如 substring, replace, concat 等)实际上都是创建了一个新的 String 对象,而原始对象保持不变。
  2. finalString 类被声明为 final,这意味着它不能被继承,这保证了 String 的行为不会被任何子类改变,从而维护了其不变性。
  3. 字符数组存储:在 Java 9 之前,String 内部使用 private final char[] value 数组来存储字符,从 Java 9 开始,为了节省内存,改用了 byte[] 数组,并引入了 coder 字段来区分是 Latin-1 字符还是 UTF-16 编码。
  4. 常量池优化:Java 为了提高性能和减少内存消耗,引入了字符串常量池,这是一个特殊的内存区域,专门用于存储字符串字面量。

String 类的内部结构 (以 Java 9+ 为例)

我们来看一下 String 类的源码(简化版):

public final class String
    implements java.io.Serializable, Comparable<String>, CharSequence {
    // @Stable 是一个注解,表示这个引用在构造后不会再改变
    @Stable
    private final byte[] value;
    private final byte coder; // 用于标识编码: LATIN1 (0) 或 UTF16 (1)
    // ... 其他字段和方法 ...
}

关键字段解析:

  • private final byte[] value:

    • 这是存储字符串实际内容的数组。
    • 使用 byte[] 而不是 char[] 是一个重要的优化,对于大部分只包含 ASCII 字符(Latin-1)的字符串,每个字符只需要一个字节存储,而 char[] 需要两个字节,这能节省 50% 的内存。
    • final 关键字确保了 value 引用本身不能被修改,从而保证了 String 的不可变性。
  • private final byte coder:

    Java String类底层如何实现?-图2
    (图片来源网络,侵删)
    • 这个字段是一个标志位,用于指示 value 数组中的编码方式。
    • static final byte LATIN1 = 0;
    • static final byte UTF16 = 1;
    • 如果字符串中所有字符都可以用 Latin-1(单字节)表示,coder 0value 数组就是 byte[]
    • 如果字符串中包含无法用 Latin-1 表示的字符(如中文、表情符号等),coder 1value 数组在逻辑上会被当作 char[] 使用(每个字符占两个字节)。

不可变性是如何实现的?

String 的不可变性由以下几个因素共同保证:

  1. 类声明为 finalpublic final class String,这阻止了任何人通过继承来覆盖其方法并改变其行为。
  2. 内部存储数组为 finalprivate final byte[] value,这确保了 value 引用一旦指向一个数组,就不能再指向另一个数组。
  3. 没有提供修改 value 数组内容的方法String 类没有提供任何 public 方法来直接修改 value 数组中的元素,所有修改操作都是“只读”的。

举例说明不可变性:

String s1 = "hello";
String s2 = s1.concat(" world"); // concat() 方法看起来修改了字符串
System.out.println(s1); // 输出: hello
System.out.println(s2); // 输出: hello world
  • s1 指向内存中一个值为 "hello" 的 String 对象。
  • 当调用 s1.concat(" world") 时,concat 方法并不会修改 s1 指向的对象。
  • 相反,它会创建一个全新的 String 对象,内容为 "hello world",然后将这个新对象的引用赋值给 s2
  • s1 仍然指向原来的 "hello" 对象。

字符串常量池

字符串常量池是 JVM 的一块特殊内存区,用于存储字符串字面量,它的主要目的是避免重复创建相同内容的字符串对象,从而节省内存

工作原理:

  1. 字面量创建:当代码中使用双引号 创建字符串时,JVM 首先会检查常量池中是否已经存在一个内容相同的字符串。
    • 如果存在,则直接返回池中对象的引用。
    • 如果不存在,则在池中创建一个新的字符串对象,并返回其引用。
String s1 = "hello"; // JVM 在常量池中创建 "hello" 对象,s1 指向它
String s2 = "hello"; // JVM 发现池中已有 "hello",直接将 s2 指向同一个对象
System.out.println(s1 == s2); // 输出: true (s1 和 s2 是同一个对象的引用)
  1. new 关键字创建:使用 new 关键字创建字符串时,JVM 会在堆内存中强制创建一个新的对象,不会检查常量池
String s3 = new String("hello"); // 在堆中创建一个新的 "hello" 对象
String s4 = new String("hello"); // 再在堆中创建另一个新的 "hello" 对象
System.out.println(s1 == s3); // 输出: false (s1 在池中,s3 在堆中)
System.out.println(s3 == s4); // 输出: false (s3 和 s4 是堆中两个不同的对象)

intern() 方法:

intern() 方法是一个可以手动操作字符串常量池的方法。

  • 当调用一个字符串的 intern() 方法时,JVM 会检查常量池。
  • 如果池中已经存在一个与该字符串内容相等的字符串,则返回池中的字符串引用。
  • 如果不存在,则将该字符串对象的内容复制到常量池中,并返回池中这个新对象的引用。
String s5 = new String("hello");
s5 = s5.intern(); // s5 现在指向常量池中的 "hello"
System.out.println(s1 == s5); // 输出: true (s5 通过 intern() 指向了池中的对象)

常用方法实现原理

String 类提供了大量方法,但它们都遵循“不可变”原则。

Java String类底层如何实现?-图3
(图片来源网络,侵删)
  • substring(int beginIndex, int endIndex):

    • 它不会复制原始字符数组,而是创建一个新的 String 对象,这个新对象的 value 数组共享了原始对象的 value 数组,只是通过 offsetcount (Java 9 之前) 或 codervalue 的索引范围来表示子串。
    • 注意: 这种共享可能会导致内存泄漏,如果一个非常大的字符串只截取了很小的一部分,那么原始大字符串因为被这个子串引用,无法被 GC 回收,这个问题在 Java 7u6 之后得到改善,substring 方法会复制一个新的字符数组。
  • concat(String str):

    • 首先计算新字符串的总长度。
    • 创建一个新的 char[] (或 byte[]) 数组,长度为总长度。
    • 将当前字符串的内容复制到新数组的前半部分。
    • 将参数 str 的内容复制到新数组的后半部分。
    • 最后用这个新数组创建一个新的 String 对象并返回。
  • replace(char oldChar, char newChar):

    • 遍历原始字符串的字符数组,检查每个字符。
    • 如果发现某个字符等于 oldChar,就在新数组中写入 newChar,否则写入原字符。
    • 如果没有任何字符被替换,为了性能,它会直接返回原始字符串对象(因为内容没变)。
    • 如果有字符被替换,则创建一个新数组,生成新的 String 对象。
  • split(String regex):

    • 使用给定的正则表达式作为分隔符来分割字符串。
    • 它内部会使用正则表达式引擎来查找所有匹配的位置。
    • 然后根据这些位置,从原始字符串中提取子串,并将这些子串放入一个 String[] 数组中返回。

性能影响与最佳实践

String 的不可性带来了很多好处,但也需要我们在使用时注意性能问题。

最佳实践:

  1. 使用 StringBuilderStringBuffer 进行字符串拼接: 在循环中频繁拼接字符串时,每次拼接都会创建新对象,导致大量内存分配和 GC 压力。

    • StringBuilder: 线程不安全,性能更高。在绝大多数单线程场景下应使用它
    • StringBuffer: 线程安全,方法大多有 synchronized 修饰,性能较低。
    // 不好的做法
    String result = "";
    for (int i = 0; i < 1000; i++) {
        result += "a"; // 每次循环都创建新对象
    }
    // 好的做法
    StringBuilder sb = new StringBuilder();
    for (int i = 0; i < 1000; i++) {
        sb.append("a"); // 在原有对象上进行操作,效率极高
    }
    String result = sb.toString();
  2. 避免不必要的字符串创建

    • 尽量使用字面量而非 new 关键字。
    • 对于在循环中不变的字符串,可以提到循环外部。
  3. 谨慎使用 String.intern()

    • intern() 方法会尝试将字符串放入全局共享的常量池,这个过程可能很耗时(需要同步)。
    • 它会永久占用内存,因为常量池中的对象不会被 GC 回收。
    • 通常只在处理大量重复且内容相同的字符串时(如解析大型 XML/JSON 文件时的大量短键)才考虑使用,以换取内存空间的节省。
特性 描述 实现方式 优点 缺点/注意事项
不可变性 不可变 final 类, final 数组, 无修改方法 线程安全、可被哈希缓存、成为 HashMap 的完美键 频繁修改时会产生大量新对象,性能差
final 不能被继承 public final class String 保证 String 行为的稳定性和不变性 失去了通过继承扩展功能的灵活性
字符数组优化 使用 byte[] 替代 char[] private final byte[] value + coder 标志 节省内存,特别是对 ASCII 字符 增加了处理逻辑的复杂性
字符串常量池 共享字符串字面量 JVM 特殊内存区 节省内存、提高创建相同字符串的效率 new 关键字会绕过池,可能导致重复对象

理解 String 类的这些实现细节,能帮助你在日常开发中做出更明智的决策,写出更高效、更健壮的代码。

分享:
扫描分享到社交APP
上一篇
下一篇