方法区
存储类的元信息 (类名、方法、字段)、静态变量 、常量池 (包括字符串常量池)、即时编译器(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+):
- 检查字符串常量池 中,是否已经存在等于当前字符串(
"ab")的对象; - 如果不存在 :将当前堆中字符串的引用地址,直接存入常量池,然后返回这个引用;
- 如果已存在:直接返回常量池中的已有对象引用。
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 本身是需要被垃圾回收的,它的回收策略经历过一次重要的"搬家"。
- "老家"与"新家" :在 JDK 1.6 及更早版本,StringTable 位于永久代,只有触发 Full GC 时才会被回收。JDK 1.7 及之后版本,它被移到了堆内存中,这样普通的 Minor GC 就能回收无用的字符串常量,效率大大提高。
- 回收机制 :回收遵循常规规则,如果存放在 StringTable 里的一个字符串字面量,在程序里没有在任何地方被引用,那它在下次垃圾回收时就会被当作"废弃常量"清理掉。
- 验证实验 :你可以用一个简单的循环来观察这个现象:
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 以上版本。