1. 面试题背后的真实考点
这道题我印象太深了。早年在京东面试候选人,我自己就挂在上面过;后来坐在面试官的位置上,也用它筛过不少简历上写着“精通Java”的候选人。String str = new String("abc") 这句话,表面上在问“创建了几个对象”,实际上考察的是三块硬功夫:字符串常量池的机制、new 关键字创建对象的流程、以及JVM内存区域的基础认知。这三块知识,几乎覆盖了Java初级到中级面试的核心内容。
很多候选人一听到“创建了几个对象”,条件反射就想答“两个”,但追问下去就露馅了:一个是字符串常量池里的 "abc",一个是堆上的 String 实例。这个答案背过的人不少,但真正能讲清楚“为什么是池里一个、堆上一个”“两个对象分别在什么时候被创建”“如果池里已经有 "abc" 了还会创建几个”“s.intern() 在其中扮演什么角色”的人,其实并不多。
这道题的实际杀伤力不在于答案本身,而在于它能一层层往下挖,直到挖出候选人知识体系的真实深度。我见过有人把这个面试题背得滚瓜烂熟,却在问到“那 String s = "abc" 和 String s = new String("abc") 在字节码层面有什么区别”时彻底卡壳。所以这篇文章我不打算只给答案,而是把整条知识链完整地拆开,从内存模型讲到字节码指令,再从常量池讲到 intern() 的坑,最后给你一套可以直接拿去用的面试回答话术。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 先说结论:一个还是两个,取决于前提条件
注意:这个问题的标准答案不是“一个”也不是“两个”,而是“要看字符串常量池里是否已经存在
"abc"这个字面量”。
具体分两种情况:
- 第一次执行
String str = new String("abc"),且常量池中没有"abc":会在字符串常量池中创建一个"abc"对象,同时在堆上创建一个 String 实例,一共 2个对象。 - 常量池中已有
"abc"(比如之前执行过String s = "abc"):只在堆上创建一个 String 实例,一共 1个对象。
这个“一个还是两个”的差异,恰恰是面试官最想听到的。候选人如果脱口而出一口咬定是“两个”,说明他背过答案但没理解机制;如果只说“一个”,说明他对常量池完全没有概念。只有能分情况讨论的人,才真正掌握了背后的原理。
但注意,这里的“2个对象”还有一个隐藏细节:如果 JVM 开启了指纹技术(即字符串常量池中 "abc" 和 String 实例的内部 value 数组引用的是同一份底层 char[] 数据),那严格来说底层字符数组只有一个。只是这个细节对于面试回答来说属于加分项,不属于必答项,后面我会详细展开。
3. 必须搞懂的两个基础概念
3.1 字符串常量池(String Constant Pool)到底是什么
字符串常量池是 JVM 为了减少字符串对象的重复创建而设计的一块内存区域。它的核心逻辑很简单:字符串是不可变对象(这是 String 类被 final 修饰的原因),既然不可变,那么同样的内容就没必要存多份。
在 JDK 1.7 之前,运行时常量池(包含字符串常量池)位于方法区(PermGen 永久代)中;JDK 1.7 开始,字符串常量池被移到了堆中。这个变化本身就是一个高频考点,面试官可能会追问“为什么要从方法区移到堆里”,答案是:永久代空间有限,字符串过多容易引发 OutOfMemoryError: PermGen space,移到堆后可以充分利用堆空间的垃圾回收能力,缓解这个问题。
还要注意区分几个容易被搞混的概念:
- Class 文件常量池:存在于
.class文件中的静态信息,包含类、方法、字段符号引用和字面量。 - 运行时常量池:JVM 加载 Class 文件后,将 Class 文件常量池中的内容加载到方法区(元空间)中形成。
- 字符串常量池:也常被称为 String Pool,JDK 1.7 之后被分配在堆中,用来存放字符串字面量。
加载一个类时,Class 文件常量池中的符号引用会被解析到运行时常量池,而字符串字面量则会被处理为 String 对象并放入字符串常量池。这个过程的细节,恰恰就是 new String("abc") 谜题的核心线索。
3.2 直接赋值与 new 的本质差异
要理解 String str = new String("abc") 做了什么,必须先拿它和 String str = "abc" 做对比。这两行代码的字节码指令完全不同,执行路径也大相径庭。
情况一:直接赋值 String str = "abc";
JVM 编译这段代码时,对应的是 ldc 指令(Load Constant)。执行 ldc 时,JVM 会先去字符串常量池查找是否已有内容为 "abc" 的对象:
- 如果池中已有
"abc",直接返回池中对象的引用,赋值给str; - 如果池中没有,就在池中创建一个新的
"abc"字符串对象,然后返回引用。
此时 str 指向的是常量池中的对象,堆上并没有额外的 String 实例。
情况二:new 创建 String str = new String("abc");
这行代码在字节码层面包含两条关键指令:ldc "abc" 和 new String。也就是说,JVM 在创建堆上的 String 实例之前,仍然会先去常量池检查 "abc" 是否存在,不存在就先在池中创建;然后再在堆上 new 出一个新的 String 实例,并将这个新实例的引用赋给 str。
这是理解整道题的关键:new 出来的 String 对象和常量池中的 "abc" 是两个不同的对象。它们唯一的关联是:堆上的 String 实例内部会引用常量池中那个 "abc" 的底层 char[] 数组(JDK 9 之后改为 byte[] 加上编码标识)。
3.3 为什么 String 是不可变的
这里必须多提一句 String 的不可变性,因为它与常量池的机制紧密相关。如果 String 可变,那么常量池中的共用机制就会失效:一个地方修改了字符串,所有引用它的地方都会跟着变,这显然是灾难。
String 在 Java 9 之前内部用 char[] value 存储,Java 9 之后改成了 byte[] value 加上一个 coder 字段,用于标识字符串是 LATIN1(单字节)编码还是 UTF-16(双字节)编码。这个优化让纯 ASCII 字符串的内存占用直接减半,算是 JVM 层面一个非常实用的调优。
不可变性带来的好处至少有这三条:
- 字符串常量池才能安全地共享字符串对象而不必担心被修改;
- String 可以安全地作为 HashMap 的 key,哈希值可以被缓存;
- 多线程环境下 String 天然线程安全,无需额外同步。
面试时如果能从不可变性串到常量池设计,再串到内存优化,这道题的回答深度会立刻高一个档次。
4. new String("abc") 的完整执行流程拆解
4.1 字节码层面的三个关键步骤
先看一段最简单的测试代码:
java复制public class StringTest {
public static void main(String[] args) {
String str = new String("abc");
}
}
使用 javap -c StringTest 查看字节码:
java复制public static void main(java.lang.String[]);
Code:
0: new #2 // class java/lang/String
3: dup
4: ldc #3 // String abc
6: invokespecial #4 // Method java/lang/String."<init>":(Ljava/lang/String;)V
9: astore_1
10: return
逐条解释一下:
new #2:在堆上为 String 对象分配内存空间,注意此时还没有调用构造函数,对象处于“半初始化”状态;dup:复制栈顶的引用值,因为后面invokespecial执行构造函数时需要使用这个引用,同时赋值语句也需要这个引用;ldc #3:去字符串常量池查找"abc",如果不存在则在池中创建,然后将常量池中的字符串引用压入操作数栈;invokespecial #4:调用 String 的构造函数,传入常量池中的"abc"作为参数,完成堆上 String 对象的初始化;astore_1:将堆上新建的 String 对象引用存储到局部变量表(即赋值给str)。
这个流程看下来,str 最终的指向是堆上的对象,而不是常量池中的对象。ldc 指令保证了常量池中一定有 "abc",new 指令保证了堆上一定会创建一个新的 String 实例。所以最经典的“两个对象”回答,其实是把这两条指令的执行结果相加。
4.2 从 JVM 内存区域看对象分配路径
再往深一步,从 JVM 内存区域的角度看:
- 栈:
main方法的局部变量str存放在 Java 虚拟机栈的局部变量表中,它是一个引用,指向堆中的对象; - 堆:
new出来的 String 实例分配在堆上,对象头、实例数据(内部的byte[] value引用)都在这里; - 字符串常量池(JDK 7+ 在堆中):
"abc"这个字符串对象存放在常量池区域; - 元空间/方法区:String 类的元数据(类信息、方法表等)存放在方法区,与本次的对象创建无直接关系。
有一个很容易被忽略的点:new String("abc") 中,构造函数的参数也是常量池中 "abc" 的引用。String 的构造函数会执行 this.value = original.value(Java 9 前写法),也就是把堆上新建对象的内部数组引用指向常量池对象内部的数组。这意味着两个不同的 String 对象,底层共享同一份字符数组。这个共享机制常被面试官拿来追问,因为在某些场景下会引发意外的内存泄漏,后面我会细讲。
4.3 String 构造函数到底做了什么
String(String original) 这个构造函数非常值得单独拎出来说。它的实现(JDK 8)是:
java复制public String(String original) {
this.value = original.value;
this.hash = original.hash;
}
如果不知道这个实现,很多衍生问题根本答不了。比如 new String("abc") 和 "abc" 的区别,并不在于内部字符数组,而在于对象壳子不同:一个是堆上一个全新的 String 实例,一个是常量池中的 String 对象。
再比如,如果让你对比这三个创建方式:
java复制String s1 = new String("abc");
String s2 = new String("abc");
String s3 = "abc";
那么 s1 == s2 是 false(两个不同的堆对象),s1 == s3 是 false(堆对象与常量池对象不同),s2 == s3 也是 false,而 s1.equals(s2)、s2.equals(s3) 则都是 true,因为 equals 比较的是字符串内容。这个对比是面试中的经典填空题,理解了对象引用与内容比较的区别,怎么出题都不怕。
4.4 一个容易混淆的补充:底层数组只有一份吗
前面提到,new String("abc") 的底层 value 数组复用了常量池 "abc" 的数组。那么从“对象”的定义出发,底层数组算不算一个对象?
严格来说,char[](或 byte[])也是一个对象,因为它也有对象头和引用,也分配在堆上。但绝大多数面试语境下,我们不把底层数组单独计数。原因是:new String("abc") 并没有自己创建数组,它只是引用了常量池创建时使用的那份数组;而常量池创建 "abc" 时,那份数组又是它的“一部分”。用“字符串对象”这个粒度来计数,是最清晰也最符合面试预期的。
不过如果面试官特别较真,你可以补一句:如果池中原本没有 "abc",那么创建过程中还隐含了一个内部的字符数组对象。这种回答既显示你的知识深度,又显得严谨,属于典型的“加分表达”。
5. 为什么很多人的回答会被面试官打回
5.1 常见错误一:只回答“一个”或“两个”就结束
最可惜的回答不是答错,而是答完就走。很多候选人说出“两个对象”后就开始等下一个问题,完全没有意识到面试官正等着他展开。
正确姿势是:给出结论后立刻补充分情况讨论,然后主动讲解执行流程。我会建议候选人用“三句话结构”回答这个问题:
- 结论句:“这取决于常量池中是否已有
"abc",一般情况下是2个,如果池中已有就是1个。” - 原理句:“
new关键字创建的是堆上的 String 实例,同时ldc指令会保证常量池中存在"abc"。” - 关联句:“所以从内存结构看,一个对象在常量池中,一个对象在堆上,它们底层共享字符数组。”
这个结构既能展示结论准确性,又能展示原理掌握程度,还能自然引导到下一轮深挖。
5.2 常见错误二:混淆 == 与 equals
围绕 String 的面试连环题中,== 和 equals 几乎是必问的配套题目。不少候选人虽然知道 == 比较引用、equals 比较内容,但放在具体代码里就翻车:
java复制String a = "abc";
String b = "abc";
String c = new String("abc");
System.out.println(a == b); // true,常量池中同一对象
System.out.println(a == c); // false,堆对象与常量池对象
System.out.println(a.equals(c)); // true,内容相同
逐行解释:
a和b都直接赋值,b编译时发现常量池已有"abc"(因为a已经触发过创建),所以直接复用,二者指向同一个对象;c通过new创建,堆上的新对象和常量池对象肯定不是同一个引用;equals()被 String 重写为逐字符比较,所以a.equals(c)为 true。
这块内容的价值不在于记住输出结果,而在于理解输出结果背后的分配逻辑。只要把 ldc 和 new 两条指令想清楚,所有变种题都能推导出来。
5.3 常见错误三:不知道 intern() 的存在
面试官如果聊完了上面的问题,十有八九会抛出最后一道杀手锏:那 intern() 是干嘛的?
intern() 是 String 类的一个本地方法,作用是把一个堆上的字符串对象“注册”到字符串常量池中。具体规则是:
- 如果常量池中已有内容相同的字符串,则返回池中对象的引用;
- 如果常量池中没有,JDK 7 之后会将当前堆对象在池中的引用记录下来(注意,是把引用放入池中,而不是复制一份新的),然后返回这个引用。
来看一个非常典型的对比:
java复制String s1 = new String("abc");
String s2 = s1.intern();
System.out.println(s1 == s2); // false,因为 "abc" 字面量已在池中,intern 返回池中对象的引用
String s3 = new String("abc") + new String("def");
String s4 = s3.intern();
System.out.println(s3 == s4); // true(JDK 7+,拼接结果不在池中,intern 将 s3 的引用放入池中)
intern() 的典型使用场景是大量重复字符串去重:比如从文件中读取大量 key,内容重复率很高,使用 intern() 可以让所有相同内容的字符串只保留一份底层数据,显著降低内存占用。但它也有风险:如果字符串数量巨大且大部分不重复,intern() 会导致字符串常量池膨胀,反而增加 GC 压力和内存占用。
6. 延伸到开发实际:这题不是白考的
6.1 字符串拼接的坑
理解了对象创建的机制后,很多实际开发中的问题就能看得更清楚。比如字符串拼接:
java复制String s = "";
for (int i = 0; i < 10000; i++) {
s += i; // 每次拼接都会 new 一个 StringBuilder 和新的 String
}
这段代码每一轮循环都会创建 2 个对象(StringBuilder + 新 String),10000 次循环就是 20000 次对象分配,GC 压力巨大。正确写法是用显式的 StringBuilder(或 StringBuffer,如果涉及多线程):
java复制StringBuilder sb = new StringBuilder();
for (int i = 0; i < 10000; i++) {
sb.append(i);
}
String s = sb.toString();
但这里也有一个反直觉的地方:在 JDK 9 之前,编译器在同一个表达式内的字符串 + 拼接会被优化为 StringBuilder 操作,比如 String x = "a" + "b" + "c" 在编译期就会被常量折叠为 "abc",因为这个拼接只在编译期就可以算出结果。所以在写代码时,真正需要警惕的是循环体内的拼接,而不是单行拼接。
6.2 字符串截取导致的内存泄漏(JDK 7 后已修复)
这是 String 内部结构最容易引发实际问题的场景。JDK 7 之前,substring() 的实现是直接复用原字符串的 char[] 数组,通过 offset 和 count 标记子串的起始位置和长度。这样做的好处是快速且零拷贝,坏处是:如果用一个 100MB 的大字符串调用 substring(0, 10),这个 10 个字符的小子串会一直持有 100MB 大数组的引用,导致大数组无法被 GC 回收。
我当时就遇到过线上 OOM,排查到最后,根因就是有同事把一个大 JSON 字符串截了一小段作为 key 缓存起来,结果整个大字符串被 key 引用着无法释放。JDK 7 之后的 substring() 改为创建新数组并复制内容,这个问题才从根上解决。但类似的故事仍然值得在面试中讲一讲,因为它能证明你不仅会背理论,还真的踩过坑。
6.3 常量池与内存调优
在实际调优场景中,字符串对象常常是堆内存占比高的主要元凶。一个非常实用的调优手段就是合理使用 intern() 配合少量可预测的重复字符串做去重。但要注意:intern() 在 JDK 8 的默认 StringTableSize 是 60013,如果池中字符串量特别大,哈希冲突会严重,查询效率下降。JDK 8 提供了 -XX:StringTableSize 参数,可以调大底层 hash table 的桶数量:
bash复制java -XX:StringTableSize=1000003 -jar your-app.jar
调优原理很容易理解:字符串常量池内部是一个哈希表,intern() 是哈希表的 put 操作,查询字符串是哈希表的 get 操作。如果桶数量太小,哈希冲突概率增大,链表变长,查询退化为遍历,性能就会劣化。设置一个接近质数的较大的桶数量,可以明显减少冲突,提升 intern() 使用场景的性能。
6.4 不可变类的设计对业务代码的启示
String 的不可变设计还能给业务代码提供借鉴。写不可变类时,有几个硬性要求:
- 类用
final修饰,防止被继承; - 所有字段用
private final修饰; - 不提供任何修改字段的方法;
- 如果字段是可变对象(如
Date、集合类),不能直接暴露引用,要么防御性拷贝,要么返回不可变视图。
对照 String 的实现,它把内部数组设为 private final,且所有可能修改内容的方法(如 concat、replace)都返回新对象而不是修改自身,完美符合这几个要求。这些思想在开发中非常值钱,因为它不只是关于 String 本身,而是关于如何设计一个线程安全、可复用的类。
7. 常见问题速查表
这里我整理了一个速查表,覆盖这道面试题最常被追问的变体,以及对应的标准回答方向。
| 面试问题 | 核心结论 | 展开要点 |
|---|---|---|
new String("abc") 创建了几个对象? |
常量池无 "abc"时2个,已有时1个 |
ldc 保证池中对象,new 创建堆对象 |
String s = "abc" 和 new String("abc") 有什么区别? |
前者引用常量池对象,后者引用堆对象 | 字节码指令、内存分配位置 |
s1 == s2 和 s1.equals(s2) 有什么区别? |
== 比引用,equals 比内容 |
String 重写了 equals 和 hashCode |
intern() 是干什么的? |
将字符串注册到常量池并返回池中引用 | JDK 7 后堆对象引用入池 |
| JDK 9 之前 String 内存为什么容易泄露? | substring 复用 char[],大数组被小字符串持有 |
JDK 7 后改为复制新数组 |
| 字符串常量池在哪个区域? | JDK 7 前在方法区,JDK 7 后在堆 | 永久代空间限制与 GC 需求 |
| 为什么 String 要设计成不可变? | 常量池共享、哈希缓存、线程安全 | final 类 + private final 字段 |
注意:面试时不要照本宣科地背表,要能随手画出一个简单的内存分配图。真正的高手是画图讲,而不是背结论。
8. 面试回答示例:这样答不丢分还加分
8.1 面向初级候选人:简洁清楚
如果面试官只问“创建了几个对象”,初级候选人可以这样答:
“要分两种情况。如果执行这句代码之前常量池里没有 "abc",JVM 会先在字符串常量池里创建一个 "abc" 对象,然后在堆上 new 一个 String 实例,所以一共2个对象。如果之前已经用 "abc" 字符串字面量触发过常量池创建,那常量池就不会重复创建,只在堆上 new 1个对象。最终 str 指向的是堆上的那个 String 实例。”
这个回答既准确又有逻辑闭环,而且主动提到了“分情况”,比单纯背“因为是两个对象”强得多。
8.2 面向中高级候选人:详略得当
中高级候选人可以在此基础上展开:
“从字节码来看,这行代码对应 new、dup、ldc、invokespecial 四条主要指令。ldc 会保证 "abc" 在常量池中存在,new 在堆上分配 String 实例,dup 是为了构造和赋值都需要引用,invokespecial 是调用构造函数。底层数组中,堆对象和常量池对象共享同一份 "abc" 的字符数据。如果是 JDK 9 之前,还需要注意 substring() 的内存共享问题,JDK 7 以后已经改为复制。”
回答到这个层次,已经超出了“背题”的范畴,完全是在展示对 JVM 底层机制的理解。绝大多数面试官到这里就已经认可候选人了。
8.3 面向架构师候选人:自己延伸
架构师岗位的候选人甚至可以主动引出 intern()、StringTableSize 调优、不可变类设计等多个方向,把一道 Java 基础题聊成一个技术专题。这恰恰说明候选人具备把单一知识点串联成知识体系的能力。能主动把一个领域讲透,是架构师面试中非常重要的信号。
我个人在面试时最吃这一套:候选人能把一个简单的问题延展到真实生产环境的问题,说明他平时是真的思考、真的调优、真的排查过问题,而不是只会刷题。
9. 我踩过的坑与最终的体会
这道题我有过两次印象深刻的经历。第一次是我自己刚工作一两年去面试,当时背了不少面经,听到这题心里一喜,张口就说“两个”,结果面试官问我“那第二个对象和第一个对象是什么关系?底层 char[] 数组有几份?”我当场卡住,只能支支吾吾说不太清楚。那次失败之后,我把 JVM 内存模型相关的书翻了几遍,才真正把这个点嚼碎。
第二次是后来做技术面试官,碰到一个候选人把答案背得滴水不漏:“两个,一个是常量池的,一个是堆上的。”我问他“如果我把 String s = "abc" 放在这行代码之前,还创建几个?”他想了一会儿说还是两个,并解释池里已有就不重复创建。我又问“那 intern() 在这个场景里能干什么?”他答不上来。那次我更确定了:背答案和真理解之间,差的是一条完整的原理链路。
最后分享两个在实际开发中最实用的建议。第一,写代码时能用字面量赋值就不要用 new String(...),前者省一次堆分配,也更容易命中常量池优化;如果真的需要用 new 构造字符串,比如从字节数组转字符串,那也要清楚每一个对象创建的代价。第二,批量处理大量重复字符串对象时,可以考虑使用 intern() 配合较大的 StringTableSize 参数做去重,前提是你确认这些字符串的重复率足够高且总量可控。这个优化做得好,堆内存使用率能明显改善;做得不好,反而会给 GC 增加负担。
面试会翻篇,但这些底层的内存意识和性能敏感度,会一直影响你日后写的每一行 Java 代码。
