Java字符串真的是不可变的吗?

我们都知道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没有改变?

57985 次浏览

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

您正在使用反射来规避String的不可变性——这是一种“攻击”形式。

你可以创建很多这样的例子(比如你甚至可以实例化一个Void对象),但这并不意味着String不是“不可变的”。

在一些用例中,这种类型的代码可能会被用于您的优势,并且是“良好的代码”,例如在尽可能早的时间(在垃圾收集之前)从内存中清除密码

根据安全管理器的不同,您可能无法执行代码。

String是不可变的*,但这只意味着你不能使用它的公共API更改它。

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

现在,s1s2改变值的原因是它们都指向同一个被拘禁的字符串。编译器执行此操作(如其他答案所述)。

s3执行的原因实际上让我有点惊讶,因为我以为它会共享value数组(在早期版本的Java中是这样的,在Java 7u6之前)。然而,查看String的源代码,我们可以看到子字符串的value字符数组实际上被复制了(使用Arrays.copyOfRange(..))。这就是它不变的原因。

你可以安装一个SecurityManager,以避免恶意代码做这样的事情。但是请记住,有些库依赖于使用这种反射技巧(通常是ORM工具,AOP库等)。

*)我最初写__abc0不是真正的不可变,只是“有效的不可变”。在当前的String实现中,这可能会引起误解,其中value数组确实被标记为private final。不过,仍然值得注意的是,在Java中没有办法将数组声明为不可变的,因此必须注意不要在类之外公开它,即使使用适当的访问修饰符。


由于这个主题似乎非常受欢迎,这里有一些建议的进一步阅读:海因茨·卡布茨的反思疯狂演讲来自JavaZone 2009,它涵盖了OP中的许多问题,以及其他反思…嗯…疯狂。

它涵盖了为什么这有时是有用的。以及为什么,大多数时候,你应该避免它。: -)

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

s1s2都被改变了,因为它们都被分配给了同一个“intern”String实例。你可以从这篇文章中找到更多关于字符串相等和实习的部分。你可能会惊讶地发现,在你的示例代码中,s1 == s2返回true!

可见性修饰符和final(即不可变性)不是针对Java中的恶意代码的度量;它们仅仅是防止出现错误并使代码更具可维护性的工具(这是系统的一大卖点)。这就是为什么你可以通过反射访问内部实现细节,比如Strings的支持字符数组。

你看到的第二个效果是所有的__abc0都改变了,而看起来你只改变了s1。它是Java字符串字面量的一个特定属性,它们被自动存储,即缓存。两个具有相同值的String字面值实际上是同一个对象。当你用new创建一个String对象时,它不会自动被占用,你也不会看到这个效果。

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

对于当前的Java和当前版本的问题,#substring没有奇怪的行为。

在Java中,如果两个字符串基元变量被初始化为相同的字面值,它会将相同的引用赋给这两个变量:

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

initialization

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

sub string

当你使用反射访问一个字符串时,你会得到实际的指针:

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

因此,改变this将改变持有指向它的指针的字符串,但由于s3是用一个新字符串创建的,由于substring(),它不会改变。

change

你用的是哪个版本的Java ?从Java 1.7.0_06开始,Oracle改变了String的内部表示形式,尤其是子字符串。

引用自Oracle调整Java的内部字符串表示:

在新的范例中,String offset和count字段已被删除,因此子字符串不再共享底层char[]值。

有了这个变化,它可能在没有反思的情况下发生(??)

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

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

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

因此,内存位置M1被修改,导致s1和s2的值发生变化。

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

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

这里有两个问题:

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

第一点:除了ROM,在你的计算机中没有不可变的内存。现在甚至ROM有时也是可写的。总有一些代码(无论是绕过托管环境的内核代码还是本机代码)可以写入内存地址。所以,在“现实”中,它们不是绝对不可变的。

对于第2点:这是因为substring可能分配了一个新的字符串实例,这可能是复制数组。substring有可能以不复制的方式实现,但这并不意味着它会这样做。这涉及到权衡。

例如,保存对reallyLargeString.substring(reallyLargeString.length - 2)的引用应该导致大量的活动内存,还是只有几个字节?

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

在任何情况下,看起来您的JVM都选择对子字符串调用使用深度拷贝。

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

s1和s2是相同的,因为在Java中它们都指向同一个被合并的字符串。这是在Java中设计的。

补充@haraldK的答案-这是一个安全黑客,可能会导致应用程序的严重影响。

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

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

将打印:

Hello Java!

当我在这样危险的字符串上实现繁重的计算时,我遇到了另一个问题。在计算过程中出现了一个bug,大约发生了100万分之一的概率,这使得结果不确定。我能够通过关闭JIT来发现问题——我总是得到相同的结果,关闭JIT。我猜原因是这个字符串安全黑客破坏了一些JIT优化契约。

[免责声明,这是一个故意固执己见的回答风格,因为我觉得一个更“不要在家里这样做,孩子们”的回答是有保证的]

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

问题中的现象是在不使用危险的代码行通过反射违反访问修饰符时永远不会看到的实现细节。显然,两个(通常)不可变字符串可以共享相同的char数组。子字符串是否共享同一个数组取决于它是否可以共享,以及开发人员是否想要共享它。通常情况下,这些都是不可见的实现细节,你不应该知道,除非你用那行代码把访问修饰符从头射穿。

依赖这些细节并不是一个好主意,因为如果不违反使用反射的访问修饰符就无法体验这些细节。该类的所有者只支持普通的公共API,并且可以在将来自由地进行实现更改。

说了这么多,当你被枪顶着头逼着你去做这种危险的事情时,这行代码真的很有用。使用后门通常是一种代码气味,您需要升级到更好的库代码,这样您就不必犯错误。这段危险代码的另一种常见用法是编写“巫毒框架”(orm,注入容器,…)。许多人都对这样的框架抱有虔诚的信仰(支持和反对它们),所以我将避免引发一场唇枪舌战,只说绝大多数程序员不需要这样做。

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

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

这里有更多细节,你可以从这里获取更多信息: 垃圾收集在Java中是如何工作的 < / em >。

字符串本质上是不可变的,因为没有方法可以修改字符串对象。 这就是他们引入StringBuilderStringBuffer

的原因

这是一个快速指南的一切


// Character array
char[] chr = {'O', 'K', '!'};


// this is String class
String str1 = new String(chr);
        

// this is concat
str1 = str1.concat("another string's ");
        

// this is format
System.out.println(String.format(str1 + " %s ", "string"));
        

// this is equals
System.out.println(str1.equals("another string"));


//this is split
for(String s: str1.split(" ")){
System.out.println(s);
}


// this is length
System.out.println(str1.length());


//gives an score of the total change in the length
System.out.println(str1.compareTo("OK!another string string's"));


// trim
System.out.println(str1.trim());


// intern
System.out.println(str1.intern());


// character at
System.out.println(str1.charAt(5));


// substring
System.out.println(str1.substring(5, 12));


// to uppercase
System.out.println(str1.toUpperCase());


// to lowerCase
System.out.println(str1.toLowerCase());


// replace
System.out.println(str1.replace("another", "hello"));


//   output


// OK!another string's  string
// false
// OK!another
// string's
// 20
// 7
// OK!another string's
// OK!another string's
// o
// other s
// OK!ANOTHER STRING'S
// ok!another string's
// OK!hello string's