String的内存模型,为什么String被设计成不可变的
原文地址:https://cloud.tencent.com/developer/article/1059701
String是Java中最常用的类,是不可变的(Immutable), 那么String是如何实现Immutable呢,String为什么要设计成不可变呢?
前言
关于String,收集一波基础,来源标明最后,不确定是否权威, 希望有问题可以得到纠正。
0. String的内存模型
- Java8以及以后的字符串新建时,直接在堆中生成对象,而字符创常量池位于Metaspace。必要的时候,会把堆中的指针存入Metaspace, 而不是复制。
- Metaspace位于虚拟机以外的直接内存,因此大小和外部直接内存有关,但也可以通过指定参数设置
-XX:MetaspaceSize=8m -XX:MaxMetaspaceSize=8m

0.1 一些真实测试,以及某些推测
很难直接从百度出的中文资料中得到确切的答案,因为大多以讹传讹,未经验证。这里且做测试,先记住,因为很不情愿啃官方文档。
前期准备
首先,要有字符串常量池的概念。然后知道String是怎么和常量池打交道的。这里的武器就是intern(),看一下javadoc:
/**
* Returns a canonical representation for the string object.
* <p>
* A pool of strings, initially empty, is maintained privately by the
* class {@code String}.
* <p>
* When the intern method is invoked, if the pool already contains a
* string equal to this {@code String} object as determined by
* the {@link #equals(Object)} method, then the string from the pool is
* returned. Otherwise, this {@code String} object is added to the
* pool and a reference to this {@code String} object is returned.
* <p>
* It follows that for any two strings {@code s} and {@code t},
* {@code s.intern() == t.intern()} is {@code true}
* if and only if {@code s.equals(t)} is {@code true}.
* <p>
* All literal strings and string-valued constant expressions are
* interned. String literals are defined in section 3.10.5 of the
* <cite>The Java™ Language Specification</cite>.
*
* @return a string that has the same contents as this string, but is
* guaranteed to be from a pool of unique strings.
*/
public native String intern();
即常量池存在,返回常量池中的那个对象,常量池不存在,则放入常量池,并返回本身。由此推断两个公式:
str.intern() == str //证明返回this本身,证明常量池不存在。
str.intern() != str //证明返回常量池中已存在的对象,不等于新建的对象。
这两个公式有什么用?
面试题虽然被很多牛人说low(请别再拿“String s = new String("xyz");创建了多少个String实例”来面试了吧),但确实经常出现new String以及几个对象之类的问题。而这个问题主要是考察String的内存模型,连带可以引出对Java中对象的内存模型的理解。
通过判断上述两个公式,我们可以知道对象究竟是新建的,还是来自常量池,如此就可以坦然面对谁等于谁的问题。
约定
- 为了准确表达,这里为伪地址表示指针位置,比如
0xab表示"ab"这个对象的地址 - 测试基于jdk1.8.0_131.jdk
- 操作系统: MacOS 10.12.6
- 内存: 16G
- CPU: 2.2 GHz Intel Core i7
Java Visual VM
JDK提供一个可视化内存查看工具jvisualvm。Mac由于安装Java后已经设置了环境变量,所以打开命令行,直接输入jvisualvm, 即可打开。Windows下应该是在bin目录下找到对应的exe文件,双击打开。
OQL语言
在Java VisualVM中可以使用OQL来查找对象。具体可以查看Oracle博客。百度出来的结果都是摘抄的[深入理解Java虚拟机]这本书附录里的内容。但我表示用来使用行不通。一些用法不一样。简单的归纳一些用的语法。
查询一个内容为RyanMiao的字符串:
select {instance:s} from java.lang.String s where s.toString() == "RyanMiao"
查询前缀为Ryan的字符串:
select {instance:s} from java.lang.String s where s.toString().substring(0,4) =="Ryan"
遍历
filter(
sort(
map(heap.objects("java.lang.String"),
function(heapString){
if( ! counts[heapString.toString()]){
counts[heapString.toString()] = 1;
} else {
counts[heapString.toString()] = counts[heapString.toString()] + 1;
}
return { string:heapString.toString(), count:counts[heapString.toString()]};
}),
'lhs.count < rhs.count'),
function(countObject) {
if( countObject.string ){
alreadyReturned[countObject.string] = true;
return true;
} else {
return false;
}
}
);
没找到匹配前缀的做法,这里使用最笨的遍历
filter(
heap.objects("java.lang.String"),
function(str){
if(str != "Ryan" && str !="Miao" && str != "RyanMiao"){
return false;
}
return true;
}
);
0.1.1 通过=创建字符串
通过=号创建对象,运行时只有一个对象存在。
/**
* @author Ryan Miao
* 等号赋值,注意字面量的存在
*/
@Test
public void testNewStr() throws InterruptedException {
//str.intern(): 若常量池存在,返回常量池中的对象;若常量池不存在,放入常量池,并返回this。
//=号赋值,若常量池存在,直接返回常量池中的对象0xs1,如果常量池不存在,则放入常量池,常量池中的对象也是0xs1
String s1 = "RyanMiao";//0xs1
Assert.assertTrue(s1.intern() == s1);//0xs1 == 0xs1 > true
Thread.sleep(1000*60*60);
}
通过Java自带的工具Java VisualVM来查询内存中的String实例,可以看出s1只有一个对象。操作方法如下。
为了动态查看内存,选择休眠1h,run testNewStr(),然后打开jvisualvm, 可以看到几个vm列表,找到我们的vm,右键heamp dump.

