Java字符串真的不可变吗?


399

我们都知道这String在Java 中是不可变的,但是请检查以下代码:

String s1 = "Hello World";  
String s2 = "Hello World";  
String s3 = s1.substring(6);  
System.out.println(s1); // Hello World  
System.out.println(s2); // Hello World  
System.out.println(s3); // World  

Field field = String.class.getDeclaredField("value");  
field.setAccessible(true);  
char[] value = (char[])field.get(s1);  
value[6] = 'J';  
value[7] = 'a';  
value[8] = 'v';  
value[9] = 'a';  
value[10] = '!';  

System.out.println(s1); // Hello Java!  
System.out.println(s2); // Hello Java!  
System.out.println(s3); // World  

为什么该程序会这样运行?为何值s1s2改变了,但没有改变s3


394
您可以使用反射来做各种愚蠢的把戏。但是,您这样做的那一刻,基本上就打破了课堂上的“如果删除,保修无效”标签。
cHao 2014年

16
@DarshanPatel使用SecurityManager禁用反射
肖恩·帕特里克·弗洛伊德

39
如果您真的想弄乱事物,可以(Integer)1+(Integer)2=42通过与缓存的自动装箱弄乱来做到;(心怀不满的炸弹Java版)(thedailywtf.com/Articles/Disgruntled-Bomb-Java-Edition.aspx
理查德·廷格

15
我将近5年前写的这个答案可能会让您感到很有趣。stackoverflow.com/a/1232332/ 27423-它与C#中的不可变列表有关,但基本上是同一件事:如何阻止用户修改数据?答案是,你不能;反射使它非常容易。一种没有这种问题的主流语言是JavaScript,因为它没有可以在闭包内部访问局部变量的反射系统,因此private的确表示私有(即使没有关键字!)
Daniel Earwicker

49
有人读完这个问题了吗?问题是,请允许我重复一遍:“为什么该程序如此运行?为什么s1和s2的值更改了,而s3却没有更改?” 问题不是为什么s1和s2会被更改!问题是:为什么s3不变?
Roland Pihlakas 2014年

Answers:


403

String 是不可变的*,但这仅意味着您无法使用其公共API对其进行更改。

您在这里所做的是使用反射来绕过常规API。同样,您可以更改枚举的值,更改整数自动装箱中使用的查找表等。

现在,原因s1s2更改值是它们都引用相同的实习字符串。编译器执行此操作(如其他答案所述)。

原因s3实际上并不令我惊讶,因为我认为它可以共享value数组(它在Java的较早版本中(在Java 7u6之前)已完成)。但是,查看的源代码String,我们可以看到value实际上已复制了子字符串的字符数组(使用Arrays.copyOfRange(..))。这就是为什么它保持不变。

您可以安装SecurityManager,以避免恶意代码执行此类操作。但是请记住,某些库依赖于使用这种反射技巧(通常是ORM工具,AOP库等)。

*)我最初写道Strings并不是真正不变的,只是“有效的不变”。这可能会在的当前实现中产生误导String,其中value确实标记了数组private final。但是,仍然值得注意的是,没有办法在Java中将数组声明为不可变的,因此,即使使用适当的访问修饰符,也必须注意不要将其暴露在类之外。


由于该主题似乎非常受欢迎,因此,建议您进一步阅读以下内容:Heinz Kabutz在JavaZone 2009上发表的《 Reflection Madness》演讲,其中涵盖了OP中的许多问题以及其他反思……嗯……疯狂。

它涵盖了为什么有时有用。为什么,在大多数情况下,您应该避免使用它。:-)


7
实际上,Stringinterning是JLS的一部分(“字符串文字始终引用类String的相同实例”)。但是我同意,依靠String类的实现细节不是一个好习惯。
haraldK 2014年

3
也许是为什么要substring复制而不是使用现有数组的“节” 的原因,否则,如果我有一个巨大的字符串s并取出一个叫作t它的细小子字符串,而我后来放弃了s却保留了t,那么该巨大数组将保持活动状态(不收集垃圾)。那么也许每个字符串值都有自己的关联数组会更自然吗?
Jeppe Stig Nielsen 2014年

10
在字符串及其子字符串之间共享数组还意味着,每个 String实例都必须携带变量,以记住所引用数组和长度的偏移量。给定字符串的总数以及应用程序中普通字符串和子字符串之间的典型比率,这是不容忽视的开销。由于必须对每个字符串操作进行评估,因此这意味着仅为了一个操作(一个便宜的子字符串)的利益而减慢每个字符串操作的速度。
Holger 2014年

