提示

本文讲解 Java 中的字符串为什么是不可变的。@ermo

# 字符串的不可变特性

在实际应用中,Java 字符串(String)是不可或缺的类型,开发人员或多或少的都会使用到 String 类型。比如用户名、密码和身份证号码等等。

声明一个 String 变量有很多种方式,这里列举一些常用到的实例化 String 的方法。

public class StringDemo {

    public static void main(String[] args) {
        String s1 = "Java";
        String s2 = new String("PHP");
        char[] s3Char = {'P', 'y', 't', 'h', 'o', 'n'};
        String s3 = new String(s3Char);
        StringBuffer s4Buffer = new StringBuffer("Shell");
        String s4 = new String(s4Buffer);
        StringBuilder s5Builder = new StringBuilder("JavaScript");
        String s5 = new String(s5Builder);

        System.out.println(s1); // Java
        System.out.println(s2); // PHP
        System.out.println(s3); // Python
        System.out.println(s4); // Shell
        System.out.println(s5); // JavaScript
    }
}

接下来思考下面的代码会输出什么结果。

public class StringDemo {

    public static void main(String[] args) {
        String s = "Java";
        s.concat(" boy");
        System.out.println(s); // Java
    }
}

答案是 Java 而不是 Java boy

为什么会是这样的输出结果?这要看下 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);
    }

concat 方法会进行判断,如果入参字符串长度大于0,就进行数组拷贝,并且重新创建一个 String 对象并返回。

所以上例中变量 s 的值并没有改变,还是原值 Java

这就是字符串的不可变性,字符串一经创建,它的状态和值不可修改也不能改变,针对字符串的任何操作只会创建一个新的字符串对象。

用一张图来解释可以理解的更加清晰。

上例中执行 String s = "Java" 代码时,在栈中声明了一个名为 s 的变量。

与此同时,在堆中的字符串常量池中创建了 Java 字符串,并将变量 s 引用到 Java 的内存地址。

之后,代码执行 s.concat(" boy") 后,系统在常量池中创建了 boyJava boy 字符串。但是变量 s 并没有引用这两个内存地址。

这也是为什么变量 s 输出的值仍是 Java

如果将代码简单的修改一下。

public class StringDemo {

    public static void main(String[] args) {
        String s = "Java";
        s = s.concat(" boy");
        System.out.println(s); // Java boy
    }
}

此时输出的就是 Java boy

与例1中的唯一区别是,变量 s 更改了堆中的引用地址,由 Java 变成了 Java boy。此时常量池中的对象 Javaboy 仍然存在,编译器会根据变量的引用失效进行自动回收(Garbage Collection)。

此时用图解释应该是这样。

字符串常量池中的内存地址可以被多个变量引用,如果出现这种情况,多个变量的内存地址是相同的。

public class StringDemo {

    public static void main(String[] args) {
        String s = "Java";
        String s1 = s;
        System.out.println(s == s1); // true
    }
}

上述代码会输出 true。

String 类覆写了超类 Object 的 equals 方法,使用 equals 方法比较的是两个字符串的值,而不是内存地址。

在实际开发过程中,建议强制使用 String 的 equals 方法进行比较两个字符串的值,避免出现低级错误。

下面是 String 类中的 equals 方法源代码。

    public boolean equals(Object anObject) {
        if (this == anObject) {
            return true;
        }
        if (anObject instanceof String) {
            String anotherString = (String)anObject;
            int n = value.length;
            if (n == anotherString.value.length) {
                char v1[] = value;
                char v2[] = anotherString.value;
                int i = 0;
                while (n-- != 0) {
                    if (v1[i] != v2[i])
                        return false;
                    i++;
                }
                return true;
            }
        }
        return false;
    }

继承可以获得父类的属性和行为,因此,为了保证 String 类的不可变特性,防止开发人员恶意修改覆写 String 类中的方法,在声明 String 类的时候加上了 final 关键字,表示当前类不可被继承。

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

String 类的不可变特性有这些优势:

  • Java 中的类加载器将 String 作为参数对象,如果 String 可变,每次加载 String 类将会不同
  • 多线程的情况下可以放心的使用 String 类,不用使用关键字 synchronized
  • 可以让应用程序更加安全,已经声明的 String 不能再被更改
  • 字符串的不可变性有助于优化内存堆的使用
  • 如果字符串可变,集合 HashMap 中的 key 将不可控

字符串真的是不可变吗?其实并非如此,有一种方式可以破坏字符串的不可变特性,那就是反射。

public class StringDemo {

    public static void main(String[] args) throws Exception {
        String s = "Hello Java ";
        Field field = String.class.getDeclaredField("value");
        field.setAccessible(true);
        char[] value = (char[]) field.get(s);
        value[6] = 'S';
        value[7] = 'h';
        value[8] = 'e';
        value[9] = 'l';
        value[10] = 'l';
        System.out.println(s); // Hello Shell
    }
}

上述代码通过反射修改了 value 数组的访问权限,使存储 String 值的属性变为 public

因此,变量 s 的值确实被修改了。

本例只作为拓展学习使用,实际开发中禁止使用这种方法,这样会给程序带来安全隐患。