-
String------字符串,使用
""进行基本表示。- 注意:字面量方式会检查字符串常量池,若存在则直接返回引用;new方式则是强制在堆内存当中开辟新空间(JDK7之后常量池在堆当中),即使内容相同也会生成新对象(除非调用String.intern)
-
String被声明为final类,不可被继承
-
public final class String:意味着String不能有子类 -
设计目的:
-
保障不可变性:防止子类修改字符串内部状态,确保字符串内容一旦创建就不可修改
-
安全性:String广泛用于网络参数、数据库连接、类加载器等关键场景,不可继承防止恶意篡改
-
缓存哈希值:内容不变,String可以安全缓存
hashCode,非常适合作为hashmap的key
-
-
-
String实现了关键接口
-
Serializable:表示字符串支持序列化,可以通过网络传输/持久化到文件/数据库
-
Comparable<String>:String定义了自然排序规则。比较方式:按照字典序逐个字符进行比较,用于String之间排序操作。
-
-
内部存储结构改变(JDK8 VS JDK9+)
-
JDK8&之前
-
内部定义为
private final char[] value; -
特点:使用UTF-16编码,每个字符固定占用2字节,即使存储纯英文(ASCII)也会浪费50%空间
-
-
JDK9&之后(引入Compact Strings)
-
内部定义为:
private final byte[] value;,并新增private final byte coder;字段 -
特点:根据字符串内容进行动态编码:
-
若仅包含
Latin-1字符(英文、西欧符号、数字等),coder标记为LATIN1,每个字符占用1个字节. -
包含非
Latin-1字符(例如中文等),coder标记为UTF16,每个字符占用2个字节。
-
-
优势:大幅减少内存占用(纯英文减少50%),降低GC压力并提升缓存命中率
-
-
String.intern()
作用
String.intern()是一个本地方法,主要作用:将字符串对象放入/取自字符串常量池当中。
具体逻辑
检查字符串常量池当中是否存在:JVM会检查字符串常量池当中是否已经存在一个与当前字符串内容相等(通过 equal())的字符串
若存在,返回池中已经有的字符串引用
不存在:
当前字符串添加到字符串常量池当中
返回这个新加入池中的引用
核心目的:确保在堆内存当中,内容相同的字符串只能保留一份实例,从而节省内存空间。
为什么JDK9改变了String结构
为什么JDK9要改用byte[]
早期Java应用当中,大量字符串是纯ASCII码(日志、json、url等)。使用char[]并强制每个字符占用两个字节及其浪费。JDK9的Compact Strings通过增加一个判断位coder,让大多数字符内存占用减少一半,显著提升整体性能。
String基本特性
-
String:代表不可变的字符序列。(不可变性)
-
当对字符串进行重新赋值时:
JVM会在内存(通常是字符串常量池当中)查找/创建一个新的字符串对象,并将变量的引用指向这个新的对象。原有的String对象不受影响,且原对象内部value数组未被修改。
- 当对现有字符串进行连接操作时:
系统创建一个新的String对象存储连接后结果,原字符串依然保持原样。
- 当调用String的
replace() 、substring()等方法时:
这些方法不会修改调用者本身,而是返回一个全新的String对象,包含修改后的内容
- 通过字面量的方式(区别于new)给字符串赋值,此时字符串值声明在字符串常量池当中
若常量池当中已经存在相同内容字符串,则直接返回引用;否则创建新的实例放入字符串常量池当中
EXMAPLE
对字符串重新赋值时,需要重新指定内存区域,不能修改原有value
@Test
public void test1() {
// 字面量定义,"abc" 存储在字符串常量池中
String s1 = "abc";
String s2 = "abc"; // s2 指向常量池中同一个 "abc" 对象
// 重新赋值:s1 现在指向常量池中新的 "hello" 对象
// 原来的 "abc" 对象依然存在,且 s2 仍然指向它
s1 = "hello"; //本质上是将局部变量表当中槽位更新为指向hello的引用
// 判断地址:s1 指向 "hello", s2 指向 "abc",地址不同
System.out.println(s1 == s2); // 输出:false
System.out.println(s1); // 输出:hello
System.out.println(s2); // 输出:abc (证明原对象未变)
}
对现有字符串进行连接操作时,需要生成新的对象,不能修改原有value
@Test
public void test2() {
String s1 = "abc";
String s2 = "abc";
// 连接操作:底层相当于 s2 = new StringBuilder(s2).append("def").toString();
// 结果生成了一个新的 String 对象 "abcdef",s2 指向新对象
s2 += "def";
System.out.println(s2); // 输出:abcdef (新对象)
System.out.println(s1); // 输出:abc (原对象 s1 未受影响,s2 原来的引用也没法改它)
// 验证:s1 和 s2 不再指向同一个对象
System.out.println(s1 == s2); // 输出:false
}
注意:推荐在循环当中使用 StringBuilder进行字符串拼接,避免产生大量临时对象。这里使用+=清洗体现了String不可变性------结果是一个新对象
调用String的replace()方法修改指定限定字符时,返回新对象,原对象不变
@Test
public void test3() {
String s1 = "abc";
// replace 方法不会修改 s1 内部的字符数组
// 而是创建一个新的 String 对象 "mbc" 并返回给 s2
String s2 = s1.replace('a', 'm');
System.out.println(s1); // 输出:abc (原对象纹丝不动)
System.out.println(s2); // 输出:mbc (新对象)
System.out.println(s1 == s2); // 输出:false
}
常见面试题:String不可变性&数组可变性&java的值传递机制
public class StringExer {
// 成员变量
String str = new String("good");
char[] ch = {'t', 'e', 's', 't'};
public void change(String str, char[] ch) {
// 1. String 参数:str 是传入引用的副本。
// 这里让局部变量 str 指向常量池中的新对象 "test ok"。
// 这不会影响外部的 ex.str 指向。
str = "test ok";
// 2. 数组参数:ch 是传入引用的副本,但指向堆中同一个数组对象。
// 通过引用副本修改数组内部元素,外部可见。
ch[0] = 'b';
}
public static void main(String[] args) {
StringExer ex = new StringExer();
ex.change(ex.str, ex.ch);
// 结果分析:
// ex.str 依然指向堆中 new String("good") 创建的对象,内容为 "good"
System.out.println(ex.str); // 输出:good
// ex.ch 指向的数组内容被修改了
System.out.println(ex.ch); // 输出:best
}
}
-
为什么ex.str没变?
-
原因1:值传递机制:Java当中对象参数传递是引用的副本。方法内str和外部的ex.str实际上是两个独立变量,知识初始值指向的是同一个对象。
-
原因2:String不可变性:即使我们在方法当中尝试修改字符串,
str="test ok"实际上也只是让方法内局部变量str指向了字符串常量池当中新对象"test ok".String对象是不可变的,我们无法通过任何手段修改原来的"good"对象内部任何内容。
-
-
为什么ex.ch变了?
-
原因:数组可变性:
char[]是可变对象。虽然传递的也是引用的副本,但是这个副本和引用指向的都是堆内存当中同一个数组对象 -
操作:
ch【0】='b'通过引用找到可堆上数组,并直接修改了数组内部数据。 -
结论:修改是物理层面的,对外部完全可见。
-
String底层------StringTable字符串常量池
核心原则:字符串常量池,StringPool当中不会存储内容重复字符串。
当尝试放入一个已经存在的字符串时,会直接返回已有对象引用。
底层数据结构与演变
StringTable是HotSpot VM内部维护的一个全局hash table(哈希表),用于实现字符串常量池。
-
数据结构:本质上是个数组,数组每个元素(Bucket)指向一个链表(Entry)
-
发生哈希冲突时,新元素添加到链表当中
-
注意⚠:与Java集合框架当中
HashMap不同的是,HotSpot内部的StringTable在链表过长时不会自动转为红黑树。因此,若桶(Bucket)数量过大,链表会变得很长,导致查找效率从O(1)退化为O(n)
-
-
性能影响:
-
若
StringTable尺寸过小,哈希冲突严重,链表过长。 -
后果:大幅降低
String.intern()方法执行效率,还会显著拖慢类加载过程(因为类文件当中字符串字面量需要在加载时解析并放入池中),进而影响应用启动速度。
-
不同版本实现差异
|-----------------|----------------|-----------------------------|-----------------------------------------------|
| JDK 版本 | 默认大小 (Buckets) | 是否可配置 (-XX:StringTableSize) | 说明 |
| JDK 6 | 1009 | 否 (或无效) | 固定大小。由于尺寸太小,在大型应用中极易发生哈希冲突,导致性能瓶颈。无法通过参数有效调整。 |
| JDK 7 (u40 之前) | 1009 | 是 | 延续了 JDK 6 的默认小尺寸,但开始允许通过参数调整。 |
| JDK 7 (u40 及之后) | 60013 | 是 | 重大优化。默认大小提升至 60013,极大减少了哈希冲突概率。 |
| JDK 8 及以上 | 60013 | 是 | 保持 60013 的默认值。这是目前生产环境的推荐基准。 |
参数配置
-
参数名称:
-XX:StringTableSize=<N> -
配置规则:
-
质数原则:为了获取最佳哈希分布,<N>最好是一个质数。若传入非质数,HotSpot通常会自动寻找并调整为最接近的质数
-
大小建议
-
对于绝大多数应用而言,默认的60013已经够用
-
若应用包含大量字符串字面量(生成的代码、巨大的配置类文件等),或者频繁调用
String.intern(),建议调大,进一步减少冲突,提升启动性能。
-
-
最小值:虽然没有强制最小值为1009这类的限制,但是设置为过小的值(例如小于1009)会导致严重的性能退化,应该避免。
-
字符串拼接内存分配
EX:
String s1 = "javaEE";
String s2 = "hadoop";
String s3 = "javaEEhadoop";
String s4 = "javaEE" + "hadoop"
String s5 = s1 + "hadoop";
String s6 = "javaEE" + s2;
String s7 = s1 + s2;
简单结论:只要拼接操作当中存在变量(注意不包含常量),最终结果一定是在堆内存当中创建一个新对象。
编译器优化&运行期计算
纯字面量拼接(优化)
String s4 = "javaEE" + "hadoop";
-
过程:Javac(java编译器)编译阶段直接进行常量折叠Constant Folding
-
结果:生成的字节码中,这一行直接变为
String s4="javaEEhadoop" -
内存:只在字符串常量池当中查找或者创建一个对象。不会在堆内存创建多余的新对象
包含变量的拼接(运行期计算)
String s5 = s1 + "hadoop";
String s6 = "javaEE" + s2;
String s7 = s1 + s2;
-
过程:因为 s1和s2是变量,它们的值在编译期未知,必须在运行期才能确定。
-
结果:
-
JVM必须分配新的内存空间存储拼接后的新字符串序列
-
这个新对象位于堆内存当中
-
新对象默认不在字符串常量池当中(除非手动调用intern方法)
-
-
内存:会在堆中
new一个新的String对象。
特殊情况:final编译器常量
若变量声明为final,情况发生变化:
final String s1="javaEE";
String s5=s1+"hadoop";
-
分析:因为
s1是final修饰的,它在编译期就是一个常量。 -
结果:编译器会将其视为纯粹字面量进行拼接,进行常量折叠
-
结论:此时不会在堆当中新建对象,而是直接使用常量池当中对象。
-
注意:若final String s1=new String("javaEE"),则s1不是编译常量,无法折叠
底层演变
JDK8以及之前:StringBuilder
jdk8当中,编译器会将s1+"hadoop"自动转换为类似以下代码:
// 编译器实际生成的伪代码
String s5 = new StringBuilder().append(s1).append("hadoop").toString();
-
StringBuilder.toString()内部会执行newString(value) -
涉及到
new操作,会在堆中创建StringBuilder对象和最终的String对象
JDK9以及之后:invokedynamic
从JDK9开始,为了优化性能,编译器不再机械的使用StringBuilder,而是使用invokedynamic指令。
-
运行机制:JVM在运行时动态决定最高效的拼接策略
-
结果:底层计数变了但是结果不变------依然是在堆上生成一个新的String对象
EX:new String("a")+new String("b")会创建几个对象
public class StringNewTest {
public static void main(String[] args) {
String str = new String("a") + new String("b");
}
}
详细分析
-
常量池对象"a":JVM检查常量池当中是否存在"a",不存在则创建
-
堆对象new String("a"):堆内开辟空间创建String对象,内容复制"a"
-
常量池对象"b" :JVM检查常量池当中是否存在"b",不存在则创建
-
堆对象 new String("b"):堆内开辟空间创建String对象,内容复制"b"
-
堆对象StringBuilder(JDK8以及之前创建):使用StringBuilder进行拼接
-
堆对象"ab":最终创建拼接后的堆对象"ab"
StringTable(字符串常量池具体实现)的垃圾回收
StringTable是什么?
其本质是一个Hashtable(c++实现)
简化版源码:
// HotSpot 源码中的 StringEntry 结构
class StringEntry : public HashtableEntry<oop, mtSymbol> {
// 没有 Key-Value 分离!
unsigned int _hash; // 字符串的 hashCode()
oop _literal; // 直接指向堆中 String 对象的弱引用
StringEntry* _next; // 链表下一个 Entry
};
- 本身不存储字符内容,字符内容存储在堆内String对象当中
核心概念
StringTable并不是堆中一个独立分区,而是HotSpot虚拟机维护的一个全局哈希表。
-
JDK6以及之前:StringTable位于永久代,难以回收,容易导致OOM
-
JDK7以及之后:StringTabl移至Java堆当中。StringTable当中条目(entry)所引用的对象和不同堆对象一样,受到GC管理
回收流程
-
触发时机:StringTable清理不会单独发生,而是伴随Minor GC或者Full GC
-
标记-清除:
-
当GC开始时,JVM会遍历所有StringTable
-
检查表中每个Entry所指向的String对象是否在GC Roots可达(即是否被栈、静态变量等引用)
-
若某个String对象在堆中已经不可达,GC不仅回收String对象占用的内存,还同步移除StringTable中对应的Entry
-
EXAMPLE
代码中硬编码的字面量
String s = "hello";
s = null;
-
来源:编译期确定,写入.class文件常量池
-
引用链:
Metaspace (Class -> ConstantPool) --> Heap (String Object) <-- StringTable。 -
虽然s不再引用我们这个字面量,但是常量池当中依然记载了这个字面量
-
GC结果:
-
记录字面量的String对象:存活(存在常量池强引用)
-
String Table对应的Entry:存活
-
结论:对应的类不卸载,
"hello"永远在堆当中,不会被GC回收。
-
运行时动态生成并intern的字符串
String s = new String("a") + new String("b"); // 生成 "ab"
s.intern();
s = null;
-
来源:运行时计算生成(valuOf、拼接等),原本并不在常量池
-
引用链:StringTable→Heap(String Object)。并不存在类常量池的强引用。
-
GC结果:
-
String对象:被回收(若业务代码也不再引用)
-
StringTable Entry:被移除
-
G1当中字符串去重操作
背景&动机
|------------------|---------------|------------------------------------------|
| 统计项 | 数据 | 说明 |
| 堆存活数据中 String 占比 | ~25% | 许多 Java 应用的内存瓶颈 |
| String 对象中重复比例 | ~13.5% (约一半) | str1.equals(str2) == true 但 str1 != str2 |
| String 平均长度 | ~45 字符 | 去重收益显著 |
核心问题:堆上存储的大量内容相同但是不同对象的String,造成内存浪费。
StringTable&String Deduplication (关键区别)
|------|------------------------|--------------------------------------|
| 特性 | StringTable | String Deduplication |
| 本质 | 字符串常量池 | G1 的堆内存优化功能 |
| 去重时机 | 类加载时 / intern() 调用时 | GC 过程中并发进行 |
| 去重范围 | 字面量 + 手动 intern() 的字符串 | 所有满足条件的 String 对象 |
| 存储位置 | 堆(JDK7+) | 堆(G1 Region 中) |
| 引用类型 | 弱引用 | 强引用(去重后共享) |
| 是否自动 | 字面量自动,其他需手动 intern() | 全自动(开启参数后) |
| 底层数组 | 不关心 | Java 8: char[] / Java 9+: byte[] |
StringTable(常量池):
2• 目的:保证字面量唯一性
3• 生命周期:类卸载前一直存在
4• 引用:弱引用
5• 范围:只有字面量和 intern() 的字符串
6
7Deduplication Table(去重表):
8• 目的:减少堆内存浪费
9• 生命周期:GC 周期内有效
10• 引用:强引用(去重后共享)
11• 范围:所有满足年龄阈值的 String 对象
Duduplication实现原理
-
Step1:GC扫描阶段
-
G1 GC遍历堆当中所有存活对象
-
检查每个对象是否是String类型
-
检查是否满足去重条件
-
年龄≥stringDeduplicationAgeThreshold(默认为3)
-
未被处理过
-
-
符合条件的String引用放入去重队列
-
-
并发处理阶段(后台线程)
-
去重线程从队列当中取出String引用
-
计算String的hash,获取内部数组引用(byte[]/char[])
-
查询去重表
-
存在相同内容的String→执行去重
-
不存在→插入去重表
-
-
-
去重执行
- 假设String A和String B内容相同
去重前:
String A ────▶ byte[] [0x111] "hello"
String B ────▶ byte[] [0x222] "hello" ← 重复内存!
去重后:
String A ────┐
├───────▶ byte[] [0x111] "hello"
String B ────┘
命令行参数
|---------------------------------------------|---------|-------|-------------------------|
| 参数 | 类型 | 默认值 | 说明 |
| -XX:+UseG1GC | boolean | 否 | 必须开启,仅支持 G1(ZGC 后续也支持) |
| -XX:+UseStringDeduplication | boolean | FALSE | 开启 String 去重功能 |
| -XX:PrintStringDeduplicationStatistics=true | boolean | FALSE | 打印去重统计信息 |
| -XX:StringDeduplicationAgeThreshold=3 | uintx | 3 | String 对象经历多少次 GC 后成为候选 |
| -XX:StringDeduplicationHashSeed=54321 | uintx | 随机 | 哈希种子(防哈希碰撞攻击) |
统计参数解读
开启 PrintStringDeduplicationStatistics 后输出示例:
String Deduplication Statistics:
Total Queued: 1000000 ← 进入去重队列的 String 总数
Total Eliminated: 500000 ← 成功去重的 String 数量
Total Failed: 0 ← 去重失败数量
Deduplication Ratio: 50.0% ← 去重比例
Memory Saved: 10.5 MB ← 节省的内存