String vs StringBuilder vs StringBuffer(底层实现)
一.搞懂字符串常量池细节
常量池是java的一项技术,8种基础数据类型除了float和double都实现了常量池技术。即,把经常用到的数据存放在某块内存中,避免频繁的数据创建与销毁。
字符串常量池是Java常量池技术的一种实现,在较新的JDK版本中,字符串常量池被实现在Java堆内存中。
对字符串常量池建立初步认识:
public static void main(String[] args){
String s1 = "hello";
String s2 = new String("hello");
System.out.println(S1==s2); //false
}
第一行代码:
JVM首先会到字符串常量池中查找该字符串是否已经存在,如果存在会直接返回该引用,如果不存在则会在堆内存中创建该字符串对象,然后到字符串常量池中去注册该字符串。
在本案例中虚拟机首先会到字符串常量池中查找是否有存在"hello"字符串对应的引用. 发现没有后会在堆内存创建"hello"字符串对象(内存地址0x0001), 然后到字符串常量池中注册地址为0x0001的"hello"对象, 也就是添加指向0x0001的引用. 最后把字符串对象返回给s1。
第二行代码:
当我们使用new关键字创建字符串对象时,JVM不会查询字符串常量池,会直接在堆内存中创建一个字符串对象,并返回给所属变量。
难一点的:
public static void mian(String []args){
String s1 = new String("hello") +new String("world");
s1.intern();
String s2 = "hello world";
System.out.println(s1 == s2);//true
}
第一行代码:
- 依次在堆内存中创建“hello”和“world”两个字符串对象
- 拼接起来(底层使用StringBuilder,后面会讲)
- 拼接完成产生新对象“hello world”,变量s1指向新对象
内存情况:
第二行代码:
String类的源码中有intern()方法的介绍:当调用intern()方法时,首先会去常量池中查找是否有该字符串对应的引用,如果有就直接返回该字符串,如果没有,就会在常量池中注册该字符串的引用,然后返回该字符串。
由于第一行用的是new方法创建,所以常量池中肯定没有对应的引用,因此,会在常量池中进行注册。
第三行代码:
先找常量池,发现刚好有指向“hello world”的引用,直接将引用指向的字符串返回给所属变量。
内存示意图如下:
总结一下就是,new创建的时候,不会查询常量池;直接创建时,会先查询常量池。
最后再做到压轴题:
public class Main{
public static void main(String []args){
String s1 ="hello";
String s2 = "world";
String s3 = s1+s2;
String s4 = "hello world";
System.out.println(s3==s4);
}
}
其实判断这题的结果为true还是false的关键在于第三行代码String s3 = s1+s2;
我们不了解。它到底是new创建的,还是直接创建的。这种时候,我们就可以去读一下这段代码的反编译代码。
在命令行中输入javap-c对应class文件的绝对路径,按回车后即可看到反编译文件的代码段。
解释:
- 首先调用构造器完成Main类的初始化
- 0:ldc#2 从常量池中获取“hello”字符串并推送至栈顶,此时拿到了“hello”的引用
- astore_1 将栈顶的字符串引用存入第二个本地变量s1,也就是s1指向了“hello”;
- 3:/5: 重复开始的步骤,此时将变量s2指向“world”
- 6: new#4 这时创建了一个StringBuilder,并把其引用值压到栈顶
- 9:dup 复制栈顶的值,并继续压入栈顶,意味着从上到下有两份StringBuilder的引用,要操作两次StringBuilder
- 10: 调用StringBuilder的一些初始化方法,静态方法或父类方法,完成初始化
- 13:aload_1 把第二个本地变量s1压入栈顶,现在栈顶从上往下数两个数据依次是:s1变量和StringBuilder的引用
- 14:调用StringBuilder的append方法,接下来s2的时候再调用一次append方法(这就是两次StringBuilder的引用拷贝目的)
- 完成后,StringBuilder已经拼接好“hello world”了。
- 21:/24: 拼接完成后,虚拟机调用StringBuilder的toString()方法获得字符串hello world,并存放至s3
- 接下来就看StringBuilder中的toString()方法源码。
从这里可以看到s3是通过new关键字获得字符串对象的。所以上面题目的答案是false。
二.详解字符串操作类
String, StringBuilder, StringBuffer的底层实现。
进入String的源码,可以看到String类是通过char类型数组实现的。
接着查看StringBuilder和StringBuffer的源码,发现这两者都继承自AbstractStringBuilder类。通过该类的源码,得知这两类也是通过char类型数组实现的。
而且通过StringBuilder和StringBuffer继承自同一个父类这点, 我们可以推断出它俩的方法都是差不多的. 通过查看源码也发现确实如此, 只不过StringBuffer在方法上添加了 synchronized关键字, 证明它的方法绝大多数方法都是线程同步方法. 也就是说在多线程的环境下我们应该使用StringBuffer以保证线程安全, 在单线程环境下我们应使用StringBuilder以获得更高的效率。
既然如此, 我们的比较也就落到了StringBuilder和String身上了。
三.StringBuilder vs String
通过对两者的源码分析,有一个关键的区别: 对于String,凡是涉及到返回参数类型为String类型的方法,在返回时都会通过new关键字创建一个新的字符串对象;而对于StringBuilder,大多数方法都会返回StringBuilder对象自身。
因为这一区别,使得两者在操作字符串时,在不同场景下会体现出不同的效率。
以拼接字符串为例,比较两者的性能:
明显地,StringBuilder类的效率更高。
通过反编译代码看造成两者性能差距的原因:
当用String拼接字符串时,每次都会生成一个StringBuilder对象,然后调用两次append()方法把字符串拼接好,最后通过StringBuilder的toString()方法new出一个新的字符串对象。
每次拼接都会new出两个对象,并进行两次方法调用。 拼接次数过多,创建对象所带来的时延会降低系统效率,同时造成巨大的内存浪费。而且,当内存不够用时,虚拟机会进行垃圾回收,这是一项相当耗时的操作,会大大降低系统性能。
下面是StringBuilder拼接字符串得到的反编译代码:
直接把要拼接的字符串放到栈顶进行append,除了开始时创建了StringBuilder对象,运行时期没有创建过其他任何对象,每次循环只调用一次append方法。所以从效率上看,拼接大量字符串时,StringBuilder要比String类快很多。
String类也不是没有优势,它操作字符串的API更多,而且,如果是简单的拼接,如:String =“hello”+“world”,它的效率会更高一点。 这里可以思考一下,为什么效率会更高一点。