提示
本文讲解 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")
后,系统在常量池中创建了 boy
和 Java 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
。此时常量池中的对象 Java
和 boy
仍然存在,编译器会根据变量的引用失效进行自动回收(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
的值确实被修改了。
本例只作为拓展学习使用,实际开发中禁止使用这种方法,这样会给程序带来安全隐患。