超过1W字深度剖析JVM常量池(全网最详细最有深度) - 跟着Mic学架构 - 博客园
问题
jdk1.8之后
- 元空间是独立存在的?还是位于方法区?
- 字符串常量池在堆中还是独立存在?
在 JDK 1.8 及之后版本:
- 元空间与方法区关系:元空间可以理解为是方法区的一种实现 。JDK 1.8 移除了永久代,引入元空间来替代。元空间使用本地内存(Native Memory ),不再像永久代那样在 JVM 堆内存中划分一块固定区域。此时方法区的功能由元空间来承担,运行时常量池等原本在方法区的部分也存在于元空间中,但元空间和传统意义上方法区概念并非完全等同,只是功能上有继承关系。 所以说元空间是方法区新的实现形式,它不是独立于方法区概念存在,而是改变了方法区的实现方式。
- 字符串常量池位置:字符串常量池在堆中。JDK 1.7 时,字符串常量池从方法区(永久代 )迁移到堆中,JDK 1.8 延续了这一做法。在堆中的字符串常量池,用于存储字符串字面量以及通过
String.intern()
方法添加进去的字符串 。其物理位置位于堆中,逻辑归属于方法区,从 Java 虚拟机规范角度,它是方法区相关概念的一部分,承担着存储字符串字面量等常量的功能,和方法区其他组成部分(如运行时常量池等)有紧密逻辑关联。
JVM运行时数据区
从类加载,到JVM运行时数据区的整体结构画出来,如下图所示(元空间位于本地内存)。Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域。这些区域都有各自的用途。
JVM运行时数据区在物理内存上并不是单一的连续内存块,而是由多个不同物理来源的内存区域组合而成的逻辑概念。
JVM中的常量池
JVM中的常量池可以分成以下几类:
- Class文件常量池/静态常量池
- (全局)字符串常量池
- 运行时常量池
Class文件常量池
程序编译后可以通过javap -c 类名.class
查看该类的字节码文件。或者使用jclasslib插件。
位置:存在于编译后的.class文件中,是.class文件的一部分。
- 在编译阶段,静态常量池在.class文件中,此时还未加载到JVM内存,纯属磁盘上的文件内容。
- 当类被JVM加载时,静态常量池的内容会被解析并存入运行时常量池(Runtime Constant Pool)。
作用:存储编译期生成的各种字面量和符号引用。
字面量:指的是在源代码中直接给出的数据值,在源代码中直接写出的数值,字符串,布尔等。比如文本字符串(String a = “Hello World”);声明为final的常量值(private int value = 1);基本数据类型的值。
符号引用
类和接口的全限定名。也就是
Ljava/lang/String;
,主要用于在运行时解析得到类的直接引用。#23 = Utf8 ([Ljava/lang/String;)V #25 = Utf8 [Ljava/lang/String; #27 = Utf8 Ljava/lang/String;
字段的名称和描述符。字段也就是类或者接口中声明的变量,包括类级别变量(static)和实例级的变量。
方法的名称和描述符。方法的描述类似于JNI动态注册时的“方法签名”,也就是参数类型+返回值类型,比如下面的这种字节码,表示
main
方法和String
返回类型。#19 = Utf8 main #20 = Utf8 ([Ljava/lang/String;)V
运行时常量池
运行时常量池就是每一个类或接口的常量池(constant pool)在运行时的表现形式。
一个类在加载的过程,会经历加载
、连接(验证、准备、解析)
、初始化
,而在类加载这个阶段,需要做以下几件事情:
- 通过一个类的全类限定名获取此类的二进制字节流。
- 在堆内存生成一个
java.lang.Class
对象,代表加载这个类,做为这个类的入口。 - 将
class
字节流的静态存储结构转化成方法区(元空间)的运行时数据结构。
而第三点就包含了class文件常量池进入运行时常量池的过程。
所以,运行时常量池的作用是存储class
文件常量池中的符号信息,在类的解析阶段会把这些符号引用转换成直接引用(实例对象的内存地址),翻译出来的直接引用也是存储在运行时常量池中。class
文件常量池的大部分数据会被加载到运行时常量池。
虽然方法区(或元空间)是全局共享的内存区域,但每个类都有自己独立的运行时常量池。
运行时常量池是 动态的符号引用解析中心,其核心行为可归纳为:
- 存储:承载 Class 文件常量池的原始符号信息
- 转换:在解析阶段将符号引用替换为直接引用
- 扩展:支持运行时动态添加新常量
- 协同:与字符串常量池、方法区元数据紧密交互
字符串常量池
位置
虽然从物理位置上看,它在堆空间内,但它是一个逻辑上独立的区域。
存在意义
JVM之所以单独设计字符串常量池,是JVM为了提高性能以及减少内存开销的一些优化:
- String对象作为
Java
语言中重要的数据类型,是内存中占据空间最大的一个对象。高效地使用字符串,可以提升系统的整体性能。 - 创建字符串常量时,首先检查字符串常量池是否存在该字符串,如果有,则直接返回该引用实例,不存在,则实例化该字符串放入常量池中。
举例
String a="Hello";
String b=new String("Mic");
a
这个变量,是在编译期间就已经确定的,会进入到字符串常量池。b
这个变量,是通过new
关键字实例化,new
是创建一个对象实例并初始化该实例,因此这个字符串对象是在运行时才能确定的,创建的实例在堆空间上。
创建了几个对象
String s = new String("二哥");
使用 new 关键字创建一个字符串对象时,Java 虚拟机会先在字符串常量池中查找有没有‘二哥’这个字符串对象,如果有,就不会在字符串常量池中创建‘二哥’这个对象了,直接在堆中创建一个‘二哥’的字符串对象,然后将堆中这个‘二哥’的对象地址返回赋值给变量 s。
如果没有,先在字符串常量池中创建一个‘二哥’的字符串对象,然后再在堆中创建一个‘二哥’的字符串对象,然后将堆中这个‘二哥’的字符串对象地址返回赋值给变量 s。
对于这行代码 String s = new String("二哥");
,它创建了两个对象:一个是字符串对象 “二哥”,它被添加到了字符串常量池中,另一个是通过 new String()
构造方法创建的字符串对象 “二哥”,它被分配在堆内存中,同时引用变量 s 存储在栈上,它指向堆内存中的字符串对象 “二哥”。
String的定义
public final class String
implements java.io.Serializable, Comparable<String>, CharSequence {
/** The value is used for character storage. */
private final char value[];
/** Cache the hash code for the string */
private int hash; // Default to 0
}
从上述源码中可以发现。
- String这个类是被
final
修饰的,代表该类无法被继承。 - String这个类的成员属性
value[]
也是被final
修饰,代表该成员属性不可被修改。
因此String
具有不可变的特性,也就是说String
一旦被创建,就无法更改。这么设计的好处有几个。
- 方便实现字符串常量池: 在Java中,由于会大量的使用String常量,如果每一次声明一个String都创建一个String对象,那将会造成极大的空间资源的浪费。Java提出了String pool的概念,在堆中开辟一块存储空间String pool,当初始化一个String变量时,如果该字符串已经存在了,就不会去创建一个新的字符串变量,而是会返回已经存在了的字符串的引用。如果字符串是可变的,某一个字符串变量改变了其值,那么其指向的变量的值也会改变,String pool将不能够实现!
- 线程安全性,在并发场景下,多个线程同时读一个资源,是安全的,不会引发竞争,但对资源进行写操作时是不安全的,不可变对象不能被写,所以保证了多线程的安全。
- 保证 hash 属性值不会频繁变更。确保了唯一性,使得类似
HashMap
容器才能实现相应的key-value
缓存功能,于是在创建对象时其hashcode就可以放心的缓存了,不需要重新计算。这也就是Map喜欢将String作为Key的原因,处理速度要快过其它的键对象。所以HashMap中的键往往都使用String。
intern()
- JDK文档中关于
intern()
方法的说明:当调用intern
方法时,如果常量池(内置在 JVM 中的)中已经包含相同的字符串,则返回池中的字符串。否则,将此String
对象添加到池中,并返回对该String
对象的引用。‘ - 注意,所有字符串字面量在初始化时,会默认调用
intern()
方法。 intern()
方法的核心逻辑- 检查常量池:JVM 会通过内容匹配(而非引用相等)查找常量池中是否已有该字符串。
- 处理结果
- 若存在:返回常量池中的引用(可能指向堆中的对象)。
- 若不存在:JDK 7 及以后:直接在常量池中记录堆中对象的引用,而非复制内容(节省内存),并返回该引用。
问题
public static void main(String[] args) {
String a = new String(new char[]{'a', 'b', 'c'}); // 堆中创建新对象,常量池未变化
String b = a.intern(); // 将a的内容放入常量池(如果不存在),并返回池中的引用
System.out.println(a == b); // true:此时常量池中的引用就是a本身
}
1. 为什么 new String(char[])
不会自动调用 intern()
?
当使用 new String(char[])
构造函数时,JVM 会创建一个全新的String
对象,但不会自动将其放入常量池。这是因为:
- 常量池的设计初衷:常量池主要用于存储编译期已知的字符串字面量(如
"abc"
)或显式调用intern()
的字符串。 - 性能考量:如果每次通过字符数组创建字符串都自动入池,会导致常量池膨胀,影响性能和内存占用。
- 语义一致性:
new
关键字的语义是 “强制创建新对象”,即使常量池中已有相同内容的字符串,new String(char[])
也会创建独立的对象。
3. 总结:何时字符串会进入常量池?
- 编译期已知的字面量(如
"abc"
)会在类加载时自动进入常量池。 - 运行时动态创建的字符串(如通过字符数组、
substring()
等方法)不会自动入池,除非显式调用intern()
。