这道题我面试时基本必问,String str = new String("abc") 到底创建了几个对象,几乎是Java程序员分水岭一样的存在。每次在面试现场,问到这一句,对面要么眼睛一亮,要么立刻开始支支吾吾。别看它只是一个看起来很简单的问题,背后牵涉到JVM内存区域、字符串常量池、字节码执行、对象引用甚至JDK版本差异。这篇文章我就把这道题彻底拆开聊透,从底层原理到面试答题思路,再到开发中的实际陷阱,一次性说清楚。
先直接给出结论的一个侧面:在绝大多数教科书语境下,答案是“可能两个对象:堆里的String对象和字符串常量池里的String对象;也可能只有一个堆里的String对象”。但完整答案远比这个复杂,只回答“两个”是远远不够的。下面我从底层开始拆。
1. 从一道面试题说起
1.1 为什么这道题被问了十几年
你可能会觉得奇怪,Java领域新技术层出不穷,为什么这道十几年前的老题至今仍然高频出现。原因在于它考察的不是一个孤立的知识点,而是串起了JVM运行时数据区、类加载解析、String底层结构、编译优化和对象引用认知。一个候选人能把这道题说透,基本上可以判断他对Java基础的理解是否扎实。
而且这道题特别适合做“追问式面试”。一开始只问结果,候选人答完再问“这两个对象的引用分别在哪”“常量池里的对象是执行这一行代码时创建的吗”“JDK9和JDK8有什么差别”,层层递进,水分立刻就能挤出来。作为面试官,我常常不是想要那个数字,而是想听候选人分析和推导的过程。
1.2 最常见的错误答案
先说几个我在面试中经常听到的答案:
- 答“一个对象”的人,通常只看到了堆里的new String,忽略了字符串字面量"abc"进入常量池这件事。
- 答“两个对象”的人,知道有常量池和堆,但说不清两个对象分别是谁、在什么条件下成立。
- 还有人会答“三个对象”,把栈上的引用str也算成一个对象,这就说明对象和引用这两个基本概念还没分清。
其实这些答案的偏差,都能通过后面的源码和字节码拉平。面试官真正在意的,是你能不能在结论之外,把“为什么”讲清楚。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. String对象创建的底层原理
2.1 字符串常量池到底放在哪
要理解这道题,第一件需要明确的事是JVM内存区域的划分。以JDK8为例,堆中存放几乎所有对象实例,虚拟机栈帧里存放局部变量和引用,方法区则存放类元信息、运行时常量池等。字符串常量池,也就是StringTable,在JDK7之前位于方法区中的永久代,JDK7之后被移到了堆中。
这就带来一个非常关键的推论:字符串常量池中保存的字符串,本质上也是一个堆中的对象。JDK7之前把StringTable放在永久代,虽然逻辑上它不属于堆,但在HotSpot实现中,永久代本身也是堆的一部分。JDK8之后,字符串常量池彻底随永久代转换为元空间而落入普通堆区。
这影响的不只是位置,还直接影响intern方法的行为。JDK6的StringTable属于永久代,intern会把字符串复制一份放进永久代;JDK7之后StringTable里的条目可以直接指向堆中已有的字符串对象,不再强制复制。这个差异如果你不清楚,后面讲intern的部分就会绕晕。
2.2 字面量、运行时常量池和StringTable的关系
很多初学者会把几个“常量池”混在一起。这里必须先掰开。Class文件里有一个常量池,存放类相关的各类常量,比如类名、方法名、字段名、CONSTANT_String_info字符串常量项。JVM加载类之后,这个Class文件常量池会进入方法区的运行时常量池。
但字符串常量池StringTable和上面两个都不是一回事。它更像是JVM内部维护的一张全局哈希表,专门记录被intern过的字符串对象。当字节码执行到ldc指令时,JVM会拿着字符串内容去StringTable里查找,如果找不到,就创建一个字符串对象并放进去,这个动作在HotSpot里叫StringTable::intern。
所以,你写下的"abc"这个字面量,在编译期会变成Class文件常量池里的一个CONSTANT_String_info项,在类加载或首次执行相关指令时,才真正在JVM中变成一个String对象,并进入字符串常量池。注意,具体何时intern,JVM规范并没有强制规定,HotSpot的常见做法是懒处理,也就是第一次执行到ldc时才做。
2.3 String对象在堆里长什么样
如果你去看JDK8的String源码,底层字段是private final char value[],也就是一个char数组存字符内容。到了JDK9,这个字段变成了private final byte[] value,并增加了COMPACT_STRINGS支持,当字符串只包含Latin-1字符时就用一个字节存一个字符,节省空间。
String的不可变性不仅仅体现在final修饰类,更体现在value字段是final的,而且String类内部不暴露修改数组内容的入口。这意味着一旦创建了一个String对象,它的字符内容就永远不可变。这也为字符串常量池复用字符串对象提供了安全前提,大家都共享同一个String对象,不怕被修改。
要理解new String("abc"),必须认识到堆里的String对象内部保存的字符内容,可以和字符串常量池中的那个对象相同,但它们是两个独立的Java对象。对象之间的相同性由equals方法判断,而引用地址是否一致则由==判断。这一点在后续代码验证中会看到。
3. 用字节码拆解 new String("abc")
3.1 javap 反编译看指令序列
与其空谈,不如直接看字节码。写一个最简单的类:
java复制public class StringDemo {
public static void main(String[] args) {
String str = new String("abc");
}
}
编译后用javap -c -v StringDemo.class反编译,重点看main方法对应的字节码。稍微精简一下,核心指令大致如下:
java复制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 String("abc")的执行过程。第一行new是创建String对象的指令,分配内存并压入引用;第二行dup复制引用,用于后面调用构造器;第三行ldc把字符串常量"abc"从运行时常量池加载到栈上;第四行invokespecial调用String的构造方法,用"abc"初始化之前new出来的对象。
3.2 执行过程一步步拆开
我们把这四步和“创建了几个对象”对应起来。
第一步new指令,JVM在堆上分配了一个全新的String对象。这一步是实打实创建的,不管"abc"这个字符串是否已经存在,只要执行到new,就必然会在堆上产生一个String对象。这就是题目里new关键字带来的那个对象。
第三步ldc指令,它会到字符串常量池中查找内容为"abc"的字符串。如果StringTable里还没有"abc",JVM就会创建一个内容为"abc"的String对象,加入StringTable,然后把这个对象的引用压入操作数栈。如果已经存在,则不创建新对象,直接复用已有的引用。
这里有个容易误解的点:那句话里的“字符串字面量‘abc’”在代码执行时并不代表一个已经创建好的Java对象,它只是一个符号,直到ldc被执行时,才可能触发真正的字符串对象创建。所以,严格来说,String str = new String("abc")这行代码执行时,如果之前没有"abc",实际创建的对象数可能是“两个String对象”;如果之前已经有"abc",实际创建的对象数就是“一个String对象”,目标是堆里的那个new出来的对象。
3.3 所以到底创建了几个对象
综合上面的过程,最严谨的答案是:在常见的前提“abc之前不在字符串常量池中”下,创建了两个String对象,一个是字符串常量池里的"abc"对象,另一个是堆里new出来的String对象,两者内容相等,但引用地址不同。str指向的是堆里的那个对象,常量池里的对象通过字面量访问时才会用到。
如果"abc"已经在字符串常量池中存在,比如之前代码里执行过String s = "abc",或者JDK类库已经intern过这个字符串,那么这行代码执行期间只会创建一个堆中的String对象。
还需要注意一个边界:这里的“两个对象”只统计String对象,不统计栈上的引用str,也不统计可能存在的底层char[]或byte[]数组。顺带一提,在JDK8的String源码中,new String(String original)构造器会直接复用original的value数组字段,并不重新复制数组,所以底层数组对象不一定会额外增加。但在其他构造器里,比如new String(char[] value),就一定会产生新的char数组对象。这也是面试加分点,说明你能分清楚对象、引用和底层存储。
4. 换个问法:拼接、intern和JDK版本差异
4.1 编译期拼接:编译器偷偷帮你做了常量折叠
面试官既然问了new String("abc"),大概率会跟着问字符串拼接。最基本的是String s = "a" + "b" + "c"。很多人一看到+就紧张,以为会创建很多对象,但这里的三个字面量会在编译期被javac直接折叠成"abc"。
不信可以反编译,字节码里只有ldc # "abc",没有拼接逻辑。这是JLS规定的编译期常量表达式优化。只要是final常量、字面量、常量之间的运算,编译器都会尽可能折叠。所以String s = "a" + "b" + "c";这一句,和String s = "abc"是等价的,并不会额外创建多个拼接对象。
反过来,如果拼接的任一部分来自运行时变量,比如String s = args[0] + "b";,编译器就必须在运行期动态拼接,这时候它不会直接用String类逐个concat,而是会创建一个StringBuilder对象,通过append方法拼接,最后调用toString得到一个新的String对象。
4.2 运行期拼接:StringBuilder在中间扮演什么角色
看一个经常被拿来当变形题的例子:
java复制String a = "a";
String b = "b";
String s = a + b;
变量a和b不是编译期常量,因此a + b不会折叠。实际编译后的逻辑等价于:
java复制StringBuilder sb = new StringBuilder();
sb.append(a);
sb.append(b);
String s = sb.toString();
这里会创建StringBuilder对象和toString返回的新String对象。注意变量a和b本身,如果来自String字面量"a"和"b",那么在类加载/首次执行时,字符串常量池已经各自有一个"a"和"b"对象。所以整段代码累计创建的String对象会更多,但核心要理解的是:动态拼接靠的是StringBuilder,而不是String类自身。
再回到new String("abc")这个题目。如果把题目升级成new String("a") + new String("b"),那就是另一道更有意思的题。因为两个new String("a")和new String("b")各自都可能在堆上创建新对象,再加上StringBuilder对象、toString返回的新String对象,如果底层数组也算上,数量会相当可观。但面试时你只要分清楚哪些对象是必然新建的,哪些是可能复用的,就已经赢了大半。
4.3 intern()方法在不同JDK下的行为差异
说到String对象创建,绕不开intern方法。intern是String提供的一个native方法,作用是主动把当前字符串对象放入字符串常量池,并返回常量池中的String引用。
JDK6和JDK7之后的一个经典例子可以完美解释版本差异:
java复制String s1 = new String("a") + new String("b");
s1.intern();
String s2 = "ab";
System.out.println(s1 == s2);
在JDK6中,字符串常量池放在永久代,s1是堆中的对象,intern会把s1的字符内容复制一份到永久代,并返回永久代中的引用。之后"ab"字面量再从常量池中查找时,拿到的是永久代对象,所以s1和s2不相等,输出false。
在JDK7及以后,字符串常量池就在堆中,intern第一次被调用时,会直接将s1这个对象引用登记到StringTable里。之后String s2 = "ab"执行ldc指令时,在StringTable中查到了s1,于是s2直接复用了s1引用。最终s1 == s2为true。
这个例子特别能说明JVM版本对字符串机制的影响。如果你在面试中能主动提到“JDK8默认可以打印s1 == s2的结果”,面试官通常会眼前一亮。
5. 面试考场的标准答法
5.1 先分情况,再给出核心结论
如果面试官问你“String str = new String("abc")创建了几个对象”,我一般建议分三步回答。
第一步先给总体结论:没有一个绝对固定的数字,要分情况。如果字符串常量池中之前没有"abc",那么会创建两个String对象,分别位于字符串常量池和堆中;如果字符串常量池中已经有"abc",那么这一行代码执行时只创建一个堆中的String对象。
第二步说明引用关系:栈上的str引用指向堆中的String对象;常量池里的"abc"对象更多是由JVM在类加载/首次执行ldc时驻留的。str本身是引用变量,不是对象,不统计在对象个数里。
第三步主动补充细节:new String("abc")的底层是通过new、dup、ldc、invokespecial四个字节码指令完成的;ldc阶段负责查找或创建常量池中的"abc",new阶段负责在堆上创建一个新String对象。这样回答有结论、有原理、有推导,基本能覆盖这道题的所有考点。
5.2 被追问时怎么接招
提问往往不会止步于此。面试官可能紧接着会问:String s = "abc"和String str = new String("abc")有什么区别?区别核心在于前者只可能去字符串常量池里拿或创建一个对象,不额外在堆里new;后者无论如何都会在堆里new一个独立对象,即使内容相同。
还会被问:什么时候用new String合适?说实话,日常开发中99%的场景不需要主动new String。直接使用字面量通常会触发常量池复用,既省内存又利于比较。只有当你确实需要一个和常量池内容相同但独立引用的String对象,或者需要处理字符数组、字节数组转换时,才会主动使用new String。
也会被问:new String("abc")创建出来的对象可以被回收吗?常量池中的对象由JVM管理,一般不按普通对象回收规则;但堆中new出来的那个String对象,只要没有引用指向了,就会像普通对象一样被GC回收。注意JDK7之后字符串常量池中的对象虽然在堆中,但StringTable持有引用,通常不会被回收,除非JVM卸载类或触发字符串去重之类的机制,实际场景中不必深究。
5.3 常见追问清单与参考思路
我整理了一份追问小清单,面试前可以自测:
- 字符串常量池在JDK7之后放哪里?答:堆中,StringTable对象本身属于堆,JDK8用元空间承载类元信息,但StringTable仍在堆。
- String的value字段是char数组还是byte数组?答:JDK8是char数组,JDK9开始是byte数组并支持压缩存储。
- String为什么设计成不可变?答:为了常量池复用、hash缓存、线程安全、安全敏感场景不被篡改,以及网络参数、文件路径等场景的稳定性。
- equals和==在String上的区别?答:==比较引用地址,equals在String重写后比较字符内容。
- 如果线上String对象太多怎么办?答:用String.intern可能导致池膨胀,谨慎使用;可以考虑字符串去重、重新设计结构等;大多数场景先检查是否有重复new String。
- 字符串常量池是用什么数据结构实现的?答:HotSpot的StringTable本质上是哈希表,可以设置初始大小-XX:StringTableSize。
这些问题没有标准答案,但每一个都能延伸出很多内容。自己平时写过相关代码、踩过内存坑的人,答起来会自然很多。
6. 回到真实开发场景
6.1 new String("abc")什么时候该用
我曾经在一个老项目中看到有人这么写:
java复制Map<String, String> map = new HashMap<>();
map.put("key", new String("value"));
这种写法除了白白在堆里多创建一个对象外,没有任何好处。你从Map里取出来的字符串,内容和常量池里的保持一致,如果拿来做key比较,更没必要让引用不同。
真正需要new String的场景通常是配合字节数组或字符数组,比如new String(bytes, StandardCharsets.UTF_8)做编码转换,或者new String(chars)从字符数组构造String。直接把字面量塞进new String构造器,唯一的实际意义就是考试和面试题。
另外有个反直觉的场景:在高并发访问相同长文本时,直接new String可以避免String对象引用被意外共享吗?其实不需要,因为String本身不可变,共享引用反而是安全的,甚至更高效。
6.2 字符串常量池相关的内存陷阱
字符串常量池虽然是JVM层面的东西,但开发中确实会遇到相关陷阱。
第一个陷阱是intern滥用。很多前辈为了省内存,会把Map里的字符串都过一遍intern,结果StringTable本身是一张全局哈希表,条目多了以后哈希冲突严重,反而拖垮GC和查询效率。而且JDK7之后intern可能把原本堆里的一个对象引用留在池中,导致某些你预期可以GC掉的对象一直被StringTable强引用,实际上帮了倒忙。
第二个陷阱是字符串常量作为锁对象。比如:
java复制synchronized ("lock") { ... }
看起来没什么问题,但"lock"这个字面量对应的String对象在JVM里是全局共享的,任何类只要用了相同字面量,都会拿到同一个对象。这等于把锁暴露给了无关代码,很容易引发无意义的阻塞。正确做法是用private static final Object LOCK = new Object()或专门的锁对象,而不是字符串常量。
第三个陷阱是不小心触发字符串拼接生成大量中间对象。在循环中直接写result += text,字节码里每轮循环都会创建StringBuilder,循环次数多时,GC压力肉眼可见。改成循环外构建一个StringBuilder,能显著减少无用对象的创建。
6.3 个人复盘:我是怎么给候选人讲这道题的
说回面试。我一般不会直接拿这个问题当“一票否决”,但它能帮我判断候选人问题拆解的层级。
有一位候选人让我印象很深。他先是沉默了两三秒,然后告诉我:“我需要确认一个前提,‘abc’是不是第一次出现。因为如果常量池里没有它,就是两个String对象;如果之前已经有了,那这行代码里新建的就是堆里那一个。另外这行代码里str是引用,不是对象。”他随后还补了一句“JDK9之后String底层用byte[],底层数组不计入String对象数量”。
这段话没有背教科书,但把对象创建的边界、前置条件、版本差异都说到了,这就是我眼中标准的好答案。
最后分享一个小技巧。如果你在准备面试,别只盯着这道题本身,可以把整个String相关的知识串成一张自问自答的网络:String源码、常量池、intern、拼接优化、不可变性、equals/哈希码、线程安全、内存占用、JVM参数调整。一个问题能带出十个问题,比死记硬背十个孤立知识点有用得多。实际项目中,我也会建议团队新人从这道题开始读String源码,一边读一边画内存示意图,很快就能把JVM基础补上来。
