高频八股 · JVM(7):方法区、元空间与字符串常量池
写在前面
前几期讲的是堆和对象。这一期讲两块"配角",但它们恰恰是面试最爱挖的坑:
先把结论放这儿:
方法区是"规范",永久代和元空间是"两代实现";而字符串常量池在 JDK 7 之后,从方法区搬到了堆里**——这一搬,改变了很多经典题目的答案。
一、方法区 / 永久代 / 元空间:三者什么关系
| 方法区 | JVM 规范里的逻辑概念 | 规范没规定 |
| 永久代(PermGen) | JDK 7 及以前的实现 | 堆内(受 -XX:MaxPermSize 限制) |
| 元空间(Metaspace) | JDK 8 及以后的实现 | 本地内存(受 -XX:MaxMetaspaceSize 限制) |
关系一句话:方法区是接口,永久代和元空间是两个不同的实现类。
1.1 为什么 JDK 8 要换成元空间?
四个原因:
换成元空间后:类元信息搬到本地内存,PermGen space 这个经典 OOM 就消失了。
但别误会——元空间不是无限大。如果不设 MaxMetaspaceSize,大量动态代理 / 热部署同样会把本地内存吃光,报 Metaspace OOM。
二、方法区里到底存了什么?
- 类的元信息(类结构、字段、方法、接口、父类);
- 运行时常量池;
- 静态变量(JDK 7 起移到了堆);
- JIT 编译后的代码缓存。
注意两个"搬家":
- JDK 7:字符串常量池从永久代 → 堆;静态变量也从永久代 → 堆;
- JDK 8:永久代整体被元空间取代。
三、三个"常量池",别再搞混
这是最容易乱的地方。其实有三个不同的东西:
| class 文件常量池 | .class 文件里 | 编译期生成的常量表(字面量 + 符号引用) |
| 运行时常量池 | 方法区 / 元空间 | class 常量池加载后的版本,可动态添加(如 intern()) |
| 字符串常量池(StringTable) | JDK 7 起在堆 | 专门存字符串字面量的哈希表 |
记法:
- class 常量池是"文件里的";
- 运行时常量池是"加载后的";
- 字符串常量池是"专门管字符串的"。
四、String 为什么不可变?
先看它的定义(JDK 9 之后):
public final class String {
private final byte[] value; // JDK 9 起从 char[] 改成 byte[]
private final byte coder; // 编码标识:LATIN1 / UTF16
private int hash; // 缓存哈希值
}
三层"不可变"保障:
JDK 9 的改动叫紧凑字符串(Compact Strings):Latin-1 字符只占 1 字节,中文等占 2 字节,整体能省 30%~50% 内存。
4.1 不可变带来四个好处
| 线程安全 | 状态不可改,天然安全 |
| 哈希可缓存 | hash 只算一次,适合做 HashMap 的 key |
| 常量池可行 | 多个引用共享同一个对象 |
| 安全 | 类名、URL、密码传参不会被中途篡改 |
五、字面量 vs new:到底创建几个对象
5.1 字面量:先查池
String a = "abc";
流程:先在字符串常量池里找 "abc"。
- 有 → 直接返回那个引用;
- 没有 → 在池里创建,再返回。
所以:
String a = "abc";
String b = "abc";
System.out.println(a == b); // true(同一个池对象)
5.2 new:绕开池,直接在堆里建
String c = new String("abc");
System.out.println(a == c); // false(一个在池,一个在堆)
5.3 new String("abc") 到底几个对象?
答案:1 个或 2 个。
- 2 个:池里原本没有 "abc" → 池里创建 1 个 + 堆里创建 1 个;
- 1 个:池里已经有 "abc" → 只创建堆里那 1 个。
5.4 两个"坑"例子
String a = "a" + "b"; // 编译期直接优化成 "ab"
String b = "ab";
System.out.println(a == b); // true
String x = "a";
String y = "b";
String a = x + y; // 运行时用 StringBuilder 拼接
String b = "ab";
System.out.println(a == b); // false(a 是堆里的新对象)
区别在于:编译期常量折叠 vs 运行时拼接。
六、intern():JDK 6 和 JDK 7 的答案不一样
intern() 的作用:把字符串放进常量池,并返回池里那个引用。
关键差异在于"池在哪":
| JDK 6 | 方法区(永久代) | 池里没有 → 复制一份到永久代 |
| JDK 7+ | 堆 | 池里没有 → 直接把堆里的引用存进池(不复制) |
经典题目:
String s = new String("a") + new String("b"); // "ab" 在堆里
s.intern();
String s2 = "ab";
System.out.println(s == s2);
答案:
- JDK 6:false —— intern() 把 "ab" 复制到永久代,s 还在堆里,两个不同对象;
- JDK 7+:true —— intern() 把 s 的引用放进池,s2 取到的就是 s 本身。
这题能答出"因为池从永久代搬到了堆",基本就赢了。
七、StringTable:只冲突,不溢出
这是你手册里一个很好的点,值得展开。
StringTable 本质是一个哈希表(底层是数组 + 链表,跟 HashMap 类似),默认容量约 60013(-XX:StringTableSize 可调)。
当字符串非常多时,会发生什么?
- 哈希冲突变多 → 链表变长 → intern() / 字面量查找变慢;
- 但不会"溢出"——因为它只是个表,链表可以无限延长(理论上)。
真正会 OOM 的是什么?
是字符串对象本身。它们住在堆里,字符串太多、太大,报的是:
java.lang.OutOfMemoryError: Java heap space
所以结论是:StringTable 只影响性能(查找变慢),字符串本身才影响内存(OOM)。
调优手段:-XX:StringTableSize=1000003(调大桶数,减少冲突)。
八、常量池会被回收吗?
会。
字符串常量池里的字符串,如果没有任何引用指向它,在 Full GC 时是可以被回收的。
这也是为什么"用 intern() 缓存大量字符串"要小心——它不是"永久存在"的。
九、高频面试题速答
Q1:方法区、永久代、元空间什么关系?
方法区是 JVM 规范里的概念;永久代是 JDK 7 及以前的实现(堆内);元空间是 JDK 8+ 的实现(本地内存)。
Q2:JDK 8 为什么用元空间替换永久代?
永久代大小难调、和堆抢空间、GC 效率低,且需与 JRockit 统一实现。
Q3:String s = new String("a") 创建几个对象?
1 个或 2 个:池里没有 "a" 就是 2 个(池 1 + 堆 1);池里已有就是 1 个(堆 1)。
Q4:intern() 在 JDK 6 和 JDK 7 有什么不同?
JDK 6 池在永久代,intern() 会复制字符串过去;JDK 7+ 池在堆,intern() 直接存引用,不复制。所以 s == s2 的结果从 false 变成 true。
Q5:三个常量池怎么区分?
class 常量池在 .class 文件里;运行时常量池在方法区(元空间),是加载后的版本;字符串常量池在堆(JDK 7+),专门管字符串。
Q6:字符串常量池会 OOM 吗?
表本身只会有哈希冲突、查找变慢;真正 OOM 的是堆里的字符串对象。
十、一句话总结
方法区是规范,永久代和元空间是两代实现;字符串常量池在 JDK 7 搬进了堆,所以 intern() 从"复制"变成了"存引用"——这一搬,改写了一批经典面试题的答案。
(本系列持续更新中,共 10 期,敬请关注~)

