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

核心特性:不可变性
这是 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 要设计成不可变?
-
字符串常量池优化:
- Java 中有一个特殊的内存区域叫做字符串常量池,它位于堆内存中。
- 当你使用双引号 创建字符串时,JVM 首先会检查常量池中是否已经存在该字符串。
- 如果存在,就直接返回该对象的引用,避免重复创建相同的对象,从而节省内存。
String是可变的,那么一个引用修改了字符串内容,会影响到所有指向该字符串的引用,这会导致常量池的管理变得极其混乱和危险。
-
线程安全:
(图片来源网络,侵删)- 不可变对象天生就是线程安全的,因为它的状态不能被改变,所以多个线程可以同时读取一个
String对象而无需进行任何同步措施,这大大简化了多线程编程的复杂性。
- 不可变对象天生就是线程安全的,因为它的状态不能被改变,所以多个线程可以同时读取一个
-
安全性:
- 许多 Java 类的底层实现都依赖于
String。HashMap的 key、ClassLoader加载类的路径、网络连接的 URL 等。 String是可变的,那么一个恶意程序可以修改作为HashMapkey 的字符串,从而导致数据错乱、安全漏洞或程序崩溃,修改一个作为文件路径的字符串,可能使其指向一个本不该访问的敏感目录。
- 许多 Java 类的底层实现都依赖于
-
作为哈希键的可靠性:
String类被广泛用做HashMap、HashSet等集合的键,这些集合依赖于对象的hashCode()来定位存储位置。String的hashCode()是在对象创建时基于其内容计算出来的,由于内容不可变,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,这有两层含义:- 引用不可变:
value变量本身不能指向另一个数组。 - 内容不可变:虽然数组本身是可变的(可以修改数组元素),但
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+0000到U+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对象记录了beginIndex和endIndex,从而表示子串的范围。 - 这是一种非常高效的实现,因为它避免了复制大量数据,但这也可能导致内存泄漏问题(详见下文)。
- 这个方法不创建新的字符数组!它只是创建一个新的
- (字符串连接):
- 在 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();。
- 在 Java 7 之前, 操作符在循环中性能很差,因为每次连接都会创建一个新的
关键的内存区域:字符串常量池
字符串常量池是 String 实现中至关重要的一环。
- 作用:缓存字符串字面量,避免重复创建,节省内存。
- 位置:
- JDK 1.6 及之前:位于永久代。
- JDK 1.7:从永久代迁移到了堆内存。
- JDK 1.8 及之后:仍在堆内存中。
- 创建方式:
- 字面量创建:
String s = "hello";- JVM 首先检查常量池中是否存在
"hello"。 - 如果存在,直接返回池中的引用。
- 如果不存在,则在池中创建
"hello",并返回其引用。
- JVM 首先检查常量池中是否存在
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 之前,smallPart 的 value 数组引用了 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[] valueJava 9+: final byte[] value + byte coder (Compact Strings 优化)。 |
| 线程安全 | 因不可变而天生线程安全,无需同步。 |
| 性能优化 | 哈希码缓存 (private int hash) 避免重复计算。字符串常量池 避免重复创建相同内容的字符串。 |
| 关键方法 | substring() 高效但需注意旧版内存泄漏问题。操作符在编译时和运行时被优化为 StringBuilder。 |
| 最佳实践 | 循环中连接字符串用 StringBuilder。处理大量重复字符串可考虑 intern(),但需谨慎。 |
理解了这些核心概念,你就能更深刻地理解 Java 程序的内存模型和运行机制,从而写出更高质量的代码。
