大家都知道, String 被声明为 final,因此它不可被继承。(Integer 等包装类也不能被继承)。我们先来看看 String 的源码。
在 Java 8 中,String 内部使用 char 数组存储数据。
public final class String
implements java.io.Serializable, Comparable, CharSequence {
/** The value is used for character storage. */
private final char value[];
}
在 Java 9 之后,String 类的实现改用 byte 数组存储字符串,同时使用 coder 来标识使用了哪种编码。
public final class String
implements java.io.Serializable, Comparable, CharSequence {
/** The value is used for character storage. */
private final byte[] value;
/** The identifier of the encoding used to encode the bytes in {@code value}. */
private final byte coder;
}
value 数组被声明为 final,这意味着 value 数组初始化之后就不能再引用其它数组。并且 String 内部没有改变 value 数组的方法,因此可以保证 String 不可变。
因为 String 的 hash 值经常被使用,例如 String 用做 HashMap 的 key。不可变的特性可以使得 hash 值也不可变,因此只需要进行一次计算。
如果一个 String 对象已经被创建过了,那么就会从 String Pool 中取得引用。只有 String 是不可变的,才可能使用 String Pool。
String 经常作为参数,String 不可变性可以保证参数不可变。例如在作为网络连接参数的情况下如果 String 是可变的,那么在网络连接过程中,String 被改变,改变 String 的那一方以为现在连接的是其它主机,而实际情况却不一定是。
String 不可变性天生具备线程安全,可以在多个线程中安全地使用。
字符串对象可以使用“+”连接其他对象,其中字符串连接是通过 StringBuilder(或 StringBuffer)类及其 append 方法实现的,对象转换为字符串是通过 toString 方法实现的。可以通过反编译验证一下:
/**
* 测试代码
*/
public class Test {
public static void main(String[] args) {
int i = 10;
String s = "abc";
System.out.println(s + i);
}
}
/**
* 反编译后
*/
public class Test {
public static void main(String args[]) { //删除了默认构造函数和字节码
byte byte0 = 10;
String s = "abc";
System.out.println((new StringBuilder()).append(s).append(byte0).toString());
}
}
由上可以看出,Java中使用"+"连接字符串对象时,会创建一个StringBuilder()对象,并调用append()方法将数据拼接,最后调用toString()方法返回拼接好的字符串。那这个 “+” 的效率怎么样呢?
使用“+”连接符时,JVM会隐式创建StringBuilder对象,这种方式在大部分情况下并不会造成效率的损失,不过在进行大量循环拼接字符串时则需要注意。比如:
String s = "abc";
for (int i=0; i<10000; i++) {
s += "abc";
}
这样由于大量StringBuilder创建在堆内存中,肯定会造成效率的损失,所以在这种情况下建议在循环体外创建一个StringBuilder对象调用append()方法手动拼接(如上面例子如果使用手动拼接运行时间将缩小到1/200左右)。
与此之外还有一种特殊情况,也就是当"+"两端均为编译期确定的字符串常量时,编译器会进行相应的优化,直接将两个字符串常量拼接好,例如
System.out.println("Hello" + "World"); /** * 反编译后 */ System.out.println("HelloWorld");
JVM为了提高性能和减少内存的开销,在实例化字符串的时候进行了一些优化:使用字符串常量池。每当创建字符串常量时,JVM会首先检查字符串常量池,如果该字符串已经存在常量池中,那么就直接返回常量池中的实例引用。如果字符串不存在常量池中,就会实例化该字符串并且将其放到常量池中。由于String字符串的不可变性,常量池中一定不存在两个相同的字符串。
实现该优化的基础是因为字符串是不可变的,可以不用担心数据冲突进行共享。
运行时实例创建的全局字符串常量池中有一个表,总是为池中每个唯一的字符串对象维护一个引用,这就意味着它们一直引用着字符串常量池中的对象,所以,在常量池中的这些字符串不会被垃圾收集器回收。
我们来看个小例子,了解下不同的方式创建的字符串在内存中的位置:
String string1 = "abc"; // 常量池
String string2 = "abc"; // 常量池
String string3 = new String("abc"); // 堆内存
public static void main(String[] args) {
String s1 = "123";
String s2 = "123";
String s3 = "1234";
String s4 = "12" + "34";
String s5 = s1 + "4";
String s6 = new String("1234");
System.out.println(s1 == s2); // true
System.out.println(s1.equals(s2)); //true
System.out.println(s3 == s4); //true
System.out.println(s3 == s5); // false
System.out.println(s3.equals(s5)); //true
System.out.println(s3 == s6); // false
}
解析:
String s1 = "123";先是在字符串常量池创建了一个字符串常量“123”,“123”常量是有地址值,地址值赋值给s1。接着声明 String s2=“123”,由于s1已经在方法区的常量池创建字符串常量"123",进入常量池规则:如果常量池中没有这个常量,就创建一个,如果有就不再创建了,故直接把常量"123"的地址值赋值给s2,所以s1==s2为true。
由于String类重写了equals方法,s1.equals(s2)比较的是字符串的内容,s1和s2的内容都是"123",故s1.equals(s2)为true。
s3创建了一个新的字符串"1234",s4是两个新的字符串"12"和"34"通过"+“符号连接所得,根据Java中常量优化机制, “12” 和"34"两个字符串常量在编译期就连接创建了字符串"1234”,由于字符串"1234"在常量池中存在,故直接把"1234"在常量池的地址赋值给s4,所以s3==s4为true。
s5是由一个变量s1连接一个新的字符串"4",首先会在常量池创建字符串"4",然后进行"+“操作,根据字符串的串联规则,s5会在堆内存中创建StringBuilder(或StringBuffer)对象,通过append方法拼接s1和字符串常量"4”,此时拼接成的字符串"1234"是StringBuilder(或StringBuffer)类型的对象,通过调用toString方法转成String对象"1234",所以s5此时实际指向的是堆内存中的"1234"对象,堆内存中对象的地址和常量池中对象的地址不一致,故s3==s5为false。
看下JDK8的API文档里的解释:
Java语言为字符串连接运算符(+)提供特殊支持,并为其他对象转换为字符串。字符串连接是通过StringBuilder (或StringBuffer )类及其append方法实现的。字符串转换是通过方法来实现toString,由下式定义0bject和继承由在Java中的所有类。有关字符串连接和转换的其他信息,请参阅Gosling,Joy 和Steele,Java 语言规范。
不管是常量池还是堆,只要是使用equals比较字符串,都是比较字符串的内容,所以s3.equals(s5)为true。
Java常量优化机制:给一个变量赋值,如果等于号的右边是常量,并且没有一个变量,那么就会在编译阶段计算该表达式的结果,然后判断该表达式的结果是否在左边类型所表示范围内,如果在,那么就赋值成功,如果不在,那么就赋值失败。但是注意如果一旦有变量参与表达式,那么就不会有编译期间的常量优化机制。
String s6 = new String("1234");在堆内存创建一个字符串对象,s6指向这个堆内存的对象地址,而s3指向的是字符串常量池的"1234"对象的地址,故s3==s6为false。
String s0 = "123";
String s1 = new String("123");
String s2 = new String("1" + "2");
String s3 = new String("12") + "3";
解析:
字符串常量池对象:“123”,1个;
共1个。
字符串常量池对象:“123”,1个;
堆对象:new String(“123”),1个;
共2个。
字符串常量池对象:“12”,1个(Jvm在编译期做了优化,“1” + "2"合并成了 “12”);
堆对象:new String(“12”),1个
共2个。
由于s2涉及字符串合并,我们通过命令看下字节码信息:
javac StrTest.java //编译源文件得到class文件
javap -c StrTest.class // 查看编译结果
得到字节码信息如下:
备注:以上编译结果基于Jdk1.8运行环境
我们可以很清晰看到,创建了一个新的String对象和一个字符串常量"12",new String("1" + "2") 相当于 new String("12"),共创建了2个字符串对象。
字符串常量池对象:“12”、“3”,2个,
堆对象: new Stringbuilder().append(“12”).append(“3”).toString();转成String对象,1个;
共3个。
我们同样看下编译后的结果:
可以看到,包括StringBuilder在内,共创建了4个对象,字符串"12"和字符串"3"是分开创建的,所以共创建了3个字符串对象。
总结:
new String()是在堆内存创建新的字符串对象,其构造参数中可传入字符串,此字符串一般会在常量池中先创建出来,new String()创建的字符串是参数字符串的副本,看下API中关于String构造器的解释:
String(String original)
初始化新创建的String对象,使其表示与参数相同的字符序列;换句话说,新创建的字符串是参数字符串的副本。
所以new String()的方式创建字符串百分百会产生一个新的字符串对象,而类似于"123"这样的字符串对象则需要在创建之前看常量池中有没有,有的话就不创建,没有则创建新的对象。 "+"操作符连接字符串常量的时候会在编译期直接生成连接后的字符串,若该字符串在常量池已经存在,则不会创建新的字符串;连接变量的话则涉及StringBuilder等字符串构建器的创建,会在堆内存生成新的字符串对象。