说在前面:
前面三篇写完了 JVM 五大区域,按计划下一篇应该是垃圾回收。但我学到这里的时候,发现有一个绕不开的东西——String 的存储机制。因为学 GC 之前得先知道对象在堆里怎么分配的,而 String 又特别典型:它既有对象实例在堆里,又有字面量在常量池里,还牵扯到两个对象还是一个新对象的问题。所以插了一篇,专门搞明白 String 到底在内存里是怎么待着的。
先问你一个问题
String s1 = "abc";
String s2 = new String("abc");
这两行代码,s1 和 s2 指向的是同一个对象吗?
我之前一直以为是一样的——不都是"abc"吗?结果答案是:不一样。 s1 指向常量池里的 “abc”,s2 指向堆里 new 出来的实例。
这个事当时颠覆了我对 String 的认知。这篇就是围绕这个问题展开的。
字符串字面量存在哪里
先说结论:字符串字面量存在字符串常量池里。
什么叫字面量?就是你在代码里直接写的那些用双引号包起来的字符串,比如:
"hello" "abc" "JVM 真的很难学"
这些字符串在编译的时候就会被编译器收进 class 文件的常量池里。等到类加载之后,它们会被放到 字符串常量池 中。
常量池的位置变过
有一点挺有意思——字符串常量池的位置在 JDK 7 的时候换过一次:
| JDK 版本 | 字符串常量池的位置 |
|---|---|
| JDK 6 及之前 | 方法区(永久代) |
| JDK 7 起 | 堆中 |
为什么要搬?因为永久代(PermGen)的空间是有限且固定的,字符串常量池里的字符串太多的话,容易把永久代撑爆,抛 OutOfMemoryError: PermGen space。搬到堆里之后,堆空间可以动态扩展,而且 GC 也能更好地管理它。
new String("abc")到底干了什么
这个是我觉得最值得搞明白的地方,也是面试常问的题。
看这行代码:
String s = new String("abc");
很多人以为它就创建了一个对象。实际上最多创建了两个对象。
我拆一下步骤:
第一步:new 指令在堆里创建一个 String 实例
new 关键字干的事就是在堆上分配内存,创建一个 String 对象。这个对象是"空壳"——它的值还没有确定。
第二步:JVM 拿着字面量去常量池里找
因为构造方法里传了 "abc" 这个字符串字面量,JVM 会去字符串常量池里找一找,有没有 "abc" 这个字符串。
这时候有两种情况:
情况一:常量池里之前没有 “abc”
那 JVM 就在常量池里创建一个 "abc" 对象。
这时候就有两个对象了:一个是常量池里的 "abc",一个是堆里的 new String() 实例。后者会引用前者的值。
情况二:常量池里已经有 “abc” 了
那就不创建新的了,直接复用已有的。这时候只创建了一个对象——就是堆里那个 new String() 实例。
第三步:引用 s 存在当前方法的栈帧里
不管创建了几个对象,最终 s 这个引用存在当前方法的栈帧里,指向堆里的那个 String 实例,不是直接指向常量池里的 “abc”。
一张图看清楚
栈(Stack) 堆(Heap)
┌──────────┐ ┌──────────────────────────┐
│ s (引用) │ ────────→ │ new String("abc") 实例 │
└──────────┘ │ │
│ 字符串常量池(堆中) │
│ ┌───────────────────┐ │
│ │ "abc" 字面量 │ │
│ └───────────────────┘ │
└──────────────────────────┘
注意箭头的方向:s → new String("abc") 实例 → 常量池中的 "abc"(作为内部 char[] 引用)。
那我怎么验证?
我学的时候写了一段代码验证了一下:
String s1 = "abc";
String s2 = new String("abc");
System.out.println(s1 == s2); // false:s1 指向常量池,s2 指向堆
System.out.println(s1.equals(s2)); // true:内容是一样的
s1 == s2 是 false,说明它们不是同一个对象——一个在常量池里,一个在堆里。
但 s1.equals(s2) 是 true,因为 equals() 比较的是字符串的内容,不是引用地址。
字符串常量池的四个关键特性
学完之后我把字符串常量池的特性总结了四条:
1. 不可变性
String 对象一旦创建,它的值就不能改了。你做的任何"修改"操作(比如 concat()、replace()、substring())都是创建了一个新对象,原来的字符串不变。
我之前不理解为什么 String 要设计成不可变的。后来才知道,不可变是常量池能工作的前提——如果字符串可变,两个引用共享同一个 “abc”,其中一个改了,另一个就乱了。
2. 共享性
相同的字符串字面量在常量池里只存一份。不管你在代码里写了多少次 "hello",常量池里只会有一个 "hello" 对象,所有引用都指向它。
这就是为什么前面那个例子可以用 == 判断:
String x = "abc"; String y = "abc"; System.out.println(x == y); // true:指向同一个常量池对象
3. 动态性
常量池不是编译完就定死了,程序跑起来之后也可以往里面加东西。
最典型的例子是 String.intern() 方法:
String s = new String("abc").intern();
intern() 的作用是:去常量池里找有没有相同内容的字符串,如果有就返回常量池里的那个引用;如果没有,就把当前字符串的内容放到常量池里。
intern() 这个方法的本质是:手动把堆里的字符串"注册"到常量池里。
4. 位置挪到了堆里
JDK 7 之前常量池在永久代,JDK 7 之后挪到了堆里。这样做的直接好处是:
- 堆空间可以动态扩展,不用担心 PermGen 太小
- 堆的 GC 机制更强,常量池里的无用字符串可以被回收(虽然概率不高,但至少有了这个能力)
一个让我懵了好久的细节
学这篇的时候有一个细节我纠结了很久:new String("abc") 创建的两个对象,在内存里到底是什么关系?
我画了一个更详细的图,帮自己理解:
栈 堆
┌─────────┐ ┌────────────────────────────┐
│ s │ ──────→│ new String (value = char[]) │
│ (引用) │ │ ┌──────────────────────┐ │
└─────────┘ │ │ char[] 引用 ────────→ │ │
│ └──────────────────────┘ │
│ │
│ 字符串常量池 │
│ ┌──────────────────────┐ │
│ │ "abc" → char[]对象 │ │
│ └──────────────────────┘ │
└────────────────────────────┘
new 出来的 String 对象内部有一个 value 字段(char[] 类型),它引用的就是常量池里那个 "abc" 的底层 char 数组。
所以整个链路是:s(栈引用)→ new String 实例(堆对象)→ 共享的 char[] 数组(常量池的底层数据)。
也就是说,常量池里的 "abc" 和 new 出来的 String,共享了底层的字符数据。常量池存了一份 char[],堆里的对象拿着一个引用指向它,不再重复存一份内容。
这个机制我之前完全不知道。了解了之后再看 “两个对象” 这个说法,才真正理解了是哪两个对象。
最后串一下:和前面几篇的关系
学到这里我发现,这一篇其实是把前面三篇的知识点串起来了:
- 栈——方法里的 String 引用存在栈帧里
- 堆——
new String()的实例存在堆里 - 方法区/元空间——类的字节码信息、class 文件常量池在这个区域
- 字符串常量池——JDK 7+ 在堆里,存字符串字面量
一个简单的 String s = new String("abc"),牵扯到了 JVM 三大内存区域。学完前三篇再来看这个,确实有一种"哦原来如此"的感觉。













