先说个很经典的面试题:new String("abc")到底创建了几个对象?如果你去网上搜,答案五花八门,有说一个的,有说两个的,还有说三个的。其实这题根本没有固定答案,得看字符串常量池里有没有"abc"。而这个池子本身,在JDK8之前和JDK8之后,行为还完全不一样。很多人就是从这类题开始,被字符串常量池绕晕的。
这篇文章我准备把JDK8+的字符串常量池整个讲透:它存在哪、怎么运作、intern()到底干了些啥、表大小怎么调、日常开发会遇到哪些坑。我会把JVM内存布局、类加载解析过程、常见面试题和调优实践串在一起,讲得尽量贴近实际场景,而不是给你背一堆八股。
1. 先搞清楚:字符串常量池到底是个什么东西
1.1 三个"常量池"别搞混
很多人提到"常量池"就晕,因为Java里叫这个名字的东西至少有三个:Class文件常量池、运行时常量池、字符串常量池。这三个东西经常被混着说,但它们的职责完全不同。
Class文件常量池是编译产物,每个.class文件里都有一张表,里面存了类的版本号、字段、方法、符号引用、字符串字面量等信息。这张表在类被加载之前就存在,内容是一堆静态的字节数据。
运行时常量池是类加载之后、在元空间(JDK8)或永久代(JDK7及之前)里为每个类单独分配的一块区域,用来存放Class文件常量池解析后的内容。它类加载阶段生成,跟着类的生命周期走,每个类一份,不是全局共享的。
字符串常量池(StringPool/StringTable)才是我们这篇文章的主角。它是一个全局共享的哈希表,专门用来存放字符串字面量以及手动通过intern()方法放入的字符串。JDK8里,这张表里的字符串对象实际存放在Java堆中。
这三者的关系简单说就是:Class文件里写了哪些字符串字面量,类加载后进入运行时常量池,解析阶段再把字符串字面量同步到全局的字符串常量池里。平时我们在代码里直接写的"abc"、"hello"这些字面量,最终在运行时就是通过字符串常量池来复用的。
1.2 为什么JVM需要一个字符串常量池
要理解String Pool存在的意义,先想想如果没它会怎样。假设你写了一个方法,里面有一万次String s = "error";,每次执行都创建一个新的String对象,内存里就会出现一万份内容一模一样"error"。不光浪费堆空间,GC压力也大得离谱。
字符串是Java程序里最常用、最容易被重复创建的对象。日志里的状态码、配置项的key、接口返回的枚举值、SQL语句片段……几乎每个系统运行时都会产生海量重复的字符串内容。把这些内容相同的字符串统一收进一个池子,引用同一个对象,就能大幅减少内存开销。
这个设计能成立,一个根本前提是String的不可变性。字符串对象一旦创建,内部字符数组就不可修改,所以池中的对象才能被多个位置安全地共享。如果String是可变的,常量池里存的对象随时可能被别人改掉,整个缓存体系就崩了。不可变性顺便还带来一个好处:String的hashCode()可以缓存,不用每次计算。
从使用者的角度,字符串常量池意味着你写"abc"的时候,JVM不是每次都生产一个全新对象,而是先去池子里捞现成的。
1.3 JDK8的"搬家史"
字符串常量池的位置在JDK的发展史里动过好几次,这个历史很关键,直接导致同样一段代码在不同版本上运行结果不一样。
JDK6及之前,字符串常量池在永久代(PermGen)里。永久代的大小是固定的,默认几十MB,要扩容得专门加-XX:MaxPermSize参数。字符串只要稍微多一点,就经常抛出OutOfMemoryError: PermGen space。
JDK7开始,HotSpot把字符串常量池从永久代挪到了Java堆中,永久代里只保留类的元信息。这一步调整的原因很简单:永久代太小,且不参与常规的堆GC,字符串对象放在那里很容易溢出,还回收困难。挪到堆里之后,字符串对象可以像普通对象一样被新生代、老年代正常管理。
JDK8彻底干掉了永久代,用元空间(Metaspace)替代。元空间使用本地内存,理论上大小不再受JVM堆上限约束,类元数据放这里。但字符串常量池并没有跟着移到元空间,它继续待在堆里。
所以JDK8之后,内存布局是这样的:字符串对象和普通的User对象、List对象一样在堆中,类的元数据、运行时常量池在元空间。如果你用jmap分析堆转储,能看到大量String对象直接躺在堆里,而永久代相关的概念已经不存在了。
直接把字符串常量池放堆里,带来的最明显变化是:它的容量不再有独立上限,跟堆内存共享同一个大小限制;GC也能更好地回收池中无用的字符串。代价是,如果程序滥用intern(),会把整个堆撑爆,后面我会专门讲这个问题。
2. 字符串创建的全流程:字面量 vs new String
2.1 字面量是如何进入常量池的
执行一句String s = "abc";,背后发生了什么?很多人以为是编译期直接往常量池里塞一个"abc",然后运行时拿引用。大致方向对,但细节上还差几步。
编译阶段,"abc"这个字符串字面量被写进当前类的Class文件常量池,以CONSTANT_String_info的形式存在。具体内容编码为UTF-8,存在CONSTANT_Utf8_info里。
类加载阶段,JVM将Class文件常量池里的信息加载到运行时常量池(元空间)。此时还不是真正的String对象,只是符号信息。
等到解析阶段,如果代码里引用了这个字符串字面量,JVM会拿着内容去全局的字符串常量池(StringTable)里查找。如果找到了相同内容的字符串,直接返回池中对象的引用;如果没找到,就在Java堆中创建一个新的String对象,并把它的引用注册到StringTable里,然后返回引用。
所以你看,一个字符串字面量,最终指向的其实是StringTable里的一个String对象。这个对象在JDK8里就活在堆中,并非什么特殊的内存区域。
这里有个容易忽略的细节:类加载解析时,如果两个类都定义了一模一样的字面量"abc",后加载的类不会重复创建对象,而是直接复用第一个类创建并注册进池中的对象。这就是池的全局共享特性。
2.2 new String("abc")到底创建了几个对象
这是绕不开的经典问题,我直接给结论,然后解释。
如果字符串常量池里已经有"abc",那么new String("abc")只在堆中创建一个新的String对象;如果常量池里没有"abc",那么会先创建两个对象:一个是在堆中创建并注册进StringTable的字符串对象,一个是new出来的String对象。
为什么是"先"?因为执行new String("abc")时,"abc"这个字面量本身就要先在常量池里存在(创建或复用),然后构造方法才会在堆中再new一个副本。所以:
String s1 = "abc"; // 常量池中有/创建"abc",s1指向池中对象 String s2 = new String("abc"); // 堆中新建一个对象,内容相同 System.out.println(s1 == s2); // false,两者不是同一个引用 System.out.println(s1.equals(s2)); // true,内容相同new String("abc")创建的堆对象和池中对象虽然内容一样,但是两个不同的对象,用==比较是false。equals比较的是内容,自然为true。
顺带说一句,new String("abc")在大部分业务代码里都是没有意义的写法:白白多创建一个对象,内容还和池里的一样。如果你需要一个副本,那没问题;如果只是想要一个字符串,直接写"abc"就行。
还有个小细节:无论是池中的字符串对象还是new出来的对象,JDK8中它们的char数组都可能在堆中被独立分配,也就是内容存储本身也是一份内存。别以为池里的对象就一定和new的对象共享一个char数组,它们通常各自持有自己的数组,除非你显式让它们指向同一个。
2.3 "拼接"里的编译期与运行期博弈
字符串拼接是平时写代码最常触发的操作,但它和字符串常量池的交互,藏着一堆容易被忽略的编译优化逻辑。
先看字面量拼接:
String s1 = "a" + "b"; String s2 = "ab"; System.out.println(s1 == s2); // true这里"a" + "b"在编译期就被折叠成了"ab",所谓"常量折叠"。因为"a"、"b"都是编译期常量,编译器直接在字节码层面把结果算出来了,运行时压根不会拼接。所以s1和s2都指向常量池中的同一个"ab"对象。
但一旦掺入变量,事情就变了:
String a = "a"; String b = "b"; String s3 = a + b; String s4 = "ab"; System.out.println(s3 == s4); // falsea + b在运行时不可能确定结果,编译器会生成一段等价于new StringBuilder(a).append(b).toString()的代码(JDK8里是StringBuilder,JDK9之后换成了invokedynamic+StringConcatFactory,但效果类似)。最终s3指向堆中一个新建的String对象,跟常量池里的"ab"不是同一个对象。
有趣的是final修饰:
final String fa = "a"; final String fb = "b"; String s5 = fa + fb; String s6 = "ab"; System.out.println(s5 == s6); // true被final修饰且初始值是编译期常量的变量,在编译期也是可以确定值的,所以fa + fb照样被折叠成"ab"。反之,如果final变量的值来自运行时方法调用,比如final String f = getStr();,那JVM就无法折叠,拼接还是要走运行期。判断依据是:这个变量的值在编译期能不能确定。
这里说一句实际的:字符串+在循环里性能极差,因为它每次循环都new一个StringBuilder。长时间循环拼接,最好手动复用StringBuilder。虽然JDK9的invokedynamic优化一定程度缓解了这个问题,但"循环里用+"仍然是重灾区,只是表现从"频繁创建StringBuilder"变成了"频繁触发方法句柄调用"。
2.4 一张表理清字符串创建方式
| 创建方式 | 对象数量 | 驻留位置 | 说明 |
|---|---|---|---|
字面量"abc" | 0或1个 | 常量池(堆) | 池中不存在则创建并注册,存在则复用 |
new String("abc") | 1个或2个 | 池中1个+堆中1个 | 池中无"abc"时先创建池对象,再new堆对象 |
a + b(变量拼接) | 1个新对象 | 堆 | 运行期通过StringBuilder生成 |
"a" + "b"(常量拼接) | 0个新对象 | 常量池 | 编译期折叠 |
s3.intern() | 0或1个注册 | 常量池 | JDK8中池中无则注册当前对象引用 |
3. intern()方法深潜:从JDK6到JDK8的改命
3.1 intern()在JDK8中的真实行为
intern()是一个native方法,作用是让一个普通堆中的字符串对象"驻留"到字符串常量池里。JDK8中的执行逻辑可以概括为:
- 在StringTable里查找内容相同的字符串(用equals比较)。
- 找到,则返回池中那个对象的引用。
- 没找到,则把当前String对象的引用注册到StringTable中,然后返回当前对象的引用。
注意这句话:"把当前对象的引用注册到StringTable中"。这是JDK7之后才有的行为。在JDK6及以前,没找到时会把当前字符串的内容复制一份到永久代里,生成一个全新的String对象再注册。也就是说,JDK6的intern返回的引用跟调用intern的那个对象不是同一个,而JDK8的intern返回的引用就是调用者的引用(池中无时)。
这个变化带来一个非常有意思的结果:调用intern()之后,如果之前池里没有,你再拿字面量去取,得到的引用跟原来那个堆对象是同一个。这成了很多面试题的题眼。
String s = new String("hello") + new String("world"); s.intern(); String t = "helloworld"; System.out.println(s == t); // JDK8:true这段代码网上已经烂大街了,但值得再拆一遍。new String("hello")和new String("world")分别是两个堆对象,拼接后s指向堆中一个新的"helloworld"对象。调用s.intern()时,池里没有"helloworld",所以把s的引用写入StringTable。之后执行String t = "helloworld",解析阶段去池里一查——居然有,直接返回s的引用。所以s == t为true。
如果在JDK6上跑同一段,结果是false,因为intern会把"helloworld"复制一份放进永久代,s还是指向堆中的对象。
这个差异在面试里非常高频,我建议你直接记住一个结论:JDK7+的intern,在池中不存在时,是"把引用放进去",不是"把对象复制进去"。
3.2 这个案例到底想考什么
有人看到上面的代码,误以为只要调了intern(),字符串就一定是"同一个"。这又走到另一个极端。比如:
String s = new String("helloworld"); s.intern(); String t = "helloworld"; System.out.println(s == t); // false这段代码和上一段的区别在哪?关键在于执行new String("helloworld")时,"helloworld"这个字面量本身就已经让池中存在一个"helloworld"对象了。所以s指向的是堆中new出来的一个独立对象,池中已经有先来的那个对象。s.intern()发现池中已有相同内容的字符串,直接返回池中原对象的引用,但s本身并没有变。因此s == t是false。
同样的现象也可以理解为:intern返回的是"池中已有的那个引用",不是"调用者的引用"。能否命中,完全取决于池中当时的状态。
所以完整分析这两段代码,核心考点有两个层面:
- 字符串字面量是否会导致池中存在对象;
- intern在无竞争条件下,JDK7+的"引用入池"语义。
3.3 该用的时候才用:小心内存和性能双杀
理论上,intern()能把重复内容的对象收敛成一份,非常省内存。但实际项目中我见到的情况是:滥用intern导致堆内存暴涨、GC时间飙升、甚至直接OOM,这种情况比"intern被合理使用"常见得多。
为什么?
因为StringTable本身是一张哈希表,它的容量是有上限的。默认StringTableSize是60013,也就是有大约6万个桶位。如果你往池子里放几百万个不同的字符串,哈希冲突会急剧上升,每个桶下的链表越来越长,查询性能从O(1)退化到O(n)。更麻烦的是,这些字符串一旦进池,因为池中的引用是强引用,字符串对象和它的char数组都不会被GC回收,等于一直占着堆内存。
举个我踩过的坑。某个系统里我们把从接口返回的状态描述字符串全部intern(),目的是让重复的状态文本只存一份。结果状态文本种类非常多,一周后堆里多出几十万个驻留字符串,堆占用直线上升。用jcmd一看StringTable的桶分布,好几个桶下挂了上百个字符串,查询耗时明显变长。
后来把策略改成:只有少数、固定、可枚举的字符串才准用intern,比如状态枚举的名称、错误码、固定的URL key;动态生成的文本(拼接出来的日志、接口返回的完整文案)一律不去intern。
另外还有一点很多人会忽略:JDK7+的intern在池中无对象时保存的是当前对象的引用。如果你调用intern的是一个只有局部引用的大字符串,它被驻留后就不会被GC回收,哪怕你已经完全不使用了。这等于手动制造了一个"准静态"内存。
3.4 一个帮忙省内存的正确姿势
intern用对了也有明显收益,特别是那些"数量有限但内容重复"的字符串。典型场景是缓存大量对象的字段名、JSON key、URL路径片段、状态码等。这些字符串通常来自代码字面量,本身就会进池,倒不太需要额外intern。真正需要intern的是"运行时生成的、内容高度重复"的字符串。
举个例子:你从数据库或网络读入一万条记录,每条里都有一个"SUCCESS"字段值。如果不做任何处理,内存里可能有一万个"SUCCESS"的String对象。你可以这样处理:
String value = readFromSource(); // 可能每次都是新对象 value = value.intern(); // 内容相同则收敛为一个对象这样一万条记录里只有第一个"SUCCESS"的String对象会持久存在,其他9999个都会被GC回收,因为intern返回的是同一个池中对象的引用。
但前提一定要确认:"SUCCESS"这类值的集合是有限的。如果值集合本身无限增长,比如每次都拼上时间戳、随机数,那intern就是灾难。
4. 实战:观察StringTable与调优
4.1 用jcmd看一眼常量池的数据
说再多不如亲自看一眼。JDK8自带的jcmd工具可以直接查看StringTable的统计信息:
jcmd <pid> VM.stringtable输出大致是这样的:
StringTable statistics: Number of buckets : 60013 = 489288 bytes, avg 8.0 Number of entries : 2077 = 49848 bytes, avg 24.0 Number of literals : 2077 = 135160 bytes, avg 65.0 Average bucket size : 0.0 Variance of bucket size : 0.0 Std. deviation of bucket size: 0.0 Maximum bucket size : 0这里有几个指标值得关注:
Number of buckets:桶数量,默认60013。从JDK8开始,如果JVM启动时不指定-XX:StringTableSize,就是60013。Number of entries:池中的字符串条目数,也就是驻留了多少字符串对象(或引用)。Maximum bucket size:单个桶下最多的元素个数。这个数字如果很大(比如超过几十),说明哈希冲突已经很严重了,StringTable的查询效率会下降。Average bucket size:平均每个桶挂多少元素。期望值是接近0或很小。
还有一种方式,启动JVM时带上参数:
java -XX:+PrintStringTableStatistics -jar your-app.jar程序退出时或触发Full GC后,会把StringTable统计数据打到标准输出。这个参数适合测试环境验证,生产环境一般不建议开着,会有额外输出开销。
我自己做性能排查时,习惯先跑一次jcmd <pid> VM.stringtable,再结合堆转储分析。如果发现Number of entries极度膨胀且Maximum bucket size异常,基本就能定位到有代码在大量intern动态字符串。
4.2 StringTableSize参数到底该怎么调
-XX:StringTableSize是启动参数,用来设置StringTable的桶数量。官方文档建议这个值尽量是质数,这样哈希分布会更均匀。JDK8里默认是60013(一个质数),在JDK6之前默认是1009。当年1009的桶数,对稍微大一点的系统来说,冲突简直不敢想,这也是JDK6时代intern非常容易被诟病的原因之一。
什么时候需要调大这个值?最典型的是使用了大量intern字符串的应用。比如你确认业务场景中会有几百万个不同内容的字符串要驻留,60013个桶肯定扛不住,单个桶下挂的链表会非常长。把StringTableSize调到100003或200003,冲突会明显下降。
调整方式就是在启动参数里加:
java -XX:StringTableSize=200003 -jar your-app.jar注意调整后jcmd看到的Number of buckets会变成设置值。但桶数量增加,本身也会占用一些内存,每个桶是一个指针大小,从60013调到200003,大致多占一百多KB到几百KB,内存代价很小。
调大小的同时,最好配合观察最大桶深度。如果调完之后Maximum bucket size还是个位数,那说明够用。如果还是几十上百,要么继续调大,要么考虑是不是intern策略本身有问题。
这里多说一句:不要一开始就把StringTableSize设成超大值(比如千万级)。桶数量过大,对查找性能的帮助并不会线性增长,反而浪费内存和局部性。先把应用跑起来,用jcmd看实际条目数和桶大小,再决定方向和幅度,这是最务实的做法。
4.3 G1字符串去重:堆里的二次去重
除了字符串常量池,JDK8u20之后G1还引入了一个字符串去重特性(String Deduplication)。很多人把它和字符串常量池混为一谈,其实不是一回事。
字符串常量池是"编译期/运行期驻留机制",决定了一个"abc"字面量在多个地方引用时是同一个对象。而G1字符串去重,是GC在做垃圾回收时,额外扫描堆中发现内容一样的String对象,让它们共享底层的char数组。简单说,它不改变String对象的数量,但让它们的char数组可以被复用,从而省掉重复的字符数据内存。
启用方式是在G1收集器上叠加参数:
java -XX:+UseG1GC -XX:+UseStringDeduplication -XX:StringDeduplicationAgeThreshold=3 -jar your-app.jarStringDeduplicationAgeThreshold表示对象经过多少次GC之后才参与去重,默认是3。太早参与可能白费力气,因为年轻代对象存活时间短,还没来得及去重就被回收了。
这个功能对什么场景帮助最大?内存里有大量内容相同、但不是来自常量池的String对象,比如从网络读入的大量JSON字段名、重复的URL、相同的SQL片段。JDK的字符串去重能省掉重复char[]的空间,实测对大堆应用可以节省可观的堆内存。
但它毕竟要额外消耗GC扫描和比较的时间,如果堆里重复字符串很少,开启反而拖慢GC。所以生产环境要观察实际收益再决定开不开,不要无脑加。
4.4 从OOM日志反推问题
我见过好几个因为字符串驻留引发的堆OOM,现象非常典型:程序运行一段时间后,GC日志显示老年代不断增长,同时大量字符串对象驻留。堆转储一看,全是内容各异的String对象,而且每个对象都被StringTable强引用着,无法回收。
JDK8里常见的OOM日志是java.lang.OutOfMemoryError: Java heap space。如果这个OOM发生在大量intern调用之后,优先怀疑StringTable被打爆。排查路径一般是这样的:
- 用
jcmd <pid> VM.stringtable看entries数量,如果达到几百万甚至上千万,基本坐实。 - 抓一份堆转储,用MAT或jvisualvm分析String对象数量和内容分布。
- 回看代码,找出所有调用
intern()的地方,确认是否有"动态、无限内容"的字符串被驻留。 - 把不必要的intern去掉,或改成有限枚举集合的缓存,同时评估是否调大
StringTableSize。
顺便提一句历史对比:JDK6时代如果intern写多了,往往报的是PermGen space异常,因为池在永久代里。JDK8就变成了Java heap space。要是哪天翻老代码看到这两种不同报错,大概就能判断出跑在什么版本上。
5. 常见问题速查与避坑技巧
5.1 高频问题速查表
| 场景 | 结果 | 原因 |
|---|---|---|
"a" == "a" | true | 同一个常量池对象 |
new String("a") == "a" | false | 堆中新对象与池中对象不同 |
"a"+"b" == "ab" | true | 编译期常量折叠 |
String a="a"; a+"b" == "ab" | false | 运行期拼接生成堆对象 |
final String a="a"; a+"b" == "ab" | true | final常量可编译期折叠 |
s.intern() == "ab"(池中无ab) | JDK8 true | 引用入池后字面量复用它 |
s.intern() == "ab"(池中已有ab) | false | intern返回池中原有引用,s不变 |
这张表基本覆盖了面试和日常判断中八成以上的场景。建议你把它存下来,写代码前看一眼,能少踩好几个坑。
5.2 几个真实踩坑场景复盘
先讲一个比较隐蔽的坑:字符串常量池里的对象,和通过反射修改String内部数组的行为。JDK8里String内部是char[],虽然数组被final修饰,但反射可以访问private字段,甚至可以改数组内容。一旦你通过反射修改了一个常量池字符串的char数组内容,所有引用它的地方都会跟着变,造成极其诡异的问题。这就是为什么生产环境严禁反射修改String内部结构。别觉得"我写的是final啊",final挡得住正常赋值,挡不住反射。
再一个是==比较的坑。项目里有人喜欢用==比较字符串,理由是"JDK8常量池会复用,直接比较引用快"。但前提必须是两边都确认指向的是同一个池中对象。只要有一侧是运行时生成的(比如方法返回值、拼接结果、从Map里取出来的值),==就可能是false。判断字符串相等,老老实实用equals或Objects.equals,不要拿==赌运气。
第三个是常量池优化误伤真实需求的例子。有一次我们想对一批来自第三方接口的响应做缓存,把响应中的关键字段值都intern了。字段值的枚举数量本来以为是有限的,结果对方接口升级后引入了一堆动态字段名,每台机器上的每分钟都在往池里塞新内容。线上开始出现越来越长的GC暂停,排查半天才发现是intern导致的StringTable冲突增大和强引用堆积。
从此我的经验是:能用HashMap做有限缓存,就不要用intern做无限驻留。如果确实要用intern,先确认内容集合是有界的,并且用jcmd观察StringTable增长曲线,设置好报警阈值。这种"看似省内存、实则吞内存"的操作,必须谨慎。
5.3 最后一个建议:培养"先确认再优化"的习惯
字符串常量池的话题延伸开去,其实是一个很大的内存管理和性能优化命题。很多人一听到"优化字符串内存",第一个念头就是intern、调StringTableSize、开去重。但我的做法是反过来的:先看清问题出在哪,再决定动哪里。
如果内存占用高是因为大量重复内容的字符串对象,intern和G1去重是有价值的优化手段。如果是大量不同的动态字符串,比如日志内容、带时间戳的消息,优化方向应该是检查是否有不必要的缓存、是否用了大对象图、是否可以改成更紧凑的数据结构,而不是盲目intern。方向错了,指标再好看,最终也会付出更大的代价。
字符串常量池的原理并不难,难的是在真实场景里判断"这个池子应该怎么用"。希望这篇文章能帮你少走一点弯路。