再谈 String s1 = "123" 与 String s2 = new String("123")
Contents
之前在介绍 JDK 源码之基础类 Object 中 equals()
方法的时候,稍微提到了关于在声明一个 String 变量的时候,通过其内存地址或具体内容来比较两个变量是否相同。本篇文章是对之前文章的进一步理解。
深入 String
先来个例子:
|
|
从输出结果可以看到,s1 == s2
此时比较的是这两个对象的引用是否相等,而不是比较具体的内容。
对于 s3 == s4
来说虽然内容相等,但是 s3
和 s4
是两个不同的引用,因此返回 false
。
在文章 Java 内存区域概述 里提到过,常量池
里存放的数据是 在编译期间生成的各种字面量和符号引用,这部分内容(基本数据类型、String、数组)将在类加载后进入方法区的运行时常量池中存放。
对于字面量,也就是说对于 String s1 = "123";
来说,其中 String
表示引用的类型;s1
表示一个引用,该引用指向的是字面量 123
在常量池中的地址; 123
表示在编译期间可被确定的内容,存放在 常量池
中。
关于 栈
与 常量池
的区别,我也在网上搜集了不少文章,比如在知乎上的回答。同时,我也想起了 阿里巴巴Java开发手册 中 编程规约 -> OOP 规约
下的第 7 条,如下所示:
所有的相同类型的包装类型对象之间
值的比较
,全部使用 equals 方法。
- 对于 Integer var = ? ,在 -128 至 127 范围内的赋值,Integer 对象是在
IntegerCache.cache
产生,会复用已有对象,这个区间内的 Integer 值可以直接使用 == 进行判断。- 但是 这个区间之外的所有数据,都会在
堆
上产生,并不会复用已有对象,这是一个大坑,推荐使用 equals 方法进行判断。
对于这条约定,我们可以用以下代码说明:
|
|
产生这种现象的原因是由于 自动装箱
造成的:
Integer i1 = 100;
产生了自动装箱,由基本数据类型int = 100
自动装箱成了对应的包装类型Integer = 100
,具体转换过程就是在源码中对应的Integer i1 = Integer.valueOf(100);
。- 这里涉及到
IntegerCache
的大小,即IntegerCache.low
默认为-128
,IntegerCache.high
默认为127
。 - 这个容量大小表示:在此范围内的对象直接放到缓存中,等到下次再取这个对象的时候,直接复用即可。这种缓存机制可以提高程序的运行效率、节省内存空间。
- 所以,
100
是在这个区间范围里面的。而200
不在此范围中,它会在堆中产生(new)新的对象,并不会复用原有的对象,这也是返回false
的原因。
好像有点扯远了。
回到 常量池
具有 数据共享
这一特点,对于语句 String s1 = "123";
:
- JVM 首先会在
常量池
中查找该字符串是否已经存在,如果存在则直接返回该字符串对应的引用(也就是地址); - 然后语句
String s2 = "123";
也会在常量池
中查看有没有这个字符串,结果发现已经存在了; - 由于
数据共享
,所以s2
就指向了123
,而不是再次创建一个123
。因此,此时返回的是true
。
对于 String s3 = new String("abc");
来说:
- 由于使用了
new
关键字,JVM 不会再查询常量池
了,而是被new
出来的对象会被放置在堆内存
中,并用s3
指向这块内存,即s3
代表的是当前对象的引用
; - 每
new
一次,就从堆中开辟一块内存。由于是两块不同的内存,所代表的是两个完全不同的对象,所以s3
和s4
也就不相等了。
使用符号 “+” 连接字符串会发生什么?
我们首先使用 javap -c Demo
反编译 Demo.class
文件,得到 Java 编译器生成的字节码:
源文件:
|
|
编译后的字节码:
|
|
通过编译结果可以看出,第 16
行对应的是源代码中的第 #4
行,也就是 String s = "123";
语句。在初始化 s
的时候,其实在内部使用了 StringBuilder
。
然后到了第 18
行,也就是对应源代码中的第 #5
行,再执行 s += "456";
语句的时候,调用了 StringBuilder
的 append
方法,最后调用 StringBuilder
的 toString
方法生成字符串。
然而 StringBuilder
的 toString
方法是创建一个新的 String
对象的拷贝,如下所示:
|
|
所以说,每次在使用符号 “+” 的时候,都会 new 一个 StringBuilder
,使用这种方式连接字符串无疑是一种对内存的浪费。
如何正确的连接字符串?
我们可以使用 StringBuilder
或者 StringBuffer
的 append
方法连接字符串。它俩的区别在于,StringBuffer
是线程安全的,因为它里面的许多方法都是使用 synchronized
修饰的,而 StringBuilder
不是线程安全的。
StringBuilder
在底层维护了一个 char
类型的数组,每次调用 append
方法的时候就往数组里面存值,在调用 toString
方法的时候,将 char
数组中的字符转换成字符串。整个过程其实只使用了一个 StringBuilder
对象,这样的话可以节省内存。
再来个例子
如下面代码所示:
|
|
如果将 s1.intern();
语句注释掉后,结果则返回 false
。为什么?
来分析一下第 3 行语句 String s1 = new String("abc ") + new String("def");
:
- 首先由于是 new 关键字,则直接在堆中生成两个字符串
abc
和def
; - 然后符号 “+” 在底层使用了
StringBuilder
进行字符串的拼接; - 拼接完成后产生新的
abc def
对象,并使用s1
指向它。
第 4 行语句 s1.intern();
的作用是:
- 先去
常量池
中查看是否有当前字符串对应的引用:- 如果有的话,就直接返回该字符串;
- 如果没有的话,则在
常量池
中生成该字符串的引用,然后再返回该字符串。
由于字符串 abc def
是在 堆
中生成的,常量池
中没有对应的引用,所以它会在 常量池
中生成该字符串的引用。
第 5 行语句 String s2 = "abc def";
就是前面讲过的,首先会在 常量池
中查找有没有当前字符串对应的引用,这时由于 s1.intern();
已经生成过引用了,再加上 数据共享机制
,所以 s2
就和 s1
一样共同指向字符串 abc def
,即 s1
和 s2
指向相同的对象,所以最后结果返回的是 true
。
如果没有 s1.intern();
这一语句,则 s2
会看到在 常量池
中没有当前字符串对应的引用,所以它会在 常量池
中生成 abc def
,此时 s1
与 s2
指向的就是两个不同的对象了,这就是返回 false
的原因。
最后一个例子
看下列代码:
|
|
经过前面的“修炼”,我想你应该知道上面代码最终的输出结果了。我们还是来分析一下最终的结果为什么是 fasle
。
- 首先,由于在
常量池
中没有创建过aaa
,则此时s1
就指向的是aaa
,s2
同理指向bbb
; - 然后,由于符号 “+” 在底层使用的是
StringBuilder
进行字符串的拼接,其生成的字符串是调用toString
方法在堆
上 new 出来的aaa bbb
,然后s3
指向堆
中的字符串; - 一个在
常量池
中,另一个在堆
中,结果自然就返回false
了。