前文介绍了 运行时常量池,它和本篇文章要介绍的「字符串常量池」是包含关系
在 JDK7 及以后,字符串常量池被挪到了 Java 堆中,但这并不影响它的作用,只是位置发生了变化
通过名字,第一印象很容易看出来,「字符串常量池」就是专门用来存放字符串的常量池,所以先来介绍一下String
类
注意:下面出现的代码大多都是从源码中摘出来的!有兴趣的可以结合String
源码一起观看!!
首先需要明确的是String
是一个final
类:
xxxxxxxxxx
// String 类不能被继承
public final class String
implements java.io.Serializable, Comparable<String>, CharSequence {
// ...
}
其次需要知道的是String
中存储数据的结构是一个char[]
数组:
xxxxxxxxxx
// private + final 让 String 对象不可变,这是实现字符串常量池的基础!!
private final char value[];
扩展:在 JDK9 之后,String
的底层数据结构发生了变化,如下所示:
xxxxxxxxxx
private final byte[] value;
Motivation: The current implementation of the String class stores characters in a char array, using two bytes (sixteen bits) for each character. Data gathered from many different applications indicates that strings are a major component of heap usage and, moreover, that most String objects contain only Latin-1 characters. Such characters require only one byte of storage, hence half of the space in the internal char arrays of such String objects is going unused.
解释:使用char[]
数组存储数据的话,每个字符占用 2 个字节 (16 bits);然而绝大多数字符串对象中仅仅只包含 Latin-1 字符,存储这些字符花 1 个字节即可。所以如果都用 2 个字节去存储,将会浪费大量的空间
问题:如何存储必须需要 2 个字节的字符呢??
Description: We propose to change the internal representation of the String class from a UTF-16 char array to a byte array plus an encoding-flag field.
解释:String
中有一个编码标志域,来标识当前存储数据的编码,如下所示:
xxxxxxxxxx
// 编码标志域
private final byte coder;
// 是否采用压缩存储
static final boolean COMPACT_STRINGS;
static {
// 默认使用
COMPACT_STRINGS = true;
}
// 返回当前编码标志域
byte coder() {
return COMPACT_STRINGS ? coder : UTF16;
}
// 当求字符串长度时,需要根据 coder
public int length() {
return value.length >> coder();
}
注意:String-related classes such as AbstractStringBuilder, StringBuilder, and StringBuffer will be updated to use the same representation, as will the HotSpot VM's intrinsic string operations.
解释:与字符串相关的类,如AbstractStringBuilder
、StringBuilder
和StringBuffer
将被更新以使用相同的表示方法,HotSpot 虚拟机的内在字符串操作也是如此
最后介绍四种创建字符串对象的方法:
xxxxxxxxxx
String s1 = "abc"; // 方法 1
String s2 = String.valueOf("abc"); // 方法 2
String s3 = new String("abc"); // 方法 3
char[] chars = {'a', 'b', 'c'};
String s4 = new String(chars); // 方法 4
字符串常量池底层是一个固定大小的HashTable
,如果长度比较小,当放进去的字符串对象特别多,就会造成严重的 Hash 冲突从而导致链表很长,更近一步导致调用String::intern
方法时效率低
可以使用参数-XX:StringTableSize=size
设置 StringTable 的大小
在 JDK7 及之后,「字符串常量池」被移到了堆中,而对象也是在堆中,此时「字符串常量池」中存储的只是实例引用!!下面的讨论均基于 JDK7 及之后,如果有例外,会另外说明
关于对象引用直接用= =
比较的详细分析可见 「equals」「hashCode」
接着上一部分,如果s1
直接和s2, s3, s4
用= =
做比较,结果会如何?具体代码如下:
xxxxxxxxxx
// 省略前面代码 ...
System.out.println(s1 == s2); // true
System.out.println(s1 == s3); // false
System.out.println(s1 == s4); // false
首先通过 JProfiler 软件,查看一下这些对象在堆中的结构,如下图所示:
上图对应的可视化解释如下图所示:
对于String s1 = "abc"
来说,它就是一个字符串对象,有一个字符数组类型的字段value
,存储实际的数据。所有双引号"xxx"
中的内容都在字符串常量池中!!所以"abc"
在字符串常量池中,更准确一点,是地址为0xb5d
的字符串对象在字符串常量池中
对于String s2 = String.valueOf("abc")
来说,先看一下valueOf()
方法,如下所示:
xxxxxxxxxx
public static String valueOf(Object obj) {
// toString() 见下方
return (obj == null) ? "null" : obj.toString();
}
public String toString() {
return this;
}
可以看到,如果传入的参数是一个字符串对象,该方法就是把传入的对象返回了,所以方法 2 和方法 1 是等价的!
对于String s3 = new String("abc")
来说,先看一下对应的构造函数,如下所示:
xxxxxxxxxx
public String(String original) {
this.value = original.value;
this.hash = original.hash;
}
可以看到,将传入参数的value
和hash
字段赋值给自身的value
和hash
字段。举个很形象的例子,如果传入的对象是羊,那么new
出来的新对象就是披着狼皮的羊
对于String s4 = new String(chars)
来说,同样的,先看一下对应的构造函数,如下所示:
xxxxxxxxxx
public String(char value[]) {
this.value = Arrays.copyOf(value, value.length);
}
上面说过,String
对象存储数据的结构是一个char[]
数组,所以这个构造函数相当于把传入的数组 copy 了一份,然后赋值给自身的value
字段
综上所述:如果只是给value
和hash
字段赋了相同的值,但对象还是不同的;用= =
直接比较,比较的是对象的地址是否相同!!
扩展:继续看一下用equals()
方法比较的原理,源码如下所示:
xxxxxxxxxx
public boolean equals(Object anObject) {
// 现在直接比较两个对象地址。如果地址相同,肯定相等
if (this == anObject) {
return true;
}
// 看看比较的两个对象类型是否相同。如果不同,直接返回 false
if (anObject instanceof String) {
String anotherString = (String)anObject;
int n = value.length;
if (n == anotherString.value.length) {
// 可以看到比较的是两个对象的 value 字符数组是否相同
char v1[] = value;
char v2[] = anotherString.value;
int i = 0;
while (n-- != 0) {
if (v1[i] != v2[i])
return false;
i++;
}
return true;
}
}
return false;
}
上面的初级比较中,都是简单直接给出一个完整字符串,这一部分介绍几种高级比较!!
两个字符串直接拼接在一起的情况!
xxxxxxxxxx
String s1 = "abcdef";
String s2 = "abc" + "def";
System.out.println(s1 == s2); // true
通过 JProfiler 软件发现 Java 堆中并没有"abc"
和"def"
,只有"abcdef"
,为什么会这样呢??
通过javap
反编译 Class 文件后,发现常量池中只有abcdef
;为s2
赋值时,也是直接把abcdef
赋给了s2
,并没有拼接的过程,具体如下所示:
xxxxxxxxxx
// 部分常量池
#30 = Utf8 abcdef
// 为 s2 赋值的字节码指令
3: ldc #2 // String abcdef
5: astore_2
这是因为在编译阶段,进行了一波优化!!(编译期优化) 不仅是字符串,整型常量也是如此,如下所示:
xxxxxxxxxx
// 源码
int a = 5 + 6;
// 为 a 赋值的字节码指令
0: bipush 11
2: istore_1
例子一
xxxxxxxxxx
String s1 = "abc";
System.out.println(s1 + "def" == "abcdef"); // false
首先可以知道编译阶段没有进行和上面一样的优化,而且从底层来看,此处的拼接是借助StringBuilder
完成的,上面代码和下面的代码等价 (编译后的字节指令完全一样):
xxxxxxxxxx
String s1 = "abc";
System.out.println(new StringBuilder().append(s1).append("def").toString() == "abcdef"); // false
下面给出编译后的字节码指令:
xxxxxxxxxx
Code:
stack=3, locals=1, args_size=0
0: ldc #2 // String abc
2: astore_0
3: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream;
6: new #4 // class java/lang/StringBuilder
9: dup
10: invokespecial #5 // Method java/lang/StringBuilder."<init>":()V
13: aload_0
14: invokevirtual #6 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
17: ldc #7 // String def
19: invokevirtual #6 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
22: invokevirtual #8 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
25: ldc #9 // String abcdef
27: if_acmpne 34
30: iconst_1
31: goto 35
34: iconst_0
35: invokevirtual #10 // Method java/io/PrintStream.println:(Z)V
38: return
上面从字节码的角度分析了等价性,下面从内存的角度分析为什么不相等!
首先搞清楚哪些字符串在字符串常量池中,在字符串常量池的有:"abc", "def", "abcdef"
,通过 JProfiler 验证如下:
然后在看看看StringBuilder::toString
的源码:
xxxxxxxxxx
public String toString() {
// Create a copy, don't share the array
return new String(value, 0, count);
}
public String(char value[], int offset, int count) {
if (offset < 0) {
throw new StringIndexOutOfBoundsException(offset);
}
if (count <= 0) {
if (count < 0) {
throw new StringIndexOutOfBoundsException(count);
}
if (offset <= value.length) {
this.value = "".value;
return;
}
}
// Note: offset or count might be near -1>>>1.
if (offset > value.length - count) {
throw new StringIndexOutOfBoundsException(offset + count);
}
// copy 了一份!!
this.value = Arrays.copyOfRange(value, offset, offset+count);
}
StringBuilder
中也是用char[] value
来存储字符,而StringBuilder::toString
方法会调用String
的构造函数public String(char value[], int offset, int count)
这个构造函数是 copy 了一份value
字符数组,而不是直接把value
赋值给自身,和public String(char value[])
差不多,注意和public String(String original)
区分!!
可视化解释如下图所示:
例子二
下面再给一个稍微特殊的例子:
xxxxxxxxxx
final String s1 = "abc";
String s2 = s1 + "def";
System.out.println(s2 == "abcdef"); // true
唯一的不同在于s1
前面被final
修饰,s1
是常量,不能再指向其他字符串对象
首先搞清楚编译后常量池中有什么,常量池的有:"abc", "abcdef"
,并没有"def"
。顺便看一下编译后的字节码指令:
xxxxxxxxxx
Code:
stack=3, locals=3, args_size=1
0: ldc #2 // String abc
2: astore_1
3: ldc #3 // String abcdef
5: astore_2
6: getstatic #4 // Field java/lang/System.out:Ljava/io/PrintStream;
9: ldc #2 // String abc
11: aload_2
12: if_acmpne 19
15: iconst_1
16: goto 20
19: iconst_0
20: invokevirtual #5 // Method java/io/PrintStream.println:(Z)V
23: return
我们发现这个时候的拼接并没有像上面那样借助StringBuilder
,甚至连拼接的过程都没有发现,都没有看到"def"
,这是为什么呢?
肯定和final
修饰有关 (废话),被final
修饰后,s1
是常量,而"def"
也是常量
所以直接在编译阶段通过优化把s1 + "def"
的结果计算出来了,这也是为什么常量池中没有"def"
,但是有"abcdef"
的原因
那为什么上一个例子不能这样处理呢?!上面一个例子中s1
不是常量,编译阶段并不能知道s1
到底指向哪一个字符串对象,只有在运行期间才能知道!!!
xxxxxxxxxx
String s1 = "abc" + new String("def");
System.out.println(s1 == "abcdef"); // false
这个和上面 字符串和变量的拼接赋值 的第一个例子差不多,字节码层面都是通过StringBuilder
拼接,如下所示:
xxxxxxxxxx
Code:
stack=4, locals=2, args_size=1
0: new #2 // class java/lang/StringBuilder
3: dup
4: invokespecial #3 // Method java/lang/StringBuilder."<init>":()V
7: ldc #4 // String abc
9: invokevirtual #5 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
12: new #6 // class java/lang/String
15: dup
16: ldc #7 // String def
18: invokespecial #8 // Method java/lang/String."<init>":(Ljava/lang/String;)V
21: invokevirtual #5 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
24: invokevirtual #9 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
27: astore_1
28: getstatic #10 // Field java/lang/System.out:Ljava/io/PrintStream;
31: aload_1
32: ldc #11 // String abcdef
34: if_acmpne 41
37: iconst_1
38: goto 42
41: iconst_0
42: invokevirtual #12 // Method java/io/PrintStream.println:(Z)V
45: return
xxxxxxxxxx
String s1 = new String("abc") + new String("def");
System.out.println(s1 == "abcdef"); // false
原因同上,可以看反编译后的字节码指令,通过StringBuilder
拼接,为了节约篇幅,这里就不贴出来了!
通过上面的分析,知道了字符串直接用+
拼接,底层是通过StringBuilder
实现,下面给出两种不同方式的性能比较:
xxxxxxxxxx
public class CompareStringAdd {
public static void main(String[] args) {
int count = 1000000;
long start = System.currentTimeMillis();
f1(count);
long end = System.currentTimeMillis();
System.out.println(end - start); // 42392
start = System.currentTimeMillis();
f2(count);
end = System.currentTimeMillis();
System.out.println(end - start); // 6
}
public static void f1(int count) {
String s = "";
for (int i = 0; i < count; i++) {
s += "a";
}
}
public static void f2(int count) {
StringBuilder s = new StringBuilder();
for (int i = 0; i < count; i++) {
s.append("a");
}
}
}
可以看到这差距还是很明显滴!!
对于f1()
方法,循环中的每一次加操作都会经历三个步骤:创建StringBuilder
对象;调用一次append()
方法;调用一次toString()
方法生成一个String
对象
可想而知内存中会创建很多StringBuilder
和String
对象,内存占用大;如果进行 GC,还需要花费额外的时间开销
对于f2()
方法,从循环开始到结束仅仅只创建了一个StringBuilder
对象,每一轮循环仅仅是调用了一次append()
方法而已,所以效率上的提升也是肉眼可见的
这个点也是阿里巴巴 Java 开发手册中所推荐的:循环体内,字符串的连接方式,使用 StringBuilder 的 append 方法进行扩展
思考:对于f2()
方法,有没有可以继续优化的地方呢?-> 既然这样说了,答案肯定是有!!!
先来看看StringBuilder
的无参构造器,如下所示:
xxxxxxxxxx
public StringBuilder() {
// 默认创建一个大小为 16 的 StringBuilder 对象
super(16);
}
再来看看append()
方法,如下所示:
xxxxxxxxxx
public StringBuilder append(String str) {
// 具体见下方
super.append(str);
return this;
}
// AbstractStringBuilder 类,StringBuilder 继承该类
public AbstractStringBuilder append(String str) {
if (str == null)
return appendNull();
int len = str.length();
// 判断是否会越界,具体见下方
ensureCapacityInternal(count + len);
str.getChars(0, len, value, count);
count += len;
return this;
}
private void ensureCapacityInternal(int minimumCapacity) {
// overflow-conscious code
if (minimumCapacity - value.length > 0) {
// 新建一个更大的 value 数组,并将原来数组的内容复制过来
// newCapacity 见下方
value = Arrays.copyOf(value, newCapacity(minimumCapacity));
}
}
private int newCapacity(int minCapacity) {
// overflow-conscious code
// 在原有的长度上 x 2 + 2
int newCapacity = (value.length << 1) + 2;
// 判断新长度够不够,如果不够,直接赋值为 minCapacity
if (newCapacity - minCapacity < 0) {
newCapacity = minCapacity;
}
return (newCapacity <= 0 || MAX_ARRAY_SIZE - newCapacity < 0)
? hugeCapacity(minCapacity)
: newCapacity;
}
扯了这么多,就是想说明如果初始化大小过小,会频繁的扩容,扩容又涉及到数组的复制,带来的开销较大
所以如果在实际开发中,可以基本确定需要添加的字符串长度不高于某个限定值,建议使用带参构造函数,避免频繁的扩容,如下所示:
xxxxxxxxxx
StringBuilder s = new StringBuilder(highLevel);
这是一道经典面试的问题,如下所示:
xxxxxxxxxx
String s = new String("abc");
首先字符串常量池中有一个对象"abc"
;其次new String("abc")
创建了一个新的对象,指向字符串常量池中的"abc"
所以这个问题中创建了两个对象!!
下面是进阶版:
xxxxxxxxxx
String s = new String("abc") + new String("def");
首先字符串常量池中有两个对象"abc", "def"
;
其次new
了两个String
对象,分别指向字符串常量池中的两个对象;
然后+
拼接的底层是通过StringBuilder
实现,所以有一个StringBuilder
对象
调用append()
方法后,最终会调用toString()
方法生成一个String
对象,并返回其引用给s
所以这个问题中创建了六个对象!!
字符串字面量在上面提到过,所有双引号"xxx"
中的内容都属于字符串字面量,现在讨论的问题是这种字面量什么时候进入字符串常量池呢??
首先可以确定的是,当一个源程序被编译后,字符串字面量就会被加入静态常量池中;当对应的类被加载到内存后,内存中存放静态常量池的地方就被称为运行时常量池,所以运行时常量池也有该字面量
在类加载过程的解析阶段,Java 虚拟机会在堆中创建对应 class 文件常量池中的字符串对象实例,并在字符串常量池中驻留其引用,字符串常量池中的内容全局共享!!
但但但《Java 虚拟机规范》中明确规定解析阶段可以是 lazy resolve 的!!!
《Java 虚拟机规范》中 Class 文件的常量池项目的类型,有两种东西:
后者是 String 常量的类型,但它并不直接持有 String 常量的内容,而是只持有一个符号引用,这个符号引用所指定的另一个常量池项必须是一个 CONSTANT_Utf8_info 类型的常量,这里才真正持有字符串的内容,具体如下所示:
xxxxxxxxxx
#3 = String #29 // abc
#29 = Utf8 abc
在 HotSpot 虚拟机的运行时常量池中:
CONSTANT_Utf8_info 会在类加载的时候就被全部创建出来,而 CONSTANT_String_info 则是 lazy resolve 的。例如:在引用该项的 ldc 指令被第一次执行的时候才会 resolve
那么在尚未 resolve 的时候,HotSpot 虚拟机把它的类型叫做 JVM_CONSTANT_UnresolvedString,内容跟 Class 文件里一样只是一个符号引用
等到 resolve 过后这个项的常量类型就会变成最终的 JVM_CONSTANT_String,而内容则变成实际的引用 (直接引用)
《深入理解 Java 虚拟机》也有一段话不谋而合:虚拟机实现可以根据需要来自行判断,到底是在类被加载器加载的时候就对常量池中的符号引用进行解析,还是等到一个符号引用将要被使用前才去解析它
最后来一波小总结:就 HotSpot 虚拟机的实现来说,在类加载的时候字符串字面量会进入到当前类的运行时常量池,不会进入全局的字符串常量池 (即在 StringTable 中并没有相应的引用,在堆中也没有对应的对象产生)
下面来强调一下ldc
指令,它的作用:将int
、float
或String
型常量值从常量池中推送至栈顶
上面强调过,类加载过程中的解析阶段是 lazy resolve (延迟),那到底会延迟到什么时候才会去解析呢?!
执行ldc
指令就是触发 lazy resolution 动作的条件
ldc
字节码在这里的执行语义是:到当前类的运行时常量池去查找该符号引用对应的项,如果该项尚未 resolve 则 resolve,并返回 resolve 后的内容
在遇到String
类型常量时,resolve 的过程如果发现 StringTable 已经有了内容匹配的java.lang.String
的引用,则直接返回这个引用
反之,如果 StringTable 里尚未有内容匹配的String
实例的引用,则会在 Java 堆里创建一个对应内容的String
对象,然后在 StringTable 记录下这个引用,并返回这个引用
可见,ldc
指令是否需要创建新的String
实例,全看在第一次执行这一条ldc
指令时,StringTable 是否已经记录了一个对应内容的String
的引用
关于这个内容的详细解释可见 Java 中new String("字面量") 中 "字面量" 是何时进入字符串常量池的? - 木女孩的回答 - 知乎
将一个字符串对象加入字符串常量池,只有两种方式:
"xxx"
中的内容都在字符串常量池中!!intern()
方法根据「字符串常量池」所在位置的不同,里面存的内容有些许的不同!!
JDK6 及之前,「字符串常量池」在永久代中 (非堆),而对象却是在堆中,所以会把首次遇到的字符串实例复制到「字符串常量池」中存储,返回的也是永久代里面这个字符串实例的引用
JDK7 及之后,「字符串常量池」被移到了堆中,而对象也是在堆中,所以就不需要再拷贝字符串的实例到「字符串常量池」,只需要在「字符串常量池」中记录一下首次出现的实例引用即可
xxxxxxxxxx
public class RuntimeConstantPoolOOM {
public static void main(String[] args) throws InterruptedException {
String str1 = new StringBuilder("计算机").append("软件").toString();
System.out.println(str1.intern() == str1);
String str2 = new String("ja") + new String("va");
System.out.println(str2.intern() == str2);
}
}
这里分三种情况讨论,分别在 JDK6、JDK7、JDK8 的版本下 (是不是很麻烦!)
JDK6
str1
和str2
引用的对象实例都在 Java 堆中str1.intern()
和str2.intern()
返回的是方法区中的字符串常量池里的实例引用false
JDK7
str1.intern()
和str2.intern()
返回的是 Java 堆中的字符串常量池里的实例引用"java"
会在一开始就被加载到字符串常量池中,所以调用str2.intern()
方法时并非首次遇到,返回的是一开始就被加载到字符串常量池中实例引用,和str2
是不同的true
,第二个会得到false
JDK8
"java"
一开始不会被加载到字符串常量池中,所以调用str2.intern()
方法时是首次遇到,返回的实例引用和str2
相同true
这里详细解释一下"java"
字符串为什么一开始会被加载到字符串常量池中
在sun.misc.Version
类中有一个静态常量,如下所示:
xxxxxxxxxx
// JDK7
public class Version {
// ...
private static final String launcher_name = "java";
// ...
}
HotSpot VM 会在初始化过程中主动触发java.lang.System
类的加载和初始化,过程中会调用到java.lang.System.initializeSystemClass()
静态方法:
xxxxxxxxxx
private static void initializeSystemClass() {
// ...
sun.misc.Version.init();
// ...
}
所以sun.misc.Version
类会被加载到内存中,静态常量会被加载到字符串常量池中!!但是在 JDK8 的时候,这个地方有点变化,如下所示:
xxxxxxxxxx
// JDK8
public class Version {
private static final String launcher_name = "openjdk";
}
关于这个内容的详细解释可见 如何理解《深入理解java虚拟机》第二版中对String.intern()方法的讲解中所举的例子? - RednaxelaFX的回答 - 知乎
再来一个例子:
xxxxxxxxxx
String str1 = new StringBuilder().append("计算机软件").toString();
System.out.println(str1.intern() == str1); // false
上文强调过「所有双引号"xxx"
中的内容都在字符串常量池中!!」所以"计算机软件"
在字符串常量池中,调用str2.intern()
方法时字符串常量池中已经有了,返回的实例引用和str1
是不同的
上个例子中String str1 = new StringBuilder("计算机").append("软件").toString()
,"计算机"
和"软件"
都在字符串常量池中,但"计算机软件"
不在!!
先给出一个例子:
xxxxxxxxxx
String s1 = new String("abc") + new String("def"); // 第一步
s1.intern(); // 第二步
String s2 = "abcdef"; // 第三步
System.out.println(s1 == s2); // true
相信有了 字符串字面量何时进入字符串常量池?!的铺垫,不难理解输出的结果,这里再强调一下!!
当执行到「第二步」时,此时字符串常量池中并没有"abcdef"
,所以会把s1
加入到字符串常量池中
当执行到「第三步」时,由于字符串常量池中已经有了"abcdef"
,所以在调用ldc
指令给s2
赋值时,直接返回字符串常量池中的引用
所以s1
和s2
的引用值是相同的!!!
xxxxxxxxxx
String s = "abc"; // 第一步
s += "def"; // 第二步
s += "ghi"; // 第三步
首先,字符串常量池中有三个字符串:"abc", "def", "ghi"
。当执行到每一步时,s
对应的实例引用如下图所示:
这里的+=
其实是一种字符串拼接,字节码指令层面的原理和 字符串和变量的拼接赋值 相同!!
从上图可以看出,拼接的过程,变化的只有s
的值 (引用),对于字符串对象本身来说并没有改变,每次都是生成了一个新的字符串对象
值的注意的是,上图中的紫色箭头是不可以变的,因为String
类中的value
字段被final
修饰,所以值 (引用) 不可变,源码如下:
xxxxxxxxxx
private final char value[];
若在「理解一」的基础上稍加修改,如下所示:
xxxxxxxxxx
final String s = "abc";
s += "def"; // 错误,编译不通过 ❌
s += "ghi"; // 错误,编译不通过 ❌
原理和「理解一」中一样,由于s
被final
修饰,所以值 (引用) 不可变,对应上图中就是红色箭头是不可以变的
给出一段代码,想想会输出什么:
xxxxxxxxxx
public static void f1(String s) {
s = "def";
}
public static void f2(char[] cs) {
cs[0] = 'A';
}
public static void main(String[] args) {
String s = "abc";
char[] cs = new char[] {'a', 'b', 'c'};
f1(s);
f2(cs);
System.out.println(s); // abc
System.out.println(cs); // Abc
}
在学习 C 语言的时候,肯定听过「传值」或「传引用」;Java 中也有类似的东西,但 Java 中的本质都是「传值」
如果将变量按照数据类型划分的话,可以分为两类:
之所以说 Java 中的本质都是「传值」,因为如果传的是一个基本数据类型,那就是值;如果传的是一个引用数据类型,那就是引用,其实也就是该变量的值
回到上面的代码,先解释s
为什么没变?!
f1
中的局部变量s
的引用指向了另外一个对象 (等价于修改了上图中的红色箭头),那关main
中的局部变量s
什么事情!!!main
中的局部变量s
的值传给了f1
,f1
中无论怎么修改它,都不会影响main
中的局部变量s
再解释cs
为什么变了?!
cs[i] = 'x'
表示把相对起始地址偏移量为i
的地址的值改为'x'
假设String
类中value
字段不是private
,即可以任意访问修改,会怎么样呢?下面我们来试试!!
可以通过反射机制访问或修改无权限的字段,关于操作运行时类的内部属性及方法的详细内容可见 反射机制
xxxxxxxxxx
public static void main(String[] args) throws NoSuchFieldException, IllegalAccessException {
String s = "abc"; // 测试字符串对象
Class<String> stringClass = String.class; // 获取 String 类型对象
Field valueField = stringClass.getDeclaredField("value"); // 获取类型的 value 字段
valueField.setAccessible(true); // 禁止访问安全检查
char[] value = (char[]) valueField.get(s); // 获取 s 的 value 字段
value[0] = 'A'; // 修改第 0 个字符
System.out.println(s); // 输出结果为 Abc
}
👇👇👇👇👇👇👇👇👇👇👇👇