
1.16 字符串创建与存储的机制
在Java语言中,对String对象提供了专门的字符串常量池。为了便于理解,首先介绍在Java语言中字符串的存储机制,在Java语言中,字符串的声明与初始化主要有如下两种情况:
1)对于String s1=new String("abc")语句与String s2=new String("abc")语句,存在两个引用对象s1、s2,两个内容相同的字符串对象"abc",它们在内存中的地址是不同的。只要用到new总会生成新的对象。
2)对于String s1="abc"语句与String s2="abc"语句,在JVM中存在着一个字符串池,其中保存着很多String对象,并且可以被共享使用,s1、s2引用的是同一个常量池中的对象。由于String的实现采用了Flyweight的设计模式,当创建一个字符串常量的时候,例如String s ="abc",会首先在字符串常量池中查找是否已经有相同的字符串被定义,它的判断依据是String类equals(Object obj)方法的返回值。如果已经定义,那么直接获取对其的引用,此时不需要创建新的对象,如果没有定义,那么首先创建这个对象,然后把它加入字符串池中,再将它的引用返回。由于String是不可变类,一旦创建好了就不能被修改,因此String对象可以被共享而且不会导致程序的混乱。
具体而言:

再例如:

为了便于理解,可以把String s = new String("abc")语句的执行人为地分解成两个过程:第一个过程是新建对象的过程,即new String("abc"),第二个过程是赋值的过程,即String s=new String("abc")。由于第二个过程中只是定义了一个名为s的String类型的变量,将一个String类型对象的引用赋值给s,因此在这个过程中不会创建新的对象。第一个过程中new String("abc")会调用String类的构方法:

由于在调用这个构造方法的时候,传入了一个字符串常量,因此语句new String("abc")也就等价于"abc"和new String()两个操作。如果在字符串池中不存在"abc",那么会创建一个字符串常量"abc",并将其添加到字符串池中,如果存在,那么不创建,然后new String()会在堆中创建一个新的对象。所以str3与str4指向的是堆中不同的String对象,地址自然也不相同了。如图1-6所示。

图1-6 两种字符串存储方式
从上面的分析可以看出,在创建字符串对象的时候,会根据不同的情况来确定字符串被放在常量区还是堆中。而intern方法主要用来把字符串放入字符串常量池中。在以下两种情况下,字符串会被放到字符串常量池中:
1)直接使用双引号声明的String对象都会直接存储在常量池中。
2)通过调用String提供的intern方法把字符串放到常量池中,intern方法会从字符串常量池中查询当前字符串是否存在,若不存在,则会将当前字符串放入常量池中。
intern方法在JDK1.6和JDK1.8下有着不同的工作原理,下面通过一个例子来介绍它们的不同之处。

以上程序的运行结果为:

从上面例子的运行结果可以看出,在JDK1.6及以前的版本中,两种写法得到的结果是类似的,从JDK1.7开始的版本中对intern方法的处理是不同的,下面分别介绍这两种不同的实现方式。
(1)在JDK1.6及以前版本中的实现原理
intern()方法会查询字符串常量池是否存在当前字符串,若不存在则将当前字符串复制到字符串常量池中,并返回字符串常量池中的引用。
如图1-7所示,在JDK1.6中的字符串常量池是在Perm区中,前面提到过使用引号声明的字符串会直接存储在字符串常量池中,而new出来的String对象是放在堆区。即使通过调用intern方法把字符串放入字符串常量区中,由于堆和Perm区是两块独立的存储空间,存储在堆和Perm区中的对象一定会有不同的存储空间,因此,它们也有不同的地址。

图1-7 intern方法在JDK1.6及更低版本中的实现原理
(2)在JDK1.7及以上版本中的实现原理
intern()方法会先查询字符串常量池是否存在当前字符串,若字符串常量池中不存在则再从堆中查询,然后存储并返回相关引用;若都不存在则将当前字符串复制到字符串常量池中,并返回字符串常量池中的引用。实现原理如图1-8所示。

图1-8 intern方法在JDK1.7及以上版本中的实现原理
1)String s1=new String("a")。这句代码生成了两个对象,常量池中的“a”和堆中的字符串对象。s1.intern();这一句代码执行的时候,s1对象首先去常量池中寻找,由于发现“a”已经在常量池里了,因此不做任何操作。
2)接下来执行String s2="a"。这句代码是在栈中生成一个s2的引用,这个引用指向常量池中的“a”对象。显然s1与s2有不同的地址。
3)String s3=new String("a")+new String("a")。这行代码在字符串常量池中生成“a”(由于已经存在了,不会创建新的字符串),并且在堆中生成一个字符串对象(字符串的内容为“aa”),s3指向这个堆中的对象。需要注意的是,此时常量池中还不存在字符串“aa”。
4)接下来执行s3.intern()。这句代码执行的过程是,首先判断“aa”在字符串常量区中不存在,此时会把“aa”放入字符串常量区中,在JDK1.6中,会在常量池中生成一个“aa”的对象。由于从JDK1.7开始字符串常量池从Perm区移到堆中了,在这种情况下,常量池中不需要再存储一份对象,而是直接存储堆中的引用。这份引用指向s3引用的对象。如图1-9所示,字符串常量区中的字符串“aa”直接指向堆中的字符串对象。由此可见,这种实现方式能够大大降低字符串所占用的内存空间。
5)执行String s4 = "aa"的时候,由于这个字符串在字符串常量区中已经存在了(指向s3引用对象的一个引用),所以s4引用就指向和s3一样了。因此s3==s4的结果是true。
如果把上面例子中的代码的顺序调整,那么就会得到不同的运行结果,如下例所示:

