学Java的人或多或少都会得到这么一个信息:String是不可变的。那么果真如此吗?
本文前置知识:反射,Java内存模型。
一、如何改变一个String
打开String的源码,赫然可以看见,其实String对象的数据储存在它的value数组中。
在早起版本的Java中,这是一个char[]类型的数组,较晚版本中替换为byte[]类型。
public final class String{
private final byte[] value;
// ……
}
复制代码
那么,如果利用反射把这个数组替换掉,是不是就能改变String了呢?
接下来进行尝试。
创建一个modifyString方法,利用反射修改字符串中的value数组,并在main函数中测试效果(注意,在低版本Java中这里的byte[]应修改为char[]):
private static void modifyString(String src, String dst) throws NoSuchFieldException, IllegalAccessException{
Field valueField = String.class.getDeclaredField("value");
valueField.setAccessible(true);
byte[] newValue = dst.getBytes();
valueField.set(src, newValue);
}
public static void main(String[] args) throws NoSuchFieldException, IllegalAccessException{
String s = "hello, world!";
modifyString(s, "you're so cool!");
System.out.println("s = " + s);
}
复制代码
可以看到,输出显示s的确改变了!
s = you're so cool!
一个大胆的想法
看到上面的结果之后,我有了一个大胆的想法。
同样是modifyString方法,但是main函数改成了下面这样:
public static void main(String[] args) throws NoSuchFieldException, IllegalAccessException{
String s = "hello, world!";
modifyString(s, "you're so cool!");
System.out.println("hello, world!");
}
复制代码
甚至直接这样:
public static void main(String[] args) throws NoSuchFieldException, IllegalAccessException{
modifyString("hello, world!", "you're so cool!");
System.out.println("hello, world!");
}
复制代码
猜猜看会输出什么?有兴趣的可以自己试试。
二、原理简析
1. 字符串常量池
Java中的字符串会存储在字符串常量池中。理论上字符串常量池位于方法区,实际是存储是在堆中(见Java内存模型)。
字符串常量池中储存了使用过的字符串对象。当需要使用某个字符串时,首先在字符串常量池中查找有没有相应的对象,如果找到了就直接返回,否则就创建一个新的字符串对象,然后放进字符串常量池中。
当运行到s = "hello, world!"时,这个字符串类型的变量s就指向了对应常量池中的"hello, world!"对象。为了方便区分,这里称字符串常量池中的"hello, world!"对象为**helloworld。
众所周知,Java中一切对象都是引用传递**,当使用modifyString(s, "you're so cool!")方法修改字符串时,其实修改的就是helloworld.value。这样一来,相当于直接修改了常量池中字符串的值。
所以,当我们运行以下代码时:
public static void main(String[] args) throws NoSuchFieldException, IllegalAccessException{
String s = "hello, world!";
String t = "hello, world!";
modifyString(s, "you're so cool!");
System.out.println(t);
}
复制代码
得到的输出是you're so cool!。本质上的原因就是,s和t都是指向的同一个字符串常量池中的对象helloworld。也就是说,s和t本质上只是类似于一个指针,真实的对象都是常量池中的helloworld。
当helloworld对象的内部值value被修改之后,表面上的感官即是s和t的值都发生了改变。
再看这段代码:
public static void main(String[] args) throws NoSuchFieldException, IllegalAccessException{
modifyString("hello, world!", "you're so cool!");
System.out.println("hello, world!");
}
复制代码
同样的道理,最后System.out.println("hello, world!")输出的是you're so cool!,谜底揭晓,也是很神奇了。
2. new String()
通过new String()创建的字符串,情况就有所变化。
查看以下代码:
public static void main(String[] args) throws NoSuchFieldException, IllegalAccessException{
String s = "hello, world!";
String t = new String("hello, world!");
modifyString(s, "you're so cool!");
System.out.println(t);
}
复制代码
最终输出hello, world!而不是you're so cool!,其原因是因为变量t指向的是在堆中创建的String对象而非字符串常量池中的helloworld对象。
在执行t = new String("hello, world!)时,会在堆内存中开辟一块空间放置这个对象,并把字符串常量池中的helloworld的value数组赋值给t。下面是String类的构造方法:
public String(String original){
this.value = original.value;
this.coder = original.coder;
this.hash = original.hash;
}
复制代码
当使用modifyString(s, "you're so cool!")修改字符串时,是把helloworld.value给替换掉了;而t.value没有被替换掉,仍旧是指向的原字符串所对应的数组。如图所示:
看到这里,不知道你有没有一个大胆的想法?
3. 又一个大胆的想法
在上面的分析中,已经知道,对于以下代码:
public static void main(String[] args) throws NoSuchFieldException, IllegalAccessException{
String s = "hello, world!";
String t = new String("hello, world!");
modifyString(s, "you're so cool!");
System.out.println("t = " + t);
}
private static void modifyString(String src, String dst) throws NoSuchFieldException, IllegalAccessException{
Field valueField = String.class.getDeclaredField("value");
valueField.setAccessible(true);
byte[] newValue = dst.getBytes();
valueField.set(src, newValue);
}
复制代码
t并没有被改变,仍然输出t = hello, world!。那么,如果改变modifyString的行为,使其直接修改value数组呢?
private static void modifyString(String src, String dst) throws NoSuchFieldException, IllegalAccessException{
Field valueField = String.class.getDeclaredField("value");
valueField.setAccessible(true);
byte[] oldValue = (byte[]) valueField.get(src);
byte[] newValue = dst.getBytes();
System.arraycopy(newValue, 0, oldValue, 0, Math.min(oldValue.length, newValue.length));
}
复制代码
这时候,再运行main函数,就发现,t的值也改变了。不过由于value数组的长度限制,只能显示原字符串的长度:
输出
t = you're so coo
三、Android之殇
打开Android SDK中的String类,会发现里面已经没有value数组了。这是因为Android修改了String类的实现,直接在native层面管理value数组,而在String中加入了数组长度count。
而一系列跟value相关的方法,比如charAt、compareTo等都改成了native方法。
并且,Android禁止了所有String的构造方法,创建字符串要么使用双引号的形式,要么使用StringFactory。
Android这么做的原因据称是为了性能,能在字节码上面优化运行效率。我觉得这同时也是为了安全,避免对字符串常量池中的值进行修改。
反正,这个花活儿在Android里是玩不了了。