引言
上一篇文章中,我们分析了java中不可变对象的构造原则,然后根据构造原则分析了String类为实现不可变性这个目的采取了哪些措施。String类的很多特性都是从它的不可变性引出来的,这篇文章,我们就分析String的创建方式和字符串常量池的概念。
先看String对象的内存布局
我们知道,Java对象都是在堆上创建,对象的内存布局可以分为三个区域:对象头、实例数据和对齐填充。我们这里重点关注实例数据部分。
String对象中最重要的字段就是代表字符序列的value,它的声明如下:
private final char value[];
它是一个字符数组对象,既然是实例字段,那它肯定是在String对象的实例数据部分,但它并不是一个简单类型(int、long等),而是一个数组类型,数组类型是引用类型的一种,所以String对象的实例数据部分存储的并不是整个字符数组对象,而是一个引用,这个引用指向堆上的另外一个位置,这个位置上才是真正的字符数组对象。下图可以清晰地展示这个关系(String中还有其他实例数据,这里只简单地画出了value):
字符串常量池与字符串字面量
String的内存布局与其他引用类型的对象没有什么区别,确实如此,String的特殊之处在于String变量与String对象之间的对应关系。在讲解这种特殊的对应关系之前,我们得先学习一下字符串常量池的知识,因为它是这种特殊对应关系的关键。
在jvm的内存划分中,字符串常量池逻辑上是方法区的一部分,实现上其实是jvm堆的一部分。但是在堆上并不代表字符串常量池里面存储的是String对象,实际上字符串常量池存储的是到堆上的String对象的引用,可以将其理解为一个保存字符串对象的引用的容器。
你可能会有疑问,既然String对象已经在堆上创建了,那么根据这个对象是在哪创建的,指向它的变量或者说引用就已经在对应的位置上了,例如:
public class StringLiteral {
String aInstanceString = "aInstanceString";
public static void main(String[] args) {
String aLocalString = "aLocalString";
}
}
aInstanceString是实例变量,那么它就放在StringLiteral对象布局的实例数据部分,aLocalString是局部变量,就放到main方法的方法栈中,它们指向各自在堆上的String对象。这些都跟字符串常量池没有关系,那么字符串常量池是做什么用的呢?它存放的是哪些字符串对象的引用呢?我先以一种字符串的创建方式举例来说明字符串常量池的机制,看下面的代码:
public static void main(String[] args) {
String s = "abc";
}
这种直接用双引号将字符序列包围起来,没有new关键字的声明方式,其实是有正规名字的,叫做字符串字面量。而字符串字面量创建方式是和字符串常量池密切相关的。
我这里先把整个机制以文字形式描述出来,稍后再用一个适当的例子验证这个机制:当一个.java文件被编译成.class文件时,每个字符串字面量都通过一种特殊的方式(后面会介绍)被记录下来。当这个class文件被加载时(注意加载发生在初始化之前),JVM在.class文件中寻找字符串字面量。当找到一个时,JVM会检查是否有相等(这里说的相等是字符序列相等)的字符串在常量池中存放了堆中引用。如果找不到,就会在堆中创建一个对象,然后将它的引用存放在池中的一个常量表中。一旦一个字符串对象的引用在常量池中被创建,这个字符串在程序中的所有字面量引用都会被常量池中已经存在的那个引用代替。
第一次看到这个过程可能不太好理解,下面这个例子可以演示这个过程:
public class StringCases {
public String s1 = "String1";
public String s2 = "String2";
public String s3 = "String3";
public static void main(String[] args) {
String localString1 = "String2";
String localString2 = "String4";
}
}
上面的代码中,我声明了3个字符串字面常量域,在main方法里面也有两个字符串字面常量,我们先看当编译成.class文件后,这些字面常量是通过什么特殊的方式记录下来的。
Constant pool:
#2 = String #34 // String1
#4 = String #36 // String2
#5 = String #37 // String4
#11 = String #44 // String3
#34 = Utf8 String1
#36 = Utf8 String2
#37 = Utf8 String4
#44 = Utf8 String3
这是去掉不相关的常量项之后的内容,如果你理解JVM中的常量池,你就会猜到,是通过CONSTANT_String_info来记录的(这是我自己猜测的),每个字符串字面常量都会对应一个CONSTANT_String_info常量项,重复的只会记录一次,例如成员变量s2和局部变量localString1,所以说,记录字符串字面常量是在编译期完成的。
之后,在加载阶段(类加载的第一阶段),jvm就会查找文件中的字符串字面常量,首先找到了s1,它对应的字符序列此时还没有对应的String对象在堆中存在,在字符串常量池中也就没有指向堆上实例的引用,那么jvm就会在堆上创建一个字符序列为String1的String对象,然后将这个对象的引用放到字符串常量池中,一旦这个引用在字符串常量池被创建,class文件中的表示相同字符序列的字符串字面常量,这里就是成员变量s就会被这个引用代替。
之后找到的是s2,jvm会执行同样的操作,不过与s1不同的是,class文件中表示相同字符序列的字符串字面常量有两个,一个是成员变量s2,一个是局部变量localString1,这两个引用都会被替换成存放到字符串常量池的引用值。
其他的几个字符串字面常量,也是同样的原理。
我画了一个图,希望能更清楚地展示最终的结果:
那栈上的localString1和StringCases的s2变量的值都是字符串常量池中的引用2的值,有什么意义呢?这样就避免了相同的字符串再在堆上创建一个对象。
为了验证,我们可以在main方法中增加这行代码:
System.out.println(localString1 == String2);
输出的结果是true。确实证明了代表相同字符序列的字符串常量的引用被替换成了同一个。
如果你理解了这个机制,那么说出下面代码的执行结果也就轻而易举了:
public static void main(String[] args) {
String s = "abc";
String s1 = "abc";
System.out.println(s == s1);
}
结果是true。
你也应该能理解下面这个更加神奇的例子:
public class StringCases {
private String s = "aString";
public static void main(String[] args) {
System.out.println(new StringCases().s == new StringCases().s);
}
}
输出结果同样为true。
注意上面我们看到的,都是以字符串字面量方式创建字符串对象为前提的。字符串字面量创建方式与字符串常量池的这个机制,可以叫做字符串字面常量的默认限定。也就是说,字符串字面量都是默认通过字符串常量池限定的,这个不需要程序员的干预。《java语言规范》中对应的描述是这样的:一个字符串字面常量总是引用String类的同一个实例。这是因为字符串字面常量,或者更一般的情况,表示常量表达式的值的字符串,被通过String.intern()方法而限定了,这样做是为了让它们可以共享唯一的实例。
String.intern()方法我们稍后会讲到。
使用new关键字创建字符串
除了使用字符串字面量,我们也经常会用new关键字来创建String对象,这种创建方式同样隐藏着很多需要我们探索的细节,我们这里只讲解String s = new String(“abc”);这种方式。首先来看构造方法:
/** * Initializes a newly created {@code String} object so that it represents
* the same sequence of characters as the argument; in other words, the
* newly created string is a copy of the argument string. Unless an
* explicit copy of {@code original} is needed, use of this constructor is
* unnecessary since Strings are immutable. * * @param original *
A {@code String} */
public String(String original) {
this.value = original.value;
this.hash = original.hash;
}
在使用一个字符串构造另外一个字符串时,只是将新创建的字符串的value指向了原有字符串的value,然后hash值也是原有字符串的hash值。这相当于生成了原有字符串对象的一个copy。
然后我通过一个例子讲解使用new关键字创建字符串对象的流程:
public static void main(String[] args) {
String s = new String("abc");
}
首先,我们使用了一个字符串字面量”abc”,它的机制肯定如上面描述,abc在编译期就会被记录下来,之后在堆上创建一个values为abc的String对象(values作为char数组类型,同样是一个堆上的对象,String对象中保存的是到这个数组对象的引用),字符串常量池中保存到堆上String对象的引用,而new关键字在堆上又会创建一个新的字符串对象,它的values引用指向前面String对象的values。也就是说,这行代码会在堆上创建两个字符串对象,两个字符串对象指向同一个char数组对象,同时字符串常量池中保存一个到第一个创建的字符串对象的引用。如下图:
为了展示清楚,我把字符串常量池和堆分开了,实际上字符串常量池在物理上是堆的一部分。
其中,s1是字符串字面常量abc在限定过程中创建的字符串对象,s2是new关键字创建的对象,abc就是字符数组对象,很明显s1和s2是两个不同的对象。
所以当使用new关键字的时候,总是会创建新的字符串对象并且不会涉及到字符串常量池,也就是说,使用new关键字创建的字符串不会限定。
所以下面两个例子的输出都是false:
public static void main(String[] args) {
String s = new String("abc");
String s1 = new String("abc");
System.out.println(s == s1);
}
public static void main(String[] args) {
String s = "abc";
String s1 = new String("abc");
System.out.println(s == s1);
}
虽然new关键字创建的字符串不会被默认限定,但是String类提供了intern()方法来显式地限定某个字符串。这是一个native方法,返回的是String。我们这里介绍的intern()方法的实现机制是针对jdk1.7及之后版本的。
intern方法的作用是:当对String s执行s.intern()方法时,会去检查字符串常量池中是否已经有引用指向与s的字符序列相同的字符串对象,如果有,就直接返回字符串常量池中的这个引用,如果没有,就会将s的引用放到字符串常量池,然后返回该引用。
我们举几个例子来更清晰地理解这个机制:
public static void main(String[] args) {
String s = new String("a") + new String("bc");
System.out.println(s.intern() == s);
}
这个例子演示的是字符串常量池中没有引用的情况。我使用new String(“a”) + new String(“bc”)来生成代表abc的字符串对象而不是直接String s = new String(“abc”),避免了abc被默认限定,所以此时字符串常量池中是没有引用的,这时候执行s.intern方法会将s的引用放到常量池然后返回,所以输出是true。
我改一下这个例子:
public static void main(String[] args) {
String s = new String("abc");
System.out.println(s.intern() == s);
}
这个例子演示的是字符串常量池中已经存在引用的情况。new String(“abc”);我们已经在前面讲过了,abc这个字符串字面常量因为默认限定会在字符串常量池中存放一个引用,所以执行s.intern()方法的时候,直接返回这个引用,而这个引用与s不是一个。
再看一个例子:
public static void main(String[] args) {
String abc1 = new StringBuilder("a").append("bc").toString();
String abc2 = new StringBuilder("ab").append("c").toString();
String abc2Intern = abc2.intern();
String abc1Intern = abc1.intern();
System.out.println(abc1Intern == abc1);
System.out.println(abc1Intern == abc2);
System.out.println(abc1Intern == abc2Intern);
}
在执行完前两行代码之后,字符串常量池中没有存放引用。执行abc2.intern(),因为常量池中没有引用,所以会将到abc2这个堆上对象的引用放到字符串常量池,而在执行abc1.intern()时,因为常量池中已经存在引用,就会直接返回这个引用,也就是到abc2对象的引用。所以输出的结果是:false、true、true。
还有下面这个例子:
public static void main(String[] args) {
String s = new String("1");
s.intern();
String s2 = "1";
System.out.println(s == s2);
String s3 = new String("1") + new String("1");
s3.intern(); String s4 = "11";
System.out.println(s3 == s4);
}
输出的是:false true
然后将s3.intern()语句下调一行,放到String s4 = “11”;后面。将s.intern()放到String s2= “1”;后面,如下所示:
public static void main(String[] args) {
String s = new String("1");
String s2 = "1";
s.intern();
System.out.println(s == s2);
String s3 = new String("1") + new String("1");
String s4 = "11";
s3.intern();
System.out.println(s3 == s4);
}
输出的是:false false。
好了,如果上面几个例子你都能明白,那么intern方法应该就没问题了。我还得强调一遍,intern()方法是为了对new关键字生成的字符串对象进行默认限定的,因为new关键字生成的字符串对象不会默认限定,而字符串字面量形式创建的的字符串是默认限定的,所以对这类对象使用intern ()来进行限定是没有什么意义的,当然也不会有错误。
jdk1.7之前的intern实现
在jdk1.6以前(包括jdk1.6)和jdk1.6之后的版本中,intern方法的表现是不同的,jdk1.6以后的版本中的实现我们在上面已经探讨过了。
jdk1.7之前,字符串常量池是方法区(永久代)的一部分,执行intern方法时,如果该字符串不在常量池中,虚拟机会在常量池中复制该字符串,如果在常量池中,就会直接返回该字符串的引用。注意这里的关键词是复制,正是这个原因,我们需要谨慎使用intern方法,防止常量池中字符串数量过多导致PermGen内存溢出。
在jdk1.7之前,执行下面的代码可以导致方法区溢出:
public static void main(String[] args){
List<String> list = new ArrayList();
int i = 0;
while(true){
list.add(String.valueOf(i++).intern);
}
}
小结
字符串的使用简单但机制复杂,不同的创建方式有着不同的实现原理,还牵扯到变量、对象、字符串常量池甚至jvm内存划分等知识,真正理解的确需要相当的时间和精力。这篇文章虽然讲的很多,但不可能面面俱到,如果有没有讲解清楚的地方,希望读者能自己摸索答案或者提出问题。
下篇文章,我们来看一下编译器对字符串的优化。