“String s = new String("111") 到底创建了几个对象?”我第一次被问到这个问题时,脱口而出“两个对象”,结果面试官接着问了一句:“你确定这个类的字面量之前没有进过字符串常量池吗?”我一下子愣住了。后来自己翻源码、反编译、做验证,才明白这道题根本不是让你背一个数字,而是考察你对对象创建、字符串常量池、类加载机制和JVM内存布局的整体理解。今天我把这个故事完整拆开讲一遍,不仅讲答案,也讲清楚每个结论是怎么来的,以及相关延伸考点和项目里容易踩的坑。
1. 面试官问出这道题时,其实在考你四件事
1.1 一个“简单答案”引发的连环追问
先说个真实场景:我一个朋友去面美团,一面上来就是基础题,前几题答得都挺顺,直到这道String题。他自信地说“程序执行的时候会创建两个对象,一个字符串常量池里的’111’,一个堆里的String对象”。面试官没有直接说错,而是反问:“如果这段代码在同一个类的同一个方法里执行第二次,还是两个对象吗?”他当场卡住了。
其实这个问题坑就坑在很多人把答案背得太死。你只记住了面经里的“两个对象”,却不知道“两个对象”成立的前提是:执行这行代码之前,字符串常量池里还没有“111”这个字符串。一旦之前已经出现过这个字面量,池子里已经有了,那new String("111")这一步就只会再创建一个堆对象。
所以,面试官听到“两个对象”之后追问一句,并不是想刁难你,而是想确认你到底是在背答案,还是真的理解String在JVM里的存放方式。如果能把前提条件、创建时机、对象与引用的关系讲清楚,哪怕最后数字说错了,也比干巴巴一个“两个”要好得多。
1.2 题目背后的四个考察点
这道题能成为经典面试题,是因为它一个知识点串了四个非常重要的基础:
- 对象和引用的区别:
String s = new String("111")里的s只是栈上的一个引用,不是对象本身。回答数量时不能把s也算一个对象。 - 字符串常量池的作用:字符串字面量会被JVM特殊处理,放进一个全局的StringTable中复用,而不是每次遇到都新建。
new关键字的语义:new一定会创建一个新的对象实例,和之前已存在的字面量对象不是同一个引用。- 类加载和字面量解析时机:字符串字面量“111”不是在你执行new的那一刻才被创建的,而是在包含它的类被加载解析的过程中,就已经被放进字符串常量池了。
这四个点,每一个都可以单独拎出来深挖。面试官问“创建了几个对象”,实际上是在同时探测你对这四个点的掌握程度,哪一环有漏洞,多问两句就能看出来。
1.3 不加任何前提时,答案确实不是唯一的
所以,严谨的答案应该这样给:
- 如果“111”这个字符串字面量在此之前没有被使用过,也就是字符串常量池里还没有它,那么
String s = new String("111")会涉及到两个String对象:一个是类加载解析阶段创建并驻留在字符串常量池里的“111”,另一个是new关键字在堆上创建的String对象。 - 如果“111”已经在字符串常量池中存在,那么这条语句执行时只创建了一个String对象,也就是堆上那个new出来的对象。
但还有第三个视角:从“当前这条Java语句”的粒度来看,即使常量池里没有“111”,池中对象的创建也不是由这条new语句本身完成的,而是在类加载阶段完成的。所以很多人会说“这一行代码只new了一个对象”,这种说法同样成立。你看,同一个问题,两个答案都对,关键在于你站在哪个语境下说话。这也是为什么我建议面试时先反问清楚再回答。
2. “111”不是在new的时候才出现的:字符串字面量有个固定“户口”
2.1 从class文件的常量池说起
要理解字符串常量池,最好从源头看起。我们写一段最简单的代码:
public class TestString { public static void main(String[] args) { String s = new String("111"); } }用javac编译之后,再执行javap -v TestString.class,你会看到class文件里有一个常量池区域,里面会记录字符串字面量的符号引用,类似这样:
Constant pool: #1 = Methodref #8.#25 #2 = Class #26 #3 = String #27 ... #27 = Utf8 111这里的#3 = String #27表示:class文件中有一个CONSTANT_String_info结构,它指向一个UTF8编码的常量“111”。注意,这个时候“111”还只是一个符号,并没有对应的Java对象。
JVM在加载TestString类的时候,会经历加载、验证、准备、解析、初始化这几个阶段。在解析阶段,JVM看到这个CONSTANT_String_info,就会去字符串常量池里查找内容为“111”的字符串是否存在;如果不存在,就会在内存中创建一个真正的String对象,并把它放进字符串常量池。如果已经存在,就直接复用已有的对象引用。
这就是为什么我说“111”在new之前就有“户口”了。只要包含这个字面量的类被加载过一次,池子里就一定有它。
2.2 类加载时的解析和初始化
有一个很容易混淆的概念:类加载的“解析”阶段和“初始化”阶段是不同的。解析阶段主要是把符号引用转换成直接引用,而字符串字面量的处理就发生在解析过程中。很多人以为String s = "111"这样的赋值发生在初始化阶段,甚至错误地认为每次执行到这一行都会重新创建对象,其实不是的。
在HotSpot虚拟机的默认实现里,字符串字面量在类加载解析时就会被放进一个叫StringTable的全局结构里。StringTable本质上就是一个哈希表,key是字符串的哈希值,value是字符串对象的引用。当代码里再次出现相同的字面量时,JVM直接查StringTable,查到就直接返回同一个引用,不会再创建新对象。
值得注意的一个细节是,类加载解析阶段不一定是在类加载时立刻发生的,有些JVM实现会延迟到第一次执行ldc指令时才去解析。但对“创建了几个对象”这个问题的结论没有本质影响:无论延迟与否,这个字面量对应的池对象都不是由new String("111")这条语句创建的。
2.3 字符串常量池到底存的是对象还是引用
这里要澄清一个常见的误解。不少教程说“字符串常量池里存储的是字符串对象”,严格来说并不准确。
HotSpot中的StringTable是一个哈希表,它存储的是指向String对象的引用,而不是把字符串的所有字符数据直接塞进哈希表里。真正的内容数据存在String对象内部的char[]数组(JDK8及以前)或者byte[]数组(JDK9及以后)中。也就是说:
- 字符串常量池 = 一张全局哈希表,维护着一组String对象的引用。
- 池中有引用的String对象,要么在永久代(JDK6及以前),要么在堆里(JDK7及以后)。
理解了这一点,后面讲JDK版本差异时就不会觉得乱了。
3. new String("111") 的字节码执行路径:new、ldc、init一个都不少
3.1 反编译看一下这行代码到底干了什么
背答案之前,先看看实际执行过程。还是用上面那个TestString类,执行javap -c TestString,你会看到main方法对应的字节码:
public static void main(java.lang.String[]); Code: 0: new #2 // class java/lang/String 3: dup 4: ldc #3 // String 111 6: invokespecial #4 // Method java/lang/String."<init>":(Ljava/lang/String;)V 9: astore_1 10: return这五条指令,每一步都很关键。
new #2:在堆上分配一块内存,用来存放一个尚未初始化的String对象,并把对象的引用压到操作数栈顶。注意,这只分配了内存空间,对象内部的字段还是默认值,还没调用构造器。dup:复制栈顶的引用。为什么需要复制?因为后面调用构造器时,会从栈上弹出一个引用作为this传给构造方法;构造方法执行完之后,我们还需要一个引用把它赋值给局部变量s。所以需要两份引用,一份用来初始化,一份用来接收初始化后的对象。ldc #3:从运行时常量池加载内容为“111”的字符串。这一步会把字符串常量池中的“111”对象引用压入操作数栈,它就是后面构造方法的参数。invokespecial #4:调用String的构造器,也就是String(String original),对new出来的那个对象进行初始化。astore_1:把初始化完成后的String对象引用存储到局部变量表下标为1的位置,也就是变量s。
看到这串字节码,你应该已经明白了:new指令创建的是一个独立的、在堆上的新对象;ldc拿到的“111”来自字符串常量池,与new出来的对象不是同一个。
3.2 为什么new过后还要dup一下
很多人第一次看字节码时,对dup感到很困惑。这里详细说下。
invokespecial调用构造器时,栈上需要有一个对象引用作为接收者,这个引用在调用结束后会被消耗掉。如果只有一份引用,消耗掉之后就没了,后面就没法把初始化好的对象引用赋值给s。因此JVM在invokespecial之前,必须用dup在栈上复制一份引用。
你可以把dup理解成“复印一份合同”:一份给构造器用,一份留着自己保存。这与对象创建无关,纯粹是字节码层面的栈操作,但它证明了new出来的对象确实在堆上经历了完整的内存分配和构造过程。
3.3 构造器调用时,底层数组可能竟然是共享的
再看一个容易被忽略的源码细节。很多人想当然地认为new String("111")会把“111”的内容“拷贝”一遍,新对象内部有一套独立的字符数组。实际看JDK8的源码会发现:
public String(String original) { this.value = original.value; this.hash = original.hash; }这里直接把original.value赋值给了新对象的value字段,并没有复制数组内容。也就是说,new String("111")创建的新对象,和常量池里的“111”对象,很可能共享同一个底层char[]数组。
因为String是不可变类,内部数组不会在构造后被修改,所以共享底层数组是安全的。这个问题如果面试官继续深挖,你能答到“两个对象,但底层字符数组可能只有一份”,绝对是个加分项。
在JDK9及以后的版本里,String内部从char[]改成了byte[],同时加了一个coder字段来表示编码方式,但共享数组的思想仍然存在。这个版本变化放到下一节详细说。
4. 版本差异和“两个对象”说法的适用范围
4.1 不同JDK的字符串常量池位置
面试题如果问到“常量池在哪里”“intern在不同版本有什么区别”,很多网上资料都已经过时了。这里用一张表理清楚:
| JDK版本 | 字符串常量池位置 | String内部存储 | 说明 |
|---|---|---|---|
| JDK6及以前 | 永久代(PermGen) | char[] | 字符串字面量对象在PermGen,intern()会把内容复制一份到PermGen中 |
| JDK7 / JDK8 | 堆(Heap) | char[] | 字符串常量池移到堆,StringTable存引用,池中对象可被GC |
| JDK9及以后 | 堆(Heap) | byte[] + coder | 改用紧凑字符串存储,池仍在堆 |
为什么JDK7要把字符串常量池从永久代移出来?因为永久代空间有限,而且不像堆那么容易扩展和回收。字符串对象一多,很容易出现OutOfMemoryError: PermGen space。移到堆之后,字符串对象就可以像普通对象一样被年轻代和老年代管理,也能被垃圾回收器正常回收了。
这个版本变化,对String s = new String("111")创建的对象数量结论并没有太大影响,但会影响你描述“字符串常量池中的对象到底在哪”的说法,也会影响intern()的行为。这也是面试官最爱挖的延伸点。
4.2 “两个对象”是主流答案,但需要说清前提
很多面经直接给“两个对象”作为标准答案,我不能说它错,但它不完整。
正确表述应该是:
- 如果这个类的字符串字面量“111”是第一次被加载到字符串常量池,那么整个过程中涉及两个String对象:池中一个,堆中一个。
- 如果“111”在这之前已经存在于字符串常量池中,那么
new String("111")这条语句执行时,只会创建一个堆中的String对象。
还有一种更极端的新版本视角:如果JVM通过逃逸分析发现,这个new出来的String对象没有被任何方法返回,也没有逃出当前作用域,就可能做标量替换,直接把这个对象拆散成若干局部变量放在栈上,甚至彻底省掉堆上的分配。这样实际运行中连堆对象都可能不创建。
当然,面试时要不要说逃逸分析,取决于面试官的表情。如果对方是资深Java工程师,你提一句“不考虑JIT优化时”会显得思考周全;如果对方只是背题式面试官,你提了反而可能把他绕晕。我的经验是,先给标准结论,再补一句“这是不考虑逃逸分析语义层面的答案”,既安全又显深度。
4.3 intern() 让池对象和堆对象的关系彻底复杂化
这道题讲到一半,面试官经常会顺势问:“那intern()你知道吗?”典型回答里会有这样一个经典例子:
String s = new StringBuilder("11").append("1").toString(); s.intern(); String s2 = "111"; System.out.println(s == s2);在JDK6里,这个输出是false;在JDK7及以后,输出是true。为什么?
JDK6中,intern()会把这个字符串对象的内容复制一份到永久代,并返回永久代里那个新对象的引用。s2字面量“111”在解析时指向的是永久代池中的对象,因此和堆里的s不是同一个引用,比较结果为false。
JDK7之后,字符串常量池移到堆中,这时候intern()的实现变成了:如果池中没有内容相同的字符串,就把当前堆对象的引用直接放入StringTable,并返回同一个引用。所以s.intern()之后,池中存的就是s指向的那个堆对象;随后String s2 = "111"这个字面量解析时,会在StringTable中找到同一个对象引用,s == s2自然就是true。
这里要特别注意:new String("111").intern()和上面这个例子不一样。因为new String("111")的参数本身是字面量“111”,在类加载解析阶段池里就已经有“111”了,intern()查不到内容等价的字符串,所以什么也不做,直接返回池中已有的对象引用。最终s指向的仍然是池里的对象,而不是new出来的那个堆对象。
5. 连环追问:从一个String变出八个面试题
5.1 字面量赋值 vs new赋值,内存里究竟差在哪
掌握了上面的原理,下面这些变体就很容易看懂了。先看一段对比代码:
String a = "111"; String b = "111"; String c = new String("111"); System.out.println(a == b); // true System.out.println(a == c); // false System.out.println(a.equals(c)); // truea和b都是字面量赋值,JVM会在字符串常量池里复用一个String对象,所以它们指向同一个引用,==比较结果是true。
c是new出来的对象,虽然内容和a一样,但身份不同,在堆里另有一个String对象,所以a == c是false。
这里要再强调一次:如果单独问String a = "111";创建了几个对象,答案是0个或1个。0个发生在常量池已有“111”的情况,1个发生在没有的情况。而单独问String c = new String("111");创建了几个对象,答案是1个或2个,取决于池中是否已存在“111”。理顺这个对应关系,面试时就不会乱了。
5.2 拼接是编译期折叠还是运行期StringBuilder
再来一个高频变体:字符串拼接。
String d = "11" + "1"; // 编译期常量折叠,等价于 String d = "111"; String e = new String("11") + "1"; // 运行期拼接第一行里的"11" + "1",javac在编译阶段就能算出来结果,直接优化成"111",所以它和字面量赋值一样,常量池里找“111”,创建0个或1个对象。
第二行就复杂了。new String("11")本身可能涉及两个对象(池中“11”+堆对象),然后和字面量“1”拼接时,JDK8里编译器会生成一个StringBuilder,调用append方法,最后通过toString()生成新的“111”字符串。这中间会额外产生StringBuilder对象和新的String对象。如果不想细数分母,只要抓住一点:只要拼接的字符串里有运行期的对象参与,结果就不会进字符串常量池,除非你手动调用intern()。
JDK9之后,字符串拼接的实现方式又变了,javac默认使用invokedynamic加StringConcatFactory来做拼接,不再固定生成StringBuilder代码。但面试回答时,用“JDK8之前StringBuilder拼接,JDK9之后可能走invokedynamic”会非常加分。
顺带提一个和StringBuilder相关的常见问题:“StringBuffer怎么转成String?”答案是直接调用toString()方法。StringBuffer和StringBuilder的区别在于前者方法加了Synchronized保证线程安全,代价是更慢。而toString()每次都会创建一个新的String对象,这也是为什么频繁拼接字符串时会建议直接复用StringBuilder或StringBuffer,避免产生大量无用的中间String对象。
5.3 ==和equals:为什么面试官总用==来考字符串
几乎每场Java面试都会问到String的==和equals,前面几个例子已经把“为什么用==不靠谱”展示得清清楚楚:
==比较的是引用是否指向同一个对象。equals比较的是字符串内容是否相等。
String类重写了equals方法,所以只要内容相同,equals返回true,不管它们是不是两个对象。hashCode也根据内容计算,这也保证了String对象能安全地放在HashMap等集合里。
如果要继续延伸,面试官还可能问substring、toCharArray、split这些常用方法在哪些版本里会创建新数组或新对象,但这些已经偏离这道题的核心了。至少你要记住:凡是new出来的String对象,哪怕内容和常量池一样,也是一次新的内存分配。这也是为什么同样是字符串,用==容易出事故。
6. 回到实战:new String在真实项目里为什么经常是坏味道
6.1 我在Code Review里看到的new String写法
这道面试题背后,其实还藏着一个代码坏味道:很多人习惯写new String(...)。我们项目里就出现过类似这样的代码:
public String deal(String input) { String safeInput = new String(input); // ... }注释是“复制一份,防止外面把字符串改坏了”。但String是不可变的,input根本不存在被外部修改的可能性,这行new String(input)不仅毫无必要,还在每次调用时多创建一个堆对象。如果这个方法是热点方法,一天调用几百万次,就会多出几百万个生命周期极短、马上变成垃圾的String对象,白白增加GC压力。
还有一种相对合理的new String用法是对字节数组转码:
String text = new String(bytes, StandardCharsets.UTF_8);这是从字节数组解码,确实需要创建新对象。但拿一个已有的String再去new一遍,属于典型的无效操作。Code Review时看到这种写法,可以直接打回去让同事删掉。
6.2 滥用字符串常量池的教训
有业务优化意识的同学可能还会这样想:“既然字符串常量池能复用,那我把所有动态字符串都intern()一下不就能省内存了?”
这个想法很危险。Java字符串常量池是一张全局哈希表,默认的桶数量是有限的。大量动态字符串都丢进去,会造成哈希冲突,反而让StringTable的查询性能严重下降。而且在旧版本Java里,永久代空间有限,intern太多很容易当场OOM。
我实际遇到过一个场景:业务里有大量订单号的字符串都是动态拼接出来的,为了“去重”全部intern,上线没多久就发现GC时间飙升,Minor GC频繁,接口性能掉了一半。后来改成用本地缓存Map做限定数量的字符串缓存,才把问题解决。
正确的做法是:只有在字符串重复率非常高、且总量可控的情况下,才值得考虑借助常量池或自定义缓存;否则,老老实实用普通堆对象,让GC去处理生命周期短的字符串,比强行intern要可靠得多。
6.3 面试回答的推荐思路
如果现在有人再拿这道题问我,我会这样组织回答:
先反问一句:“您说的对象数量,是指不考虑类加载阶段的字符串常量池,只算这一次new执行里新出现的对象吗?还是算整个过程中新出现的所有String对象?”
然后自己给两层答案:
- 按字面量第一次被使用来计算,
String s = new String("111")涉及两个String对象:一个在字符串常量池,一个在堆。 - 如果池中已有“111”,则只创建一个堆对象。
- 如果严格限定“这条语句通过new新建的对象”,在没有JIT逃逸分析优化的情况下,始终只有一个堆对象。
最后再补一句:“另外JDK7之后字符串常量池在堆里,intern行为也比JDK6合理,如果需要我可以再展开说。”
这套回答下来,既展示了问题分析能力,又把主动权握在自己手里。很多面试官听完就会进入下一个问题,因为这个问题你已经答得足够完整了。
回顾整个问题,你会发现最难的不是记住“两个对象”或“一个对象”这个数字,而是理解数字背后的内存全貌。我自己后来每次遇到String相关的问题,都习惯先在脑子里画出对象引用图,再报答案。这个方法分享给你,面试前建议多练几遍,直到形成肌肉记忆。