“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及以前永久代PermGenchar[]字符串字面量对象在PermGenintern()会把内容复制一份到PermGen中JDK7 / JDK8堆Heapchar[]字符串常量池移到堆StringTable存引用池中对象可被GCJDK9及以后堆Heapbyte[] 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 1javac在编译阶段就能算出来结果直接优化成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相关的问题都习惯先在脑子里画出对象引用图再报答案。这个方法分享给你面试前建议多练几遍直到形成肌肉记忆。