当前位置 博文首页 > 星空下的程序猿:面试|String、StringBuilder、StringBuffer 之

    星空下的程序猿:面试|String、StringBuilder、StringBuffer 之

    作者:[db:作者] 时间:2021-09-12 12:06

    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

    cs