深入解析JVM方法区与StringTable机制

方法区

存储类的元信息 (类名、方法、字段)、静态变量常量池 (包括字符串常量池)、即时编译器(JIT)编译后的代码等。

方法区(逻辑概念)

├── 类的元数据

│ ├── 类名、父类名

│ ├── 字段信息(字段名、类型)

│ ├── 方法信息(方法名、返回值、参数、字节码)

│ └── 访问修饰符(public/private 等)

├── 运行时常量池

│ ├── 字面量("abc"、"hello" 等字符串符号)

│ └── 符号引用(类名、方法名、字段名的符号)

├── 静态变量(JDK 8+ 移到了堆里,但逻辑上仍属于方法区)

└── JIT 编译后的代码缓存

1. 内存溢出

JDK 8 的方法区用元空间(Metaspace)实现,存在本地内存里。

默认情况下,元空间没有上限 ,能吃多少物理内存就吃多少。但如果用 -XX:MaxMetaspaceSize 设了上限,或者物理内存本身不够了,就会爆。

和 JDK 6/7 的区别
JDK 6/7 JDK 8
错误信息 PermGen space Metaspace
存在哪 JVM 堆内的永久代 堆外的本地内存
默认大小 很小(约 82M) 不限制,能吃满物理内存
调参方式 -XX:MaxPermSize -XX:MaxMetaspaceSize

2.常量池

一张表, jvm指令根据这张常量表找到要执行的**:类名、方法名、参数类型、字面量等信息**。

包含Class文件常量池运行时常量池

