String字符串在Java程序中与基本数据类型一样使用频率较高,因此各大公司面试题里面少不了对String的提问,因此有必要好好认识一下String类。
1 String类的基本认知
有几个基本的知识点作为基础:
- 1 String类是引用类型;
- 2 String类重写Object类的equals()和hashCode(),用于比较内容是否相等,而非引用地址;
- 3 “==”运算符,对基本数据类型比较的是字面值,对引用类型比较的则是引用地址;
基于这些基本知识点看一下下面的代码:
public class StringTest {
public static void main(String[] args) {
String str = "main";
String newStr = new String("main");
String newStr1 = new String(str);
String str1 = "main";
String str2 = newStr;
String str3 = newStr1;
System.out.print(str == str1); // t
System.out.println(str.equals(str1)); // t
System.out.print(str == newStr); // f
System.out.println(str.equals(newStr)); // t
System.out.print(str == newStr1); // f
System.out.println(str.equals(newStr1)); // t
System.out.print(str == str2); // f
System.out.println(str.equals(str2)); // t
System.out.print(newStr == newStr1); // f
System.out.println(newStr.equals(newStr1));// t
System.out.print(newStr == str2); // t
System.out.println(newStr.equals(str2)); // t
System.out.print(newStr == str3); // f
System.out.println(newStr.equals(str3)); // t
}
}
结果如果跟你预测的完全一样,那么恭喜你这部分内容可以不用看了;如果跟你想的有出入,不着急,下面咱们一一分析各种情况。
第一种情况:str和str1的比较
JVM加载StringTest类并执行静态的main方法,str变量的声明方式使得在方法区的运行时常量池生成一个"main"值,str引用指向该值的地址;str1变量在创建的过程中,首先会去运行时常量池检测是否已经有相同的常量,如果有则直接指向该值的地址,否则新建;因此str变量和str1变量都指向运行时常量池中的同一个地址,所以“==”运算符和equals()方法的运行结果都是true。
第二种情况:str和newStr的比较
str指向的是方法区运行时常量池中的内容,而newStr对象声明的方式并不会去常量池检测,而直接在堆上生成一个新的对象,因此str和newStr引用指向的地址不相等,但地址内存储的内容相等,所以“==”运算符返回false,equals()方法返回true。
第三种情况:str和newStr1的比较
newStr1引用变量的声明方式与newStr类似,只不过通过str变量给newStr1引用的内容赋值,newStr1引用指向的对象还是在堆上,因此str和newStr1引用指向的地址不相等,但地址内存储的内容相等,所以“==”运算符返回false,equals()方法返回true。
第四种情况:str和str2的比较
str引用指向方法区运行时常量池,而str2引用指向引用newStr指向的堆上的对象,因此str和str2指向的地址不同,但是常量池和对象的内容一样都是“main”,所以“==”运算符返回false,equals()方法返回true。
第五种情况:newStr和newStr1
这种情况最明了,newStr和newStr1是两个完全不同的引用,分别指向堆上不同的地址,但堆上内存存储的内容都是“main”,所以“==”运算符返回false,equals()方法返回true。
第六种情况:newStr和str2的比较
代码中把引用newStr赋值给str2,表明引用str2指向引用newStr指向的内存地址,所以“==”运算符和equals()方法的运行结果都是true。
第七种情况:newStr和str3的比较
引用str3实际指向引用newStr1的内存地址,str3与newStr的比较等价于newStr1与newStr之间的比较;所以“==”运算符返回false,equals()方法返回true。
以上这些实例都是为了说明之前强调的三点基本知识。
2 String类的常规操作
通过源码发现String类是不可变类。关于String类为什么设计成不可变类可以看这篇文章。String类重写了equals()和hashCode()两个方法,hashCode()是通过存储的内容计算hashCode值从而确定其存储位置,假设我们修改了某String引用类型的内容,那么该引用类型的hashCode值也会跟着改变,即重新分配一个内存地址,也就意味着重新存储了一个String类型的引用;所以Java中对String类型引用的值的修改都会新建一个新的String对象,而不会修改原始值。
public class StringTest {
public static void main(String[] args) {
String str = "hello";
System.out.println(str.toUpperCase()); // HELLO
System.out.println(str); // hello
}
}
从输出的结果看,str并没有改变。toUpperCase()源码也能看出new一个新的String对象,由于源码太长,此处就不黏贴。除了对字符串的修改,字符串拼接也经常使用。
public class StringTest {
public static void main(String[] args) {
String str = "hello", str1 = "world";
String str3 = str + str1;
String str4 = str + "world";
String str5 = "hello" + "world";
System.out.println(str3 == str4); // f
System.out.println(str3 == str5); // f
System.out.println(str4 == str5); // f
}
}
这里通过javap命令查看StringTest类的字节码。
上面字节码主要看0-50行,后面都是打印比较的字节码。
第0/2行存储字符串hello,第3/5行存储字符串world;第6行new一个StringBuilder的对象,第10行到21行都是对该StringBuilder对象进行操作,包括< init >和append操作,最后调用toString()方法返回字符串;那么这个过程实际上是str3变量生成的过程。
第25行又重新new一个StringBuilder对象,29行和30行表示从常量池获取str引用的值,33行基于获取的String初始化StringBuilder对象,36行加载常量“word”到操作数栈,注意因为常量池已经有"world”,所以此处不会重新声明;38行调用StringBuilder的append方法,连接str引用和“world”,41行调用toString()方法生成信息的String对象。第25行到44行实际上是变量str4生成的过程。
第46行直接把常量值helloworld加载到操作数栈并打印,没有生成任何StringBuilder对象;这就是变量str5生成的过程。
从上面字节码可以分析得出:
- 对String类型的引用进行拼接操作,实际都会通过StringBuilder对象来实现,最后通过toString()方法返回一个新的对象;这里再次证明String类型的对象内容不可变;
- 直接对字符串进行拼接操作(而非引用),与直接声明一样,过程中不会生成新的对象;因此str4的处理速度肯定比str3和str2要快;理论上说,拼接过程中引用类型越多,处理的时间就会越长。
字节码中出现StringBuilder类型的对象,何许类也?
可以看出StringBuilder类提供append()方法来改变自身的值,方法返回的是对象本身而非新的StringBuilder对象。因此,StringBuilder类完美的解决String类不可变的问题。下面看两段代码比较下String类和StringBuilder类带来的差异:
public class StringTest {
public static void main(String[] args) {
method();
method1();
}
public static void method() {
String str = "";
for (int i = 0; i < 1000; i++) {
str += "+";
}
}
public static void method1() {
StringBuilder sb = new StringBuilder("");
for (int i = 0; i < 1000; i++) {
sb.append("+");
}
}
}
这里我们不关心结果,只关心过程,所以不打印结果而直接查看字节码:
method()方法的字节码:
第5行到31行是循环体,第8行表明生成一个StringBuilder类型的对象,意味着循环1000次要生成1000个StringBuilder对象;把循环体 str += “+” 操作解读成以下几个步骤:
StringBuilder stringBuilder = new StringBuilder(str); // str是每次从常量池获取的新值
stringBuilder.append("+");
stringBuidler.toString();
在循环体内会不断的生成StringBuilder和String类型的对象,从而造成不必要的空间浪费。
method1()方法的字节码:
第12行到25行是循环体,在一开始就会new一个StringBuilder对象,循环体内只会执行对该StringBuilder对象的append()方法而不会生成额外的对象,所以StringBuilder类的字符串拼接占用的内存更小。
既然String类对象不可变的问题已经通过StringBuilder类解决了,还需要StringBuffer类干嘛。
既然StringBuilder类对象可变,那么当其声明成全局变量,必然会带来线程安全问题(一个类是否线程安全取决于类的全局变量状态是否可以改变,能改变则说明该类线程不安全,否则线程安全。来自《Java并发编程的艺术》)。
为了解决StringBuilder类线程不安全的问题,StringBuffer类就出来了。除了这一点外,StringBuffer类与StringBuilder类完全一样。StringBuffer类线程安全的实现方式是用synchronized关键字修饰方法,即同步方法的方式。
3 String\StringBuilder\StringBuffer类的性能
看到这里,想必大家心里对三者的性能有一个比较。按照从快到满的顺序:
StringBuilder > StringBuffer > String
当然,这是一般情况下的顺序,也有特殊的场景,如:
String string = "hello" + "world";
就会优于
StringBuilder sBuilder = new StringBuilder();
sBuilder.append("hellow");
sBuilder.append("world");
因此需要根据合适的场景的选择合适的类型:
需要考虑线程安全,优先考虑StringBuffer;需要考虑到字符串的拼接操作,优先考虑StringBuilder;而对于常量优先考虑String。
4 String使用中的陷阱
a. final修饰的String类型变量
public class StringTest {
public static void main(String[] args) {
String str = "hello";
final String str1 = "hello";
String str2 = "helloworld";
String str3 = str1 + "world";
String str4 = str + "world";
System.out.println(str == str1); // t
System.out.println(str2 == str3); // t
System.out.println(str2 == str4); // f
}
}
想必很多人对第三个运行结果都很吃惊,啥也不说先看字节码。
第9行的字节码是str3引用的生成过程,可见在编译阶段str1引用的值会参与拼接生成str3引用;其实,对于JVM来说,被final修饰的str1引用不会被改变,即生命周期内始终指向保存“hello”内容的内存地址,为了避免执行过程中再耗费时间去常量池中取值,就会被编译器提前优化。
b. 字符串的复合运算
public class StringTest {
public static void main(String[] args) {
String str = "hello";
str += " world " + "!"; // a
str = str + " world " + "!"; // b
}
}
可以先想一下运算过程是否一样,然后看下面的字节码验证自己的想法。
因为涉及到字符串拼接,所以运算a和运算b都会生成一个StringBuilder对象,但运算a只会调用一次append()方法,直接把字符串“ world !”与引用str拼接,而运算b需要调用两次append()方法,分别把str引用先后与字符串“ world ”和“!”拼接;表明复合运算“+=”使得编译器在编译阶段会优化字符串“ world ”和“!”的拼接。所以运算a的效率会高于运算b。