阅读 1176

String 为什么不可变 ?

众所周知, String 是一个不可变的,由 final 修饰的类。那么它的不可变性体现在哪里呢? 看下面一段简单的代码:

  String str= "123";
  str = "456";
复制代码

相信应该没人会觉得这段代码是错误的,那么这符合 String 的不可变性吗?String 的不可变性是如何体现的? 不可变性的好处是什么?带着这些疑问,read the fuck source code !

什么是不可变类 ?

Effective Java 中第 15 条 使可变性最小化 中对 不可变类 的解释:

不可变类只是其实例不能被修改的类。每个实例中包含的所有信息都必须在创建该实例的时候就提供,并且在对象的整个生命周期内固定不变。为了使类不可变,要遵循下面五条规则:

1. 不要提供任何会修改对象状态的方法。

2. 保证类不会被扩展。 一般的做法是让这个类称为 final 的,防止子类化,破坏该类的不可变行为。

3. 使所有的域都是 final 的。

4. 使所有的域都成为私有的。 防止客户端获得访问被域引用的可变对象的权限,并防止客户端直接修改这些对象。

5. 确保对于任何可变性组件的互斥访问。 如果类具有指向可变对象的域,则必须确保该类的客户端无法获得指向这些对象的引用。

在 Java 平台类库中,包含许多不可变类,例如 String , 基本类型的包装类,BigInteger, BigDecimal 等等。综上所述,不可变类具有一些显著的通用特征:类本身是 final 修饰的;所有的域几乎都是私有 final 的;不会对外暴露可以修改对象属性的方法。通过查阅 String 的源码,可以清晰的看到这些特征。

为什么 String 不可变 ?

String 的源码实现

回到开头提出的问题,对于这段代码:

String str= "123";
str = "456";
复制代码

下面这张图清楚地解释了代码的执行过程:

执行第一行代码时,在堆上新建一个对象实例 123 ,str 是一个指向该实例的引用,引用包含的仅仅只是实例在堆上的内存地址而已。执行第二行代码时,仅仅只是改变了 str 这个引用的地址,指向了另一个实例 456。所以,正如前面所说过的,不可变类只是其实例不能被修改的类。str 重新赋值仅仅只是改变了它的引用而已,并不会真正去改变它本来的内存地址上的值。这样的好处也是显而易见的,最简单的当存在多个 String 的引用指向同一个内存地址时,改变其中一个引用的值并不会对其他引用的值造成影响。当然还有其他的一些好处,这个放在后面再总结。

那么,String 是如何保持不可变性的呢?结合 Effective Java 中总结的五条原则,阅读它的 源码 之后就一清二楚了。

看一下 String 的几个域:


public final class String
    implements java.io.Serializable, Comparable<String>, CharSequence {
    /** The value is used for character storage. */
    private final char value[];

    /** Cache the hash code for the string */
    private int hash; // Default to 0

    /** use serialVersionUID from JDK 1.0.2 for interoperability */
    private static final long serialVersionUID = -6849794470754667710L;

    /**
     * Class String is special cased within the Serialization Stream Protocol.
     *
     * A String instance is written into an ObjectOutputStream according to
     * <a href="{@docRoot}/../platform/serialization/spec/output.html">
     * Object Serialization Specification, Section 6.2, "Stream Elements"</a>
     */
    private static final ObjectStreamField[] serialPersistentFields =
        new ObjectStreamField[0];

        ...
        ...
}
复制代码

String 类是 final 修饰的,满足第二条原则:保证类不会被扩展。 分析一下它的几个域:

  • private final char value[] : 可以看到 Java 还是使用字节数组来实现字符串的,并且用 final 修饰,保证其不可变性。这就是为什么 String 实例不可变的原因。

  • private int hash : String的哈希值缓存

  • private static final long serialVersionUID = -6849794470754667710L : String对象的 serialVersionUID

  • private static final ObjectStreamField[] serialPersistentFields = new ObjectStreamField[0] : 序列化时使用

其中最主要的域就是 value,代表了 String对象的值。由于使用了 private final 修饰,正常情况下外界没有办法去修改它的值的。正如第三条 使所有的域都是 final 的。 和第四条 使所有的域都成为私有的 所描述的。难道这样一个 private 加上 final 就可以保证万无一失了吗?看下面代码示例:

    final char[] value = {'a', 'b', 'c'};
    value[2] = 'd';
复制代码

这时候的 value 对象在内存中已经是 a b d 了。其实 final 修饰的仅仅只是 value 这个引用,你无法再将 value 指向其他内存地址,例如下面这段代码就是无法通过编译的:

    final char[] value = {'a', 'b', 'c'};
    value = {'a', 'b', 'c', 'd'};
