我认为要理解的主要点在于区分Java对象
String
和它的内容-
私有value
字段下的
char[]
之间的区别。
String
基本上是围绕
char[]
数组的包装器,封装它并使其不可修改,以便
String
可以保持不变。此外,
String
类记住了实际使用的这个数组的哪些部分(见下文)。所有这些意味着您可以拥有两个不同的
String
对象(相当轻量级),指向相同的
char[]
。
我会给你展示一些例子,每个字符串的
hashCode()
和内部
char[] value
字段的
hashCode()
(我将其称为
文本以区分于字符串)。最后我将展示
javap -c -verbose
输出,以及我的测试类的常量池。请不要将类常量池与字符串字面池混淆。它们并不完全相同。另请参见
理解 javap 对常量池的输出。
先决条件
为了进行测试,我创建了这样一个实用方法,可以打破String
的封装:
private int showInternalCharArrayHashCode(String s) {
final Field value = String.class.getDeclaredField("value");
value.setAccessible(true);
return value.get(s).hashCode();
}
它将打印
char[] value
的
hashCode()
,有效地帮助我们理解这个特定的
String
是否指向相同的
char[]
文本。
类中的两个字符串字面量
让我们从最简单的例子开始。
Java代码
String one = "abc"
String two = "abc"
顺便说一句,如果你只是简单地写
"ab" + "c"
,Java 编译器会在编译时执行连接操作,并生成完全相同的代码。这仅在所有字符串在编译时已知的情况下才有效。
类常量池
每个类都有自己的常量池 - 一个常量值列表,如果它们在源代码中多次出现,则可以重复使用。它包括常见的字符串、数字、方法名等。
以下是我们上面示例中常量池的内容。
const
//...
const
重要的是要注意
String
常量对象(
#2
)和Unicode编码文本
"abc"
(
#38
),即字符串指向的区别。
字节码
下面是生成的字节码。请注意,
one
和
two
引用都被赋予相同的
#2
常量,指向
"abc"
字符串:
ldc #2; //String abc
astore_1 //one
ldc #2; //String abc
astore_2 //two
输出
我将打印每个示例的以下值:
System.out.println(showInternalCharArrayHashCode(one));
System.out.println(showInternalCharArrayHashCode(two));
System.out.println(System.identityHashCode(one));
System.out.println(System.identityHashCode(two));
没有什么奇怪的,两个配对是相等的。
23583040
23583040
8918249
8918249
这意味着不仅两个对象都指向相同的
char[]
(下面相同的文本),因此
equals()
测试将通过。更重要的是,
one
和
two
是完全相同的引用!因此,
one == two
也为真。显然,如果
one
和
two
指向同一对象,则
one.value
和
two.value
必须相等。
字面量和new String()
Java代码
现在我们等待的示例 - 一个字符串字面量和一个使用相同字面量的新String
。这将如何工作?
String one = "abc";
String two = new String("abc");
“abc”常量在源代码中被使用了两次,这一事实应该给你一些提示……
类常量池
同上。
字节码
ldc #2; //String abc
astore_1 //one
new #3; //class java/lang/String
dup
ldc #2; //String abc
invokespecial #4; //Method java/lang/String."<init>":(Ljava/lang/String;)V
astore_2 //two
仔细看!第一个对象的创建方式与上面相同,没有什么意外。它只需要从常量池中获取对已创建的String(#2)的常量引用。然而,第二个对象是通过普通的构造函数调用创建的。但是!第一个String被作为参数传递。这可以反编译为:
String two = new String(one);
输出
输出结果有些出乎意料。第二对表示对String
对象的引用是可以理解的 - 我们创建了两个String
对象 - 其中一个在常量池中为我们创建,另一个则是手动为two
创建的。但是,为什么第一对建议两个String
对象都指向相同的char[] value
数组?!
41771
41771
8388097
16585653
当你看一下
String(String)
构造函数的工作原理(这里大大简化)时,它变得清晰明了:
public String(String original) {
this.offset = original.offset;
this.count = original.count;
this.value = original.value;
}
看到了吗?当您基于现有的
String
对象创建新的对象时,它会
重用char[] value
。
String
是不可变的,因此无需复制已知永远不会被修改的数据结构。
我认为这是您问题的线索:即使您有两个
String
对象,它们仍可能指向相同的内容。而且,正如您所看到的,
String
对象本身非常小。
运行时修改和
intern()
Java代码
假设您最初使用了两个不同的字符串,但在一些修改后它们都相同:
String one = "abc"
String two = "?abc".substring(1)
Java编译器(至少我的)不够聪明,在编译时不能执行这样的操作,请看:
类常量池
突然间,我们有了两个指向不同常量文本的常量字符串:
const #2 = String #44; // abc
const #3 = String #45; // ?abc
const #44 = Asciz abc;
const #45 = Asciz ?abc;
字节码
ldc #2; //String abc
astore_1 //one
ldc #3; //String ?abc
iconst_1
invokevirtual #4; //Method String.substring:(I)Ljava/lang/String;
astore_2 //two
第一个字符串按照通常的方式构建。第二个字符串是通过首先加载常量字符串
"?abc"
,然后调用
substring(1)
来创建的。
输出:
毫不意外 - 我们有两个不同的字符串,指向内存中的两个不同的
char[]
文本。
27379847
7615385
8388097
16585653
“好的,这些文本并不是真正的不同,equals() 方法仍将返回 true。我们有两份相同文本的不必要副本。”
“现在我们应该运行两个练习。首先,尝试运行:”
two = two.intern();
在打印哈希码之前。不仅
one
和
two
都指向相同的文本,而且它们是相同的引用!
11108810
11108810
15184449
15184449
这意味着
one.equals(two)
和
one == two
测试都将通过。此外,我们节省了一些内存,因为
"abc"
文本只出现一次(第二个副本将被垃圾回收)。
第二个练习略有不同,请看:
String one = "abc";
String two = "abc".substring(1);
显然,
one
和
two
是指向两个不同文本的两个不同对象。但为什么输出表明它们都指向相同的
char[]
数组?!?
23583040
23583040
11108810
8918249
我会把答案留给你。这将教你如何使用
substring()
函数,这种方法的优点以及何时可能会
导致大麻烦。