一、你以为的常识
1.1 不可变性的理解
稍有些基础的同学都知道 Java 中 String 字符串是“不可变”的,想要使用“可变字符串”可以使用 StringBuilder
和 StringBuffer
。
大多数讲字符串不可变性的文章大同小异。
不可变的定义:
An immutable object is an object whose internal state remains constant after it has been entirely created. This means that once the object has been assigned to a variable, we can neither update the reference nor mutate the internal state by any means.
-- 《Why String is Immutable in Java?》
所谓不可变对象,即对象创建之后内部状态保持不变。换句话说,一旦对象被赋值给一个变量,将不再允许通过任何方式改变引用、修改内部状态。
1.2 不可变性的实现
String “不可变性”的保障:
- (1) String 类被 final ,导致不继承;
- (2) 存储 String 的字符的 char 数组为 final 则引用不可改变。
- (3) 所有修改方法(如 concat)都会返回一个新的字符串对象。
public final class String
implements java.io.Serializable, Comparable<String>, CharSequence {
/** The value is used for character storage. */
private final char value[];
//省略其他
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);
}
}
1.3 不可变性的好处
1.3.1 节省内存
由于字符串的不可变性,不同的字符串变量可以引用同一个示例来实现节省堆内存的目的。
String s1 = "明明如月学长";
String s2 = "明明如月学长");
String s3 = new String("明明如月学长");
assertThat(s1 == s2).isTrue();
assertThat(s1 == s3).isFalse();
1.3.2 更安全
字符串的不可变性保证了安全性。
请看下面的示例代码,先执行参数的合法性检查,然后执行一些次要的认为,最后执行重要的任务:
void criticalMethod(String userName) {
//1 执行安全检查
if (!isAlphaNumeric(userName)) {
throw new SecurityException();
}
//2 执行一些次要的任务
initializeDatabase();
//3 重要的任务
connection.executeUpdate("UPDATE Customers SET Status = 'Active' " +
" WHERE UserName = '" + userName + "'");
}
假如字符串可变,在第一步安全检查通过后字符串发生修改,代码运行可能出现不符合预期的结果,比如造成 SQL 注入等。
字符串的不可变性也保证了多线程访问时的现成安全性。
1.3.3 hashCode 缓存
大家可以看到 String 的 hashCode 的计算和构成字符串的字符有关,由于 String 的不可变性就可以将 hashCode 缓存起来。源码中也可以看出计算过之后,下次调用 hashCode 直接返回。
/**
* Returns a hash code for this string. The hash code for a
* {@code String} object is computed as
* <blockquote><pre>
* s[0]*31^(n-1) + s[1]*31^(n-2) + ... + s[n-1]
* </pre></blockquote>
* using {@code int} arithmetic, where {@code s[i]} is the
* <i>i</i>th character of the string, {@code n} is the length of
* the string, and {@code ^} indicates exponentiation.
* (The hash value of the empty string is zero.)
*
* @return a hash code value for this object.
*/
public int hashCode() {
int h = hash;
if (h == 0 && value.length > 0) {
char val[] = value;
for (int i = 0; i < value.length; i++) {
h = 31 * h + val[i];
}
hash = h;
}
return h;
}
二、怀疑人生
2.1 质疑
不知道你是否真正认真思考过,字符串真的不可变吗?
即使字符串类用 final 修饰,字符串值字符数组也用 final 修饰,所有修改方法都返回新的字符串对象,那么值一定无法修改吗?
答案是否定的!!
我们可以用反射来修改字符串对象的值。
2.2 验证
public static void main(String[] args) throws NoSuchFieldException, IllegalAccessException {
String name ="明明如月学长 admin";
System.out.println("name 修改前:"+name+", hashCode:"+name.hashCode());
String newName = "明明如月学长";
System.out.println("newName:"+newName+", hashCode:"+newName.hashCode());
replace(name,newName);
System.out.println("name 修改后: "+name+", hashCode:"+name.hashCode());
}
private static void replace(String name,String newName) throws NoSuchFieldException, IllegalAccessException {
// 去掉私有
Field value = String.class.getDeclaredField("value");
value.setAccessible(true);
// 去掉 final
Field mod = Field.class.getDeclaredField("modifiers");
mod.setAccessible(true);
mod.setInt(value, value.getModifiers() & ~Modifier.FINAL);
// 直接替换 value 字符数组
value.set(name, newName.toCharArray());
}
输出结果:
name 修改前:明明如月学长 admin, hashCode:557981902
newName:明明如月学长, hashCode:-292262689
name 修改后: 明明如月学长, hashCode:557981902
2.3 带来的问题
如果字符串的值可以修改,程序就可能出现不符合预期的行为,
import java.lang.reflect.Field;
import java.lang.reflect.Modifier;
import java.util.concurrent.TimeUnit;
public class StringDemo {
public static void main(String[] args) throws InterruptedException {
String name ="明明如月学长 admin";
new Thread(()->{
try {
mockExecute(name);
} catch (InterruptedException e) {
e.printStackTrace();
}
}).start();
TimeUnit.SECONDS.sleep(2);
new Thread(()->{
try {
mockInject(name);
} catch (Exception e) {
e.printStackTrace();
}
}).start();
TimeUnit.SECONDS.sleep(10);
}
private static synchronized void mockInject(String name) throws InterruptedException, NoSuchFieldException, IllegalAccessException {
// sleep 让 子线程执行到 wait
TimeUnit.SECONDS.sleep(1);
// 检查前使用合规的名称
System.out.println("mockInject: 去掉 admin,绕过校验");
replace(name,"明明如月学长");
StringDemo.class.notifyAll();
System.out.println("mockInject: 恢复 admin");
// 检查后换成不合规的名称
replace(name,"明明如月学长 admin");
System.out.println("mockInject: 恢复 admin 完毕");
StringDemo.class.notifyAll();
}
private static void replace(String name,String newName) throws NoSuchFieldException, IllegalAccessException {
Field value = String.class.getDeclaredField("value");
value.setAccessible(true);
Field mod = Field.class.getDeclaredField("modifiers");
mod.setAccessible(true);
mod.setInt(value, value.getModifiers() & ~Modifier.FINAL);
value.set(name, newName.toCharArray());
}
private static synchronized void mockExecute(String name) throws InterruptedException {
System.out.println("mockExecute: [1] name.contains(\"admin\"):"+name.contains("admin"));
StringDemo.class.wait();
//1 参数检查
if(name.contains("admin")){
throw new IllegalArgumentException("参数检查失败");
}
System.out.println("mockExecute: 不含 admin 关键字,参数检查通过");
System.out.println("mockExecute: [2] name.contains(\"admin\"):"+name.contains("admin"));
//2 执行次要任务
System.out.println("mockExecute: 执行次要任务");
//3 执行重要人物
System.out.println("mockExecute: 执行重要任务");
}
}
这里简单使用 wait/notify 来模拟多线程情况下字符串修改带来的问题。
输出的结果:
mockExecute: [1] name.contains("admin"):true
mockInject: 去掉 admin,绕过校验
mockInject: 恢复 admin
mockInject: 恢复 admin 完毕
mockExecute: 不含 admin 关键字,参数检查通过
mockExecute: [2] name.contains("admin"):false
mockExecute: 执行次要任务
mockExecute: 执行重要任务
三、总结
字符串的不可变性是指通过 String 的方法来修改字符串都会产出新的字符串对象。但并非指字符串的字符一定无法被修改,我们可以通过反射一样可以对字符串的“状态/值” 进行修改。
正常情况下不会有人去这么做,否则会产出很多不出乎意料的 BUG。
通过本文想提醒大家,尽信书不如无书,对于看到的知识要有自己的思考。
创作不易,如果本文对你有帮助,欢迎点赞、收藏加关注,你的支持和鼓励,是我创作的最大动力。