在设计层面,java的字符串要求其值一旦定义,不可再发生变化。在实现层面,通过使用final修饰String类的成员变量value实现其不可变性。在代码实践中,字符串具有极高的使用频率,那么同样存在大量使用重复字符串的场景。出于节省jvm内存空间和提升读取效率的考虑,结合java字符串的不可变性,很自然的考虑在内存中设计一个共享区,存放一份可能重复使用的字符串,使所有使用该字符串的程序指令直接从共享区读取,而不是每次使用时重新创建。
在java体系中,这个共享区被称为字符串常量池,把字符串放入字符串常量池的过程被称为字符串池化。本文接下来将介绍字符串池化过程中的相关内容。
String类结构
在String类有一个名为value的成员变量,它在JDK1.8中是一个char类型数组,在JDK1.9中是一个byte类型数组,它是存储String类实例值的最底层数据结构。同时,该变量使用final关键字修饰,实现了java字符串不可变的特性,进而保证了线程安全。因为数组在jvm内存中是连续存储的,那么value可被看作是一个指向jvm内存中某个数组的首地址的指针。
特别注意区分,Stirng类实例String s = "abc”
的引用是指向jvm内存中的实例地址,即所谓的String类对象地址。而在String类实例内,维护了一个数组地址vlaue,它指向了另外一块存储char字符数组(jdk1.9 byte字符数组)的内存地址。即一个String类对象有两个引用:1)有一个实例引用,2)实例内部有一个数组引用。
四种常量池
顾名思义、池一般指某个容器或范围内放置若干数量具有相同属性的东西,而常量指内容不发生变化的量。那么,常量池是指在jvm的内存放置若干的常量的区域。在java体系中,可分为Class文件常量池、运行时常量池、部分基本数据类型包装类常量池、全局字符串常量池,他们的作用都是为了复用常量,提高读取性能。
-
Class文件常量池
java程序编译之后,会在磁盘上生成.class后缀的文件,其中用于存储字面量和符号引用的部分称为常量池。这个常量池在编译之后就固定在磁盘上,因此可称为静态常量池。 -
运行时常量池
在jvm完成类加载之后,Class文件常量池(静态常量池)会被加载进jvm的内存,成为运行时常量池。它位于方法区中,1.6 在永久代,1.7 在元空间中,永久代跟元空间都是对方法区的实现 -
部分基本数据类型包装类常量池
在四类八种基本数据类型,除Float和Double之外,其他六种基本数据类型的包装类都实现了常量池技术。其实质指当创建的数值范围在[-128,128]内时,jvm会将其放入常量池中,而不是创新新的对象。当数值超出范围后,则需要重新创建对象。如下代码
Integer a = 12; Integer b = 12; System.out.println(a == b)// true. 12在范围内,将被置入常量池中,变量a,b指向同一位置。 Integer c = 129; Integer b = 129; System.out.println(a == b)//false. 数字129超出范围,创建两个对象。
-
全局字符串常量池
英文称为String table,又称为String pool。在JDK1.7之前,该常量池位于永久代中,之后为位于堆中。它的本质是一个Hashtable(底层采用数组和链表实现,Hashtable无法扩容,这一点不同于HashMap),其中存储的是字符串实例对象的引用,而不是字符串实例对象自身,类似C语言中的指针,指向字符串值在jvm内存中的位置。这个表被整个jvm共享且唯一存在,所有创建字符串的指令执行之前,都会先查询这个哈希表。如果有目标字符串,则返回目标字符串的引用,避免重复创建。
如下图所示,字符串常量池中存储了指向存储字符串实例的地址,而字符串实例中的成员变量value指向了存储字符的数组地址,且多个值相等(依据String#equal方法)的String实例的成员变量value可以指向同一个字符数组。需要注意的是,在JDK1.9中,String类的char[] 数组被改为 byte[]数组,以节约内存和减少GC次数。
当调用String#intern方法时,会扩充字符串常量池。当发生gc时,会缩减字符串常量池。
图二 字符串常量池内存结构
String#intern()
这个方法是一个 native 的方法,其方法注释为:如果常量池中存在当前字符串, 就会直接返回当前字符串. 如果常量池中没有此字符串, 会将此字符串放入常量池中后, 再返回。从注释来看,该方法的作用一方面是获取字符串常量池中用于全局共享的某个String类实例的引用,又因为它会向字符串常量池中添加新的引用,所以另一方面是可以起到扩充字符串常量池的作用,即所谓字符串池化。
在JDK6中, 当一个字符串S调用intern()方法时,如果字符串常量池中不存在指向同S值相同的String类实例的引用, 则根据S复制一份新的字符串实例S1,并S1的引用添加到字符串常量池中;如果字符串常量池中存在指向同S值相同的String类实例的引用, 直接返回字符串常量池中的引用地址。
在JDK7中, 当一个字符串S调用intern()方法时,如果字符串常量池中不存在指向同S值相同的String类实例的引用, 则直接将字符串S的引用添加到字符串常量池中;如果字符串常量池中存在指向同S值相同的String类实例的引用, 直接返回字符串常量池中的引用地址。
String的”+“拼接
使用”+“拼接字符串,编译器会在编译阶段把代码优化成使用StringBuilder
(jdk1.5后引入,之前是StringBuffer)类,并调用StringBuilder#append
方法进行字符串拼接,最后调用StringBuilder#toString
方法。 可以看到在StringBuilder#toString
中使用new新建了一个String实例,并返回该实例。注意此处的new字符串实例的方式与其他位置的new字符串实例不同,下文会介绍。
@Override
public String toString() {
// Create a copy, don't share the array
return new String(value, 0, count);
}
其基本过程为:在运行时, 两个字符串str1, str2的拼接首先会调用 String.valueOf(obj)
,这个Obj为str1,而String.valueOf(Obj)
中的实现是return obj == null ? “null” : obj.toString()
, 然后产生StringBuilder, 调用的StringBuilder(str1)
构造方法, 把StringBuilder初始化,长度为str1.length()+16
,并且调用append(str1)
接下来调用StringBuilder.append(str2)
, 把第二个字符串拼接进去, 然后调用StringBuilder.toString
返回结果。
String s=null;
s=s+"abc";
System.out.println(s); // "nullabc"
String的创建方式及内存状态
1、String s = “xyz”
这种创建方式在编译器已经完成,编译器会检查字符串常量池中是否存在指向值为“xyz”的String类实例。若存在,则返回实例的引用赋值给变量s。若不存在,则创建一个值为“xyz”的String类实例,将该实例的引用放入字符串常量池用于共享,并将该实例的引用赋值给变量s。
同这种方式类似,String s = "x" + "yz"
也会在编译器直接优化为String s = "xyz"
,然后执行上面的步骤。
其底层执行以下步骤
- 查询字符串常量池,查看JVM内存中是否已存在”xyz“实例。此处使用equal(判断值)而不是“==”(判断地址)
- 若不存在,则创建一个“xyz”实例,将这个实例的引用放入字符串常量池,并将“xyz”实例的引用返回给栈区中的变量s。
图三 创建字符串”xyz“ 之前内存结构
对比图三、图四,可以看到,创建字符串”xyz“之后,该字符串的引用被添加进了字符串常量池中。
图四 创建字符串”xyz“ 之后内存结构 - 若存在,则将“xyz”的实例引用赋值给变量s。
图五 字符串常量池已有字符串
可以看到,这种方式声明字符串,堆中最后只有一个String实例,其值为“xyz”;有两个引用指向该实例,分别是字符串常量池中的一个引用和栈区的一个引用。 该结论可使用如下代码说明//栈区中的变量s是“xyz”的引用 String s = "xyz"; //intern方法返回字符串常量池中指向值为“xyz"的String类实例的引用 String stringPool = s.intern(); //true。“==”比较地址, // 说明这个两个引用指向的地址相同,即只有一个值为“xyz”的String类实例。 System.out.println(s == stringPool);
2、String s = new String(“xyz”)
这种创建方式主要完成两部分工作。
- 查询字符串常量池中是否存在指向值为“xyz”的String类实例,若有,则无操作。若没有,则创建一个值为“xyz”的String类实例,并将该实例的引用放入字符串常量池中。这一步的操作是为了实现字符串共享。
- 因为有new关键字,因此触发创建实例动作。此处会创建一个值为“xyz”的String类实例,并将该实例的引用赋值给变量s.
需要注意的是,此处字符串常量池创建的值为“xyz”的String类实例不同于new关键字创建的值为“xyz”的String类实例。但由于两者的值都为“xyz”,按上文介绍,若多个String类实例的成员变量value指向的字符数组值相同,则该数组可以共享。此处两个实例的成员变量value都指向同一个字符数组(jdk1.8)。其最终内存状态如下。

//1、池化。创建一个将值为“xyz”的String类实例,并将这个实例的引用放入字符串常量池中。
//2、new触发创建一个新的String实例,并将这个实例的引用赋值给s0
String s0 = new String("xyz");
//获取字符串常量池中值为“xyz”的String类实例的引用
String s1 = s0.intern();
//false。“==”比较地址,
//说明s0和s1是不同类实例。
System.out.println(s0 == s1);
下图说明,不同实例共享同一个字符数组(JDK1.8)

3、String s = 表达式1 + 表达式2;
使用“+“拼接方式创建字符串,可有以下方式
String a = "x";
String b = a + "yz"; //拼接方式1
String c = new String("x") + "yz"; //拼接方式2
String d = new String("x") + new String("yz"); 拼接方式3
上文已经介绍,字符串”+“拼接底层使用StringBuild完成拼接过程,在完成的最后一步重写toString方法中,返回使用new关键字新建的String类实例。与上一小节介绍的 String s = new String("xyz")
不同,StringBuild#toString()中字符串创建完成后,不会将新建的字符串添加进字符串常量池,不管常量池中有没有对应的字符串实例。
假设我们使用String d = new String("x") + new String("yz");
拼接字符串,其内存状态如下图。从图中可以看到,新生成的”xyz“实例未添加进字符串常量池,即字符串常量池中没有存储指向”xyz“实例的引用。


//拼接字符串,将最终生成的字符串实例引用赋值给s1
String s1 = new StringBuilder("计算机").append("软件").toString();
//池化。获取字符串常量池中的引用
String s11 = s1.intern();
//true.说明两者指向同一个地址。
System.out.println(s1 == s11);
String s2 = new StringBuilder("ja").append("va").toString();
String s22 = s2.intern();
//false.原因是”java“字符串在虚拟机启动时已经添加进字符串常量池。
System.out.println(s2 == s22);
实践意义
如果程序中存在大量重复字符串,使用intern()
会节省大量空间(用一个共享字符串代替若干重复字符串),与JDK版本无关。