杰瑞科技汇

Java String类底层如何实现?

String 类是 Java 中最基础、最常用的类之一,它的设计体现了 Java 的一些核心思想,例如不可变性性能优化国际化支持

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

核心特性:不可变性

这是 String 类最最重要的特性,一个 String 对象在创建后,其内容(字符序列)就永远不能被改变

什么是不可变性?

一旦一个 String 对象被创建,你就无法修改它的值,任何看似修改 String 的操作,实际上都是创建了一个新的 String 对象,而原始对象保持不变。

String s1 = "Hello";
String s2 = s1; // s1 和 s2 现在指向同一个对象
// 看似修改了 s1,但实际上:
// 1. JVM 在字符串常量池中查找 "Hello World"。
// 2. 如果不存在,则创建一个新的 "Hello World" 字符串对象。
// 3. 将 s1 的引用指向这个新对象。
// 4. s2 仍然指向原来的 "Hello" 对象。
s1 = s1 + " World"; 
System.out.println(s1); // 输出: Hello World
System.out.println(s2); // 输出: Hello

为什么 String 要设计成不可变?

  1. 字符串常量池优化

    • Java 中有一个特殊的内存区域叫做字符串常量池,它位于堆内存中。
    • 当你使用双引号 创建字符串时,JVM 首先会检查常量池中是否已经存在该字符串。
    • 如果存在,就直接返回该对象的引用,避免重复创建相同的对象,从而节省内存。
    • String 是可变的,那么一个引用修改了字符串内容,会影响到所有指向该字符串的引用,这会导致常量池的管理变得极其混乱和危险。
  2. 线程安全

    Java String类底层如何实现?-图2
    (图片来源网络,侵删)
    • 不可变对象天生就是线程安全的,因为它的状态不能被改变,所以多个线程可以同时读取一个 String 对象而无需进行任何同步措施,这大大简化了多线程编程的复杂性。
  3. 安全性

    • 许多 Java 类的底层实现都依赖于 StringHashMap 的 key、ClassLoader 加载类的路径、网络连接的 URL 等。
    • String 是可变的,那么一个恶意程序可以修改作为 HashMap key 的字符串,从而导致数据错乱、安全漏洞或程序崩溃,修改一个作为文件路径的字符串,可能使其指向一个本不该访问的敏感目录。
  4. 作为哈希键的可靠性

    • String 类被广泛用做 HashMapHashSet 等集合的键,这些集合依赖于对象的 hashCode() 来定位存储位置。
    • StringhashCode() 是在对象创建时基于其内容计算出来的,由于内容不可变,hashCode 也不可变,这确保了键一旦放入集合,其哈希值就永远不会改变,从而保证了集合的正确性和稳定性。String 可变,其 hashCode 也会变,导致在集合中再也找不到这个键。

内部数据结构

在 Java 9 之前,String 的内部实现有一个重大的变化。

Java 8 及之前版本

在 Java 8 中,String 的内部实现如下:

public final class String implements java.io.Serializable, Comparable<String>, CharSequence {
    /** The value is used for character storage. */
    private final char value[];
    /** Cache the hash code for the string */
    private int hash; // Default to 0
    // ... 其他代码
}
  • private final char value[]:这是一个字符数组,用于真正存储字符串的内容。
  • private final:这个数组被声明为 final,这有两层含义:
    1. 引用不可变value 变量本身不能指向另一个数组。
    2. 内容不可变:虽然数组本身是可变的(可以修改数组元素),但 String 类的所有方法都没有提供修改 value 数组内容的公共 API,从外部看来,字符串内容是不可变的。
  • private int hash:这是一个哈希码缓存String 类重写了 hashCode() 方法,由于字符串内容不变,其哈希码也不变,缓存哈希码可以避免在每次调用 hashCode() 时都重新计算,从而提升性能。

Java 9 及之后版本 (JEP 254: Compact Strings)

为了优化内存占用,Java 9 引入了 Compact Strings 的特性。

