面试|String、StringBuilder、StringBuffer 之间的区别?

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类的字节码。
1
上面字节码主要看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()方法的字节码:
2
第5行到31行是循环体,第8行表明生成一个StringBuilder类型的对象,意味着循环1000次要生成1000个StringBuilder对象;把循环体 str += “+” 操作解读成以下几个步骤:

StringBuilder stringBuilder = new StringBuilder(str);  // str是每次从常量池获取的新值
stringBuilder.append("+");
stringBuidler.toString();

在循环体内会不断的生成StringBuilder和String类型的对象,从而造成不必要的空间浪费。

method1()方法的字节码:
3
第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
	}
}

想必很多人对第三个运行结果都很吃惊,啥也不说先看字节码。
4
第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
	}
}

可以先想一下运算过程是否一样,然后看下面的字节码验证自己的想法。
5
因为涉及到字符串拼接,所以运算a和运算b都会生成一个StringBuilder对象,但运算a只会调用一次append()方法,直接把字符串“ world !”与引用str拼接,而运算b需要调用两次append()方法,分别把str引用先后与字符串“ world ”和“!”拼接;表明复合运算“+=”使得编译器在编译阶段会优化字符串“ world ”和“!”的拼接。所以运算a的效率会高于运算b

  • 3
    点赞
  • 15
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值