1. 从一个诡异的现象说起:为什么两个"相等"的字符串却不相等
刚入行那会儿,我写过一段至今想起来都脸红的代码。当时在做一个用户标签匹配的功能,逻辑很简单:从数据库里读出一个标签字符串,跟代码里写死的常量做比较,相等就走某个分支。本地测试一切正常,上了测试环境也没问题,结果到了预发布环境,同样的数据、同样的代码,判断结果却反了。
我盯着屏幕看了两个小时,打印出来的两个字符串肉眼看上去一模一样,长度一样,字符一样,连编码都查了。最后用==换成equals才恢复正常。那一刻我才真正意识到,Java 里那个看似简单的==,背后藏着一整套关于内存布局的学问,而这一切的起点,就是今天要聊的主角——常量池。
这篇文章不是那种把"常量池分为 Class 常量池、运行时常量池、字符串常量池"三句话讲完就收工的八股文。我想做的是,把常量池放回到栈、堆、方法区这三大内存区域的大背景下,讲清楚它到底住在哪、什么时候被创建、什么时候被回收、为什么==和equals会打架、intern()到底干了什么、以及那些面试里常被追问但很少有人讲透的细节。不管你是刚学 Java 的新手,还是写了几年代码但对内存模型一直模模糊糊的老手,看完应该都能有点收获。
我尽量不堆术语,遇到绕的地方就用生活化的类比拆开讲,同时把能动手验证的代码都给你备好,你可以直接复制到本地跑一遍,眼见为实。
2. 先把地图画出来:栈、堆、方法区到底各管什么
在聊常量池之前,必须先把 JVM 的内存地图铺开。很多人学常量池学得痛苦,根本原因不是常量池本身难,而是脑子里没有一张清晰的内存分区图,导致常量池像悬在半空中,不知道它跟谁有关系。所以我们先花点篇幅把这张图建立起来。
2.1 用一家公司来类比 JVM 内存
你可以把整个 JVM 想象成一家公司,公司里有几个不同的部门,各司其职:
- 栈(Stack):相当于每个员工的私人办公桌。每个线程都有自己的栈,线程私有的。你在方法里定义的局部变量、方法调用的现场信息,都放在这张桌子上。桌子很小,用完就清,速度极快。方法一执行完,对应的栈帧就弹出,桌上的东西立刻消失。
- 堆(Heap):相当于公司的公共仓库。所有线程共享,你
new出来的对象、数组,全都堆在这里。仓库很大,但东西多了要清理(垃圾回收),清理是有成本的。 - 方法区(Method Area):相当于公司的档案室。存放类的结构信息、字段描述、方法描述、常量、静态变量等。它是线程共享的,生命周期跟 JVM 差不多长。在 JDK 8 之前,这块区域的具体实现叫永久代(PermGen),JDK 8 之后换成了元空间(Metaspace),从堆内存挪到了本地内存。
这三者里,常量池主要跟方法区打交道,但字符串常量池又跟堆有着千丝万缕的联系,这正是它让人头晕的地方。
2.2 栈的细节:为什么局部变量活得那么短
栈里最基本的单位叫栈帧(Stack Frame),每调用一个方法就压入一个栈帧,方法返回就弹出。一个栈帧里通常包含四块内容:局部变量表、操作数栈、动态链接、方法返回地址。
局部变量表里存的是方法参数和方法内定义的局部变量。这里有个关键点:基本类型(int、long、boolean 等)直接存值,引用类型存的是指向堆或方法区的引用(可以理解为一个地址)。所以当你在方法里写String s = "abc";时,局部变量表里的s存的是一个引用,真正的"abc"这个字符串对象并不在栈上。
栈的空间通常很小,默认也就几百 KB 到 1 MB 左右(可以用-Xss调整)。这也是为什么递归太深会抛StackOverflowError——桌子就那么大,你非要往上堆几百层盘子,肯定塌。
2.3 堆的细节:对象都住在这里,但字符串有点特殊
堆是 GC 的主战场,几乎所有对象实例都在这里分配。堆内部又分新生代和老年代,新生代再分 Eden、Survivor 区,这套分代模型是为了提高 GC 效率,这里不展开。
对于字符串来说,情况稍微特殊一点。字符串对象本身是存在堆里的,但字符串常量池(String Pool)在 JDK 7 之后也被挪到了堆里。也就是说,字符串相关的"常量"和"对象"其实都住在堆这个公共仓库里,只是分属不同的货架。
2.4 方法区的细节:常量池的老家
方法区里最核心的内容之一就是运行时常量池(Runtime Constant Pool)。每个类被加载时,它的 Class 文件里的常量池(Constant Pool)会被解析并放入方法区的运行时常量池。这里面存的东西包括:字面量(文本字符串、final 常量值等)和符号引用(类和接口的全限定名、字段名、方法名等)。
这里要区分两个概念,很多人会混:
| 概念 | 位置 | 产生时机 | 内容 |
|---|---|---|---|
| Class 常量池 | Class 文件中 | 编译期 | 字面量 + 符号引用 |
| 运行时常量池 | 方法区 | 类加载后 | Class 常量池的内容 + 动态添加的常量 |
| 字符串常量池 | 堆(JDK 7+) | 运行期 | 字符串字面量的引用 |
看到没,"常量池"这个词其实指代了三个不同的东西,它们位置不同、时机不同、职责不同。面试里如果只答"常量池在方法区",那基本是要被追问到露馅的。
3. 常量池三兄弟:Class 常量池、运行时常量池、字符串常量池
把地图铺好之后,我们逐个拆解这三个"常量池",搞清楚它们各自是什么、住在哪、什么时候登场。
3.1 Class 常量池:编译期就写死在字节码里的清单
你写一个.java文件,编译成.class文件后,用javap -verbose反编译一下,会看到一大段Constant pool的输出。这就是 Class 常量池,它是 Class 文件结构的一部分,在编译期就已经确定。
它里面主要放两类东西:
- 字面量(Literal):比如文本字符串、声明为 final 的常量值。
- 符号引用(Symbolic Reference):比如类和接口的全限定名、字段的名称和描述符、方法的名称和描述符。
为什么要有符号引用?因为编译的时候,你引用的其他类可能还没加载,编译器没法知道它们真实的内存地址,只能用"名字"先占个位。等到运行期类加载的时候,再把这些符号引用解析成真正的直接引用(内存地址)。
你可以自己动手验证一下。写一个最简单的类:
public class Demo { public static void main(String[] args) { String s = "hello"; int a = 100; } }编译后执行javap -verbose Demo,你会看到常量池里出现了"hello"这个字符串字面量,以及java/lang/String、main、([Ljava/lang/String;)V这些符号引用。这些就是 Class 常量池的内容。
3.2 运行时常量池:类加载后搬到方法区的"活"版本
Class 常量池是静态的、躺在文件里的。当类被 JVM 加载时,Class 常量池的内容会被原封不动地搬进方法区,形成运行时常量池。
但运行时常量池比 Class 常量池多了一个能力:它支持动态添加。最典型的就是String.intern()方法,运行时可以把新的字符串常量加进去。这一点是 Class 常量池做不到的,因为文件已经编译完了,改不了。
注意:运行时常量池是每个类一份的。你加载 100 个类,方法区里就有 100 个运行时常量池。它们彼此独立,各自对应自己的 Class 文件。
3.3 字符串常量池:最特殊、最容易踩坑的一个
字符串常量池(String Pool,也叫 String Table)是三个里面最特殊的一个,原因有三:
第一,它的位置变过。JDK 6 及之前,字符串常量池在方法区(永久代)里;JDK 7 开始,被挪到了堆里;JDK 8 之后方法区变成元空间,字符串常量池依然留在堆里。这个变化直接影响了intern()的行为,后面会细讲。
第二,它的设计目的是复用。字符串是程序里用得最频繁的类型之一,如果每次写"abc"都创建一个新对象,内存会被迅速撑爆。所以 JVM 维护了一个哈希表(StringTable),相同的字面量只存一份,大家共享引用。
第三,它和堆里的字符串对象是两回事。字符串常量池里存的是引用,真正的字符串对象在堆里。这个区分非常关键,很多人搞混就栽在这。
用一个类比:字符串常量池就像图书馆的索引卡片柜,卡片上写着"《Java 编程思想》在 3 楼 A 区"。真正的书(字符串对象)在书架上(堆),卡片柜里只有索引(引用)。你查卡片柜,拿到的是书的位置,而不是书本身。
4. 字符串创建的两条路:字面量 vs new,差别到底在哪
理解了字符串常量池的定位,我们就能解释那个经典问题了:String s1 = "abc"和String s2 = new String("abc")到底有什么区别?为什么s1 == s2是 false?
4.1 字面量方式:先查池子,有就复用
当你写String s1 = "abc";时,JVM 的执行流程是这样的:
- 去字符串常量池里查,有没有内容为
"abc"的字符串。 - 如果有,直接把池子里的引用返回给
s1。 - 如果没有,在堆里创建一个
"abc"对象,把它的引用登记到字符串常量池,然后返回这个引用。
所以字面量方式最多只会创建一个对象(池子里没有时),或者一个都不创建(池子里已有时)。这就是复用的威力。
4.2 new 方式:至少创建一个新对象
当你写String s2 = new String("abc");时,流程复杂一些:
- 先看
"abc"这个字面量在不在字符串常量池里,不在就先创建并登记(这一步跟字面量方式一样)。 - 然后在堆里再 new 一个全新的 String 对象,把
"abc"的内容复制进去。 - 返回这个新对象的引用给
s2。
所以new String("abc")至少创建一个对象,最多创建两个(池子里没有时,池子一个 + 堆里一个)。
4.3 用代码验证,眼见为实
光说没用,跑一遍代码最清楚:
public class StringDemo { public static void main(String[] args) { String s1 = "abc"; String s2 = "abc"; String s3 = new String("abc"); String s4 = new String("abc"); System.out.println(s1 == s2); // true,都指向池子里同一个对象 System.out.println(s1 == s3); // false,s3 是堆里新对象 System.out.println(s3 == s4); // false,两个不同的堆对象 System.out.println(s1.equals(s3)); // true,内容相同 } }运行结果跟我注释里写的一致。s1 == s2为 true,是因为它们都指向字符串常量池里那唯一一份"abc"。s1 == s3为 false,是因为s3指向堆里一个全新的对象,地址不同。
实操心得:判断字符串相等,永远用
equals(),不要用==。==比的是引用地址,equals()比的是内容。这个坑我踩过,你也别踩。
4.4 编译期优化:那些你以为会创建对象其实没创建的情况
有个细节值得单独拎出来说。下面这段代码,s3是几个对象?
String s1 = "a"; String s2 = "b"; String s3 = "a" + "b"; String s4 = s1 + s2;答案是:s3在编译期就被优化成了"ab",直接去常量池找,不会创建新对象。而s4因为涉及变量,编译期无法确定,会在运行期通过StringBuilder拼接后toString(),创建一个新的堆对象。
所以:
System.out.println(s3 == "ab"); // true System.out.println(s4 == "ab"); // false这个差异在面试里被问到的频率极高,很多人只知道"字符串拼接用 StringBuilder",但不知道纯字面量拼接编译器会直接优化,只有涉及变量时才会走 StringBuilder。
5. intern() 方法:字符串常量池的"手动挡"
如果说字面量方式是自动挡,那intern()就是手动挡。它允许你在运行时手动把一个字符串塞进常量池,或者从池子里取引用。这个方法的行为在 JDK 6 和 JDK 7+ 之间发生了变化,是面试的重灾区。
5.1 intern() 到底做了什么
intern()的逻辑可以概括为:
- 如果字符串常量池里已经有一个内容相等的字符串,直接返回池子里那个的引用。
- 如果没有,把当前字符串的引用登记到池子里,然后返回这个引用。
注意 JDK 6 和 JDK 7+ 的区别就在第二步:
- JDK 6:字符串常量池在永久代。如果池子里没有,会把当前字符串复制一份到永久代,返回永久代里那个副本的引用。
- JDK 7+:字符串常量池在堆里。如果池子里没有,直接把当前堆对象的引用登记到池子里,返回的就是这个堆对象的引用,不再复制。
这个区别导致同一段代码在两个版本上结果不同,我们来看经典例子。
5.2 经典面试题:这段代码在 JDK 6 和 JDK 7+ 结果不同
public class InternDemo { public static void main(String[] args) { String s = new String("1"); s.intern(); String s2 = "1"; System.out.println(s == s2); // JDK 6: false, JDK 7+: false String s3 = new String("1") + new String("1"); s3.intern(); String s4 = "11"; System.out.println(s3 == s4); // JDK 6: false, JDK 7+: true } }第一组s == s2在两个版本都是 false,因为s是new出来的堆对象,s2是池子里的引用,两者地址不同。
第二组就有意思了。s3是通过拼接得到的堆对象,内容为"11"。此时字符串常量池里并没有"11"(因为"11"不是字面量,是拼出来的)。调用s3.intern()后:
- JDK 6:把
"11"复制到永久代,池子里存的是副本。之后s4 = "11"拿到的是副本的引用,跟s3不是同一个,所以 false。 - JDK 7+:直接把
s3这个堆对象的引用登记到池子里。之后s4 = "11"拿到的就是这个引用,跟s3相同,所以 true。
注意:这个差异的根源就是字符串常量池的位置变化。理解了"池子在堆里还是方法区",这个结果就顺理成章了。
5.3 intern() 的实际用途:省内存
intern()不只是面试题,它在实际项目里是有用的。假设你要处理海量日志,里面有很多重复的字符串(比如状态码、地区名、设备型号),如果每个都独立存一份,内存会爆。这时候可以用intern()把它们收敛到常量池,相同的字符串只留一份。
不过要小心:常量池本身也是要占内存的,而且 JDK 7+ 之后它在堆里,受 GC 管理。如果 intern 的字符串太多太杂,反而会拖慢 GC。所以这个技巧适合"重复率高、种类有限"的场景,不适合"每个字符串都独一无二"的场景。
6. 常量池与 GC:谁会被回收,谁不会
聊完创建和复用,我们来说说回收。常量池里的东西是不是永远不回收?答案是否定的,但回收条件比较苛刻。
6.1 字符串常量池的回收
字符串常量池里的字符串,如果没有任何引用指向它,是可以被回收的。但注意,这里的"引用"包括池子本身的登记。也就是说,一个字符串要被回收,得满足:
- 堆里没有任何地方引用它(包括栈上的局部变量、其他对象里的字段等)。
- 它没有被字符串常量池登记,或者虽然登记了但可以被清理。
实际上,字符串常量池的回收依赖于 GC 对 StringTable 的扫描。如果某个字符串已经"无人问津",GC 会把它从池子里移除,释放内存。这也是为什么 JDK 7+ 把池子挪到堆里——堆里的东西才能被 GC 高效管理,永久代的回收效率低得多。
6.2 运行时常量池的回收
运行时常量池属于方法区,它的回收主要针对不再使用的类和常量。类的卸载条件非常严格:该类的所有实例都被回收、加载该类的 ClassLoader 被回收、该类的 Class 对象没有被引用。三个条件同时满足,类才会被卸载,对应的运行时常量池才会被清理。
在实际应用里,类卸载很少发生,因为大部分类都是系统类加载器加载的,生命周期跟 JVM 一样长。所以运行时常量池基本可以认为是"常驻"的。
6.3 一个容易忽略的点:常量池也会 OOM
很多人以为只有堆会 OOM,其实常量池也会。JDK 6 时代,如果疯狂intern()大量字符串,会抛PermGen space的 OOM。JDK 7+ 之后,字符串常量池在堆里,疯狂 intern 会导致堆 OOM,错误信息变成Java heap space。
这个变化也提醒我们:常量池不是无限大的,用intern()优化内存时要有节制,最好配合监控观察 StringTable 的大小。
7. 常见问题与排查技巧实录
理论讲完了,最后来点实战的。下面这些问题是这些年我在项目里、在帮同事排查时反复遇到的,整理成速查表,方便你对号入座。
7.1 常见问题速查表
| 现象 | 可能原因 | 排查思路 | 解决方式 |
|---|---|---|---|
==判断字符串相等时结果不稳定 | 一个来自常量池,一个来自 new 或拼接 | 打印System.identityHashCode()看地址 | 统一用equals() |
| 大量字符串导致内存飙升 | 重复字符串未复用 | 用 MAT 或 JProfiler 看 String 对象数量 | 考虑intern()或改用枚举 |
intern()后内存反而更高 | 字符串种类太多,池子膨胀 | 观察 StringTable 大小和 GC 频率 | 限制 intern 的使用范围 |
| 拼接字符串性能差 | 循环里用+拼接 | 看是否有大量临时 String 对象 | 改用StringBuilder |
| 类加载后常量池异常 | Class 文件损坏或版本不匹配 | javap -verbose检查常量池 | 重新编译,确认 JDK 版本 |
7.2 几个独家避坑技巧
技巧一:判断字符串来源,用identityHashCode。当你怀疑两个字符串是不是同一个对象时,System.out.println(System.identityHashCode(s))比==更直观,因为它打印的是对象的原始哈希(基于地址),能直接看出是不是同一个。
技巧二:循环拼接一定用 StringBuilder。下面这段代码是性能杀手:
String result = ""; for (int i = 0; i < 10000; i++) { result += i; }每次+=都会创建一个新的 String 对象,10000 次循环就是 10000 个临时对象。改成StringBuilder后,只在最后toString()时创建一个对象,性能差距是数量级的。
技巧三:常量池的字符串比较,编译期能确定的尽量用字面量。比如配置里的固定值,直接写成字面量,让编译器帮你复用,比运行时拼接再 intern 更省事。
技巧四:JDK 版本升级时,重点回归 intern 相关逻辑。如果你的代码里用了intern(),从 JDK 6 升到 JDK 8 时一定要重新测试,因为行为变了,原来 false 的判断可能变成 true,逻辑分支可能走错。
7.3 一个真实的排查案例
之前有个同事反馈,说他们的缓存 key 偶尔匹配不上,导致缓存击穿。我帮他看代码,发现 key 是用new String(...)拼出来的,然后跟配置里的字面量 key 用==比较。大部分时候能匹配上,是因为某些路径下 JVM 恰好复用了池子里的对象,但一旦走new的路径就匹配失败。
这个问题隐蔽就隐蔽在它不是每次都错,而是"看运气"。修复方式很简单,把==改成equals(),问题立刻消失。所以我现在写代码,只要涉及字符串比较,条件反射就是equals(),绝不给自己留隐患。
8. 把常量池放回内存模型里再看一遍
写到这里,我想把整篇文章的逻辑再串一下,帮你形成一个完整的认知闭环。
常量池不是一个孤立的概念,它是JVM 内存模型的一个横切面。Class 常量池是编译期的产物,躺在字节码文件里;类加载后,它变成运行时常量池,住进方法区;而字符串常量池作为其中最活跃的部分,在 JDK 7 之后搬进了堆,接受 GC 的管理。栈上的局部变量持有引用,堆里存放真正的对象,方法区保存类的元信息和常量——这三者通过"引用"这条线连在一起。
理解了这条线,你就能解释:为什么==和equals结果不同,为什么intern()在不同 JDK 版本行为不同,为什么字符串拼接有时创建对象有时不创建,为什么常量池也会 OOM。这些问题表面上是"字符串的坑",本质上是"内存布局的坑"。
我个人在实际项目里的体会是,大部分字符串相关的 bug,根源都不是字符串本身,而是对内存模型理解不到位。一旦你把栈、堆、方法区、常量池的关系理顺了,很多看似玄学的问题都会变得理所当然。所以与其死记硬背面试题答案,不如花点时间把这张内存地图真正画进脑子里,后面遇到任何变体都能自己推出来。
最后再分享一个小习惯:每次写完涉及字符串比较、拼接、缓存的代码,我都会下意识问自己一句——"这里比的是地址还是内容?这个对象是在池子里还是堆里?"养成这个习惯之后,我踩的坑明显少了很多。