杰瑞科技汇

Java字符串底层如何存储?

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 要设计成不可变?

  1. 字符串常量池:这是最主要的原因,由于字符串不可变,JVM 可以将字符串字面量(如 "hello")存储在一个叫做“字符串常量池”的特殊区域,当其他代码也使用 "hello" 时,可以直接复用这个对象,而不用创建新的,从而节省内存
  2. 线程安全:不可变对象天生就是线程安全的,因为内容不能被修改,所以多个线程可以同时访问一个 String 对象,而无需担心数据被其他线程篡改,也无需额外的同步开销。
  3. 安全性:在许多场景中,字符串被用作参数传递,例如在 File 构造函数中指定文件路径,或在 URL 类中指定网络地址,如果字符串是可变的,这些路径或地址可能会在程序执行过程中被恶意修改,导致严重的安全问题,不可变性保证了这些关键信息的安全。
  4. 哈码缓存String 对象被广泛用作 HashMapHashSet 等哈希集合的键,因为 String 不可变,所以它的哈希码(hashCode())在创建后就不会改变,这保证了键在集合中的位置是稳定的,使得哈希表能够正常工作。

内存分配:堆与字符串常量池

String 对象存储在堆内存中,但为了优化性能和内存,Java 引入了一个特殊的内存区域——字符串常量池

字符串常量池 的位置变迁:

  • Java 6 及之前:位于方法区(也称为永久代,PermGen)。
  • Java 7 及之后:被移动到了堆内存中,这个改动的原因是方法区的大小通常比较固定,而字符串常量池可能会占用大量内存,容易导致 OutOfMemoryError,将其移到堆中,可以利用垃圾回收器进行管理。

字符串的创建方式与内存分配

理解 String 的存储,关键在于理解两种不同的创建方式。

字面量赋值

String s1 = "hello";
String s2 = "hello";

内存分配过程:

  1. 当 JVM 执行第一行代码 String s1 = "hello"; 时,它会先在字符串常量池中查找是否存在 "hello" 这个字符串。
  2. 如果不存在,JVM 就会在常量池中创建一个 "hello" 对象,然后将 s1 引用指向这个对象。
  3. 当执行第二行代码 String s2 = "hello"; 时,JVM 再次在常量池中查找 "hello"
  4. 这次找到了,所以不会创建新对象,而是直接将 s2 引用指向常量池中已有的 "hello" 对象。

结果s1s2 指向的是同一个对象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");

内存分配过程:

  1. 当执行 String s3 = new String("world"); 时:
    • JVM 会在字符串常量池中检查 "world" 是否存在,如果不存在,就先在常量池里创建一个 "world" 对象。
    • new 关键字会在堆内存中创建一个新的 String 对象,并用常量池中的 "world 对象来初始化这个堆中的新对象。
    • s3 引用指向这个堆中的新对象。
  2. 当执行 String s4 = new String("world"); 时,过程与上面类似:
    • 常量池中已经有 "world",所以不再创建。
    • 再次在堆内存中创建一个新的 String 对象。
    • s4 引用指向这个堆中的新对象。

结果s3s4 指向的是堆中两个不同的对象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() 的作用:

  1. 它会检查当前字符串的内容是否已经存在于字符串常量池中。
  2. 如果存在,它直接返回常量池中那个对象的引用。
  3. 如果不存在,它会将当前字符串对象的内容复制一份,添加到字符串常量池中,并返回这个新入池对象的引用。

示例:

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() 会尝试将堆中这个对象的引用直接指向常量池,而不是复制内容,这能进一步节省内存。


StringBuilderStringBuffer

由于 String 的不可变性,频繁地进行字符串拼接(如使用 号)会创建大量临时对象,导致性能下降和内存浪费。

// 效率低,会创建多个中间 String 对象
String s = "";
for (int i = 0; i < 1000; i++) {
    s = s + "a";
}

为了解决这个问题,Java 提供了 StringBuilderStringBuffer

  • 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 == s2true
new 创建 总是在堆中创建新对象,常量池中会缓存一份。 s1 = new String("world"); s2 = new String("world");
s1 == s2false
intern() 手动将字符串入池,返回常量池中的引用。 s1 = new String("java"); s2 = s1.intern();
s2 == "java"true
StringBuilder 可变字符串,非线程安全,用于高效拼接。 sb.append("a"); 性能远高于 s = s + "a";

理解 String 的存储机制,对于优化 Java 程序的性能、内存使用以及编写健壮的代码至关重要,记住它的核心是不可变常量池优化

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