public final class String implements java.io.Serializable, Comparable<String>, CharSequence {
    @Stable
    private final byte[] value;
    /** The identifier of the encoding used to store the bytes in {@code value}. */
    private final byte coder;
    // ... 其他代码
}
  • 核心变化:存储字符的数组从 char[] 变为了 byte[]
  • coder 字段:这是一个 byte 类型的标志位,用于指示 value 数组中存储的字符编码方式:
    • LATIN1 (或 0):表示字符串中的所有字符都可以用一个字节表示(ASCII 字符,Unicode 码点在 U+0000U+00FF 之间)。
    • UTF16 (或 1):表示字符串中包含需要两个字节才能表示的字符(例如中文字符,某些表情符号等)。
  • @Stable 注解:这是一个 JVM 内部使用的注解,告诉 JIT 编译器这个字段“可能”会初始化,并且之后很少会被修改,这有助于编译器进行优化。
  • 优势
    • 内存节省:对于一个纯英文字符串,char[] 需要每个字符占 2 字节,而 byte[] 只需 1 字节,在 64 位 JVM 中,一个对象头占用 16 字节,一个 char[] 数组头占用 16 字节,总共 32 字节,而 byte[] 数组头也是 16 字节,加上 String 对象自身的开销,总内存占用显著减少,对于大量小字符串,这种优化效果非常明显。

常用方法剖析

理解了内部结构,就能更好地理解常用方法的实现逻辑。

  • length():直接返回 value.length
  • charAt(int index):根据 coder 的值,从 value 数组中取出对应的字符,如果是 LATIN1,直接返回 (char)(value[index] & 0xff);如果是 UTF16,则按 char[] 的方式处理。
  • substring(int beginIndex, int endIndex)
    • 这个方法不创建新的字符数组!它只是创建一个新的 String 对象,但其 value 数组指向原始字符串的 value 数组
    • 新的 String 对象记录了 beginIndexendIndex,从而表示子串的范围。
    • 这是一种非常高效的实现,因为它避免了复制大量数据,但这也可能导致内存泄漏问题(详见下文)。
  • (字符串连接):
    • 在 Java 7 之前, 操作符在循环中性能很差,因为每次连接都会创建一个新的 String 对象。
    • 从 Java 7 开始,JVM 对 操作符进行了优化,在编译时,它会尝试将 操作重写为使用 StringBuilder 的形式,从而大大提升了性能。
    • 在编译时,String s = "a" + "b" + "c"; 会被优化为 String s = "abc";
    • 在运行时,String s = a + b + c; (a,b,c为变量) 会被优化为 StringBuilder sb = new StringBuilder(); sb.append(a).append(b).append(c); String s = sb.toString();

关键的内存区域:字符串常量池

字符串常量池是 String 实现中至关重要的一环。

  • 作用:缓存字符串字面量,避免重复创建,节省内存。
  • 位置
    • JDK 1.6 及之前:位于永久代
    • JDK 1.7:从永久代迁移到了堆内存
    • JDK 1.8 及之后:仍在堆内存中。
  • 创建方式
    1. 字面量创建String s = "hello";
      • JVM 首先检查常量池中是否存在 "hello"
      • 如果存在,直接返回池中的引用。
      • 如果不存在,则在池中创建 "hello",并返回其引用。
    2. new 关键字创建:String s = new String("hello");
      • new 关键字总是在堆内存中创建一个新的对象。
      • "hello" 这个字面量会先被处理(会进入常量池)。
      • new String(...) 会在堆中创建一个全新的 String 对象,其内容是 "hello" 的拷贝,这个堆中的对象不一定会被放入常量池。
// s1 指向常量池中的 "hello"
String s1 = "hello"; 
// s2 也指向常量池中的 "hello",因为 "hello" 已经存在
String s2 = "hello"; 
// s3 指向堆中一个新创建的 String 对象,其内容是 "hello"
String s3 = new String("hello"); 
// s4 指向常量池中的 "hello world"
String s4 = "hello world"; 
// s5 指向常量池中的 "hello world",因为 "hello" + "world" 在编译时就能确定为 "hello world"
String s5 = "hello" + "world"; 
System.out.println(s1 == s2); // true, 引用相同
System.out.println(s1 == s3); // false, 一个在池,一个在堆
System.out.println(s4 == s5); // true, 编译时优化,都指向池中的同一个对象

