类的继承关系
String、StringBuffer、StringBuilder 都实现了 CharSequence 接口。下面是类关系图:
数据结构
它们内部都是用一个char类型的数组实现,虽然它们都与字符串相关,但是其处理机制不同。
1.String:是不可改变的量,创建后就不能再修改了。可以看到String源码中对char数组的定义:
private final char[] value;
注意:
这里看到了char数组被声明为了final。但是并不是因为被声明为了final,char数组就不可变了。确实char数组的地址值不可变,但是char数组存储的元素是可以改变的。然而SUN公司的工程师,在后面所有String的方法里很小心的没有去动char数组里的元素,没有暴露内部成员字段。private final char[] value这一句里,private的私有访问权限的作用都比final大。而且设计师还很小心地把整个String设成final禁止继承,避免被其他人继承后破坏。所以String是不可变的关键都在底层的实现,而不是一个final。考验的是工程师构造数据类型,封装数据的功力。
如果我们选用可变的StringBuffer或者StringBuilder作为HashMap和HashSet的键值,就很有可能出现线程安全问题。
2.StringBuilder与StringBuffer都继承自AbstractStringBuilder,我们看到AbstractStringBuilder源码中对char数组的定义:
char[] value;
如果进一步查看AbstractStringBuilder的源码,就能发现其中的数组在进行append()操作时会进行扩容,所以它是一个可扩容的动态数组。
线程安全
String既然是不可变的,那么肯定线程安全。
而StringBuilder与StringBuffer的区别呢?两个类中的方法基本一样,只是StringBuffer中的所有方法都加上了synchronized关键字。
所以StringBuffer是线程安全的,StringBuilder是线程不安全的。
使用效率
对String对象进行 + 操作,实际上,会创建一个临时的StringBuilder对象进行拼接操作,用StringBuilder的append()方法拼接完毕,再调用toString()方法返回一个新的String对象,然后拿原来的索引指向这个新产生的对象。当然频繁地创建对象就会比较影响系统的性能。
而StringBuilder和StringBuffer都是调用append()对内部的数组进行扩容。因为StringBuffer的方法都加了锁,所以会比StringBuilder慢。
所以一般情况下使用效率从高到低:StringBuilder > StringBuffer > String。
知识补充
在 JAVA 语言中有8中基本类型和一种比较特殊的类型String。这些类型为了使他们在运行过程中速度更快,更节省内存,都提供了一种常量池的概念。常量池就类似一个JAVA系统级别提供的缓存。
8种基本类型的常量池都是系统协调的,String类型的常量池比较特殊。它的主要使用方法有两种:
直接使用双引号声明出来的String对象会直接存储在常量池中。
如果不是用双引号声明的String对象,可以使用String提供的intern方法。intern 方法会从字符串常量池中查询当前字符串是否存在,若不存在就会将当前字符串放入常量池中。
面试题
第一题
String s1 = “abc”;
String s2 = “abc”;
System.out.println(s1 == s2);
结果是true。由于字符串是常量(内存中创建对象后不能修改), 而且字符串在程序中经常使用,所以Java对其提供了缓冲区。缓冲区内的字符串会被共享。使用双引号的形式定义字符串常量就是存储在缓冲区中的。使用”abc”时会先在缓冲区中查找是否存在此字符串,没有就创建一个,有则直接使用。第一次使用”abc”时会在缓冲区中创建,第二次则是直接引用之前创建好的了。
第二题
问:String s1 = new String(“abc”);
创建了几个对象?
答:创建了一个或者两个 ,首先new String(“abc”)肯定会在内存中创建一个对象,它是参照常量”abc”进行创建对象,所以首先会看缓冲区中是否存在常量”abc”,如果不存在,要先在缓冲区中创建一个常量”abc”,否则不用创建,所以答案为1个或者2个。
第三题
String s1 = new String(“abc”);
String s2 = new String(“abc”);
System.out.println(s1 == s2);
结果是false。new String()就是在堆内存中创建一个新的对象,然后把常量池中的”abc”的副本放到了堆内存中新的对象中,并且分配新的地址值,和常量池中的不一样 。
第四题
String s1 = “abc”;
String s2 = “a”;
String s3 = “bc”;
String s4 = s2 + s3;
System.out.println(s1 == s4);
结果是false。Java中字符串的相加其内部是使用StringBuilder类的append()方法和toString()方法来实现的,而StringBuilder类toString()方法返回的字符串是通过new String()创建的。
第五题
String s1 = “abc”;
String s2 = “a” + “bc”;
System.out.println(s1 == s2);
结果是true。其实这里的s2并没有进行字符串相加, 两个双引号形式的字符串常量相加,在编译阶段直接会被转为一个字符串“abc”。
第六题
String str = “abc”;
str.substring(3);
str.concat(“123″);
System.out.println(str);
结果是”abc”。由于字符串是常量(内存中创建对象后不能修改),该类中所有方法都不会改变字符串的值。如果希望使用一个可变的字符串,可以使用StringBuilder或StringBuffer类。
下面两道题就很有意思了。
第七题
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);
JDK6:false,false;
JDK7:false,true。
第八题
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);
JDK6:false,false;
JDK7:false,false。
出现如此现象的原因,是因为JDK6中的常量池在方法区中,而从JDK7开始常量池在堆中。
在第七题中,看s3和s4字符串。String s3 = new String("1") + new String("1");
,这句代码中现在生成了2最终个对象,是字符串常量池中的“1” 和 JAVA Heap 中的 s3引用指向的对象。中间还有2个匿名的new String(“1”)我们不去讨论它们。此时s3引用对象内容是”11”,但此时常量池中是没有 “11”对象的。
接下来s3.intern();
这一句代码,是将 s3中的“11”字符串放入 String 常量池中,因为此时常量池中不存在“11”字符串,如果是在JDK6中会在常量池中生成一个 “11” 的对象,关键点是 JDK7 中常量池不在 Perm 区域了,这块做了调整。常量池中不需要再存储一份对象了,可以直接存储堆中的引用,这份引用指向 s3 引用的对象,也就是说引用地址是相同的。
详解请参见深入解析String#intern。
参考:
1.String类的面试题
2.java面试题中常见的关于String类问题总结
3.在java中String类为什么要设计成final?
4.深入解析String#intern