如何理解 String 类型值的不可变?

什么是不可变?

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

String为什么不可变?

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

public final class String implements java.io.Serializable, Comparable, 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指向堆区另一个地址。但如果我直接对数组元素动手,分分钟搞定。

final int[] value={1,2,3};
value[2]=100; //这时候数组里已经是{1,2,100}

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

不可变有什么好处?

这个最简单地原因,就是为了安全。看下面这个场景(有评论反应例子不够清楚,现在完整地写出来),一个函数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 hs = new HashSet();
        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

参考:https://www.zhihu.com/question/20618891/answer/114125846

© 版权声明
THE END
喜欢就支持一下吧
点赞3赞赏 分享
评论 抢沙发

请登录后发表评论

    请登录后查看评论内容