Java String 核心原理:一篇讲透字符串常见面试题

String 是面试里被问得最多的一块。单看每个问题都不难,但答案之间互相牵连:不可变是常量池能成立的前提,常量池又决定了 == 和 intern() 的结果。

String、StringBuilder、StringBuffer

三个类都表示字符串,底层都是一个字节数组,区别在于这个数组能不能改。

  • String 是 final class,内部的 value 是 private final byte[],所有看起来像"修改"的方法(concat、replace、substring)都返回新对象。
  • StringBuilder 的 value 是普通 byte[],append 直接往数组里写,写完把 count 加一,不产生新对象。
  • StringBuffer 和 StringBuilder 共用 AbstractStringBuilder,主要方法上加了 synchronized。
String StringBuilder StringBuffer
可变性 不可变 可变 可变
线程安全 安全(不可变) 不安全 安全(方法同步)
频繁拼接 每次新建对象 快 比 StringBuilder 多一层锁
适用 内容固定的字符串 单线程拼接 多线程共享的拼接

StringBuffer 的线程安全是靠给方法加锁实现的,同一时刻只有一个线程能 append。String 的线程安全是因为它根本改不了,两者不是一回事。


不可变性

"不可变"指对象创建之后,它内部的字符内容不会再变。注意区分对象和变量:

java 复制代码
String a = "abc", b = a;
a = a + "d";
System.out.println(b);   // abc

a = a + "d" 改的是变量 a 指向谁,原来那个 "abc" 对象一个字都没动,b 还指着它。新拼接出来的 "abcd" 是一个新对象。

JDK 25 里 String 的字段是这样:

text 复制代码
private final byte[] value;
private final byte coder;
private int hash;
  • final class:不能被继承,防止子类加一些能改内容的方法。
  • value 是 private final:外部拿不到这个数组的引用,类内部也没有任何方法去改它的元素。
  • 构造函数收到外部传进来的 char[] / byte[] 时会拷贝一份,传完之后调用方再改原数组也影响不到 String。

hash 没加 final,因为它不是构造时算的,而是第一次调 hashCode() 时算出来存进去,后面直接读缓存。

所以"修改字符串"这个动作在 Java 里根本不存在,我们能做的只是创建一个内容不同的新 String。

不可变带来四个好处:

好处 原因
常量池可以复用 池里的对象不会被谁改掉,多个引用共享是安全的
线程安全 没有可变状态,不需要同步
哈希值能缓存 内容不变,hashCode() 算一次就够了
安全性 类名、文件路径、URL 这类内容如果能在校验后被改掉,检查就白做了

Java 9 之前 value 是 char[],每个字符固定占 2 字节。Java 9 之后 改成了 byte[] 加一个 coder:如果所有字符都在 Latin-1 范围内,就用 1 字节存一个字符(coder = LATIN1),否则用 UTF-16(coder = UTF16)。这就是 compact strings,纯英文的字符串内存直接减半。对外行为没变,变的只是内部存储。


字符串常量池

字符串字面量在代码里出现得非常频繁,如果每次都新建对象,内存浪费很大,而且比较时还得逐字符比。常量池的作用就是让相同内容的字面量共用同一个对象。

java 复制代码
String a = "abc";              // 字面量,指向池里的对象
String b = new String("abc");  // 堆里新建一个

前者编译进 class 文件时就是一个 CONSTANT_String 常量,第一次执行 ldc 时会在池里找到或创建 "abc",把引用给 a。后者一定会 new 一个新对象,构造时把 "abc" 的内容拷进去。

所以:

text 复制代码
a == b          : false
a.equals(b)     : true

池在哪:Java 7 之前字符串常量池在永久代里,Java 7 移到了堆,Java 8 永久代被元空间取代。现在可以认为池是堆里的一张表,表里存的是 String 对象的引用,字面量对应的对象本身也在堆里。

new String("abc") 涉及几个对象

这题要分情况:

  • 池里还没有 "abc":创建一个字面量对象放进池,再 new 一个,两个。
  • 池里已经有 "abc"(比如前面已经用过这个字面量):只 new 一个,一个。

面试里说"最多两个"是安全的。被问细节时,把"池里有没有"这个前提讲出来就行。

== 和 equals