上述代码的运行结果为:

1)String s1=newString("a"),生成了常量池中的字符串“a”、堆空间中的字符串对象和指向堆空间对象的引用s1。
2)String s2="a",这行代码是生成一个s2的引用并直接指向常量池中的“a”对象。
3)s1.intern(),由于“a”已经在字符串常量区中存在了,因此这一行代码没有什么实际作用。显然s1与s2的引用地址是不相同的。
4)String s3 = new String("a") + newString("a"),这行代码在字符串常量池中生成“a”(由于已经存在了,不会创建新的字符串),并且在堆中生成一个字符串对象(字符串的内容为“aa”),s3指向这个堆中的对象。需要注意的是,此时常量池中还不存在字符串“aa”。
5)String s4 = "aa",这一行代码执行的时候,首先在字符串常量区中生成字符串“aa”,接着s4指向字符串常量区中的“aa”。
6)s3.intern(),由于“aa”已经存在了,这一行代码没有实际的作用。
引申1:intern方法内部是怎么实现的?
intern方法主要通过JNI调用C++实现的StringTable的intern方法来实现的,StringTable的intern方法与Java中的HashMap的实现非常类似,但是C++中的StringTable没有自动扩容的功能。在JDK1.6中,它的默认大小为1009。由此可见,String的String Pool使用了一个固定大小的Hashtable来实现,如果往字符串常量区中放入过多的字符串,那么就会造成Hash冲突严重,解决冲突需要额外的时间,这就会导致使用字符串常量池的时候性能会下降。因此在编写代码的时候需要注意这个问题。为了提供一定的灵活性,JDK1.7中提供了下面的参数来指定StringTable的长度:

引申2:如何验证从JDK1.7开始字符串常量被移到堆中了?
可以通过intern方法把大量的字符串都存放在字符串常量池中,直到常量池空间不够了导致溢出,根据抛出的异常可以查看是哪部分内存不够而导致溢出的,如下例所示:


在JDK1.6及以下的版本运行会抛出“java.lang.OutOfMemoryError:PermGen space”异常,说明字符串常量池是存储在永久代中的。而在JDK1.7及以上的版本中运行上述代码,会抛出“java.lang.OutOfMemoryError:Java heap space”异常,说明从JDK1.7开始,字符串常量池被存储在堆中。
常见面试笔试题:
(1)new String("abc")创建了几个对象?
答案:一个或两个。如果常量池中原来有“abc”,那么只创建一个对象,如果常量池中原来没有“abc”,那么就会创建两个对象。
(2)Java中由substring方法是否会引起内存泄漏?
答案:这道题考查了两方面的内容,一方面是对Java中String类的substring方法的理解,另一方面考查的是对Java中内存泄漏的理解。众所周知,在Java编程中,程序员是不需要关心内存的分配与释放的,这些工作都是由垃圾回收器来完成的。但是垃圾回收器只能回收不再被使用的对象,如果想让垃圾回收器回收一个对象,那么必须要保证这个对象不再被引用,否则垃圾回收器无法回收这个对象。在Java中,内存泄漏通常指的是程序员认为一个对象会被垃圾回收器收集,但是由于某种原因垃圾回收器无法回收这个对象。
对于这道题而言,首先需要理解subString的内部实现原理。只有Java1.6之前的版本才会有内存泄漏的问题。substring(int beginIndex, int endIndex)方法返回一个字符串的子串,这个子串从beginIndex开始,结束于endindex-1(下标从0开始,子字符串包含beginIndex而不包含endIndex)。例如:

前面介绍过String是不可变量,给字符串赋新值会创建一个新的字符串。也就是说在上面的例子中,在执行第一行代码的时候,会在常量池中创建一个字符串“Hello world”,第二行代码执行后,s会指向常量池中新的字符串“world”,因此,“Hello world”就没有人访问了,可以被垃圾回收器回收。但是在Java1.6中,“Hello world”是无法被垃圾回收器回收的。为了理解其中的原因,下面首先给出substring的实现源码:


在JDK1.6中,String类中存储了三个重要的属性:char[] value、int offset和int count,分别用来表示字符串对应的字符数组、数组的起始位置及String中包含的字符数。由这三个变量就可以唯一决定一个字符串。在调用substring方法的时候,虽然会创建一个新的字符串,但是新对象的value仍然会使用原来字符串的value属性。只是count和offset的值不一样而已,如图1-9所示。

图1-9 String在JDK1.6中的存储方式
虽然字符串在堆中是一个新的对象,但是它与原字符串都指向了相同的字符数组。对于垃圾回收器来说,这个字符数组仍然被使用,因此无法回收。“Hello world”这个字符串虽然不被使用了,但是仍然无法被垃圾回收器回收,因此就造成了内存泄漏。
从JDK1.7开始,这个方法内部的实现被修改了,从而避免了内存泄漏,下面是JDK1.7中substring的实现源码:


从上面的代码可以看出,在copyOfRange方法中,新的子串通过new char[newLength]创建了一个独立的字符数组,显然没有与原字符串使用相同的字符数组,如图1-10所示。

图1-10 String在JDK1.7中的存储方式
从图1-10可以看出,在调用substring后,字符串“Hello world”将不再被引用,因此可以被垃圾回收器回收。