一、引言
在 Java 学习与面试中,常量池永远是绕不开、又极易混淆的核心知识点。很多开发者哪怕有多年开发经验,依然会被这几个概念绕得晕头转向:
- 日常说的「常量池」到底是一个统称,还是特指某一种?
- 静态常量池、运行时常量池、字符串常量池,三者在 JVM 中分别存放在哪里?
- 它们的加载时机、作用范围、生命周期又有哪些本质区别?
这些概念看似只是「名词差异」,实则是理解 JVM 类加载机制、字符串内存布局的关键。本文将从 JVM 底层视角出发,把静态常量池、运行时常量池、字符串常量池这三大核心概念逐一拆解,讲清它们的定义、关联与区别,让你彻底吃透常量池体系,不再被易混概念困扰。
二、常量池
2.1 定义
常量池有两种,一种是字节码文件中的常量池(静态常量池),一种是内存中的常量池(运行时常量池)
这两个概念本质是同一套常量数据在「磁盘」和「内存」两个不同阶段的形态,名字里的「静态 / 运行时」就是对阶段的标注。
| 存储位置 | 方法区 / 元空间(内存) | .class 字节码文件(磁盘) |
| 生成时机 | 类加载阶段 | 编译阶段 |
| 内容特性 | 可动态扩展、包含直接引用 | 固定不变、仅包含符号引用 + 字面量 |
| 生命周期 | 随类的加载 / 卸载而创建 / 回收 | 随.class 文件永久存在 |
| 核心作用 | 运行期提供常量的内存访问 | 编译期记录常量的静态信息 |
2.2 好处
- 常量集中存储,减少内存冗余:把类中不变的常量数据(字面量、类 / 方法 / 字段信息)集中管理,避免零散存储导致的内存碎片化;
- 提前固化 / 解析常量,提升运行效率:编译期固化静态常量池,运行期解析为直接引用,避免运行时重复解析、计算常量;
- 支撑类加载与执行:是 JVM 加载类、定位方法 / 字段、执行代码的核心数据载体。
三、静态常量池
2.1 定义
静态常量池(也叫 Class 文件常量池)是 Java 源码经javac编译后,固化在.class字节码文件中的二进制常量表,是编译期产物,存储类 / 接口中所有编译期可知的常量数据,包括字符串字面量、基本类型常量、final 常量值等字面量,以及类全限定名、方法名、字段名、方法参数类型等符号引用,它不占用 JVM 内存,仅作为类加载时生成运行时常量池的 “原材料”,且一旦编译完成即只读不可修改。
2.2 储存内容
静态常量池(Class 文件常量池)存储的是编译期就能确定的、类 / 接口相关的所有常量数据
2.2.1 字面量
字面量是程序中直接写死的 “固定值”,编译期就能确定的具体值,是 “数据本身”,是能直接对应到具体数据的常量,也是最容易理解的部分:
2.2.2 符号引用
这是静态常量池最核心、也是和运行时常量池关联最紧密的部分,不是内存地址,只是 “标识性的名字 / 描述符”,编译期无法确定具体值,只能用符号(名字) 指代的引用,是 “指向数据的标识”,JVM 加载类时会把它解析成内存中的直接引用:
2.3 字面量,符号引用与变量之间的关系
- 字符串字面量(如"hello")、基本类型字面量(如10)本身会独立存在于静态常量池(这是 JVM 的规则,和变量无关)
- 只有static final修饰 + 编译期可确定值(直接赋值字面量,而非方法调用 / 计算)的变量,才会把「变量的符号引用」和「常量池里的字面量」永久绑定(编译期就把变量和字面量的关系写死在常量池里,运行期直接用字面量,无需再找变量赋值。);
-
其他变量(无 final / 只有 final 但值需运行期计算):
- 会记录「变量的符号引用」(告诉 JVM 变量的位置 / 类型);
- 但不会把「变量」和「字面量」绑定(因为值可改 / 编译期不确定);
- 变量只是运行期临时引用常量池里的字面量,而非「绑定」。
四、符号引用
4.1 不使用符号引用的后果
如果编译时就把代码里的变量 / 方法 / 类直接替换成内存地址,会遇到 3 个致命问题:
-
跨平台性彻底失效,Java 的口号是 “一次编写,到处运行”,但不同操作系统(Windows/Linux/Mac)、不同 JVM 版本,甚至同一台机器不同次运行,内存布局都完全不同,如果直接写死地址,代码换个环境就直接崩溃 —— 这等于废掉了 Java 的跨平台特性。
-
编译期根本 “不知道” 运行时的地址 ,Java 代码的编译(javac)和运行(java)是完全分离的两个阶段,编译时,JVM 还没启动,类还没加载,变量 / 方法 / 类都还没分配内存,不知道地址。
-
动态加载 / 热更新无法实现,Java 支持 “运行时动态加载类”,如果编译时就写死地址,动态加载的类在编译期根本不存在,地址更是无从谈起,甚至同一个类,不同时间加载(比如热更新),地址也可能变,写死地址就等于锁死了代码。
4.2 编译期未知,运行时绑定
符号引用本质是 “先记名字,运行时再查地址”,完美解决了直接写地址的所有问题:
编译期只记 “名字”,不依赖地址
- 编译时,JVM 只把 “名字” 存入常量池(符号引用),完全不关心它们未来的地址 —— 不管代码跑在哪个平台,“名字” 都是不变的。
运行时 “解析” 成实际地址
- 当代码运行到需要使用这些符号时(比如new User()),JVM 会做两件事:
- 先检查User类是否已加载,没加载就先加载;
- 把符号引用(User)解析成该类在当前 JVM 中的实际内存地址;
- 后续再用到User时,会缓存解析后的地址,避免重复解析(保证性能)。
支持动态链接和多态
- Java 的多态(比如父类引用指向子类对象)也依赖符号引用:
- 如果直接写死地址,编译时就会绑定父类的地址,运行时无法动态切换到子类—— 多态特性直接失效。
4.3 好处
五、类的加载过程
5.1 加载
JVM 通过类加载器(ClassLoader)完成 3 件事:
常量池的变化
- 静态常量池:无变化(仍存在于 .class 文件中,是编译期生成的,不随 JVM 运行而改变。)
- 运行时常量池:新增该类的所有常量信息,但此时全部是「符号引用」(而非实际内存地址)。
5.2 验证
验证阶段是 JVM 的「安全关卡」,确保加载的字节码符合 JVM 规范,不会导致安全问题。其中针对常量池的校验包括:
常量池的变化
- 运行时常量池内容和结构无任何变化,仅做合法性检查,不修改任何常量信息。
5.3 准备
为类的静态变量(包括静态常量)分配内存(存于方法区 / 元空间),并设置「默认初始值」(零值):
- 基本类型(int/long/boolean 等):默认值为 0/0L/false;
- 引用类型(String/Object 等):默认值为 null;
- final 静态常量:分两种情况处理。
常量池的变化
这是常量池首次出现「值层面」的变化,核心分两类常量:
编译期确定的常量(编译期常量)
- 定义:static final 修饰,且值在编译期就能确定(如 static final int NUM = 100;、static final String MSG = "hello";);
- 变化:准备阶段直接将「字面量值」存入运行时常量池,而非赋默认值。例如 NUM=100 会直接写入常量池,跳过「默认值 0」的步骤。
运行期确定的常量(运行时常量)
- 定义:static final 修饰,但值需运行期计算(如 static final int RAND = new Random().nextInt(););
- 变化:准备阶段仅为其分配内存,赋默认值(如 RAND=0),值不会写入运行时常量池,需等到初始化阶段才赋值。
5.4 解析
JVM 将运行时常量池中的「符号引用」替换为「直接引用」(即内存地址),包括:
- 延迟解析:解析不是必须在准备阶段后立即执行,JVM 可「懒解析」—— 直到首次使用该符号引用时才解析(如首次调用 Demo.main() 时才解析 main 方法的引用);
- 异常触发:若解析时发现引用的类 / 方法 / 字段不存在(如引用了一个未加载的类),会抛出 NoClassDefFoundError/NoSuchMethodError 等异常。
常量池的变化
- 运行时常量池中的「符号引用」被替换为「直接引用」,从「字符串标识」变为「内存地址」;
六、运行时常量池
6.1 定义
运行时常量池是类加载后,静态常量池在内存中的「运行时版本」 —— 简单说,它是 JVM 把 .class 文件里的「静态常量池」加载到内存后,形成的一个可动态扩展、存储常量信息的内存区域。
6.2 存储内容
| 静态常量池的副本 | 从 .class 复制过来的字面量、符号引用(类加载阶段) | "hello" 字面量、Test.NUM 符号引用 |
| 解析后的直接引用 | 符号引用替换成的内存地址(解析阶段) | Test.NUM 符号引用 → 0x123456 内存地址 |
| 动态生成的常量 | 运行期新增的常量(使用阶段) | 通过 String.intern() 新增的字符串常量 |
6.3 替换为直接引用的问题
跨平台性问题
- 直接引用是当前 JVM 运行时计算出来的内存地址,换环境后 JVM 会重新解析,生成新的适配当前环境的地址。→ 跨平台性完全保留。
编译期不知道地址的问题
- 直接引用是类加载 / 解析阶段才生成的,此时类已经加载、内存已经分配,地址是确定的。→ 完美解决「编译期未知地址」的问题。
动态加载 / 热更新问题
- 动态加载类时,JVM 会在使用时才解析符号引用,生成新类的地址;
- 热更新后类重新加载,JVM 会重新解析符号引用,绑定新的内存地址;
- 多态场景下,符号引用会在运行时根据实际对象类型解析为子类地址。→ 动态加载、热更新、多态都能正常工作。
七、字符串常量池
7.1 定义
字符串常量池是 JVM 为优化字符串使用设计的特殊内存区域,核心目的是减少字符串对象的重复创建、节省内存、提高访问效率。是 JVM 中专门存储字符串字面量的引用(而非字符串对象本身,JDK 7 后)的内存区域,本质是一个「哈希表(HashTable)」—— 它会缓存所有使用过的字符串字面量,保证同一个字符串字面量在 JVM 中只有一个唯一的实例。
7.2 位置
| JDK 6 及之前 | 方法区(永久代) | 方法区(永久代) | 两者都属于方法区的一部分,字符串常量池是运行时常量池的子集 |
| JDK 7 | 堆(Heap) | 方法区(永久代) | 永久代内存有限,字符串常量池易触发 OOM,因此将其移至堆中以利用更大内存;运行时常量池仍留在永久代 |
| JDK 8 及之后 | 堆(Heap) | 元空间(Metaspace,原方法区逻辑) | 永久代被元空间替代,运行时常量池随方法区逻辑移入元空间;字符串常量池继续留在堆中 |
八、创建字符串
8.1 直接使用字面量创建(String s = "hello")
String s1 = "hello";
String s2 = "hello";
System.out.println(s1 == s2); // true
- JVM 先检查字符串常量池:是否有 "hello" 的引用?
- 若没有:在堆中创建 "hello" 对象,把对象引用存入字符串常量池;
- 若有:直接返回常量池中的引用;
8.2 new 关键字创建(String s = new String("hello"))
new String("xxx") 每次执行,都会在堆中创建一个全新的字符串对象 —— 不管执行多少次,堆里就会有多少个独立的对象;✅ 但 new 的过程中,JVM 会先去字符串常量池检查 "xxx" 这个字面量:
- 如果常量池里没有 "xxx" 的引用 → 先在堆创建 "xxx" 对象,把引用存入常量池,再创建 new 对应的新对象(总共 2 个对象);
- 如果常量池里已有 "xxx" 的引用 → 直接创建 new 对应的新对象。最终:new 出来的这些对象永远不会自动进入字符串常量池,只有 "xxx" 这个字面量会在解析阶段入池(且只入一次)。
8.3 intern() 方法手动入池
String s1 = new String("hello");
String s2 = s1.intern();
String s3 = "hello";
System.out.println(s2 == s3); // true
System.out.println(s1 == s3); // false
intern() 是 String 类的核心方法,作用是将字符串对象的引用手动存入字符串常量池
- s1 指向堆中新建的 "hello" 对象(非常量池引用);
- s1.intern():检查常量池,若已有 "hello" 引用,直接返回该引用;若没有,把 s1 的引用存入常量池并返回常量池的引用;
- s2 指向常量池中的引用,s3 也指向常量池引用 → s2 == s3 为 true。
8.4 运行期字符串拼接(a + b 形式)
String a = "1";
String b = "2";
String c = "12";
System.out.println(a + b == c); // false
- 编译期行为:
- a、b 是普通变量,编译器不会将 a + b 优化为字面量 "12";
- c = "12" 是字面量,直接存入静态常量池,运行时指向字符串常量池的 "12" 引用。
- a + b 会被编译为 new StringBuilder().append(a).append(b).toString(),在堆中创建一个新的字符串对象,内容为 "12";
- 这个新对象不会自动进入字符串常量池;
8.5 编译期常量拼接
public class test {
static final String a = "1";
static final String b = "2";
public static void main(String[] args) {
String c = "12";
System.out.println(a+b == c);//true
}
}
编译器优化为字面量 → 直接指向常量池
8.6 普通字符串(非变量)拼接
纯字面量拼接会被编译器直接优化为完整字面量,和直接写 "xxx" 完全一样,会自动入字符串常量池。
8.9 类加载过程
所有字符串字面量(如 "hello"),无论变量是否有默认值、是否被 static/final 修饰、是编译期 / 运行期常量,都会且只会在「解析阶段」被加载(创建对象 + 存入引用)到字符串常量池;
但有两个例外 :