== 比的是引用,两个变量指向同一个对象才为 true。equals() 在 String 里被重写过,比的是内容。判断两个字符串内容是否相同,一律用 equals()。


intern()

intern() 做的事只有一件:去常量池里查有没有内容相同的字符串。

  • 有:返回池里的那个引用。
  • 没有:把当前对象的引用登记进池,返回当前对象。

注意第二句是 Java 7 之后的行为。Java 6 及以前,池在永久代,如果不存在会把字符串复制一份放进去;Java 7 把池移到堆之后,直接存堆里那个对象的引用,不再复制。所以 intern() 不一定会创建新对象。

用代码验证:

java 复制代码
String a = "abc";
String b = new String("abc");
System.out.println(b.intern() == a);

String c = new String("计算") + new String("机");
System.out.println(c == c.intern());
text 复制代码
b.intern() == a     : true
c == c.intern()     : true

第一个:b 是堆里 new 出来的,intern() 发现池里已经有字面量 "abc",返回池里那个,所以和 a 相等。

第二个:"计算机" 从头到尾没有以字面量出现过(拼接用的是 "计算" 和 "机" 两个字面量),池里没有,intern() 就把 c 自己登记进去,返回的当然还是 c。

这行代码的结果依赖池里的状态。如果前面某处写过 String x = "计算机";,池里已经有了,上面第二个输出就会变成 false。

intern() 的实际用途是给大量重复的字符串去重,比如解析日志时字段值反复出现。代价是要查一次哈希表,而且池只增不减,不适合拿外部输入无脑 intern()。


字符串拼接

编译期常量会直接折叠

两个都是编译期常量时,+ 在编译阶段就算完了:

java 复制代码
String d = "a" + "b";
final String e = "a";
String f = e + "b";
String g = new String("a");
String h = g + "b";

System.out.println(d == "ab");   // true
System.out.println(f == "ab");   // true
System.out.println(h == "ab");   // false

d 编译出来就是字面量 "ab"。e 是 final 且初始值是常量表达式,编译器把它当常量,e + "b" 照样折叠。g 虽然内容也是 "a",但它是运行时 new 出来的变量,h 只能运行时拼,结果是一个新对象。

编译器怎么处理运行时的 +

Java 8 及以前,javac 把 a + b 编译成 new StringBuilder().append(a).append(b).toString()。Java 9 之后改成了 invokedynamic,javap -c 看到的是:

text 复制代码
2: invokedynamic #7,  0   // InvokeDynamic #0:makeConcatWithConstants:(...)

字节码里已经没有 StringBuilder 了。两种方式的结果一样:每次执行 + 都会产生一个新的 String。

循环里用 + 的问题

java 复制代码
String s = "";
for (int i = 0; i < 10000; i++) {
    s += i;
}

第 i 次要复制前面已经拼出来的所有字符,总复制量是 1 + 2 + ... + n,复杂度 O(n²)。次数一多,时间和内存都会明显上去。

换成 StringBuilder:

java 复制代码
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 10000; i++) {
    sb.append(i);
}
String s = sb.toString();

只在一开始分配数组,后面 append 往里写,扩容时按 oldCapacity * 2 + 2 翻倍,均摊下来每次追加是 O(1)。

默认容量 16。实测写满 16 个字符时容量还是 16,再写第 17 个就变成 34(16 * 2 + 2),然后 Arrays.copyOf 把旧数据拷过去。如果大概知道最终长度,直接 new StringBuilder(10000),能省掉多次扩容和拷贝。

什么时候用哪个

  • 一次性的、两三个值的拼接:直接用 +,可读性最好,编译器也会优化。
  • 循环里拼、或者有很多条件分支往里追加:StringBuilder。
  • 多线程共享同一个拼接缓冲:StringBuffer,或者每个线程各自用 StringBuilder 再合并。

equals()

Object.equals() 的默认实现就是引用比较:

java 复制代码
public boolean equals(Object obj) {
    return (this == obj);
}

String 重写了它,先比引用,再比长度,最后逐字节比较内容。引用相同直接返回 true,省掉后面的比较。

为什么要重写:字符串的语义相等是内容相等,不是同一个对象。new String("abc") 和 "abc" 是两个对象,但我们希望它们相等。String 又经常当 HashMap 的 key,没有内容级的 equals 和 hashCode,查表就找不到。