性能考量与最佳实践

substring 的内存泄漏陷阱

在 Java 7 之前,substring 方法会持有对原始字符串整个 char[] 的引用,即使原始字符串非常大,而子串非常小,这会导致原始字符串无法被垃圾回收,从而造成内存泄漏。

问题代码

// 假设这个字符串非常大,有 10MB
String hugeString = "..."; 
// 只需要开头的一小部分
String smallPart = hugeString.substring(0, 5); 

在 Java 7 之前,smallPartvalue 数组引用了 hugeString 的整个 10MB 的数组,只要 smallPart 存在,hugeString 就无法被回收。

解决方案 (Java 7+): 从 Java 7 开始,substring 方法被修改,如果子串的长度小于原始字符串,它会创建一个新的 char[] 数组,只拷贝子串所需的部分,这样就避免了内存泄漏问题。

如果你在非常古老的 Java 版本(Java 6 或更早)中工作,并且需要从一个非常大的字符串中提取一个小子串,可以使用以下方式来避免问题:

String smallPart = new String(hugeString.substring(0, 5));
// 或者更直接
String smallPart = hugeString.substring(0, 5).toString(); // 效果同上

这种方式会强制创建一个新的 String 对象和一个新的 char[] 数组,切断对原始大数组的引用。

频繁字符串连接使用 StringBuilder

虽然现代 JVM 优化了 操作符,但在循环中进行大量字符串连接时,显式使用 StringBuilder 仍然是更清晰、更可控的做法,并且性能上通常更有保障。

// 不推荐
String result = "";
for (int i = 0; i < 1000; i++) {
    result += "a"; // 每次循环都会创建一个新的 StringBuilder 和一个新的 String 对象
}
// 推荐
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 1000; i++) {
    sb.append("a"); // 在同一个 StringBuilder 对象上追加
}
String result = sb.toString();

intern() 方法

String.intern() 方法是一个手动干预字符串常量池的手段。

  • 作用:当调用一个字符串的 intern() 方法时,JVM 会在常量池中查找是否有与该字符串内容相同的字符串。
  • 如果存在:返回常量池中该字符串的引用。
  • 如果不存在:将当前字符串对象的内容拷贝一份到常量池中,并返回常量池中这个新创建的字符串的引用。

intern() 方法主要用于在处理大量相同的字符串时,节省内存,在处理大量用户名、ID、URL 等场景。

String s1 = new String("hello");
String s2 = s1.intern(); // s2 现在指向常量池中的 "hello"
String s3 = "hello"; // s3 也指向常量池中的 "hello"
System.out.println(s1 == s2); // false, s1 在堆,s2 在池
System.out.println(s2 == s3); // true, s2 和 s3 都指向池中的同一个对象

注意intern() 会增加 GC 的压力,因为它会修改常量池,在 JDK 7 之前,常量池在永久代,空间有限,频繁使用 intern() 可能会导致 OutOfMemoryError,在 JDK 7+ 中,常量池在堆中,风险降低,但仍需谨慎使用。


特性 描述
不可变性 String 对象创建后,其内容不可变,任何修改操作都会创建新对象。
内部存储 Java 8: final char[] value
Java 9+: final byte[] value + byte coder (Compact Strings 优化)。
线程安全 因不可变而天生线程安全,无需同步。
性能优化 哈希码缓存 (private int hash) 避免重复计算。
字符串常量池 避免重复创建相同内容的字符串。
关键方法 substring() 高效但需注意旧版内存泄漏问题。
操作符在编译时和运行时被优化为 StringBuilder
最佳实践 循环中连接字符串用 StringBuilder
处理大量重复字符串可考虑 intern(),但需谨慎。

理解了这些核心概念,你就能更深刻地理解 Java 程序的内存模型和运行机制,从而写出更高质量的代码。

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