这段Java代码片段是如何运作的?(字符串池和反射)

87

Java字符串池与反射相结合可能会在Java中产生一些难以想象的结果:

import java.lang.reflect.Field;

class MessingWithString {
    public static void main (String[] args) {
        String str = "Mario";
        toLuigi(str);
        System.out.println(str + " " + "Mario");
    }

    public static void toLuigi(String original) {
        try {
            Field stringValue = String.class.getDeclaredField("value");
            stringValue.setAccessible(true);
            stringValue.set(original, "Luigi".toCharArray());
        } catch (Exception ex) {
            // Ignore exceptions
        }
    }
}

上面的代码将输出:

"Luigi Luigi" 

Mario发生了什么事?


7
@Joe 我觉得就算了。[Jeff Atwood:“我已经学会不再担心并且喜欢(某些)重复。你也应该这样做。”] (https://blog.stackoverflow.com/2010/11/dr-strangedupe-or-how-i-learned-to-stop-worrying-and-love-duplication/) - Mindwin Remember Monica
3
@Mindwin:这并不意味着我们应该停止关闭重复的问题,如果它们确实是重复的话。事实上,Jeff的文章鼓励我们关闭重复的问题——因为这是将它们链接在一起的方法。 - Andriy M
7个回答

98

Mario 发生了什么?

其实是你改变了它。是的,使用反射可以违反字符串的不变性……由于字符串驻留(string interning)的缘故,这意味着除了在编译时解析的大型字符串常量表达式之外的任何“Mario”使用都将在程序的其余部分中以“Luigi”的形式出现。

这就是为什么反射需要安全权限的原因...

请注意,表达式str + " " + "Mario"并没有执行任何编译时串联操作,因为+是从左到右结合的。它实际上是(str + " ") + "Mario",这就是为什么您仍然会看到Luigi Luigi的原因。如果您更改代码:

System.out.println(str + (" " + "Mario"));

...然后你会看到Luigi Mario,因为编译器将会把" Mario""Mario"存储成不同的字符串。


“other than in a larger string constant expression” 这一点可能并不完全正确。在问题中,System.out.println 调用使用了一个编译时常量表达式 (" " + "Mario"),然而 "Mario" 的这个实例最终还是被改变了。我怀疑这是由于一种优化方式,即 " Mario" 被内部化,而 "Mario" 由于是后缀匹配而引用了相同的内存空间,尽管我还没有确认。这是一个有趣的边缘情况,与通常的说法略有不同(或者我误解了这是否为编译时常量)。 - Chris Hayes
7
不,这不是一个编译时常量表达式,因为“+”的结合性。它被解释为“(str + " ") + "Mario"”。如果你只打印“ " + Mario”或者“ " + "Mario" + str”,那么你就有了编译时的连接,并且仍然会在输出中得到“Mario”。 - Jon Skeet
啊,我明白了。如果它不是立即直观的话,那就有道理了。感谢您的解释。 - Chris Hayes
3
@ChrisHayes:在答案中添加了更多的解释,因为这通常很有用。 - Jon Skeet
1
是的,如果你将str变量声明为final,它会彻底改变结果。请注意,在理论上,经过第一次连接后,被操作的“Mario”实例可能被垃圾回收,并为下一个出现的字符串创建一个新的规范化“Mario”。但这种情况非常不可能发生。最近JVM实现的字符串去重特性也可能导致错误的数组被复制到其他未经过内部处理的“Mario”实例中。此外,可能仍然存在反映旧内容的缓存哈希码,从而导致有趣的效果... - Holger

24

这段代码将字符串设置为 "Luigi"。在Java中,字符串是不可变的;因此,编译器可以将所有对"Mario"的引用解释为指向同一个字符串常量池条目(大致上是“内存位置”)。你使用反射来改变该条目;所以你代码中的所有"Mario"现在就像你写了"Luigi"一样。


1
“...作为对同一内存位置的引用...” - 编译器不涉及内存位置,运行时系统也不能这样做,因为任何字符串的内存位置都可以随时被垃圾回收器更改。我理解你想表达什么,但你表达得不正确。如果你在谈论C或C++,那么这个解释大致是正确的。但对于Java来说,它并不正确。 - Stephen C
@StephenC:虽然最好说“字符串常量池中的相同索引”,但最终效果是相同的:“Mario”确实存储在内存位置中(因为即使JVM最终需要在底层架构上进行解释,它也将被分配到某个地方),如果gc移动它,仍然是真的,所有提到“Mario”的地方都将引用相同(移动后的)位置。不过,你说得对-我应该使用适合Java的术语,所以我会改变它。 - Amadan
1
最好的说法是它们都是同一个对象。而这最终是由Java运行时系统来确保,而不是编译器。 - Stephen C

16
为了更好地解释现有答案,让我们看一下您生成的字节码(仅此处的main()方法)。

Byte Code

现在,对该位置内容的任何更改都将影响到引用(以及您提供的其他引用)。

9

字符串字面量存储在字符串池中,它们的规范值被使用。两个"Mario"字面量不仅仅是具有相同值的字符串,它们是同一个对象。操作其中一个(使用反射)将修改“两者”,因为它们只是指向同一对象的两个引用。


8

您刚刚将被多个字符串引用的字符串常量池中的Mario更改为Luigi,因此每个引用字面量 Mario 的地方现在都是Luigi

Field stringValue = String.class.getDeclaredField("value");

您已经从类String中获取了名为valuechar[]字段。
stringValue.setAccessible(true);

使其易于访问。
stringValue.set(original, "Luigi".toCharArray());

你将original String字段更改为Luigi。但是,原本是Mario这个String字面值属于String池,并且所有字面值都是interned。这意味着具有相同内容的所有字面值均指向相同的内存地址。
String a = "Mario";//Created in String pool
String b = "Mario";//Refers to the same Mario of String pool
a == b//TRUE
//You changed 'a' to Luigi and 'b' don't know that
//'a' has been internally changed and 
//'b' still refers to the same address.

基本上,你改变了String池中的Mario,这反映在所有引用字段中。如果你创建StringObject(即new String("Mario"))而不是字面量,你就不会遇到这种行为,因为那时你将有两个不同的Marios。


5
其他答案已经充分解释了发生了什么。我只想补充一点,这仅在没有安装安全管理器的情况下才有效。默认情况下,在命令行中运行代码时通常不会安装安全管理器,因此您可以执行此类操作。但是,在混合了可信代码和不可信代码的环境中(例如生产环境中的应用程序服务器或浏览器中的小程序沙盒),通常会存在安全管理器,因此您将无法执行此类恶作剧,因此这并不像它看起来那样是一个可怕的安全漏洞。

3

另一个相关的点:您可以使用常量池在某些情况下提高字符串比较的性能,方法是使用 String.intern() 方法。

该方法返回与从其调用它的 String 具有相同内容的 String 实例,如果尚未存在,则在 String 常量池中添加它。换句话说,在使用 intern() 后,具有相同内容的所有字符串都保证是相同的字符串实例,包括任何具有这些内容的字符串常量,因此您可以在它们上使用等于运算符 (==)。

这只是一个不太有用但用来说明问题的示例:

class Key {
    Key(String keyComponent) {
        this.keyComponent = keyComponent.intern();
    }

    public boolean equals(Object o) {
        // String comparison using the equals operator allowed due to the
        // intern() in the constructor, which guarantees that all values
        // of keyComponent with the same content will refer to the same
        // instance of String:
        return (o instanceof Key) && (keyComponent == ((Key) o).keyComponent);
    }

    public int hashCode() {
        return keyComponent.hashCode();
    }

    boolean isSpecialCase() {
        // String comparison using equals operator valid due to use of
        // intern() in constructor, which guarantees that any keyComponent
        // with the same contents as the SPECIAL_CASE constant will
        // refer to the same instance of String:
        return keyComponent == SPECIAL_CASE;
    }

    private final String keyComponent;

    private static final String SPECIAL_CASE = "SpecialCase";
}

这个小技巧并不值得为之设计你的代码,但是在性能敏感的代码中,通过使用intern()和谨慎地使用==运算符来比较字符串,可以提高一些速度。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接