字符串String是日常Java开发中使用最多的数据类型之一,今天我将从JVM层面来分析一下字符串的原理
先简单解释一下什么是字符串:
在Java中String表示字符串,使用一对""来表示,它有如下特点:
- String类被声明为final类型,说明String不可被继承
- String实现了Serializable和Comparable接口,表示字符串支持序列化,并且可以比较大小的
- String在JDK8及以前内部定义了final char[] value用于存储字符串数据,JDK9改为byte[],说明字符串对象一旦创建就不可修改
字符串常量池
字符串常量池是JVM内存中的一块区域用来存储字符串常量的,底层是一个固定大小的Hashtable,字符串常量池中不会存储内容相同的字符串。字符串常量池在JDK6、JDK7、JDK8版本中都有一些变化:
在JDK6中,字符串常量池(String Table)在永久代中:
StringTable的长度默认为1009,可以通过-XX:StringTableSize设置长度
在JDK7中,永久代中的静态变量和字符串常量池则被移动到了堆中(StringTable逻辑上仍属于方法区)
并且StringTable的默认长度改成了60013,同样可以通过-XX:StringTableSize设置长度
在JDK8中,字符串常量池仍然在堆中,不过原来的永久代变成了元空间实现。需要注意的是使用-XX:StringTableSize设置StringTable长度时,指定参数不能小于1009,也就是字符串常量池的长度不低于1009。在JVM中的布局如下:
字符串拼接操作
字符串的拼接有如下特点:
1.字面量与字面量的拼接,包括常量引用(final修饰的String变量)的拼接,结果在常量池中,原理是编译期优化
例如:
String s1 = "a" + "b";
String s2 = "ab";
System.out.println(s1 == s2); // true,直接使用字面量形式字符串进行拼接,编译期会直接优化成拼接后的字符串
// final修饰是变量是常量,会在编译期优化存储到常量池
final String s3 = "c";
final String s4 = "d";
String s5 = s3 + s4;
String s6 = "cd";
System.out.println(s5 == s6); // true,常量的拼接,结果在常量池中
2.只要拼接参数中有一个是变量,结果就在堆中,拼接原理是StringBuilder的append
对于第二点,我们可以通过字节码来进行验证,对于如下代码:
public class StringDemo {
public static void main(String[] args) {
String s1 = "a";
String s2 = "b";
String s3 = s1 + s2;
}
}
我们通过jclasslib工具来查看main的字节码如下:
前4步:分别从常量池中取出"a"和"b"放入局部变量表索引为1和2的位置中。
第5行: 6 new #4 <java/lang/StringBuilder>,可以看到为StringBuilder类分配了内存,
第7行:10 invokespecial #5 <java/lang/StringBuilder.< init>>,执行StringBuilder的构造方法,第5行到第7行的代码就代表创建了一个StringBuilder对象
第9行到第11行,表示从局部变量表中加载之前保存的两个字符串"a",“b”,分别执行append(“a”),append(“b”)
第12行:执行StringBuilder对象的toString(),得到拼接后的字符串对象
以上我们证明了在有变量参与字符串拼接时,底层实际上是调用了创建了一个StringBuilder对象,并通过append方法进行拼接,最后通过toString方法返回字符串对象
因此,如果我们要对大量字符串变量进行拼接时,尤其是循环拼接并赋值的情况,最好直接使用StringBuilder,而不要用"+“来拼接。因为只要有变量参与字符串拼接,并且有赋值操作,就会创建一个StringBuilder对象,如下一个例子可以证明使用”+"拼接字符串和直接使用StringBuilder进行字符串拼接在效率上的惊人差距:
执行结果:
可见两者的差距是很大很大的
intern()
String提供了一个intern()方法:intern方法会从字符串常量池中查询当前字符串是否存在,如果存在,则返回常量池中的字符串对象,若不存在,则在字符串常量池中创建该字符串并返回常量池中该字符串的引用
也就是说,任意字符串调用intern方法,返回的结果所指向的字符串实例,必定和直接以常量形式出现的字符串实例完全相同。因此,下列的表达式为true:
("a" + "b" + "c").intern() == "abc"
保证变量s指向字符串常量池中的字符串实例有两种方式:
- 直接通过字面量定义:String s = “abc”;
- 任何字符串形式调用intern方法:
String s = new String(“abc”).intern();
String s = new StringBuilder(“abc”).toString().intern();
字符串相关面试题
1.String s = new String(“ab”);会创建几个对象?
答案:2
我们依然从字节码层面看,上面一行代码的字节码如下:
其中第4步,ldc #3 ,表示从常量池中取出"ab"并压入操作数栈,说明JVM会提前将参数"ab"放入常量池,第6步又调用了String的构造方法,会在堆中重新创建一个字符串对象"ab",因此有有两个对象
2.String s = new String(“a”) + new String(“b”);会创建几个对象?
同样结合字节码:
我就不一一解释每一行代码的作用了,大致可以分析出如下对象:
1.1个StringBuilder对象
2.跟问题1同理,new String(“a”)和new String(“b”)都各创建2个对象,一共4个
3.第31步调用StringBuilder的toString方法,定位到常量池#9中的toString方法字节码:
发现toString方法仅创建了一个String对象,没有从常量池中取数据,也就是说拼接后的字符串没有放入常量池。原理可以通过查看StringBuilder的toString方法源码,其实是通过拷贝String内部的char[]数组来构造出新的拼接后的字符串对象
所以一共创建了6个对象
3.如下代码执行结果如何?
public static void main(String[] args) {
String a1 = new String("ab");
a1.intern();
String a2 = "ab";
System.out.println(a1 == a2);
String b1 = new String("c") + new String("d");
b1.intern();
String b2 = "cd";
System.out.println(b1 == b2);
}
答案:
JDK7及以后的版本结果:
false
true
JDK1.6及之前的版本结果:
false
false
第一段代码new String(“ab”);会分别在堆和常量池中创建一个字符串对象"ab",但只会返回堆中的字符串地址,a.intern()执行并不会做什么,因为常量池中已经有"ab"了,而a2指向的是常量池中"ab",所以a1和a2不相等
为什么第二段代码中b1==b2成立?原来,intern方法在底层有这么一个优化:在字符串常量池中创建字符串对象时,如果堆中已经有相同的字符串,那么常量池就不会再创建该字符串了,而是将堆中该字符串的地址拷贝一份保存在StringTable中,如果堆中没有相同字符串,才会创建。我们已经知道new String(“c”) + new String(“d”);会返回一个拼接后的字符串对象,且这个对象不会放在常量池,而是在堆中。所以代码中b2所指向"cd"其实就是堆中的"cd"
至于JDK1.6及之前的版本,由于字符串常量池在永久代中,而对象是在堆中,intern方法不会优先去查看堆中是否已有相同字符串存在,所以执行结果均为false
总结String的intern():
1.在JDK1.6中:
》如果字符串常量池中有,则不会放入,返回串池中已有的字符串对象的地址
》如果字符串常量池中没有,则把此对对象复制一份,放入串池,并返回串池中对象的地址
2.从JDK1.7起:
》如果字符串常量池中有,则不会放入,返回串池中已有的字符串对象的地址
》如果字符串常量池中没有,但堆中其他位置已有相同内容的字符串,则将堆中字符串地址保存在池中,如果堆中没有相同内容的字符串,则会在池中创建一个字符串对象并返回地址
全文总结:遇事不决字节码!