Java 中的 String 对象是不可变的(Immutable),其内容存储在堆内存的字符串常量池中。
下面我们从几个方面深入剖析。
不可变性
这是 String 最核心的特性,一旦一个 String 对象被创建,它的内容就不能被改变。
如何体现不可变?
所有的 String 类的方法,如 substring(), replace(), toUpperCase() 等,都不会修改原始字符串,而是返回一个新的 String 对象,新对象包含了修改后的内容。
示例代码:
String s1 = "hello"; String s2 = s1.toUpperCase(); // s1.toUpperCase() 不会改变 s1 System.out.println(s1); // 输出: hello System.out.println(s2); // 输出: HELLO
为什么 String 要设计成不可变?
- 字符串常量池:这是最主要的原因,由于字符串不可变,JVM 可以将字符串字面量(如
"hello")存储在一个叫做“字符串常量池”的特殊区域,当其他代码也使用"hello"时,可以直接复用这个对象,而不用创建新的,从而节省内存。 - 线程安全:不可变对象天生就是线程安全的,因为内容不能被修改,所以多个线程可以同时访问一个
String对象,而无需担心数据被其他线程篡改,也无需额外的同步开销。 - 安全性:在许多场景中,字符串被用作参数传递,例如在
File构造函数中指定文件路径,或在URL类中指定网络地址,如果字符串是可变的,这些路径或地址可能会在程序执行过程中被恶意修改,导致严重的安全问题,不可变性保证了这些关键信息的安全。 - 哈码缓存:
String对象被广泛用作HashMap、HashSet等哈希集合的键,因为String不可变,所以它的哈希码(hashCode())在创建后就不会改变,这保证了键在集合中的位置是稳定的,使得哈希表能够正常工作。
内存分配:堆与字符串常量池
String 对象存储在堆内存中,但为了优化性能和内存,Java 引入了一个特殊的内存区域——字符串常量池。
字符串常量池 的位置变迁:
- Java 6 及之前:位于方法区(也称为永久代,PermGen)。
- Java 7 及之后:被移动到了堆内存中,这个改动的原因是方法区的大小通常比较固定,而字符串常量池可能会占用大量内存,容易导致
OutOfMemoryError,将其移到堆中,可以利用垃圾回收器进行管理。
字符串的创建方式与内存分配
理解 String 的存储,关键在于理解两种不同的创建方式。
字面量赋值
String s1 = "hello"; String s2 = "hello";
内存分配过程:
- 当 JVM 执行第一行代码
String s1 = "hello";时,它会先在字符串常量池中查找是否存在"hello"这个字符串。 - 如果不存在,JVM 就会在常量池中创建一个
"hello"对象,然后将s1引用指向这个对象。 - 当执行第二行代码
String s2 = "hello";时,JVM 再次在常量池中查找"hello"。 - 这次找到了,所以不会创建新对象,而是直接将
s2引用指向常量池中已有的"hello"对象。
结果:s1 和 s2 指向的是同一个对象。s1 == s2 的结果为 true。
String s1 = "hello"; String s2 = "hello"; System.out.println(s1 == s2); // 输出 true,因为它们指向同一个对象
new 关键字创建
String s3 = new String("world");
String s4 = new String("world");
内存分配过程:
- 当执行
String s3 = new String("world");时:- JVM 会在字符串常量池中检查
"world"是否存在,如果不存在,就先在常量池里创建一个"world"对象。 new关键字会在堆内存中创建一个新的String对象,并用常量池中的"world对象来初始化这个堆中的新对象。- 将
s3引用指向这个堆中的新对象。
- JVM 会在字符串常量池中检查
- 当执行
String s4 = new String("world");时,过程与上面类似:- 常量池中已经有
"world",所以不再创建。 - 再次在堆内存中创建一个新的
String对象。 - 将
s4引用指向这个堆中的新对象。
- 常量池中已经有
结果:s3 和 s4 指向的是堆中两个不同的对象。s3 == s4 的结果为 false,但它们的内容相同,s3.equals(s4) 的结果为 true。
String s3 = new String("world");
String s4 = new String("world");
System.out.println(s3 == s4); // 输出 false,因为它们指向堆中两个不同的对象
System.out.println(s3.equals(s4)); // 输出 true,因为它们的内容相同
intern() 方法
intern() 是 String 类的一个方法,它允许开发者手动将字符串对象“入池”。
intern() 的作用:
- 它会检查当前字符串的内容是否已经存在于字符串常量池中。
- 如果存在,它直接返回常量池中那个对象的引用。
- 如果不存在,它会将当前字符串对象的内容复制一份,添加到字符串常量池中,并返回这个新入池对象的引用。
示例:
String s5 = new String("java"); // s5 指向堆中的对象
String s6 = s5.intern(); // s6 指向常量池中的 "java"
System.out.println(s5 == s6); // 输出 false,因为 s5 仍在堆中
String s7 = "java"; // s7 直接指向常量池中的 "java"
System.out.println(s6 == s7); // 输出 true,因为 s6 和 s7 都指向常量池中的同一个对象
在 Java 7 之后,intern() 的策略有所优化,如果一个字符串已经存在于堆中,但常量池里没有,intern() 会尝试将堆中这个对象的引用直接指向常量池,而不是复制内容,这能进一步节省内存。
StringBuilder 和 StringBuffer
由于 String 的不可变性,频繁地进行字符串拼接(如使用 号)会创建大量临时对象,导致性能下降和内存浪费。
// 效率低,会创建多个中间 String 对象
String s = "";
for (int i = 0; i < 1000; i++) {
s = s + "a";
}
为了解决这个问题,Java 提供了 StringBuilder 和 StringBuffer。
StringBuilder:非线程安全的,性能更高,适用于单线程环境。StringBuffer:线程安全的,所有方法都带有synchronized关键字,适用于多线程环境。
它们都是可变的,内部维护一个字符数组,拼接操作会直接修改这个数组,而不是创建新对象,直到最后调用 toString() 方法时,才会生成最终的 String 对象。
推荐用法:
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 1000; i++) {
sb.append("a"); // 高效,不会创建新对象
}
String result = sb.toString(); // 只在最后创建一个 String 对象
| 特性/方法 | 描述 | 示例 & 结果 |
|---|---|---|
| 不可变性 | String 对象创建后内容不可变。 |
s1 = "hello"; s2 = s1.toUpperCase(); s1 仍为 "hello"。 |
| 字面量赋值 | 优先使用字符串常量池,实现对象复用,节省内存。 | s1 = "hello"; s2 = "hello"; s1 == s2 为 true。 |
new 创建 |
总是在堆中创建新对象,常量池中会缓存一份。 | s1 = new String("world"); s2 = new String("world"); s1 == s2 为 false。 |
intern() |
手动将字符串入池,返回常量池中的引用。 | s1 = new String("java"); s2 = s1.intern(); s2 == "java" 为 true。 |
StringBuilder |
可变字符串,非线程安全,用于高效拼接。 | sb.append("a"); 性能远高于 s = s + "a";。 |
理解 String 的存储机制,对于优化 Java 程序的性能、内存使用以及编写健壮的代码至关重要,记住它的核心是不可变和常量池优化。
