String对象不可变剖析

何为不可变

        如何理解String类型值的不可变,首先需要理解何为不可变。
         对于Java而言,除了primitive type值(即int, long, double等),其余的都是对象。提炼一下,更普遍的问题是:如何理解不可变对象?
        对于何为不可变对象,JLS并没有给出一个明确定义。《java concurrency in practice》一书给出了一个粗略的定义:对象一旦创建后,其状态不可修改,则该对象为不可变对象。一般一个对象满足以下三点,则可以称为是不可变对象:

  1. 其状态不能在创建后再修改;
  2. 所有域都是final类型;
  3. 其构造函数构造对象期间,this引用没有泄露。

        这里重点说明一下第2点,一个对象其所有域都是final类型,该对象也可能是可变对象。因为final关键字只是限制对象的域的引用不可变,但无法限制通过该引用去修改其对应域的内部状态。因此,严格意义上的不可变对象,其final关键字修饰的域应该也是不可变对象和primitive type值。

PS:从技术上讲,不可变对象内部域并不一定全都声明为final类型,String类型即是如此。在String对象的内部我们可以看到有一个名为hash的域并不是final类型,这是因为String类型惰性计算hashcode并存储在hash域中(这是通过其他final类型域来保证每次的hashcode计算结果必定是相同的)。
除此之外,String对象的不可变是由于对String类型的所有改变内部存储结构的操作都会new出一个新的String对象。

1. 我们图解一下上文的解释 什么是不可变? 

String不可变很简单,如下图,给一个已有字符串"abcd"第二次赋值成"abcedl",不是在原内存地址上修改数据,而是重新指向一个新对象,新地址。 

 2. String为什么不可变? 

        翻开JDK源码,java.lang.String类起手前三行,是这样写的: 

public final class String implements java.io.Serializable, Comparable<String>, CharSequence { 
    /** String本质是个char数组. 而且用final关键字修饰.*/ 
    private final char value[]; 
... 
... 
} 

        首先String类是用final关键字修饰,这说明String不可继承。再看下面,String类的主力成员字段value是个char[ ]数组,而且是用final修饰的。final修饰的字段创建以后就不可改变。 

        有的人以为故事就这样完了,其实没有。因为虽然value是不可变,也只是value这个引用地址不可变。挡不住Array数组是可变的事实。Array的数据结构看下图, 

       也就是说Array变量只是stack上的一个引用,数组的本体结构在heap堆。String类里的value用final修饰,只是说stack里的这个叫value的引用地址不可变。没有说堆里array本身数据不可变。看下面这个例子, 

final int[] value={1,2,3} 
int[] another={4,5,6}; 
value=another;    //编译器报错,final不可变 

       value用final修饰,编译器不允许我把value指向堆区另一个地址。但如果我直接对数组元素动手,分分钟搞定。 

       所以String是不可变,关键是因为SUN公司的工程师,在后面所有String的方法里很小心的没有去动Array里的元素,没有暴露内部成员字段。private final char value[]这一句里,private的私有访问权限的作用都比final大。而且设计师还很小心地把整个String设成final禁止继承,避免被其他人继承后破坏。所以String是不可变的关键都在底层的实现,而不是一个final。考验的是工程师构造数据类型,封装数据的功力。 

3. 不可变有什么好处? 

       这个最简单地原因,就是为了安全。看下面这个场景(有评论反应例子不够清楚,现在完整地写出来),一个函数appendStr( )在不可变的String参数后面加上一段“bbb”后返回。appendSb( )负责在可变的StringBuilder后面加“bbb”。 

class Test{ 
    //不可变的String 
    public static String appendStr(String s){ 
        s+="bbb"; 
        return s; 
    } 
 
    //可变的StringBuilder 
    public static StringBuilder appendSb(StringBuilder sb){ 
        return sb.append("bbb"); 
    } 
 
