0%

字符串常量

Java 字符串常量与常量池:深度解析

String 作为 Java 中最常用的引用类型,其 “不可变性” 和 “字符串常量池” 机制是优化内存使用、提升性能的核心。本文将从字符串的不可变性出发,详细解析字符串常量池的结构、存储位置演变、字符串拼接规则及 intern() 方法的底层逻辑,帮助理解 String 类型的底层工作机制。

字符串的不可变性

String 被设计为不可变字符序列(Immutable),其不可变性体现在:一旦字符串对象被创建,其内部的字符序列(value 数组)就无法被修改。任何看似 “修改” 字符串的操作(如重新赋值、拼接、替换)都会创建新的字符串对象,而非修改原有对象。

不可变性的三种表现

  1. 重新赋值:原字符串对象不变,变量指向新的内存地址。

    1
    2
    String s = "hello";
    s = "world"; // "hello" 仍存在于常量池,s 指向新的 "world" 对象
  2. 字符串拼接:生成新的字符串对象,原对象不变。

    1
    2
    String s1 = "a";
    String s2 = s1 + "b"; // s1 仍为 "a",s2 指向新的 "ab" 对象
  3. replace() 方法:返回新的字符串,原对象内容不变。

    1
    2
    String s = "abc";
    String s2 = s.replace('a', 'x'); // s 仍为 "abc",s2 为 "xbc"

不可变性的实现原理

String 类的核心是内部的 value 数组(存储字符),其被 private final 修饰:

1
2
3
4
public final class String {
private final char value[]; // 字符数组,final 修饰确保引用不可变
// 其他方法...
}
  • final 修饰 value 数组的引用,确保其无法指向新的数组;
  • private 修饰确保外部无法直接修改数组内容。

这种设计保证了字符串的不可变性,使得 String 可以安全地作为 HashMap 的键,且多线程环境下无需额外同步。

字符串常量池:复用字符串的内存优化机制

字符串常量池(String Pool,又称字符串池)是 JVM 为 String 类型设计的内存池,用于存储唯一的字符串字面量,避免相同字符串的重复创建,节约内存。

常量池的底层结构

字符串常量池的底层是一个固定大小的哈希表(Hashtable),官方称为 StringTable。其工作原理是:

  • 字符串字面量(如 "abc")被放入池中时,会计算哈希值并存储在哈希表中;
  • 若新字符串的哈希值与池中已有字符串冲突,会通过链表解决(哈希冲突链)。

常量池的配置参数

  • -XX:StringTableSize:设置 StringTable 的长度(哈希表容量)。

    • 默认值:JDK 1.8 及以上默认为 60013,最小值为 1009
    • 作用:容量过小会导致哈希冲突频繁,链表过长,intern() 方法调用性能下降(需遍历链表);容量过大则浪费内存。

    查看当前 JVM 的 StringTableSize

    1
    jinfo -flag StringTableSize <进程ID>  # 例如:jinfo -flag StringTableSize 12345

字符串常量池的存储位置演变

字符串常量池的存储位置随 JDK 版本演变,核心变化是从 “永久代” 迁移到 “堆”,以提升回收效率:

JDK 版本 存储位置 问题与优化
JDK 6 及以前 永久代(方法区) 永久代回收效率极低(仅 Full GC 触发),大量字符串易导致 PermGen OOM
JDK 7 及以后 堆内存 堆的 GC 频率高(Minor GC 即可回收),字符串常量可及时释放,减少 OOM 风险。

字符串拼接的底层逻辑

字符串拼接的结果存储位置(常量池或堆)取决于参与拼接的是否为 “常量”,具体规则如下:

常量与常量拼接:编译期优化

若拼接的所有元素都是编译期可确定的常量(如字符串字面量、final 变量),则拼接结果在编译期就会合并为单个字符串,并放入常量池。

示例

1
2
3
4
5
6
7
8
9
// 源码
String s1 = "a" + "b" + "c";
String s2 = "abc";

// 编译后(.class 文件):编译器直接合并为 "abc"
String s1 = "abc";
String s2 = "abc";

System.out.println(s1 == s2); // true(均指向常量池中的 "abc")

包含变量的拼接:运行期生成新对象

若拼接中包含变量(非 final 修饰,编译期无法确定值),则拼接操作在运行期通过 StringBuilder 完成,结果存储在堆中,不会自动放入常量池

示例

1
2
3
4
5
6
7
8
9
10
11
12
13
// 源码
String s1 = "a";
String s2 = "b";
String s3 = s1 + s2; // 包含变量,运行期处理
String s4 = "ab";

// 编译后(.class 文件):等价于使用 StringBuilder 拼接
String s1 = "a";
String s2 = "b";
String s3 = new StringBuilder().append(s1).append(s2).toString(); // 结果在堆中
String s4 = "ab"; // 在常量池中

System.out.println(s3 == s4); // false(s3 在堆,s4 在常量池)

关键StringBuilder.toString() 方法会创建新的 String 对象(new String(...)),该对象存储在堆中,不会自动加入常量池

intern() 方法:手动将字符串加入常量池

intern() 方法是 String 类的 native 方法,用于将字符串 “入池”,其行为在 JDK 7 后有重要变化:

intern() 方法的核心作用

  • 若字符串常量池中已存在当前字符串,则返回常量池中该字符串的引用;
  • 若常量池中不存在,则将当前字符串的引用(JDK 7+)或副本(JDK 6 及以前)放入池中,并返回该引用。

JDK 7+ 中 intern() 的行为(重点)

JDK 7 后,由于常量池移至堆中,intern() 对 “新字符串” 的处理改为存储引用而非副本,大幅优化内存:

示例 1:拼接结果调用 intern()

1
2
3
4
5
6
7
8
9
10
// 步骤1:s1 是堆中的新对象(内容为 "11",常量池中无 "11")
String s1 = new String("1") + new String("1");

// 步骤2:常量池中无 "11",将 s1 的引用放入常量池,返回该引用
s1.intern();

// 步骤3:s2 指向常量池中的引用(即 s1 的引用)
String s2 = "11";

System.out.println(s1 == s2); // true(s1 和 s2 指向同一对象)

示例 2:常量池已存在该字符串

1
2
3
4
5
6
String s1 = "11"; // "11" 先入池
String s2 = new String("1") + new String("1");
s2.intern(); // 常量池已存在 "11",返回池中的引用
String s3 = "11";

System.out.println(s2 == s3); // false(s2 在堆,s3 指向池中的 "11")

intern() 的使用场景

  • 复用字符串:对于频繁出现的字符串(如日志中的固定前缀),调用 intern() 可减少重复对象,节约内存;
  • 保证唯一性:确保相同字符序列的字符串在内存中只有一份(通过 == 比较可快速判断相等性)。

关键结论与最佳实践

  1. 不可变性的本质Stringvalue 数组被 private final 修饰,任何修改都会创建新对象。
  2. 常量池的位置:JDK 7+ 位于堆中,回收效率高,避免永久代 OOM。
  3. 拼接规则:
    • 全常量拼接:编译期合并,结果在常量池;
    • 含变量拼接:运行期用 StringBuilder 处理,结果在堆中(需手动 intern() 入池)。
  4. intern() 的优化:JDK 7+ 不再复制字符串,而是存储引用,减少内存开销。

通过理解这些机制,可更合理地使用 String 类型,避免不必要的内存浪费(如避免频繁拼接变量生成大量临时对象,优先使用 StringBuilder

欢迎关注我的其它发布渠道