字符串常量池(string constant pool)
字符串常量池是 java 中用于优化字符串内存使用的核心机制,属于 jvm 运行时数据区的重要组成部分。其本质是一个字符串驻留池(string intern pool),核心目标是复用相同内容的字符串对象,减少内存开销,提升性能。下面从底层原理、jvm 存储位置、创建机制、地址比较、intern 方法、常见问题等维度,全面解析字符串常量池的所有内容。
一、字符串常量池的底层本质与设计初衷
1. 设计初衷:解决字符串的内存浪费问题
字符串是 java 中最常用的对象之一,若每次创建相同内容的字符串都新建对象,会导致大量重复对象占用内存。例如:
// 若没有常量池,会创建1000个内容为"hello"的字符串对象
for (int i = 0; i < 1000; i++) {
string s = "hello";
}
字符串常量池通过 **“驻留(intern)” 机制 **,让相同内容的字符串仅保留一份副本,所有指向该内容的字符串引用都指向这个副本,从而节省内存。
2. 本质:一个哈希表(hashtable)
字符串常量池的底层实现是一个固定大小的哈希表(jdk1.7+),默认容量为 1009(可通过 jvm 参数-xx:stringtablesize调整,jdk1.8 后默认是 60013)。
- 哈希表的key:字符串的内容(字符序列)。
- 哈希表的value:字符串对象的引用(jdk1.7+)或字符串对象本身(jdk1.6 及以前)。
- 哈希表的作用:快速查找是否存在相同内容的字符串,时间复杂度接近 o (1)。
二、字符串常量池的存储位置(jdk 版本差异)
字符串常量池的存储位置在不同 jdk 版本中存在重大变化,这是理解其机制的关键:
| jdk 版本 | 存储位置 | 存储内容 | 关键说明 |
|---|---|---|---|
| jdk1.6 及以前 | 方法区(永久代,permgen) | 字符串对象本身(字符数组 + 属性) | 永久代内存空间有限,容易出现outofmemoryerror: permgen space。 |
| jdk1.7+ | 堆内存(heap) | 字符串对象的引用(指向堆中的字符串对象) | ① 常量池从永久代移到堆,解决永久代内存限制;② 存储的是引用,而非对象本身,更节省内存。 |
| jdk1.8+ | 堆内存(heap) | 字符串对象的引用 | jdk1.8 移除永久代,方法区改为元空间(metaspace),常量池仍在堆中。 |
关键差异示例(jdk1.6 vs jdk1.7+)
string s = new string("abc");
// jdk1.6:常量池在永久代,存储"abc"对象;堆中存储另一个"abc"对象,s指向堆对象。
// jdk1.7+:常量池在堆中,存储堆中"abc"对象的引用;堆中有一个"abc"对象(字面量),s指向堆中新建的另一个"abc"对象。
三、字符串的创建方式与常量池的交互
字符串的创建方式直接决定了其与常量池的交互逻辑,也是判断 “地址是否相同” 的核心依据。
场景 1:直接使用字符串字面量创建(string s = "abc")
这是最常见的方式,完全依赖常量池的驻留机制。
执行流程(jdk1.7+):
- jvm 首先检查字符串常量池的哈希表,是否存在键为 “abc” 的条目。
- 若不存在:
- 在堆内存中创建一个字符串对象(内容为 “abc”)。
- 将该对象的引用存入字符串常量池的哈希表中(key=“abc”,value = 对象引用)。
- 将引用变量
s指向堆中的这个字符串对象。
- 若存在:
- 直接从常量池的哈希表中取出 “abc” 对应的对象引用。
- 将引用变量
s指向该引用对应的堆对象。
代码示例:
string s1 = "abc"; string s2 = "abc"; system.out.println(s1 == s2); // true(指向同一个堆对象) system.out.println(s1.equals(s2)); // true(内容相同)
场景 2:使用new string("内容")创建
这种方式会创建至少一个、最多两个字符串对象,是最容易产生内存浪费的方式。
执行流程(jdk1.7+):
- 处理字符串字面量
"abc":jvm 检查常量池,若没有则在堆中创建 “abc” 对象,并将引用存入常量池(这是第一个对象)。 - 执行
new string("abc"):在堆内存中新建一个独立的字符串对象(这是第二个对象),该对象的字符数组与常量池中的 “abc” 对象共享(jdk1.7 + 优化,字符数组存储在堆中,复用字符数组以节省内存)。 - 引用变量
s指向第二步新建的堆对象。
代码示例:
string s1 = "abc"; // 常量池存引用,s1指向堆中的"abc"对象
string s2 = new string("abc"); // 堆中新建对象,s2指向该对象
string s3 = new string("abc"); // 堆中再新建对象,s3指向该对象
system.out.println(s1 == s2); // false(指向不同堆对象)
system.out.println(s2 == s3); // false(指向不同堆对象)
system.out.println(s1.equals(s2)); // true(内容相同)
system.out.println(s2.intern() == s1); // true(intern()返回常量池引用)关键:new string("abc")创建的对象数量
- 最少 1 个:若常量池中已存在 “abc” 的引用,则仅在堆中创建一个新对象。
- 最多 2 个:若常量池中不存在 “abc” 的引用,则先在堆中创建 “abc” 对象(供常量池引用),再在堆中创建一个新对象(供
s引用)。
场景 3:字符串拼接(编译期常量拼接 vs 运行期拼接)
字符串拼接的结果是否指向常量池对象,取决于拼接的是常量还是变量。
子场景 3.1:编译期常量拼接(字面量 /final常量拼接)
特征:拼接的所有部分都是编译期就能确定值的常量(字符串字面量、final字符串常量、基本类型常量)。原理:java 编译器会对这种拼接进行编译期优化,直接将拼接结果作为一个字符串字面量存入常量池,运行时不再执行拼接操作。
代码示例:
// 示例1:字面量拼接
string s1 = "abc";
string s2 = "ab" + "c"; // 编译期优化为"abc"
string s3 = "a" + "b" + "c"; // 编译期优化为"abc"
system.out.println(s1 == s2); // true
system.out.println(s1 == s3); // true
// 示例2:final常量拼接
final string str1 = "ab"; // final常量,编译期确定值
final int num = 123; // 基本类型常量
string s4 = str1 + "c"; // 编译期优化为"abc"
string s5 = "num" + num; // 编译期优化为"num123"
system.out.println(s1 == s4); // true
system.out.println("num123" == s5); // true子场景 3.2:运行期拼接(变量拼接)
特征:拼接的部分包含运行期才能确定值的变量(非final字符串变量、对象引用等)。原理:这种拼接会在运行期通过stringbuilder的append()方法拼接,再调用tostring()方法生成新的字符串对象,该对象是堆中的新对象,与常量池无关。
代码示例:
string s1 = "abc";
string s2 = "ab";
string s3 = s2 + "c"; // 运行期拼接,底层用stringbuilder实现
string s4 = new string("ab") + new string("c"); // 运行期拼接,堆新对象
system.out.println(s1 == s3); // false(s3指向堆新对象)
system.out.println(s1 == s4); // false(s4指向堆新对象)
system.out.println(s3 == s4); // false(两个不同的堆对象)场景 4:使用string.intern()方法(手动驻留字符串)
intern()是 string 类的 native 方法(底层由 c/c++ 实现),作用是将字符串对象的内容驻留到常量池,并返回常量池中的引用。这是手动控制字符串常量池的核心方法。
// 案例1:new string() + intern()
string s1 = "abc";
string s2 = new string("abc").intern();
system.out.println(s1 == s2); // true(s2指向常量池中的对象)
// 案例2:运行期拼接 + intern()
string s3 = "ab";
string s4 = s3 + "c"; // 堆中新对象
string s5 = s4.intern(); // 将s4的内容"abc"加入常量池(已存在,返回引用)
system.out.println(s1 == s5); // true
system.out.println(s1 == s4); // false(s4还是堆中的对象)
// 案例3:new string() + intern()
string s1 = "abc";
string s2 = new string("abc").intern();
system.out.println(s1 == s2); // true(s2指向常量池中的对象)
// 案例4:运行期拼接 + intern()
string s3 = "ab";
string s4 = (s3 + "c").intern(); // 修正:补充intern()的括号
system.out.println(s4 == s1); // 结果:true
}
}原理:
- 调用
intern()时,jvm 检查常量池:- 若常量池中已有该内容的字符串,直接返回常量池中的引用。
- 若没有,将当前字符串的内容加入常量池(jdk1.7 + 是将堆中对象的引用存入常量池,jdk1.6 是复制对象到常量池),并返回常量池中的引用。结论:
intern()可以让运行期创建的字符串指向常量池的地址,实现 “内容相同则地址相同”。
执行流程(jdk1.7+):
- 调用
s.intern()时,jvm 检查字符串常量池的哈希表:- 若存在键为
s内容的条目:直接返回该条目对应的引用。 - 若不存在:将
s的引用存入常量池的哈希表(key=s 的内容,value=s 的引用),并返回s的引用。
- 若存在键为
- jdk1.6 与 jdk1.7 + 的关键差异:
- jdk1.6:若常量池中不存在该字符串,会复制一份字符串对象到永久代的常量池,并返回永久代中对象的引用。
- jdk1.7+:若常量池中不存在该字符串,会将堆中对象的引用存入常量池,而非复制对象,更节省内存。
代码示例(jdk1.7+):
// 示例1:new string() + intern()
string s1 = new string("abc"); // 堆对象1,常量池存"abc"的引用(指向堆中字面量对象)
string s2 = s1.intern(); // 常量池已有"abc"引用,返回该引用
system.out.println(s1 == s2); // false(s1指向堆对象1,s2指向常量池引用的对象)
// 示例2:运行期拼接 + intern()
string s3 = new string("ab") + new string("c"); // 堆对象2,内容为"abc",常量池无该引用
string s4 = s3.intern(); // 将s3的引用存入常量池,返回s3的引用
system.out.println(s3 == s4); // true(s4指向s3的堆对象2)
system.out.println(s4 == "abc"); // true("abc"指向常量池中的s3引用)
// 示例3:字面量 + intern()
string s5 = "hello";
string s6 = s5.intern();
system.out.println(s5 == s6); // true(常量池已有引用,直接返回)应用场景:海量字符串去重
当处理大量重复字符串(如日志、数据库数据)时,调用intern()方法可将重复字符串的引用指向常量池,大幅减少内存占用。例如:
// 假设有100万个内容为"user_123"的字符串
string s = new string("user_123").intern();
// 后续所有"user_123"的字符串都调用intern(),则仅占用一份内存
四、字符串常量池的地址比较:核心规则
java 中判断地址是否相同用==(比较引用),判断内容是否相同用equals()(比较字符序列)。结合常量池的机制,地址比较的核心规则如下:
| 字符串创建方式 | 内容相同时,地址是否相同(==) | 底层原因 |
|---|---|---|
字符串字面量("abc") | 是 | 常量池自动复用相同内容的引用。 |
new string("abc") | 否 | 每次new都创建堆新对象,与常量池引用无关。 |
编译期常量拼接("ab"+"c") | 是 | 编译器优化为字面量,常量池复用引用。 |
运行期变量拼接(s2+"c") | 否 | 底层stringbuilder生成堆新对象。 |
new string("abc").intern() | 是 | intern()返回常量池中的引用,与字面量引用相同。 |
运行期拼接 + intern() | 是(jdk1.7+) | intern()将堆对象引用存入常量池,后续字面量指向该引用。 |
五、字符串常量池的常见问题与误区
误区 1:“字符串常量池存储的是字符串对象”
纠正:jdk1.7 + 常量池存储的是字符串对象的引用,而非对象本身;jdk1.6 及以前存储的是对象本身。
- 原因:jdk1.7 + 将常量池移到堆中,存储引用可避免复制对象,节省内存。
误区 2:“string s = new string("abc")创建了一个对象”
纠正:最多创建两个对象(常量池的字面量对象 + 堆的新对象),最少创建一个对象(常量池已有字面量,仅堆新对象)。
误区 3:“intern()方法一定能节省内存”
纠正:intern()的哈希表是固定大小的,若大量调用intern()存储不同内容的字符串,会导致哈希表冲突,性能下降(从 o (1) 变为 o (n))。因此:
- 适合大量重复字符串的场景(如去重)。
- 不适合大量唯一字符串的场景(如 uuid),会浪费常量池空间,降低性能。
误区 4:“字符串是不可变的,所以常量池中的字符串也不能变”
纠正:字符串的不可变是指字符数组被final修饰,且没有提供修改字符的方法(如setcharat()),但通过反射可以修改字符数组的内容(不推荐)。例如:
string s = "abc";
// 通过反射修改字符数组
field valuefield = string.class.getdeclaredfield("value");
valuefield.setaccessible(true);
char[] value = (char[]) valuefield.get(s);
value[0] = 'x';
system.out.println(s); // 输出:xbc
system.out.println("abc"); // 输出:xbc(常量池中的引用指向同一个对象,因此也被修改)
注意:这种操作会破坏常量池的驻留机制,导致所有指向该字符串的引用都被修改,属于高危操作,禁止在生产环境使用。
误区 5:“空字符串""也在常量池中”
纠正:空字符串""是一个合法的字符串字面量,会被驻留到常量池中。例如:
string s1 = ""; string s2 = ""; system.out.println(s1 == s2); // true(常量池复用引用)
六、字符串常量池的 jvm 参数调优
1. 调整字符串常量池的哈希表大小
jvm 参数:-xx:stringtablesize=<size>
- jdk1.6:默认大小 1009,最大可设为 65535。
- jdk1.7+:默认大小 60013(jdk1.8),可设为更大的质数(如 1000003),减少哈希冲突。
- 适用场景:当系统中存在大量
intern()调用时,增大哈希表大小可提升查找性能。
2. 开启字符串去重(jdk1.8u20+)
jvm 参数:-xx:+usestringdeduplication
- 作用:在 g1 垃圾收集器中,自动检测堆中重复的字符串对象,让它们共享同一个字符数组,进一步节省内存。
- 区别:与常量池的驻留机制不同,该参数是在堆中对字符串对象的字符数组去重,而常量池是对引用的驻留。
七、总结
字符串常量池是 java 优化字符串内存的核心机制,其关键要点可归纳为:
- 存储位置:jdk1.6 在永久代,jdk1.7 + 在堆中。
- 底层实现:哈希表,存储字符串内容与引用的映射(jdk1.7+)。
- 核心机制:驻留(intern),相同内容的字符串仅保留一份引用。
- 创建方式:字面量创建复用常量池,
new string()创建新对象,拼接分为编译期和运行期。 - intern 方法:手动将字符串驻留到常量池,jdk1.7 + 存储引用,jdk1.6 存储对象。
- 地址比较:
==比较引用,equals()比较内容,常量池中的字符串引用相同。
理解字符串常量池的机制,不仅能避免内存浪费,还能在面试和生产环境中解决字符串相关的性能问题。
到此这篇关于java字符串常量池(string constant pool)原理、实现全解析的文章就介绍到这了,更多相关java字符串常量池内容请搜索代码网以前的文章或继续浏览下面的相关文章希望大家以后多多支持代码网!
发表评论