    public static void main(String[] args){ 
        //String做参数 
        String s=new String("aaa"); 
        String ns=Test.appendStr(s); 
        System.out.println("String aaa >>> "+s.toString()); 
 
        //StringBuilder做参数 
        StringBuilder sb=new StringBuilder("aaa"); 
        StringBuilder nsb=Test.appendSb(sb); 
        System.out.println("StringBuilder aaa >>> "+sb.toString()); 
    } 
} 
 
//Output:  
//String aaa >>> aaa 
//StringBuilder aaa >>> aaabbb  

       如果程序员不小心像上面例子里,直接在传进来的参数上加"bbb",因为Java对象参数传的是引用,所以可变的的StringBuffer参数就被改变了。可以看到变量sb在Test.appendSb(sb)操作之后,就变成了"aaabbb"。有的时候这可能不是程序员的本意。所以String不可变的安全性就体现在这里。 

       再看下面这个HashSet用StringBuilder做元素的场景,问题就更严重了,而且更隐蔽。 

class Test{ 
    public static void main(String[] args){ 
        HashSet<StringBuilder> hs=new HashSet<StringBuilder>(); 
        StringBuilder sb1=new StringBuilder("aaa"); 
        StringBuilder sb2=new StringBuilder("aaabbb"); 
        hs.add(sb1); 
        hs.add(sb2);    //这时候HashSet里是{"aaa","aaabbb"} 
 
        StringBuilder sb3=sb1; 
        sb3.append("bbb");  //这时候HashSet里是{"aaabbb","aaabbb"} 
        System.out.println(hs); 
    } 
} 
//Output: 
//[aaabbb, aaabbb] 

       StringBuilder型变量sb1和sb2分别指向了堆内的字面量"aaa"和"aaabbb"。把他们都插入一个HashSet。到这一步没问题。但如果后面我把变量sb3也指向sb1的地址,再改变sb3的值,因为StringBuilder没有不可变性的保护,sb3直接在原先"aaa"的地址上改。导致sb1的值也变了。这时候,HashSet上就出现了两个相等的键值"aaabbb"。破坏了HashSet键值的唯一性。所以千万不要用可变类型做HashMap和HashSet键值。 

       还有一个大家都知道,就是在并发场景下,多个线程同时读一个资源,是不会引发竟态条件的。只有对资源做写操作才有危险。不可变对象不能被写,所以线程安全。 

      最后别忘了String另外一个字符串常量池的属性。像下面这样字符串one和two都用字面量"something"赋值。它们其实都指向同一个内存地址。 

String one = "someString"; 
String two = "someString"; 

 

       这样在大量使用字符串的情况下,可以节省内存空间,提高效率。但之所以能实现这个特性,String的不可变性是最基本的一个必要条件。要是内存里字符串内容能改来改去,这么做就完全没有意义了。 

       前两天有个同学看书看到System.arraycopy()函数是浅拷贝(只拷贝对象的引用,而非对象本身的复制),就试了下,下面代码把字符串数组s1的3个元素拷贝到s2, 

String[] s1 = new String[]{"Hi","Hi","Hi"}; 
String[] s2 = new String[s1.length]; 
System.arraycopy(s1,0,s2,0,s1.length);  

       然后他修改了s1中某个元素的值,希望看到s2中对应元素的值也改变,但结果却没有。 

s1[2] = "Hier"; 
System.out.println(Arrays.toString(s1)); // output: [Hi,Hi,Hier] 
 
// 他期待的输出:[Hi,Hi,Hier] 
// 实际输出: [Hi,Hi,Hi] 
System.out.println(Arrays.toString(s1));  

       问题就出在String的不可变性上。s1[2]="Hier"为元素赋值, 实际上不是将s1[2]元素的值改成Hier,而是重新指向了一个新的字符串对象。  

       同样的问题还会出在自动装箱类,用int型为Integer对象赋值时,实际是创建了一个新对象(这点自动装箱和String有点不同,只有[-128,127]区间的数字,虚拟机才会缓存)。  

Integer i = 1000; 
Integer j = 1000; 
System.out.println(i == j); // false 
 
Integer i = 10; 
Integer j = 10; 
System.out.println(i == j); // true 

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章