2
@Holger-是的,我的理解是,在最近的JVM中已删除了offset字段。即使它存在,它也不经常使用。
热门点击2014年

2
@supercat:是否具有本机代码,在同一JVM中对字符串和子字符串的实现不同,byte[]对于ASCII字符串和char[]其他字符串都没有关系,这意味着每个操作都必须检查它之前是哪种字符串操作。这阻碍了使用字符串将代码内联到方法中,这是使用调用方的上下文信息进行进一步优化的第一步。这是一个很大的影响。
Holger 2014年

93

在Java中,如果将两个字符串原始变量初始化为相同的文字,则它将对两个变量分配相同的引用:

String Test1="Hello World";
String Test2="Hello World";
System.out.println(test1==test2); // true

初始化

这就是比较返回true的原因。使用创建的第三个字符串创建substring()一个新字符串,而不是指向该字符串。

子字符串

使用反射访问字符串时,将获得实际的指针:

Field field = String.class.getDeclaredField("value");
field.setAccessible(true);

因此,对此进行更改将更改持有指向它的指针的字符串,但是s3由于substring()不会更改,因此使用新字符串创建的字符串也不会更改。

更改


这仅适用于文字,并且是编译时优化。
SpacePrez 2014年

2
@ Zaphod42不正确。您还可以intern手动调用非文字字符串并获得收益。
克里斯·海斯

但是请注意:您要intern谨慎使用。对所有内容进行实习并不会给您带来太多好处,并且当您向混合中添加反射时,可能会成为某些令人费解的时刻的来源。
cHao 2014年

Test1并且Test1test1==test2Java命名约定不一致并且不遵循。
c0der


30

您正在使用反射来访问字符串对象的“实现细节”。不变性是对象公共接口的功能。


24

可见性修饰符和最终修饰符(即不变性)并不是针对Java中恶意代码的度量;它们仅仅是防止错误并使代码更易于维护的工具(系统的最大卖点之一)。这就是为什么您可以String通过反射访问内部实现细节(例如的后备char数组)的原因。

您看到的第二个效果是,所有String的事物都在改变,而好像您只是在改变s1。Java String文字的某些属性是它们会自动进行intern(即缓存)。具有相同值的两个String文字实际上将是同一对象。当您使用new它创建一个String时,它不会被自动检查,也不会看到这种效果。

#substring直到最近(Java 7u6)都以类似的方式工作,这将在问题的原始版本中解释这种行为。它没有创建一个新的支持char数组,而是重用了原始String中的那个数组。它只是创建了一个新的String对象,该对象使用一个偏移量和一个长度来仅表示该数组的一部分。通常,这是因为Strings是不可变的-除非您对此进行规避。此属性#substring还意味着,当仍然存在从原始字符串创建的较短子字符串时,无法对整个原始字符串进行垃圾回收。

从当前的Java和当前版本的问题开始,没有奇怪的行为#substring


2
其实,可视性修饰符(或者至少是)旨在保护反对票恶意代码-然而,你需要设置一个安全管理器(System.setSecurityManager())来激活保护。这实际上有多安全是另一个问题……
sleske 2014年

2
值得赞扬,因为您强调访问修饰符并非旨在 “保护”代码。在Java和.NET中,这似乎都被误解了。尽管先前的评论确实与之相反;我对Java不太了解,但是在.NET中确实是这样。用户不能以两种语言都认为这会使他们的代码防黑客。
2014年

final即使通过反思也不可能违反合同。另外,如另一个答案中所述,由于Java 7u6,#substring不共享数组。
ntoskrnl 2014年

实际上,final随着时间的流逝,... 的行为发生了变化...:-O根据我在另一线程中发布的海因茨的“ Reflection Madness”演讲,final这意味着在JDK 1.1、1.3和1.4中为final,但可以始终使用1.2使用反射进行修改,并且在大多数情况下为1.5和6 ...
haraldK 2014年

1
finalnative当读取序列化实例的字段以及System.setOut(…)修改最终System.out变量时,可以通过代码来更改字段,就像序列化框架所做的那样。后者是最有趣的功能,因为具有访问覆盖的反射无法更改static final字段。
霍尔格

11

字符串不变性是从接口角度来看的。您正在使用反射绕过接口并直接修改String实例的内部。

