发布于2026-07-04 阅读(0)
扫一扫,手机访问
关于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也能更从容地管理它。
这个操作,可以说是最值得搞明白的细节了,也是面试题里的常客。
看这行代码:
String s = new String("abc");
很多人以为它就创建了一个对象。实际上最多创建了两个对象。
拆解一下步骤:
new关键字就是在堆上分配内存,创建一个String对象。这个对象此刻还是个“空壳”——它的值还没确定。
因为构造方法里传了"abc"这个字符串字面量,JVM会去字符串常量池里找一找,有没有"abc"这个字符串。
这时候有两种情况:
情况一:常量池里之前没有“abc”
那JVM就会在常量池里创建一个"abc"对象。这时候就有两个对象了:一个是常量池里的"abc",一个是堆里的new String()实例。后者会引用前者的值。
情况二:常量池里已经有“abc”了
那就不创建新的了,直接复用已有的。这时候只创建了一个对象——就是堆里那个new String()实例。
不管创建了几个对象,最终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()比较的是字符串的内容,不是引用地址。
梳理下来,字符串常量池有四个关键特性值得记住:
String对象一旦创建,它的值就不能改了。你所做的任何“修改”操作(比如concat()、replace()、substring())都是创建了一个新对象,原来的字符串纹丝不动。
很多人不理解为什么String要设计成不可变的。其实,不可变性是常量池能工作的前提——如果字符串可变,两个引用共享同一个“abc”,其中一个改了,另一个就跟着乱套了。
相同的字符串字面量在常量池里只存一份。不管你在代码里写了多少次"hello",常量池里只会有一个"hello"对象,所有引用都指向它。
这就是为什么前面那个例子可以用==判断:
String x = "abc"; String y = "abc"; System.out.println(x == y); // true:指向同一个常量池对象
常量池不是编译完就定死了,程序跑起来之后也可以往里面加内容。
最典型的例子是String.intern()方法:
String s = new String("abc").intern();
intern()的作用是:去常量池里找有没有相同内容的字符串,如果有就返回常量池里的那个引用;如果没有,就把当前字符串的内容放到常量池里。可以说,intern()方法的本质是:手动把堆里的字符串“注册”到常量池里。
JDK 7之前常量池在永久代,JDK 7之后挪到了堆里。这样做的好处很明显:
学这篇的时候有一个细节让人纠结了很久: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[],堆里的对象拿着一个引用指向它,而不会再重复存一份内容。
明白了这个机制,再回头看“两个对象”这个说法,才能真正理解是哪两个对象,以及它们之间的关系。
学到这里会发现,这一篇其实是把前面几篇的知识点串起来了:
new String()的实例存在堆里一个简单的String s = new String("abc"),牵扯到了JVM三大内存区域。学完前三篇再回头看这个,确实有一种“哦,原来如此”的感觉。
售后无忧
立即购买>office旗舰店
售后无忧
立即购买>office旗舰店
售后无忧
立即购买>office旗舰店
售后无忧
立即购买>office旗舰店
正版软件
正版软件
正版软件
正版软件
正版软件
1
2
3
7
8