java 复制代码
二进制字节码(类基本信息,常量池,类方法定义,包含了虚拟机指令)
public class HelloWorld {
public static void main(String[] args) {
System.out.println("hello world");
}

3. 运行时常量池

常量池是 *. class文件中的, 当该类被加载, 它的常量池信息就会放入运行时常量池, 并把里面的符号地址变为真实地址

4. StringTable

运行时常量池专门用于管理字符串字面量 的一张哈希表,只存一样东西:字符串对象的引用

谁会把引用放进去?*

方式 什么时候放 放进去的东西
ldc 指令 执行 String s = "xxx" 堆里新建的 String 对象引用
intern() 手动调用 调用者 String 对象的引用(JDK 7+ 直接存引用,JDK 6 存副本的引用)
编译器优化 "a"+"b" 编译成 "ab" 被 ldc 加载时放入
知识先检
java 复制代码
String s1 = "a";
String s2 = "b";
String s3 = "a" + "b";
String s4 = s1 + s2;
String s5 = "ab";
String s6 = s4.intern();

//问
System.out.println(s3 == s4);
System.out.println(s3 == s5);
System.out.println(s3 == s6);

String x2 = new String("c") + new String("d");
String x1 = "cd";
x2.intern();

//问,如果调换了【最后两行代码】的位置呢,如果是jdk1.6呢
System.out.println(x1 == x2);
  • ldc = Load Constant(加载常量)是 JVM 字节码里的一条指令,作用是把常量池里的符号 加载到操作数栈,并触发字符串对象的创建。

4.1 常量池与串池的关系

常量池是在.class文件中, 当类被加载时,常量池中的信息会加载到运行时常量池。这时a b ab还只是常量池中的符号,还没变为 java中的字符串对象。(懒加载)

java 复制代码
public static void main(String[] args) {
String s1 = "a";
String s2 = "b";
String s3 = "ab";
}

当执行到String s1 = "a"具体代码时,ldc #2 会把 a 变为"a"字符串对象, 并放入串池StringTable。(hashTable结构,不能扩容)

ldc:JVM 自动从常量池加载,自动查 StringTable,自动创建并放入

4.2 StringTable字符串变量拼接

java 复制代码
public static void main(String[] args) {
String s1 ="a";//懒惰的
String s2 = "b";
String s3 = "ab";    //放入串池
String s4 = s1 + s2;  // new StringBuilder().append("a").append("b").toString(); new String("ab") 放入堆中
    
System.out.println(s3 == s4) // false(是两个对象,s3在串池/s4在堆中)


String s5 = "a" + "b";
System.out.println(s5 == s4)   // true, 编译器优化(常量在编译期 确定为ab)

两个字符串变量拼接:创建了一个StringBuilder, 然后调用了StringBuilder的无参构造, 接着加载变量 a 作为append方法参数拼接, 最后toString(内部使用 new String()创建了新方法, 并转移内部变量)

编译器优化: String s5 = "a" + "b"; ldc 又要去常量池加载 #4位置的 "ab", 因为StringTable已有"ab", 此时不会创建新对象,直接延用。

4.3 intern() 尝试将对象放入StringTable

intern() 方法的核心规则(JDK 8+):

  1. 检查字符串常量池 中,是否已经存在等于当前字符串("ab")的对象;
  2. 如果不存在 :将当前堆中字符串的引用地址,直接存入常量池,然后返回这个引用;
  3. 如果已存在:直接返回常量池中的已有对象引用。
java 复制代码
public class Demo23 {
    public static void main(String[] args) {
        // 1. 字符串拼接,生成堆中的新String对象
        String s = new String("a") + new String("b");
        
        // 2. 调用intern()方法,尝试将字符串放入串池
        String s2 = s.intern();
        
        // 3. 比较引用地址,输出结果
        System.out.println(s2 == "ab");
    }
}

先执行 new String("a"):在 中创建 "a" 对象,同时字符串常量池 也会放入 "a"

再执行 new String("b"):在 中创建 "b" 对象,同时字符串常量池 也会放入 "b"

字符串拼接 + 操作:Java 会创建 StringBuilder 进行拼接,最终调用 toString() 生成新的堆对象 new String("ab")

关键:此时字符串常量池中没有 "ab" 这个字面量s 指向的是堆中的 "ab" 对象

  • 1.6将这个字符串对象尝试放入串池,如果有则并不会放入。 如果没有先拷贝一份对象,然后放入串池,会把串池中的对象返回

4.4 StringTable垃圾回收

StringTable 的垃圾回收和性能调优,核心围绕一个点:它是一个固定大小、不可动态扩容的哈希表

🗑️ 垃圾回收

StringTable 本身是需要被垃圾回收的,它的回收策略经历过一次重要的"搬家"。

  1. "老家"与"新家" :在 JDK 1.6 及更早版本,StringTable 位于永久代,只有触发 Full GC 时才会被回收。JDK 1.7 及之后版本,它被移到了堆内存中,这样普通的 Minor GC 就能回收无用的字符串常量,效率大大提高。
  2. 回收机制 :回收遵循常规规则,如果存放在 StringTable 里的一个字符串字面量,在程序里没有在任何地方被引用,那它在下次垃圾回收时就会被当作"废弃常量"清理掉。
  3. 验证实验 :你可以用一个简单的循环来观察这个现象:for (int i = 0; i < 100000; i++) { String.valueOf(i).intern(); }。当生成的字符串数量超过堆内存限制时,控制台会输出 GC 日志,并且 PrintStringTableStatistics 会显示 StringTable 中的条目数量经历了一个先增后降的过程,这就是垃圾回收在起作用。

⚙️ 性能调优

我们知道哈希表底层就是:数组 + 链表 实现的。

StringTable 的性能瓶颈在于哈希冲突 。它的数据结构像是一个有编号的货架(哈希表),如果很多字符串堆在同一个货架格子里,查找时就得遍历这个桶的长链表,导致intern()等操作变慢。

调优主要是为了解决这个问题。

1. 核心调优参数:-XX:StringTableSize

这是最主要的调优手段,它决定了这个数组中桶的数量, 间接减少了链表长度。

  • 设置原则 :建议设置成一个素数 (如 65537, 100003),这能让字符串的分布更均匀。默认值在 JDK 8 中是 60013
  • 何时需要调大? 当你的应用有大量独特字符串(如海量用户名、地址、URL)需要通过 intern() 或字面量放入 StringTable 时。
  • 何时可以调小? 可以反过来利用哈希冲突,将 StringTableSize 设得很小(比如 1009),加快"判断一个字符串是否已入池"的速度。但这属于小众优化,需谨慎。
  • 效果验证 :你可以启动时加上 -XX:+PrintStringTableStatistics,让 JVM 在程序结束时打印统计信息。重点关注 Average bucket size(平均链表长度)和 Maximum bucket size(最大链表长度),如果这两个值过大,就说明冲突严重,需要调大 StringTableSize
2. 使用 intern() 减少堆内存占用

这是另一个重要的调优思路,目标是节省内存

  • 场景 :当程序里会反复出现大量内容相同的字符串时(比如一个大型社交网站里,很多用户的居住地址都是"北京"、"上海")。
  • 做法 :对这些字符串手动调用 intern() 方法。这样,堆里只会存在一份字符串对象,所有地方共享这一个引用,原来的那些副本对象就可以被 GC 回收掉。
  • 效果 :一个经典案例,读取一个包含 48 万行地址的文件,如果不入池,内存占用可能高达 300M;而使用 intern() 入池后,占用可能骤降到 30-40M。
3. G1 的字符串去重 (-XX:+UseStringDeduplication)

这是一个 JVM 层面的自动化优化,和手动调优不冲突。

  • 特点 :它是在 GC 工作的后台,将多个 String 对象底层那些内容一模一样char[] 数组共享,只保留一份。
  • 适用对象:更适合那些长期存活的重复字符串(如 session id,长生命周期的配置信息)。
  • 注意 :这个特性仅在 G1 垃圾收集器下生效,需要 JDK 8u20 以上版本。
相关推荐
Dicky-_-zhang9 小时前
分布式锁实战:Redis与ZooKeeper对比选型与实现方案
java·jvm
深蓝轨迹10 小时前
JVM 垃圾回收器详解:Serial、Parallel、CMS 与 G1 的原理与实践
jvm·垃圾回收·gc调优
自律懒人13 小时前
阿里Qoder 1.0实测:对比Cursor和Claude Code,国产AI编程工具做到哪一步了?
jvm·深度学习·ai编程
小匠石钧知13 小时前
01_以RockyLinux的镜像为基础_构建自己开发学习所需的镜像
linux·docker·jdk·mariadb
高级c13 小时前
10分钟上手昇腾 NPU 算子开发入门与实战
java·jvm·spring
没文化的阿浩14 小时前
【Linux系统】线程的同步与互斥(1)——互斥量mutex
linux·运维·jvm
深蓝轨迹14 小时前
JVM 类加载机制详解(生命周期・双亲委派・自定义加载器)
jvm·类加载器·双亲委派
Dicky-_-zhang1 天前
分布式事务解决方案TCC实战
java·jvm