ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

彻底搞懂new String(“abc“)创建几个对象:常量池与堆机制全解析

彻底搞懂new String(“abc“)创建几个对象:常量池与堆机制全解析 1. 先说结论这个问题的答案分两种情况这道题几乎是Java面试的送分题但也是踩坑率最高的题之一。很多人背过“创建了两个对象”这个标准答案结果面试官一追问就翻车。原因很简单答案不是固定的取决于字符串abc之前有没有在常量池里出现过。先给结论如果JVM常量池中已经存在字符串abc那么new String(abc)只创建一个对象也就是堆中那个新的String实例。如果JVM常量池中不存在abc那么会创建两个对象一个是常量池中的abc字符串一个是堆中的String对象。为什么会这样因为new String(abc)这句代码实际上包含两层含义第一层是字符串字面量abc本身它会在类加载或执行期间被放进字符串常量池第二层是new关键字它强制在堆上创建了一个新的String对象。这两件事是独立的。很多刚入行的同学会疑惑一句代码怎么可能创建两个对象其实你把这句话拆开看就清楚了。abc是一个字符串字面量它走的是常量池的机制new String(...)走的是堆内存分配机制。两者各管各的互不干涉。面试官问这道题表面上是考字符串创建机制实际上是考三块知识JVM运行时数据区、字符串常量池的存储结构、类加载过程中的常量池解析。任何一块没搞透都会在这个问题上露馅。1.1 常量池里已经有了abc的情况假设业务代码前面已经执行过String s1 abc;或者类加载时就因为其他地方引用了这个字面量导致它被放进了常量池。此时再执行String str new String(abc)JVM先解析字符串字面量abc发现常量池里已经有这个字符串了直接复用不再创建新的常量池对象。new关键字在堆上创建一个新的String对象这个对象的内部value数组引用指向常量池中abc的字符数组JDK 8及以前是char[]JDK 9以后是byte[]。最终堆上多出一个String对象常量池中的abc保持原样。所以这种情况下只创建一个对象。注意str只是一个栈上的引用变量它不是对象。1.2 常量池里没有abc的情况如果这是程序第一次出现abc这个字面量情况就不同了类加载阶段JVM解析new String(abc)对应的符号引用时发现常量池中还没有abc于是在字符串常量池中创建这个字符串。接着执行new指令在堆上再创建一个String对象并把常量池中abc的字符内容复制一份给堆中的对象要准确说的话是堆中String对象内部的value数组指向了常量池中字符串的字符数据具体指向关系在不同JDK版本下稍有区别后面详述。一个在常量池一个在堆两个都是实实在在的对象。这句话就是创建了两个对象说法的来源。但你要清楚这有个前提之前没有出现过abc这个字面量。实际开发中常量池里大概率已经存在abc了。因为只要你在任何地方写过abc这个字面量它就会进常量池。所以严格的答案是最多两个通常一个。如果你张口就说两个说明你没搞懂前提条件。2. 深入理解字符串常量池存放位置与存储内容面试官问这道题还有一个用意考察你对JVM内存结构的理解是否停留在背书上。很多人知道常量池在方法区但方法区在Java 7前后经历了什么变化、常量池里存放的到底是什么、JDK 9之后字符串底层结构改了会不会影响答案这些细节才是拉开差距的地方。2.1 方法区和字符串常量池的搬家史Java 7是一个重要的分水岭。在此之前字符串常量池放在运行时数据区的永久代PermGen中。永久代的大小是固定的你通过-XX:PermSize和-XX:MaxPermSize来调。这导致一个臭名昭著的坑大量创建字符串很容易触发OutOfMemoryError: PermGen space。Java 7开始字符串常量池从永久代移到了堆中确切地说是堆中的一块独立区域。永久代虽然还没彻底废除直到Java 8才被元空间Metaspace替代但字符串常量池已经不在里面了。为什么这么改因为永久代空间有限而且GC回收效率不高。字符串是业务系统中最高频的对象之一很多字符串都是有生命周期可回收的放在堆里就可以用新生代的Minor GC和Full GC统一管理内存利用率大幅提升。Java 8的时候永久代被元空间取代字符串常量池依然留在堆中。这个结论很关键面试时如果能主动说出来Java 7之后字符串常量池在堆中而不是在方法区会让面试官高看你一眼。2.2 常量池里放的是对象本身还是引用这个问题有点绕但对理解new String(abc)创建几个对象很重要。在JDK 7之前字符串常量池中存放的是String对象本身也就是完整的字符串对象实例。池中的对象和堆中的对象是两个独立的实体。到了JDK 7及以后情况发生了变化。字符串常量池中存储的不再是完整的String对象而是引用。这个引用指向堆中真正的String对象。也就是说池中abc对应的String对象的实体存放在堆里常量池只保存指向它的引用。这一点怎么验证有两种常见的推断方式。第一种是intern()方法的返回值比较。同一个字符串多次调用intern()拿到的引用必须是同一个这是JDK规范强制要求的。如果常量池存的是引用那多个地方指向同一个堆对象引用自然一致。第二种方式是看堆内存的对象数量。JDK 7之后如果池中保存的是对象实体而不是引用那么逻辑上和堆中的对象会有重复创建的问题内存浪费很明显。改成引用之后一份数据处处复用GC压力也小得多。顺带提一个高频记忆点new String(abc)创建出来的堆对象它的内部字符数组其实也是引用常量池中abc的字符数据。所以在JDK 7之后确实只创建了一个新的String对象壳体数据是共享的。这个认识对你的理解深度有质的提升。2.3 JDK 9 压缩字符串后答案变了吗还有一个新情况。JDK 9引入了字符串压缩Compact StringsString类的内部存储从char[]改成了byte[]同时增加了一个编码标志位coder用来区分LATIN-1单字节还是UTF-16双字节。这会影响上面的答案吗不影响。无论是char[]还是byte[]字符串对象的本质还是一个对象引用加一段字符数据。字面量abc仍然会进入常量池new仍然会在堆上创建新的String对象。只是内部数据存储格式变了创建对象的数量计算逻辑没有任何变化。但这种变化可以作为一个加分项。当面试官问你对JDK 9之后String底层的改动有了解吗时你能答出这一点说明你在持续跟进新版本的JVM演进。3. 验证结果三组代码和一把反编译工具光说不练假把式。下面用真实代码验证一下顺便把相关的边界情况也测一遍。建议你直接在自己电脑上跑一遍印象会深很多。3.1 三组最经典的对比代码先看第一组String s1 abc; String s2 abc; System.out.println(s1 s2); // trueabc是字面量s1赋值时JVM把abc放入了常量池假设之前没有s2赋值时直接在常量池中找到了abc复用了同一个引用。所以两个引用相等。第二组String s1 new String(abc); String s2 new String(abc); System.out.println(s1 s2); // false两个new创建了两个不同的堆对象它们的引用指向不同地址所以不相等。注意这里的常量池中只有一个abc因为字面量只会在第一次解析时创建一次。第三组String s1 abc; String s2 new String(abc); System.out.println(s1 s2); // falses1指向常量池中的字符串s2指向堆中的字符串两个不同的对象。这个结果也是混乱的重灾区很多人以为字面量赋值和new之间有些“自动优化”的关联实际上没有。再看一个intern()的用法String s1 abc; String s2 new String(abc); String s3 s2.intern(); System.out.println(s1 s2); // false System.out.println(s1 s3); // trues2.intern()做的事情是如果常量池中已经有abc返回池中对应字符串的引用这里的引用其实指向的是堆中那个和s1相同的对象如果池中没有把s2的引用放入池中并返回。所以s3和s1指向同一个字符串相等。JDK 7之后的intern()返回的不一定是“常量池里新创建的”而可能是堆中已有字符串对象的引用这个细节也值得知道。3.2 用javap反编译看看到底执行了什么javap -v是分析这类问题的利器。写一段最简单的代码public class StringTest { public static void main(String[] args) { String str new String(abc); } }编译之后执行javap -v StringTest.class关键字节码段长这样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逐条解释new在堆中分配String对象空间对象引用入栈。dup复制引用栈顶的值因为接下来调用构造器会消耗一份引用后面保存引用还要一份。ldc从常量池加载字符串abc的引用压入操作数栈。这里就是触发常量池字符串创建或复用的关键指令。invokespecial调用构造方法把abc作为参数传给String的构造函数完成对象初始化。注意ldc只是把常量池中的abc取出来传参不会创建新的字符串。真正的对象记忆点还是new那一条指令。如果你把代码改成String str abc;看字节码0: ldc #2 // String abc 2: astore_1只有一条加载指令没有new。这就是为什么字面量赋值不会在堆上创建String对象的原因。在实际面试中如果能手写出这段字节码并解释ldc、new、invokespecial各自的作用说明你是真懂而不是背答案效果直接拉满。3.3 连续new一百次会不会有一百个对象有同学会问如果循环里执行new String(abc)一千次那一千个对象必然有但常量池中的abc始终只有一个。因为ldc指令每次都会去常量池中找abc找到了就直接复用。写段代码验证一下String[] arr new String[1000]; for (int i 0; i 1000; i) { arr[i] new String(abc); } System.out.println(arr[0] arr[1]); // false堆上两个不同对象用jmap或者VisualVM看堆中的对象数量你会发现有1000个String对象但char[]或byte[]数组大概率远少于1000个因为字符数据是共享的。这一点再次印证了JDK 7之后字符串对象和数据存储分离的设计。顺便说一句这种代码写多了会产生大量无用对象属于典型的坏味道。实际业务中写new String(abc)基本是多余操作除非你要故意生成一个可变字符串的副本。这也是面试官后面经常追问的那你还写new String干什么3.4 一个容易犯的错凡字符串都进常量池常量池不是无限的。JDK 7之后虽然在堆中但堆也不是无限的。如果你在代码里动态拼接大量字符串比如String s ; for (...) { s other; }这些拼接产生的中间字符串不会全部进常量池它们就是普通的堆对象占用堆内存。能进常量池的通常是编译期可知的字符串字面量以及显式调用intern()的字符串。运行时拼接出来的新字符串如果没有intern()就是纯粹的堆对象和常量池没有关系。写代码时别觉得字符串会进常量池很省就肆意拼接常量池满了照样OOM。4. 面试官的连环追问从这一题延伸出来的考点这题很少被单独问完就放你走。面试官基本都会顺着往下连环追问。我总结了一串高频追问每一个都值得认真准备。4.1 追问一String s abc创建了几个对象答案是最多一个。如果常量池中已经有abc直接复用0个新对象如果没有常量池中创建一个总共一个对象。对比一下这里和new String(abc)的差别在new关键字。没有new就不会在堆上额外创建对象。有人会抠细节类加载阶段会不会提前把abc创建好如果你这个类里其它地方或者其他类已经引用了abc那么在你执行这一行之前池里就已经有它了。这就是为什么说最多一个而不是必然一个。4.2 追问二字符串拼接到底创建了几个对象这是另外一个经典问题。看几种情况第一种String s a b c;编译期就会优化成abc常量池中只创建一个abc。字节码里只有一条ldc指令不会有任何new和拼接操作。第二种String a a; String b b; String s a b;引用了变量编译期无法确定值走的是StringBuilder拼接。实际执行等价于String s new StringBuilder().append(a).append(b).toString();创建了StringBuilder对象和一个新的String对象。如果还要较真的话如果拼接结果在常量池中不存在toString()产生的字符串不会自动进池abc就可能同时存在池中一个如果之前有字面量出现过、堆中一个。这就是为什么在循环里做字符串拼接性能极差每次循环都创建两个新对象StringBuilder和String简单粗暴地说是性能灾难。4.3 追问三String为什么要设计成不可变判断你对API设计的理解。核心原因有三条第一字符串常量池能够复用的前提就是不可变。如果字符串能变那么多个引用指向同一个abc改一个就影响所有引用这会造成严重的逻辑错误。第二String被广泛用作HashMap的key和缓存键值。HashCode在第一次计算后被缓存起来如果字符串内容可变hashCode就失效了HashMap的整个查找机制就崩了。第三线程安全。不可变对象天然线程安全任何线程读到的都是同一个状态不需要加锁同步。还有网络安全方面的考虑比如路径处理、网络协议解析中大量使用字符串如果可变中途被篡改会产生安全漏洞。这题的延伸很宽答得好能体现出你有系统设计的全局观。4.4 追问四说说String、StringBuffer、StringBuilder的区别顺便把这条也备好。三句话解释String不可变适合字符串常量场景优点是线程安全、可复用缺点是拼接效率低。StringBuffer线程安全方法用synchronized修饰适合多线程公共字符串操作的场景缺点是性能稍差。StringBuilder线程不安全方法不加锁是单线程下的最佳性能选择日常开发中使用频率远高于StringBuffer。面试官如果追问“StringBuffer转成String怎么做”直接答toString()即可但可以说清楚转换过程会生成一个新的String对象原StringBuffer不受影响。4.5 怎么回答才能拉开差距我的建议是分三层递进回答。第一层直接给结论分情况说清楚一个对象和两个对象的前提条件。这一层就能过大多数面试。第二层补充说明JDK版本带来的差异。包括Java 7常量池移入堆、JDK 9之后的byte[]存储、intern()在JDK 7前后的行为变化。这一层能让面试官确定你有实战经验不是背面试题。第三层主动提及字节码层面的执行过程说清楚ldc、new、invokespecial各干了什么。这一层能体现出你真的读过Class文件、理解JVM指令集。到了这个深度面试官通常会满意地点点头然后换下一个话题。还有一个小技巧回答时可以用一个实际线上问题举例。比如我们之前排查过一个内存泄漏就是因为把大量运行时生成的字符串intern()进了常量池导致堆中无用字符串无法被回收最终Full GC频繁。这样的实战案例比单纯背书有说服力得多。5. 操作建议与避坑心得最后聊点实在的。这道题刷三遍不如亲手验证一遍推荐几个低成本的动手方式第一在本地用JDK 8和JDK 17分别跑一遍上面的测试代码对比输出结果。你会发现结论完全一致这印证了字符串创建机制在JDK 8到JDK 17之间是稳定的。顺便观察一下两个版本默认GC的变化你会对JVM演进有更强的体感。第二用javap -c -verbose实际反编译几个类不光是String相关的还包括字符串拼接、常量折叠、switch-case字符串匹配这些场景。字节码看得多了再遇到类似问题就有画面感了。第三有条件的话用JFRJava Flight Recorder或者VisualVM抓一下堆转储看看字符串对象和字符数组的实际数量关系。眼见为实比看十篇文章都管用。一些容易踩的坑再强调一遍不要在循环中用拼接字符串。性能损耗很大还容易触发GC压力。不要滥用intern()。JDK 7之后intern()会把引用存入堆中的常量池区域但池不是无限大的大量动态字符串intern()到池里会导致这块区域膨胀且难以回收。不要用比较字符串内容除非你能确认两个引用指向同一个常量池对象。业务代码中一律用equals如果你想追求性能至少也要在理解机制的前提下再用而且只限于你能把控的场景。如果做字符串去重优化优先考虑JVM的G1垃圾收集器自带的字符串去重特性String Deduplication而不是自己手动intern()前者的安全性和效果在绝大多数场景下都更好。我自己在带团队做code review时几乎每次都能遇到字符串拼接相关的性能问题。有一次排查线上Full GC频繁就是因为一个定时任务在循环里用拼接大字符串每次迭代产生大量中间String对象年轻代直接被撑爆。改成StringBuilder之后GC频率直线下降。所以这些知识不只是面试用实际排查问题的时候非常管用。还有一次处理一个报表导出的功能里面大量使用String.valueof()和字符串拼接生成CSV内存占用居高不下。后来我们把所有已知枚举值改成常量引用动态部分用StringBuilder内存下降了将近40%。这就是字符串对象创建机制在真实业务中的价值体现。希望这篇内容能帮你在面试时讲清楚原理在写代码时不再踩字符串的坑。千言万语汇成一句话理解对象创建的本质比背答案重要得多。
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表