目录
- 源码的角度解析String不可变
- String Pool 的角度解析String不可变
- String对象不可变性的优缺点
- String对象是否真的不可变
- 从源码的角度解析StringBuilder的可变
- 从源码的角度解析StringBuffer和StringBuilder的异同
- 编译器对String做出了哪些优化
从源码的角度解析String不可变
所谓的不可变类是指这个类的实例一旦创建完成后,就不能改变其成员变量值。
String类中每一个看起来会修改String值的方法,实际上都是创建了一个全新的String对象
public static String upcase(String s){ |
当把name传给upcase()方法的时候,实际上传递的是一个引用的拷贝。而该引用所指的对象其实一直待在单一的物理位置上,从未动过。
回到upcase()的定义,传入其中的引用有了名字s,只有upcase()运行的时候,局部引用s才存在。一旦upcase()运行结束,s就消失了。当然了,upcase()的返回值,其实只是最终结果的引用。这足已说明,upcase()返回的引用已经指向了一个新的对象name2,而原本的name则还在原地。
既然String类型的变量name没有变过,我们从源码的角度去看为什么没有改变
public final class String |
可以清楚的看到String类是一个final类,但这并不是String不可变的真正原因,继续看String实现了CharSequence接口
public interface CharSequence { |
CharSequence是一个接口,它只包括length(), charAt(int index), subSequence(int start, int end)这几个API接口。同时除了String实现了CharSequence之外,StringBuffer和StringBuilder也实现了CharSequence接口。
也就是说,CharSequence其实也就是定义了字符串操作的接口,其他具体的实现是由String、StringBuilder、StringBuffer完成的,String、StringBuilder、StringBuffer都可以转化为CharSequence类型。
继续看String类,private final char value[];
这个final类型的字符型变量才是真正存储字符串的容器,也正是因为这个变量是final的,才真正决定了字符串不可变,也许你不相信,你可以说Stirng类也是final修饰的,也是不可变的,那么如果StringBuilder和StringBuffer也是final修饰的呢?
String Pool 的角度解析String不可变
JVM为了提升性能和减少内存开销,避免字符串的重复创建,其维护了一块特殊的内存空间,这就是我们今天要讨论的核心,即字符串池(String Pool)。
我们知道,在Java中有两种创建字符串对象的方式:
采用字面值的方式赋值
采用new关键字新建一个字符串对象。
这两种方式在性能和内存占用方面存在着差别。
方式一:采用字面值的方式赋值,例如:
采用字面值的方式创建一个字符串时,JVM首先会去字符串池中查找是否存在”aaa”这个对象,如果不存在,则在字符串池中创建”aaa”这个对象,然后将池中”aaa”这个对象的引用地址返回给字符串常量str,这样str会指向池中”aaa”这个字符串对象;如果存在,则不创建任何对象,直接将池中”aaa”这个对象的地址返回,赋给字符串常量。
在本例中,执行:str == str2 ,会得到以下结果:
这是因为,创建字符串对象str2时,字符串池中已经存在”aaa”这个对象,直接把对象”aaa”的引用地址返回给str2,这样str2指向了池中”aaa”这个对象,也就是说str和str2指向了同一个对象,因此语句System.out.println(str == str2)输出:true。
方式二:采用new关键字新建一个字符串对象,例如:
采用new关键字新建一个字符串对象时,JVM首先在字符串池中查找有没有”aaa”这个字符串对象,如果有,则不在池中再去创建”aaa”这个对象了,直接在堆中创建一个”aaa”字符串对象,然后将堆中的这个”aaa”对象的地址返回赋给引用str3,这样,str3就指向了堆中创建的这个”aaa”字符串对象;如果没有,则首先在字符串池中创建一个”aaa”字符串对象,然后再在堆中创建一个”aaa”字符串对象,然后将堆中这个”aaa”字符串对象的地址返回赋给str3引用,这样,str3指向了堆中创建的这个”aaa”字符串对象。
在这个例子中,执行:str3 == str4,得到以下结果:
因为,采用new关键字创建对象时,每次new出来的都是一个新的对象,也即是说引用str3和str4指向的是两个不同的对象,因此语句System.out.println(str3 == str4)输出:false。
字符串池的实现有一个前提条件:String对象是不可变的。因为这样可以保证多个引用可以同时指向字符串池中的同一个对象。如果字符串是可变的,那么一个引用操作改变了对象的值,对其他引用会有影响,这样显然是不合理的。
Java语言规范(Java Language Specification)中对字符串做出了如下说明:每一个字符串常量都是指向一个字符串类实例的引用。字符串对象有一个固定值。字符串常量,或者一般的说,常量表达式中的字符串都被使用方法 String.intern进行保留来共享唯一的实例。
以上是Java语言规范中的原文,比较官方,用更通俗易懂的语言翻译过来主要说明了三点:
- 每一个字符串常量都指向字符串池中或者堆内存中的一个字符串实例。
- 字符串对象值是固定的,一旦创建就不能再修改。
- 字符串常量或者常量表达式中的字符串都被方法String.intern()在字符串池中保留了唯一的实例。
其他包
结果
结论:
- 同一个包下同一个类中的字符串常量的引用指向同一个字符串对象;
- 同一个包下不同的类中的字符串常量的引用指向同一个字符串对象;
- 不同的包下不同的类中的字符串常量的引用仍然指向同一个字符串对象;
- 由常量表达式计算出的字符串是在编译时进行计算,然后被当作常量;
- 在运行时通过连接计算出的字符串是新创建的,因此是不同的;
- 通过计算生成的字符串显示调用intern方法后产生的结果与原来存在的同样内容的字符串常量是一样的。
从上面的例子可以看出,字符串常量在编译时计算和在运行时计算,其执行过程是不同的,得到的结果也是不同的。我们来看看下面这段代码:
代码输出如下:
为什么出现上面的结果呢?这是因为,字符串字面量拼接操作是在Java编译器编译期间就执行了,也就是说编译器编译时,直接把”java”、”language”和”specification”这三个字面量进行”+”操作得到一个”javalanguagespecification” 常量,并且直接将这个常量放入字符串池中,这样做实际上是一种优化,将3个字面量合成一个,避免了创建多余的字符串对象。而字符串引用的”+”运算是在Java运行期间执行的,即str + str2 + str3在程序执行期间才会进行计算,它会在堆内存中重新创建一个拼接后的字符串对象。总结来说就是:字面量”+”拼接是在编译期间进行的,拼接后的字符串存放在字符串池中;而字符串引用的”+”拼接运算实在运行时进行的,新创建的字符串存放在堆中。
到这里我们也能理解了什么是字符串的不可变性,其本质是在字符串池中开辟了一块空间,字符串的地址不变,字符串变量重新赋值感觉是字符串变了,其实是在字符串池中开辟了另外一块空间,并且字符串的引用重新指向新的空间地址,而原来的字符串内容和内存地址在字符串池中没有改变过。
String name = "aaa"; |
字符串池的位置是在堆中,那么GC的时候字符串如何保证不被GC?
为了优化空间,运行时实例创建的全局字符串常量池中有一个表,总是为池中每个唯一的字符串对象维护一个引用。这就意味着它们一直引用着字符串常量池中的对象,所以,在常量池中的这些字符串不会被垃圾收集器回收。
总结:字符串是常量,字符串池中的每个字符串对象只有唯一的一份,可以被多个引用所指向,避免了重复创建内容相同的字符串;通过字面值赋值创建的字符串对象存放在字符串池中,通过关键字new出来的字符串对象存放在堆中。
String对象不可变性的优缺点
1.字符串常量池的需要.
字符串常量池可以将一些字符常量放在常量池中重复使用,避免每次都重新创建相同的对象、节省存储空间。但如果字符串是可变的,此时相同内容的String还指向常量池的同一个内存空间,当某个变量改变了该内存的值时,其他遍历的值也会发生改变。所以不符合常量池设计的初衷。
2. 线程安全考虑。
同一个字符串实例可以被多个线程共享。这样便不用因为线程安全问题而使用同步。字符串自己便是线程安全的。
3. 类加载器要用到字符串,不可变性提供了安全性,以便正确的类被加载。譬如你想加载java.sql.Connection类,而这个值被改成了myhacked.Connection,那么会对你的数据库造成不可知的破坏。
4. 支持hash映射和缓存。
因为字符串是不可变的,所以在它创建的时候hashcode就被缓存了,不需要重新计算。这就使得字符串很适合作为Map中的键,字符串的处理速度要快过其它的键对象。这就是HashMap中的键往往都使用字符串。
缺点:
- 如果有对String对象值改变的需求,那么会创建大量的String对象(使用StringBuffer或者StringBuilder替代)。
String对象是否真的不可变
String对象的不可变,其根本是内存地址的不可变,这在字符串池中有解析
虽然String对象将value设置为final,并且还通过各种机制保证其成员变量不可改变。但是还是可以通过反射机制的手段改变其值。
//创建字符串"Hello World", 并赋给引用s |
打印结果为:
s = Hello World |
发现String的值已经发生了改变。也就是说,通过反射是可以修改所谓的“不可变”对象的
从源码的角度解析StringBuilder的可变
StringBuilder可以动态构造字符串,并且是线程不安全的,我们从源码的角度解析StringBuilder为什么可以动态构造字符串。
public final class StringBuilder |
我们首先看到StringBuilder也是final修饰的, 和String一样,不仅如此StringBuffer也是final修饰的,下面将不再解释,它继承了AbstractStringBuilder,并且和String、StringBuffer一样,都实现了CharSequence接口
看构造方法默认容量是16,指定了容量则使用父类的构造方法,我们现在去看下父类中如何实现的
abstract class AbstractStringBuilder implements Appendable, CharSequence { |
父类的构造方法中是new了一个指定长度的char字节数组,这说明StringBuilder底层也是使用字符数组保存字符串的,需要注意的是value的定义,和String类中的实现不同,这里没有private和final修饰,正是因为这点,所以StringBuilder是可变的,StringBuilder的value字节数组可以动态的改变大小。
我们已经知道了StringBuilder为什么可变,还需要注意的是它的append方法,该方法直接决定了StringBuilder如何追加字符串。也是和StringBuffer唯一不同的地方
|
直接重写的父类方法
public AbstractStringBuilder append(String str) { |
我们发现append方法的底层是对字符数组内容的复制,并且容量不够时,是扩容为原字节数组长度的两倍+2,是字节数组,不是容量
从源码的角度解析StringBuffer和StringBuilder的异同
StringBuffer和StringBuilder的所有实现一模一样,包括继承的父类,实现的接口,扩容机制,value的定义,正是这些特性让他们两很像,同时也都支持动态构造字符串。
我们知道StringBuffer和StringBuilder最大的不同是线程安全性的问题,StringBuffer在所有以StringBuilder为基础的代码上,在重写父类的方法的同时加了synchronized修饰,保证了线程的安全
//下面只是节选一些StringBuffer中的函数 |
编译器对String做出了哪些优化
String的不可变性会带来一定的效率问题。为String对象重载的 “+” 操作符就是一个例子。重载的意思是,一个操作符在应用于特定的类时,被赋予了特殊的意义。
我们用一段代码来验证 “+” 用来拼接String
String mongo = "mongo"; |
我们猜想一下字符串s的工作方式,它可能有一个append方法,首先s的内容是abc,然后新建一个字符串的内容是abcmongo,继续新建内容是abcmongodef的字符串,最后新建abcmongodef47的字符串,也许你会说为什么不是 “abc” + mongo + “def” +47 一起生成一个字符串然后赋值给s,但是我们不要忘记字符串String,它是一个类。
这种设计方法可以行的通,但是为了最终生成的String,产生了一大堆的需要GC的中间对象。这样的性能是非常糟糕的。
那么String是如何做优化的?我们使用JDK自带的工具javap来反编译以上代码,-c表示生成JVM字节码,删除没用的部分,剩下的内容如下
javap -c StringTest
Compiled from "StringTest.java" |
即使看不懂编译语句也不重要,我们需要注意的重点是:编译器自动引入了java.lang.StringBuilder类,虽然我们在源码中并没有使用StringBuilder类,但是编译器却自动使用了它,因为它更加高效。
现在也许你会觉得可以随意的使用String对象,反正编译器会自动优化性能,可是我们千万要记住一点,在循环的内部拼接字符串,并不会起到优化的效果。
下面的程序采用两种方式生成String:方法一使用多个String对象;方法二中使用了StringBuilder。
public class WitherStringBuilder { |
运行javap -c WitherStringBuilder,可以看到两个方法对应的(简化过的)字节码,首先是implicit()方法:
public java.lang.String implicit(java.lang.String[]); |
注意从第8行到第35行构成一个循环体。
第8行:对堆栈中的操作数进行 “大于或等于的整数比较运算”,循环结束时跳到第38行。
第35行:返回循环体的起始点(第5行)。
要注意的重点是:StringBuilder是在循环之内构造的,这意味着每经过循环一次,就会创建一个新的StringBuilder对象。这样的操作没有任何优化可言。
下面是explicit()方法对应的字节码
public java.lang.String explicit(java.lang.String[]); |
可以看到,不仅循环部分的代码更加简短,而且它只生成了一个StringBuilder对象。所以遇到循环内拼接字符串时在循环体的外部定义StringBuilder()可以大大提升程序的性能。当然,如果字符串操作简单的话,那么就可以信赖编译器的优化。
而且显示地创建StringBuilder还允许你预先为其指定大小。如果你已经知道最终的字符串大概多长,那预先指定StringBuilder的大小还可以避免多次重新分配缓冲。
参考书籍:《Java编程思想(第4版)》
参考文章:
https://www.cnblogs.com/kissazi2/p/3648671.html
https://www.cnblogs.com/xudong-bupt/p/3961159.html