s1s2都被更改,因为它们都分配给了相同的“ intern” String实例。您可以从本文中有关字符串相等和实习的那部分中找到更多信息。您可能会惊讶地发现在示例代码中,s1 == s2 return true


10

您正在使用哪个Java版本?从Java 1.7.0_06起,Oracle更改了String的内部表示形式,尤其是子字符串。

引用Oracle调整Java的内部字符串表示形式

在新的范例中,字符串偏移量和计数字段已删除,因此子字符串不再共享基础char []值。

有了此更改,它可能会在没有反射的情况下发生(???)。


2
如果OP使用的是较旧的Sun / Oracle JRE,则最后一条语句将显示“ Java!”。(因为他不小心张贴了)。这仅影响字符串和子字符串之间的值数组共享。如果没有技巧,例如反射,您仍然无法更改该值。
haraldK 2014年

7

这里确实有两个问题:

  1. 字符串真的是不变的吗?
  2. 为什么s3不变?

要点1:除ROM外,您的计算机中没有不变的内存。如今,有时ROM也可写。总是有一些代码可以写到您的内存地址(无论是内核代码还是本机代码都避开了托管环境)。因此,在“现实”中,没有,它们不是绝对不变的。

要点2:这是因为子字符串可能正在分配一个新的字符串实例,这很可能会复制该数组。可以以不会复制的方式实现子字符串,但这并不意味着它会这样做。需要权衡。

例如,应该引用 reallyLargeString.substring(reallyLargeString.length - 2)使大量内存保持活动状态,还是仅保留几个字节?

这取决于子字符串的实现方式。深拷贝将保留较少的活动内存,但运行速度会稍慢。浅表副本将保留更多的内存,但速度会更快。使用深层副本还可以减少堆碎片,因为可以在一个块中分配字符串对象及其缓冲区,而不是2个单独的堆分配。

无论如何,您的JVM似乎选择对子字符串调用使用深拷贝。


3
真正的ROM与封装在塑料中的照片一样不变。当对晶片(或印刷品)进行化学显影时,图案将永久设置。如果写入RAM所需的控制信号无法在未向其安装电路增加电连接的情况下被激励,则包括RAM芯片在内的可电气更改的存储器可以充当“真正的” ROM。嵌入式设备包括在工厂设置并由备用电池维护的RAM的情况并不少见,如果电池失灵,则需要由工厂重新加载其内容。
超级猫

3
@supercat:但是,您的计算机不是那些嵌入式系统之一。:)十年来,真正的硬连线ROM在PC中并不常见。一切都是EEPROM,现在已经刷新了。基本上,每个指向内存的用户可见地址都指向潜在的可写内存。
cHao 2014年

@cHao:许多闪存芯片允许以某种方式对部分进行写保护,如果可以完全撤消,则将需要施加与正常操作所需要的电压不同的电压(主板将无法配备此电压)。我希望主板可以使用该功能。此外,我不确定今天的计算机,但是从历史上看,有些计算机具有在启动阶段受写保护的RAM区域,并且只能通过重置来取消保护(这将迫使执行从ROM开始)。
2014年

2
@supercat我认为您错过了本主题的要点,那就是存储在RAM中的字符串永远不会真正不变。
Scott Wisniewski 2014年

5

要补充@haraldK的答案-这是一个安全漏洞,可能会对应用程序造成严重影响。

第一件事是对存储在字符串池中的常量字符串的修改。当string声明为a时String s = "Hello World";,它被放入一个特殊的对象池中以供进一步重用。问题在于,编译器将在编译时放置对修改后的版本的引用,并且一旦用户在运行时修改了存储在此池中的字符串,代码中的所有引用都将指向修改后的版本。这将导致以下错误:

System.out.println("Hello World"); 

将打印:

Hello Java!

当我在如此高风险的字符串上执行大量计算时,我遇到了另一个问题。在计算过程中,每100万次错误中有1次发生了错误,因此结果不确定。我可以通过关闭JIT来找到问题-在关闭JIT的情况下,我总是得到相同的结果。我的猜测是,原因是此String安全黑客破坏了一些JIT优化合同。


可能是线程安全问题,如果没有JIT,执行时间会变慢,并发性会降低。
Ted Pennings 2014年