复制代码

所以仅仅通过一个 final 是无法保证其值不变的,如果类本身提供方法修改实例值,那就没有办法保证不变性了。Effective Java 中的第一条原则 不要提供任何会修改对象状态的方法 。String 类也很好的做到了这一点。在 String 中有许多对字符串进行操作的函数,例如 substring concat replace replaceAll 等等,这些函数是否会修改类中的 value 域呢?我们看一下 concat() 函数的内部实现:

public String concat(String str) {
    int otherLen = str.length();
    if (otherLen == 0) {
        return this;
    }
    int len = value.length;
    char buf[] = Arrays.copyOf(value, len + otherLen);
    str.getChars(buf, len);
    return new String(buf, true);
}
复制代码

注意其中的每一步实现都不会对 value产生任何影响。首先使用 Arrays.copyOf() 方法来获得 value 的拷贝,最后重新 new 一个String对象作为返回值。其他的方法和 contact 一样,都采取类似的方法来保证不会对 value 造成变化。的的确确,String 类中并没有提供任何可以改变其值的方法。相比 final 而言,这更能保障 String 不可变。

String 对象在内存中的位置

关于 Java 内存区域的相关知识可以阅读 《深入理解 Java 虚拟机》 一书中相关章节或者我的对应的 读书笔记

细心的读者应该发现上面出现过两种 String 对象的写法:

  String str1 = "123";
  String str2 = new String("123");
  System.out.println(str1 == str2);
复制代码

结果显然是 false。我们都知道字符串常量池的概念,JVM 为了字符串的复用,减少字符串对象的重复创建,特别维护了一个字符串常量池。第一种字面量形式的写法,会直接在字符串常量池中查找是否存在值 123,若存在直接返回这个值的引用,若不存在创建一个值为 123 的 String 对象并存入字符串常量池中。而使用 new 关键字,则会直接在堆上生成一个新的 String 对象,并不会理会常量池中是否有这个值。所以本质上 str1str2 指向的内存地址是不一样的。

那么,使用 new 关键字生成的 String 对象可以进入字符串常量池吗?答案是肯定的,String 类提供了一个 native 方法 intern() 用来将这个对象加入字符串常量池:

  String str1 = "123";
  String str2 = new String("123");
  str2=str2.intern();
  System.out.println(str1 == str2);
复制代码

打印结果为 truestr2 调用 intern() 函数后,首先在字符串常量池中寻找是否存在值为 123 的对象,若存在直接返回该对象的引用,若不存在,加入 str2 并返回。上述代码中,常量池中已经存在值为 123str1 对象,则直接返回 str1 的引用地址,使得 str1str2 指向同一个内存地址。

那么说了半天的字符串常量池在内存中处于什么位置呢?常量池是方法区的一部分,用于存放编译期生成的各种字面量和符号引用,运行期间也有可能将新的常量放入池中。在 Java 虚拟机规范中把方法区描述为堆的一个逻辑部分,但它却有一个别名叫 Non-Heap,目的应该是为了和 Java 堆区分开。

不可变类的好处

Effective Java 中总结了不可变类的特点。

  • 不可变类比较简单。
  • 不可变对象本质上是线程安全的,它们不要求同步。不可变对象可以被自由地共享。
  • 不仅可以共享不可变对象,甚至可以共享它们的内部信息。
  • 不可变对象为其他对象提供了大量的构建。
  • 不可变类真正唯一的缺点是,对于每个不同的值都需要一个单独的对象。

String 真的不可变吗?

综上所述,String 的确是个不可变类,但是真的没有办法改变 String 对象的值吗?答案肯定是否定的,反射机制可以做到很多平常做不到的事情。

String str = "123";
System.out.println(str);
Field field = String.class.getDeclaredField("value");
field.setAccessible(true);
char[] value = (char[]) field.get(str);
value[1] = '3';
复制代码

执行结果:

  123
  133
复制代码

很显然,通过反射可以破坏 String 的不可变性。

总结

除了 String 类,系统类库中还提供了一些其他的不可变类,基本类型的包装类、BigInteger、BigDecimal等等。这些不可变类比可变类更加易于设计、实现和使用,不容易出错且更加安全。另外,要记住并不仅仅是靠一个 final 关键字来实现不可变的,更多的是靠类内部的具体实现细节。

文章同步更新于微信公众号: 秉心说 , 专注 Java 、 Android 原创知识分享,LeetCode 题解,欢迎关注!

关注下面的标签,发现更多相似文章
评论