JVM学习笔记:第九章——StringTable字符串常量池

  1. String------字符串,使用""进行基本表示。

    1. 注意:字面量方式会检查字符串常量池,若存在则直接返回引用;new方式则是强制在堆内存当中开辟新空间(JDK7之后常量池在堆当中),即使内容相同也会生成新对象(除非调用String.intern)
  2. String被声明为final类,不可被继承

    1. public final class String :意味着String不能有子类

    2. 设计目的:

      1. 保障不可变性:防止子类修改字符串内部状态,确保字符串内容一旦创建就不可修改

      2. 安全性:String广泛用于网络参数、数据库连接、类加载器等关键场景,不可继承防止恶意篡改

      3. 缓存哈希值:内容不变,String可以安全缓存hashCode,非常适合作为hashmap的key

  3. String实现了关键接口

    1. Serializable:表示字符串支持序列化,可以通过网络传输/持久化到文件/数据库

    2. Comparable<String>:String定义了自然排序规则。比较方式:按照字典序逐个字符进行比较,用于String之间排序操作。

  4. 内部存储结构改变(JDK8 VS JDK9+)

    1. JDK8&之前

      1. 内部定义为 private final char[] value;

      2. 特点:使用UTF-16编码,每个字符固定占用2字节,即使存储纯英文(ASCII)也会浪费50%空间

    2. JDK9&之后(引入Compact Strings)

      1. 内部定义为:private final byte[] value; ,并新增private final byte coder;字段

      2. 特点:根据字符串内容进行动态编码:

        1. 若仅包含Latin-1字符(英文、西欧符号、数字等),coder标记为 LATIN1,每个字符占用1个字节.

        2. 包含非Latin-1字符(例如中文等),coder标记为UTF16,每个字符占用2个字节。

      3. 优势:大幅减少内存占用(纯英文减少50%),降低GC压力并提升缓存命中率

String.intern()

作用

String.intern()是一个本地方法,主要作用:将字符串对象放入/取自字符串常量池当中。

具体逻辑

  1. 检查字符串常量池当中是否存在:JVM会检查字符串常量池当中是否已经存在一个与当前字符串内容相等(通过 equal())的字符串

  2. 若存在,返回池中已经有的字符串引用

  3. 不存在:

    1. 当前字符串添加到字符串常量池当中

    2. 返回这个新加入池中的引用

核心目的:确保在堆内存当中,内容相同的字符串只能保留一份实例,从而节省内存空间。

为什么JDK9改变了String结构

官方文档:JEP 254: Compact Strings

为什么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
    }
}
  1. 为什么ex.str没变?

    1. 原因1:值传递机制:Java当中对象参数传递是引用的副本。方法内str和外部的ex.str实际上是两个独立变量,知识初始值指向的是同一个对象。

    2. 原因2:String不可变性:即使我们在方法当中尝试修改字符串,str="test ok"实际上也只是让方法内局部变量str指向了字符串常量池当中新对象"test ok" .String对象是不可变的,我们无法通过任何手段修改原来的"good"对象内部任何内容。

  2. 为什么ex.ch变了?

    1. 原因:数组可变性:char[] 是可变对象。虽然传递的也是引用的副本,但是这个副本和引用指向的都是堆内存当中同一个数组对象

    2. 操作:ch【0】='b' 通过引用找到可堆上数组,并直接修改了数组内部数据。

    3. 结论:修改是物理层面的,对外部完全可见。

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通常会自动寻找并调整为最接近的质数

    • 大小建议

      1. 对于绝大多数应用而言,默认的60013已经够用

      2. 若应用包含大量字符串字面量(生成的代码、巨大的配置类文件等),或者频繁调用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";
  • 分析:因为s1final修饰的,它在编译期就是一个常量。

  • 结果:编译器会将其视为纯粹字面量进行拼接,进行常量折叠

  • 结论:此时不会在堆当中新建对象,而是直接使用常量池当中对象。

  • 注意:若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");
    }
}
详细分析
  1. 常量池对象"a":JVM检查常量池当中是否存在"a",不存在则创建

  2. 堆对象new String("a"):堆内开辟空间创建String对象,内容复制"a"

  3. 常量池对象"b" :JVM检查常量池当中是否存在"b",不存在则创建

  4. 堆对象 new String("b"):堆内开辟空间创建String对象,内容复制"b"

  5. 堆对象StringBuilder(JDK8以及之前创建):使用StringBuilder进行拼接

  6. 堆对象"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管理

回收流程

  1. 触发时机:StringTable清理不会单独发生,而是伴随Minor GC或者Full GC

  2. 标记-清除:

    1. 当GC开始时,JVM会遍历所有StringTable

    2. 检查表中每个Entry所指向的String对象是否在GC Roots可达(即是否被栈、静态变量等引用)

    3. 若某个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实现原理

  1. Step1:GC扫描阶段

    1. G1 GC遍历堆当中所有存活对象

    2. 检查每个对象是否是String类型

    3. 检查是否满足去重条件

      1. 年龄≥stringDeduplicationAgeThreshold(默认为3)

      2. 未被处理过

    4. 符合条件的String引用放入去重队列

  2. 并发处理阶段(后台线程)

    1. 去重线程从队列当中取出String引用

    2. 计算String的hash,获取内部数组引用(byte[]/char[])

    3. 查询去重表

      1. 存在相同内容的String→执行去重

      2. 不存在→插入去重表

  3. 去重执行

    1. 假设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   ← 节省的内存
相关推荐
心前阳光2 小时前
Mirror网络库插件使用4
java·linux·网络·unity·c#·游戏引擎
Rsun045512 小时前
定时任务如何保证任务的可靠性和幂等性?
java
Amazing_Cacao2 小时前
品鉴师高级|全局判断成体系(精品可可,精品巧克力)
笔记·学习
西野.xuan2 小时前
【effective c++】条款四十三:学习处理模版化基类内的名称
java·c++·学习
Nontee2 小时前
Java 后端开发面试技能清单
java·面试
1104.北光c°2 小时前
JVM虚拟机【八股篇】:类加载机制与性能调优
java·开发语言·jvm·笔记·程序人生·调优·双亲委派
Shining05962 小时前
前沿模型系列(一)《大模型学习方法》
学习·其他·学习方法·infinitensor
Accerlator2 小时前
MySQL 学习
学习
JTCC2 小时前
Java 设计模式西游篇 - 第一回:单例模式显神通 悟空巧解资源劫
java·单例模式·设计模式