这篇文章解决了我很多关于String拼接创建的问题,是直接从我的OneNote上复制下来的,觉得这个总结太棒了一定要与大家分享,但是原文内容已按我容易阅读的方式进行了整理,如果侵犯到您请联系我,我将删除该内容:),如果下面的格式不易阅读,最下面还提供了带有格式的图片,在系统附件的画图程序中打开是最佳分辨率:)
阅读前最好先对Java的.class文件的文件结构有所了解:)
原文地址:http://www.iteye.com/topic/522167
.java文件首先要被编译器编译成字节码文件(即.class文件),然后再由JVM解释执行,.class文件是二进制流。
public class HelloWorld{
void hello(){
System.out.println("Hello world");
}
}
在.class文件中的常量池专门放置源代码中的符号信息,且不同的符号信息放置在拥有不同标志(常量池项头)的常量表(常量池项)中,下图右侧是HelloWorld.class所对应的常量表,其中有四个不同类型的常量项。
通过上图可见,代码中的"Hello world"字符串字面值被编译之后,被放到了.class文件的常量池中的字符串常量池中(右侧红框区域)
当JVM加载.class文件的时,会为对应的常量池建立一个内存数据结构,并存放在方法区中。同时JVM会自动为常量池项头为CONSTANT_String_info的常量池项中的字符串字面值在堆中创建新的String对象(intern字符串对象,又叫拘留字符串对象),然后把常量池项头为CONSTANT_String_info的常量池项的入口地址变为堆中String对象的直接地址(常量池解析)。
源代码中所有相同字面值的字符串常量只可能建立唯一一个拘留字符串对象,JVM是通过一个记录了拘留字符串引用的内部数据结构来维持这一特性的。在Java程序中,可以调用String的intern()方法来使得一个常规字符串对象成为拘留字符串对象。
下面根据二进制指令来区别两种字符串对象的创建方式:
(1)String s=new String("Hello world");编译成class文件后的指令(在Eclipse中查看Class字节码指令集代码):
0 new java.lang.String [15] //在堆中分配一个String类对象的空间,并将该对象的地址堆入操作数栈。
3 dup //复制操作数栈顶数据,并压入操作数栈。该指令使得操作数栈中有两个String对象的引用值。
4 ldc <String "Hello world"> [17] //将常量池中的字符串常量"Hello world"指向的堆中拘留String对象的地址压入操作数栈
6 invokespecial java.lang.String(java.lang.String) [19] //调用String的初始化方法,弹出操作数栈栈顶的两个对象地址,用拘留String对象的值初始化new指令创建的String对象,然后将这个对象的引用压入操作数栈
9 astore_1 [s] // 弹出操作数栈顶数据存放在局部变量区的第一个位置上。此时存放的是new指令创建出的,已经被初始化的String对象的地址 (此时的栈顶值弹出存入局部变量中去)。
注意:这里有个dup指令。其作用就是复制之前分配的Java.lang.String空间的引用并压入栈顶。那么这里为什么需要这样么做呢?因为invokespecial指令通过[15]这个常量池入口寻找到了java.lang.String()构造方法,构造方法虽然找到了。但是必须还得知道是谁的构造方法,所以要将之前分配的空间的应用压入栈顶让invokespecial命令应用才知道原来这个构造方法是刚才创建的那个引用的,调用完成之后将栈顶的值弹出。之后调用astore_1将此时的栈顶值弹出存入局部变量中去。
事实上,在运行这段指令之前,JVM就已经为"Hello world"在堆中创建了一个拘留字符串(如果源程序中还有一个"Hello world"字符串常量,那么它们都对应于同一个堆中的拘留字符串)。然后利用这个拘留字符串的值来初始化堆中用new指令创建出来的新的String对象,局部变量s实际上存储的是new出来的堆对象地址。
注意:此时在JVM管理的堆中,有两个相同字符串值的String对象:一个是拘留字符串对象,另一个是new新建的字符串对象,如果还有一条创建语句:
String s1=new String("Helloworld");
此时堆中有几个值为"Hello world"的字符串呢?答案是3个 。
(2)将Strings="Hello world";编译成class文件后的指令:
0 ldc <String "Hello world"> [15]//将常量池中的字符串常量"Hello world"指向的堆中拘留String对象的地址压入操作数栈
2 astore_1 [str] // 弹出操作数栈顶数据存放在局部变量区的第一个位置上,此时存放的是拘留字符串对象在堆中的地址
和上面的创建指令有很大的不同,局部变量s存储的是早已创建好的拘留字符串的堆地址(没有new的对象了)。如果还有一条创建语句:
String s1="Helloword"
此时堆中有几个值为"Hello world"的字符串呢?答案是1个
String a = new String("Helloworld"); //先根据"kill"创建拘留字符串对象,再根据该拘留字符串对象创建新的String对象,并将该地址赋给s1
String b = new String("Helloworld"); //再次根据该拘留字符串对象创建新的String对象,并将该地址赋给s1
System.out.println(a == b); // false
局部变量a、b中存储的是JVM在堆中new出来的两个String对象的内存地址。虽然这两个String对象的值(char[]存放的字符序列)都是"Hello world",但“==”比较的是两个不同的堆地址。
String a = "Helloworld";
String b = "Helloworld";
System.out.println(a == b); // true
局部变量a、b中存储的也是地址,但都是常量池中"Helloworld"解析到堆中唯一的那个拘留字符串对象的地址 ,自然相等。
String a = "ab"; //产生拘留字符串对象
String b = "cd"; //产生拘留字符串对象
String c = a + b; // 产生StringBuilder对象、String对象,最后将String对象的值赋给c
String d = "abcd"; //产生拘留字符串对象
System.out.println(c == d); // false
a、b存储的是堆中两个拘留字符串对象的地址,而当执行a + b时,JVM首先会在堆中创建一个StringBuilder对象,同时用a指向的拘留字符串对象完成初始化,然后调用append()方法完成对b所指向的拘留字符串的合并操作,接着调用StringBuilder的toString()方法在堆中创建一个String对象,最后将刚生成的String对象的堆地址存放在局部变量c中。而d存储的是常量池中"abcd"所对应的拘留字符串对象的地址 ,自然不相等。注意:代码的堆中实际上有五个字符串对象,三个拘留字符串对象、一个String对象和一个StringBuilder对象。
String a = "ab" + "cd";
String b = "abcd";
System.out.println(a == b ); // true
代码中"ab" + "cd"会直接在编译期就合并成常量"abcd", 因此相同字面值常量"abcd"所对应的是同一个拘留字符串对象,自然地址相同。
String、StringBuilder、StringBuffer
String | JDK1.0 | 不可变字符序列 |
StringBuffer | JDK1.0 | 线程安全的可变字符序列 |
StringBuilder | JDK1.5 | 非线程安全的可变字符序列 |
String.class
public final class Stringimplementsjava.io.Serializable, Comparable<String>, CharSequence {
private final char value[];
private int hash;
public String(String original) { //将拘留字符串对象的字符数组赋给新创建的String对象的字符数组
this.value =original.value;
this.hash= original.hash;
}
}
StringBuffer.class
public final class StringBufferextends AbstractStringBuilder implementsjava.io.Serializable, CharSequence {
private transient char[] toStringCache;
public StringBuffer(String str) {
super(str.length() + 16);
append(str);
}
}
String和StringBuffer中的字符数组value[]都用于存储字符序列,区别在于:
(1)String.class中的字符数组是final类型,只能被赋值一次。
如:new String("abc")使得新创建的String对象的字符数组为value[]={'a','b','c'},之后这个String对象中的字符数组value[]不能被重新赋值再也不能改变了。这也是String不可变的原因。
(2)StringBuffer中的字符数组value[]是一个普通数组,而且可以通过append()方法将新字符串加入value[]末尾,这样就改变了字符数组value[]的内容和大小。
如:new StringBuffer("abc")使得新创建的StringBuffer对象的字符数组为value[]={'a','b','c','',''...}(注意构造的长度为str.length()+16)。如将该对象进行append("abc")操作,则对象中的字符数组将变为value[]={'a','b','c','a','b','c',''....}。这就是为什么StringBuffer是可变字符串 的原因。从这一点也可以看出,StringBuffer中的value[]完全可以作为字符串的缓冲区功能。其累加性能是很不错的,在后面我们会进行比较
注意:讨论String、StringBuffer是否可变,指的是对象中包含的字符数组value[]是否可变,而不是对象的引用是否可变。
StringBuffer与StringBuilder的线程安全问题
StringBuffer是线程安全的,因为StringBuffer中的很多方法都被关键字synchronized修饰,而StringBuilder则没有。
注意:是不是String线程安全的?事实上不存在这个问题,String是不可变的。线程对于堆中指定的一个String对象只能读取,无法修改,所以不涉及线程安全的问题。
String和StringBuffer的效率问题比较
如果不考虑线程安全,StringBuilder应该是首选。另外,JVM运行程序主要的时间耗费是在创建对象和回收对象上,用下面的代码运行1000w次字符串的连接操作,测试String、StringBuffer运行所需时间。
public class Demo {
public static void main(String[] args) {
// 测试代码位置①
long beginTime = System.currentTimeMillis();
for (int i= 0; i < 10000000; i++) {
// 测试代码位置②
}
long endTime = System.currentTimeMillis();
System.out.println(endTime - beginTime);
}
}
String常量的“+”连接(平均耗时3)
测试代码位置①:
String str = "";
测试代码位置②:
str = "Wel" + "come";
String变量的“+”连接 (平均耗时700)
测试代码位置①:
String str1 = "Wel";
String str2 = "come";
String str = "";
测试代码位置②:
str = str1 + str2;
结论:String常量的“+”连接稍优于String变量的“+”连接。
原因:
测试①中"Wel" + "come"在编译阶段就已经被连接起来,形成了一个字符串字面值"HeartRaid"保存在字符串常量池中,类被加载后在堆中创建了对应的拘留字符串对象。运行时只需要将"Welcome"指向的拘留字符串对象地址取出并赋给变量str,重复1000w次,所以不需要什么时间。
测试②中s1、s2存放的是两个不同的拘留字符串对象地址,然后通过下面三个步骤完成“+”连接。
StringBuilder temp=new StringBuilder(s1),
temp.append(s2);
str=temp.toString();
虽然在中间的时候也用到了append()方法,但是在开始和结束的时候分别创建了StringBuilder和String对象,可想而知,调用1000w次次,就是将这两个对象创建了1000w次这两种对象,而JVM运行程序主要的时间耗费是在创建对象和回收对象上,而且这里还涉及append()扩容时所消耗的时间。
下面将循环次数改为1w
测试代码位置①:(平均耗时300)
String s1 = "Welcome";
String s = "";
测试代码位置②:
s = s + s1;
测试代码位置①:(平均耗时2)
String s1 = "Welcome";
StringBuffer sb = new StringBuffer();
测试代码位置②:
sb.append(s1);
原因:
执行s =s + s1时,JVM会先创建一个StringBuilder对象,利用s初始化该StringBuilder对象,并利用append()完成与s1所指向的字符串对象值的合并操作,接着调用StringBuilder的toString()方法在堆中创建一个新的String对象,其值为刚才的合并结果,此时s指向了新的String对象。
因为String对象中的字符数组value[]是不能改变的,每一次合并后字符串值都需要创建一个新的String对象来存放,循环1W次自然需要创建1W个String对象和1W个StringBuilder对象,造成效率低下。
而sb.append(s1);只需要StringBuilder对象中的字符数组value[]的尺寸不停扩容来存放s1即可,循环过程中无需在堆中创建任何新的对象,所以效率更高。
intern()
String.class
public native String intern();
String s1 = "kill"; //"kill"编译后保存.class文件的字符串常量池,加载.class文件后会根据字符串字面量生成对应的拘留字符串对象,s1保存的是该拘留字符串对象的地址
String s2 = new String("kill"); // s2、s3根据s1所指向的拘留字符串对象的值,在堆中new出来的两个新的String对象的内存地址
String s3 = new String("kill");
System.out.println(s1 == s2); // false
s2.intern(); //如果s2所指向的String对象中的字符串在堆中已经有对应的拘留字符串对象,则返回该拘留字符串对象的地址,如果没有,生成相应的拘留字符串对象
s3 = s3.intern(); //此时s3指向的是拘留字符串对象的地址
System.out.println(s1 == s2); // false
System.out.println(s1 == s2.intern()); // true
System.out.println(s1 == s3); // true
String s1 = new String("kill"); //先根据"kill"创建拘留字符串对象,再根据该拘留字符串对象创建新的String对象,并将该地址赋给s1
String s2 = s1.intern(); //获取s1所指向的String所对应的拘留字符串对象
System.out.println(s1); // kill
System.out.println(s2); // kill
System.out.println(s1 == s1.intern()); // false
System.out.println(s2 == s1.intern()); // true