然后,选择右侧的OQL,在查询内容编辑框里输入:
select {instance:s} from java.lang.String s where s.toString() == "RyanMiao"
可以发现,只有一个对象。

0.1.2 通过new创建字符串
通过new创建对象时,参数RyanMiao作为字面量会生成一个对象,并存入字符创常量池。而后,new的时候又将创建另一个String对象,所以,最好不要采用这种方式使用String, 不然就是双倍消耗内存。
/**
* @author Ryan Miao
*
* 暴露的字面量(literal)也会生成对象,放入Metaspace
*/
@Test
public void testNew(){
//new赋值,直接堆中创建0xs2, 常量池中All literal strings and string-valued constant expressions are interned,
// "RyanMiao"本身就是一个字符串,并放入常量池,故intern()返回0xab
String s2 = new String("RyanMiao");
Assert.assertFalse(s2.intern() == s2);//0xRyanMiao == 0xs2 > false
}

0.1.3 通过拼接创造字符串
当字符创常量池不存在此对象的的时候,返回本身。
/**
* @author Ryan Miao
* 上栗中,由于字面量(literal)会生成对象,并放入常量池,因此可以直接从常量池中取出(前提是此行代码运行之前没有其他代码运行,常量池是干净的)
*
* 本次,测试非暴露字面量的str
*/
@Test
public void testConcat(){
//没有任何字面量为"RyanMiao"暴露给编译器,所以常量池没有创建"RyanMiao",所以,intern返回this
String s3 = new StringBuilder("Ryan").append("Miao").toString();
Assert.assertTrue(s3.intern() == s3);
}
在Java Visual VM中,查询以"Ryan"开头的变量:
select {instance:s} from java.lang.String s where s.toString().substring(0,4) =="Ryan"
但,根据以上几个例子,可以明显看出来,字符串字面量(literal)都是对象,于是上栗中应该有三个对象:Ryan,Miao,RyanMiao。验证如下:

此时的内存模型:

0.1.4 针对常量池中已存在的字符串
/**
* @author Ryan Miao
* 上栗中,只要不暴露我们最终的字符串,常量池基本不会存在,则每次新建(new)的时候,都会放入常量池,intern并返回本身。即常量池的对象即新建的对象本身。
*
* 本次,测试某些常量池已存在的字符串
*/
@Test
public void testExist(){
//为毛常量池存在java这个单词
//s4 == 0xs4, intern发现常量池存在,返回0xexistjava
String s4 = new StringBuilder("ja").append("va").toString();
Assert.assertFalse(s4.intern() == s4); //0xexistjava == 0xs4 > false
//int也一开始就存在于常量池中了, intern返回0xexistint
String s5 = new StringBuilder().append("in").append("t").toString();
Assert.assertFalse(s5.intern()==s5); // 0xexistint == 0xs5 > false
//由于字面量"abc"加载时,已放入常量池,故s6 intern返回0xexistabc, 而s6是新建的0xs6
String a = "abc";
String s6 = new StringBuilder().append("ab").append("c").toString();
Assert.assertFalse(s6.intern() == s6); //0xexistabc == 0xs6 > false
}
验证如下:

使用命令行工具javap -c TestString可以反编译class,看到指令执行的过程。
% javap -c TestString
Warning: Binary file TestString contains com.test.java.string.TestString
Compiled from "TestString.java"
public class com.test.java.string.TestString {
public com.test.java.string.TestString();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
public void testNewStr() throws java.lang.InterruptedException;
Code:
0: ldc #2 // String RyanMiao
2: astore_1
3: aload_1
4: invokevirtual #3 // Method java/lang/String.intern:()Ljava/lang/String;
7: aload_1
8: if_acmpne 15
11: iconst_1
12: goto 16
15: iconst_0
16: invokestatic #4 // Method org/junit/Assert.assertTrue:(Z)V
19: return
public void testNew() throws java.lang.InterruptedException;
Code:
0: new #5 // class java/lang/String
3: dup
4: ldc #2 // String RyanMiao
6: invokespecial #6 // Method java/lang/String."<init>":(Ljava/lang/String;)V
9: astore_1
10: aload_1
11: invokevirtual #3 // Method java/lang/String.intern:()Ljava/lang/String;
14: aload_1
15: if_acmpne 22
18: iconst_1
19: goto 23
22: iconst_0
23: invokestatic #7 // Method org/junit/Assert.assertFalse:(Z)V
26: return
public void testConcat() throws java.lang.InterruptedException;
Code:
0: new #8 // class java/lang/StringBuilder
3: dup
4: ldc #9 // String Ryan
6: invokespecial #10 // Method java/lang/StringBuilder."<init>":(Ljava/lang/String;)V
9: ldc #11 // String Miao
11: invokevirtual #12 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
14: invokevirtual #13 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
17: astore_1
18: aload_1
19: invokevirtual #3 // Method java/lang/String.intern:()Ljava/lang/String;
22: aload_1
23: if_acmpne 30
26: iconst_1
27: goto 31
30: iconst_0
31: invokestatic #4 // Method org/junit/Assert.assertTrue:(Z)V
34: return
public void testExist() throws java.lang.InterruptedException;
Code:
0: new #8 // class java/lang/StringBuilder
3: dup
4: ldc #14 // String ja
6: invokespecial #10 // Method java/lang/StringBuilder."<init>":(Ljava/lang/String;)V
9: ldc #15 // String va
11: invokevirtual #12 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
14: invokevirtual #13 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
17: astore_1
18: aload_1
19: invokevirtual #3 // Method java/lang/String.intern:()Ljava/lang/String;
22: aload_1
23: if_acmpne 30
26: iconst_1
27: goto 31
30