String.hashCode() 的公式是:
hash=s0×31n−1+s1×31n−2+⋯+sn−1 \LARGE hash = s0 \times 31^{n-1} + s1 \times 31^{n-2} + \cdots + sn-1 hash=s0×31n−1+s1×31n−2+⋯+sn−1

"abc".hashCode() 算出来是 96354,和实测一致。结果第一次算完会存进 hash 字段,后面直接读。

三者关系:

  • == 为 true → equals() 一定为 true
  • equals() 为 true → hashCode() 一定相等
  • 反过来都不成立

判断内容时用 equals()。如果担心空指针,把常量放前面:"abc".equals(s),或者用 Objects.equals(s, "abc")。


几个容易混淆的点

变量和常量参与 + 运算。 两个字面量(或编译期常量)相加,编译期折叠,结果在池里;只要有一个是运行时变量,就产生新对象。上面那组 d / f / h 的输出就是这三种情况。

final String 不等于内容可改。 final 修饰的是引用,s = "b" 编译不过,因为不能重新赋值。但对象内容本身照样改不了,因为 String 不可变。如果换成 final List<String> list,引用不能换,list.add() 却是可以的 ------ final 管的是变量,不是对象。

StringBuilder 为什么适合频繁拼接。 它从头到尾只有一个 byte[],append 是往数组末尾写,必要时扩容。不像 String,每次拼接都要新建对象再拷贝。

常量池、堆和引用的关系。 变量里存的是引用;new 出来的 String 在堆里,变量指向它;字面量的对象也在堆里,常量池这张表存的是它的引用。intern() 就是在"池里有没有内容相同的引用"这件事上做文章。


总结

问题 结论
内容比较 equals();== 只比引用
字面量 vs new 字面量复用池里的对象,new 必新建
new String("abc") 最多两个对象:池里一个,堆里一个
intern() 池里有就返回池里的,没有就登记当前引用,不一定新建
拼接 编译期常量会折叠;运行时 + 每次产生新对象;循环里用 StringBuilder
线程安全 String 靠不可变,StringBuffer 靠同步,StringBuilder 不安全

几条能串起来的结论:

  • 不可变是常量池能成立的前提,不然共享的对象被改掉都不会被发现。
  • 常量池决定了 == 的结果:字面量和 intern() 返回的是池里的引用,new 出来的是新对象。
  • 拼接的优化分两层:编译期能算的直接折叠,算不了的运行时生成新对象,循环里换成 StringBuilder。
相关推荐
imDwAaY1 小时前
API 和 SPI 有什么区别?一篇讲透两者的设计思想
后端
鶴哥只手遮天1 小时前
Spring Boot 4.1.1 深度源码解析-第 26 章 · 指标、观测与链路追踪:Micrometer 与 OpenTelemetry
java·后端·开源
for_ever_love__1 小时前
Agentic RAG 实战:让 Agent 自己决定怎么检索、检索几次
人工智能·后端·agent·控制·自适应·rag·检索策略
鶴哥只手遮天1 小时前
Spring Boot 4.1.1 深度源码解析-第 19 章 · Spring MVC 自动配置:DispatcherServlet、消息转换
java·后端·开源
鶴哥只手遮天1 小时前
Spring Boot 4.1.1 深度源码解析-第 28 章 AOT 与 GraalVM Native与序列化模型
java·后端·开源
鶴哥只手遮天1 小时前
Spring Boot 4.1.1 深度源码解析-第 27 章 · 可执行归档与 Loader:`JarLauncher` 与嵌套 jar
java·后端·开源
鶴哥只手遮天1 小时前
Spring Boot 4.1.1 深度源码解析-第 25 章 · Actuator 与健康:端点机制、`HealthIndicator`、管理端口子上下文
java·后端·开源
鶴哥只手遮天1 小时前
Spring Boot 4.1.1 深度源码解析-第 16 章 · 排序、元数据与报告:`AutoConfigurationSorter`、配置元数据、条件报告
java·后端·开源
鶴哥只手遮天1 小时前
Spring Boot 4.1.1 深度源码解析-第 14 章 · 自动配置引擎:`AutoConfigurationImportSelector` 全流程
java·后端·开源