@TedPennings根据我的描述,我只是不想过多地讨论细节。实际上,我花了几天的时间尝试将其本地化。它是一种单线程算法,用于计算以两种不同语言编写的两个文本之间的距离。我发现了针对该问题的两种可能的解决方法-一种是关闭JIT,另一种是String.format("")在内部循环之一内部添加no-op 。它有可能是另一个JIT失败的问题,但我相信是JIT,因为添加此no-op后再也没有重现此问题。
Andrey Chaschev 2014年

我当时使用的是JDK〜7u9的早期版本,可能就是这样。
Andrey Chaschev 2014年

1
@Andrey Chaschev:“我发现了该问题的两个可能的修复程序”…第三个可能的修复程序,不是要侵入String内部,还是没有想到?
Holger 2014年

1
@Ted Pennings:线程安全问题和JIT问题通常是相同的。允许JIT生成依赖于final现场线程安全性保证的代码,这些保证在对象构造后修改数据时会中断。因此,您可以根据需要将其视为JIT问题或MT问题。真正的问题是入侵String预期的数据并对其进行修改。
Holger 2014年

5

根据池的概念,所有包含相同值的String变量都将指向相同的内存地址。因此,s1和s2都包含相同的“ Hello World”值,它们将指向相同的存储位置(例如M1)。

另一方面,s3包含“世界”,因此它将指向不同的内存分配(例如M2)。

所以现在发生的是,正在更改S1的值(通过使用char []值)。因此,由s1和s2指向的存储位置M1的值已更改。

因此,结果,存储器位置M1已经被修改,这导致s1和s2的值改变。

但是位置M2的值保持不变,因此s3包含相同的原始值。


5

s3实际上没有改变的原因是因为在Java中,当您执行子字符串操作时,会在内部复制子字符串的值字符数组(使用Arrays.copyOfRange())。

s1和s2是相同的,因为在Java中它们都引用相同的内部字符串。它是用Java设计的。


2
这个答案如何在您之前的答案中添加任何内容?
2014年

另请注意,这是一个相当新的行为,并且不受任何规范的保证。
圣保罗Ebermann

执行String.substring(int, int)更改与Java 7u6。7u6之前,JVM将只保留一个指向原来Stringchar[]带有索引和长在一起。7u6之后,它将子字符串复制到新的字符串String中。优点和缺点。
Eric Jablow 2014年

2

String是不可变的,但是通过反射,您可以更改String类。您刚刚将String类重新定义为实时可变的。如果需要,可以将方法重新定义为公共,私有或静态。


2
如果您更改字段/方法的可见性,那么它就没有用,因为在编译时它们是私有的
Bohemian

1
您可以更改方法的可访问性,但不能更改其公共/私有状态,也不能使其成为静态。
2014年

1

[免责声明,这是一种故意为之作答的答案,因为我认为更“不在家做孩子”的答案是必要的]

罪过是field.setAccessible(true);通过允许访问私有字段来违反公共api 的行。那是一个巨大的安全漏洞,可以通过配置安全管理器将其锁定。

问题中的现象是实现细节,当您不使用该危险代码行通过反射违反访问修饰符时,您将永远看不到实现细节。显然,两个(通常)不可变字符串可以共享同一char数组。子字符串是否共享同一数组取决于它是否可以以及开发人员是否考虑共享它。通常,这些是看不见的实现细节,除非您使用该行代码从头开始访问访问修饰符,否则就不必知道这些细节。

依靠这样的细节根本不是一个好主意,如果不使用反射违反访问修饰符,就无法体验这些细节。该类的所有者仅支持普通的公共API,以后可以自由更改实现。

说了这么多,当您拿着枪抬起头来迫使您执行此类危险的事情时,代码行确实非常有用。使用该后门通常是一种代码味道,您需要将其升级到不必犯错的更好的库代码。该危险代码行的另一个常见用法是编写“伏都教框架”(orm,注入容器等)。许多人对这样的框架(包括赞成和反对)都抱有宗教信仰,因此我将避免激怒战争,除了绝大多数程序员不必去那里之外,别无其他。


1

在JVM堆内存的永久区域中创建字符串。因此,是的,它确实是不可变的,创建后就无法更改。因为在JVM中,堆内存有三种类型:1.年轻的一代2.老的一代3.永久的一代。

创建任何对象时,它将进入为字符串池保留的年轻代堆区域和PermGen区域。

您可以从这里获取更多详细信息,并从中获取更多信息: 垃圾回收如何在Java中工作


By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.