第1题,奇怪的 nullnull
下面这段代码最终会打印什么?
public class Test1 {
private static String s1;
private static String s2;
public static void main(String[] args) {
String s= s1+s2;
System.out.println(s);
}
}
JAVA 复制 全屏
揭晓答案,看一下运行结果,打印了nullnull
:
在分析这个结果之前,先扯点别的,说一下为空null
的字符串的打印原理。查看一下PrintStream
类的源码,print
方法在打印null
前进行了处理:
public void print(String s) {
if (s == null) {
s = "null";
}
write(s);
}
因此,一个为null
的字符串就可以被打印在我们的控制台上了。
再回头看上面这道题,s1
和s2
没有经过初始化所以都是空对象null
,需要注意这里不是字符串的"null"
,打印结果的产生我们可以看一下字节码文件:
编译器会对String
字符串相加的操作进行优化,会把这一过程转化为StringBuilder
的append
方法。那么,让我们再看看append
方法的源码:
public AbstractStringBuilder append(String str) {
if (str == null)
return appendNull();
//...
}
如果append
方法的参数字符串为null
,那么这里会调用其父类AbstractStringBuilder
的appendNull
方法:
private AbstractStringBuilder appendNull() {
int c = count;
ensureCapacityInternal(c + 4);
final char[] value = this.value;
value[c++] = 'n';
value[c++] = 'u';
value[c++] = 'l';
value[c++] = 'l';
count = c;
return this;
}
这里的value
就是底层用来存储字符的char
类型数组,到这里我们就可以明白了,其实StringBuilder
也对null
的字符串进行了特殊处理,在append
的过程中如果碰到是null
的字符串,那么就会以"null"
的形式被添加进字符数组,这也就导致了两个为空null
的字符串相加后会打印为"nullnull"
。
第2题,改变String的值
如何改变一个String字符串的值,这道题可能看上去有点太简单了,像下面这样直接赋值不就可以了吗?
String s="Hydra";
s="Trunks";
恭喜你,成功掉进了坑里!在回答这道题之前,我们需要知道String是不可变的,打开String的源码在开头就可以看到:
private final char value[];
可以看到,String的本质其实是一个char
类型的数组,然后我们再看两个关键字。先看final
,我们知道final
在修饰引用数据类型时,就像这里的数组时,能够保证指向该数组地址的引用不能修改,但是数组本身内的值可以被修改。
是不是有点晕,没关系,我们看一个例子:
final char[] one={'a','b','c'};
char[] two={'d','e','f'};
one=two;
如果你这样写,那么编译器是会报错提示Cannot assign a value to final variable 'one'
,说明被final
修饰的数组的引用地址是不可改变的。但是下面这段代码却能够正常的运行:
final char[] one={'a','b','c'};
one[1]='z';
也就是说,即使被final
修饰,但是我直接操作数组里的元素还是可以的,所以这里还加了另一个关键字private
,防止从外部进行修改。此外,String类本身也被添加了final
关键字修饰,防止被继承后对属性进行修改。
到这里,我们就可以理解为什么String是不可变的了,那么在上面的代码进行二次赋值的过程中,发生了什么呢?答案很简单,前面的变量s
只是一个String对象的引用,这里的重新赋值时将变量s
指向了新的对象。
上面白话了一大顿,其实是我们可以通过比较hashCode
的方式来看一下引用指向的对象是否发生了改变,修改一下上面的代码,打印字符串的hashCode
:
public static void main(String[] args) {
String s="Hydra";
System.out.println(s+": "+s.hashCode());
s="Trunks";
System.out.println(s+": "+s.hashCode());
}
查看结果,发生了改变,证明指向的对象发生了改变:
那么,回到上面的问题,如果我想要改变一个String的值,而又不想把它重新指向其他对象的话,应该怎么办呢?答案是利用反射修改char
数组的值:
public static void main(String[] args) throws NoSuchFieldException, IllegalAccessException {
String s="Hydra";
System.out.println(s+": "+s.hashCode());
Field field = String.class.getDeclaredField("value");
field.setAccessible(true);
field.set(s,new char[]{'T','r','u','n','k','s'});
System.out.println(s+": "+s.hashCode());
}
再对比一下hashCode
,修改后和之前一样,对象没有发生任何变化:
最后,再啰嗦说一点题外话,这里看的是jdk8
中String的源码,到这为止还是使用的char
类型数组来存储字符,但是在jdk9
中这个char
数组已经被替换成了byte
数组,能够使String对象占用的内存减少。
第3题,创建了几个对象?
相信不少小伙伴在面试中都遇到过这道经典面试题,下面这段代码中到底创建了几个对象?
String s = new String("Hydra");
其实真正想要回答好这个问题,要铺垫的知识点还真是不少。首先,我们需要了解3个关于常量池的概念,下面还是基于jdk8
版本进行说明:
- class文件常量池:在class文件中保存了一份常量池(
Constant Pool
),主要存储编译时确定的数据,包括代码中的字面量(literal
)和符号引用 - 运行时常量池:位于方法区中,全局共享,class文件常量池中的内容会在类加载后存放到方法区的运行时常量池中。除此之外,在运行期间可以将新的变量放入运行时常量池中,相对class文件常量池而言运行时常量池更具备动态性
- 字符串常量池:位于堆中,全局共享,这里可以先粗略的认为它存储的是String对象的直接引用,而不是直接存放的对象,具体的实例对象是在堆中存放
可以用一张图来描述它们各自所处的位置:
接下来,我们来细说一下字符串常量池的结构,其实在Hotspot JVM中,字符串常量池StringTable
的本质是一张HashTable
,那么当我们说将一个字符串放入字符串常量池的时候,实际上放进去的是什么呢?
以字面量的方式创建String对象为例,字符串常量池以及堆栈的结构如下图所示(忽略了jvm中的各种OopDesc
实例):
实际上字符串常量池HashTable
采用的是数组加链表的结构,链表中的节点是一个个的HashTableEntry
,而HashTableEntry
中的value
则存储了堆上String对象的引用。
那么,下一个问题来了,这个字符串对象的引用是什么时候被放到字符串常量池中的?具体可为两种情况:
- 使用字面量声明String对象时,也就是被双引号包围的字符串,在堆上创建对象,并驻留到字符串常量池中(注意这个用词)
- 调用
intern()
方法,当字符串常量池没有相等的字符串时,会保存该字符串的引用
注意!我们在上面用到了一个词驻留,这里对它进行一下规范。当我们说驻留一个字符串到字符串常量池时,指的是创建HashTableEntry
,再使它的value
指向堆上的String实例,并把HashTableEntry
放入字符串常量池,而不是直接把String对象放入字符串常量池中。简单来说,可以理解为将String对象的引用保存在字符串常量池中。
字符串常量池比较特殊,它的主要使用方法有两种:
-
直接使用双引号声明出来的
String
对象会直接存储在常量池中。 -
如果不是用双引号声明的
String
对象,使用String
提供的intern()
方法也有同样的效果。String.intern()
是一个 Native 方法,它的作用是:如果字符串常量池中已经包含一个等于此 String 对象内容的字符串,则返回常量池中该字符串的引用;如果没有,JDK1.7 之前(不包含 1.7)的处理方式是在常量池中创建与此String
内容相同的字符串,并返回常量池中创建的字符串的引用,JDK1.7 以及之后,字符串常量池被从方法区拿到了堆中,jvm 不会在常量池中创建该对象,而是将堆中这个对象的引用直接放到常量池中,减少不必要的内存开销。示例代码如下(JDK 1.8) :
String s1 = "Javatpoint"; String s2 = s1.intern(); String s3 = new String("Javatpoint"); String s4 = s3.intern(); System.out.println(s1==s2); // True System.out.println(s1==s3); // False System.out.println(s1==s4); // True System.out.println(s2==s3); // False System.out.println(s2==s4); // True System.out.println(s3==s4); // False
总结 :
- 对于基本数据类型来说,==比较的是值。对于引用数据类型来说,==比较的是对象的内存地址。
- 在编译过程中,Javac 编译器(下文中统称为编译器)会进行一个叫做 常量折叠(Constant Folding) 的代码优化。常量折叠会把常量表达式的值求出来作为常量嵌在最终生成的代码中,这是 Javac 编译器会对源代码做的极少量优化措施之一(代码优化几乎都在即时编译器中进行)。
- 一般来说,我们要尽量避免通过 new 的方式创建字符串。使用双引号声明的
String
对象(String s1 = "java"
)更利于让编译器有机会优化我们的代码,同时也更易于阅读。 - 被
final
关键字修改之后的String
会被编译器当做常量来处理,编译器程序编译期就可以确定它的值,其效果就相当于访问常量。
String s1 = new String(“abc”);这句话创建了几个字符串对象?
答案是 会创建 1 或 2 个字符串。
- 如果字符串常量池中已存在字符串常量“abc”,则只会在堆空间创建一个字符串常量“abc”。
- 如果字符串常量池中没有字符串常量“abc”,那么它将首先在字符串常量池中创建,然后在堆空间中创建,因此将创建总共 2 个字符串对象。
测试
@Test
public void testString() {
String a = "111";
String b = new String("111");
System.out.println(a == b);//比较引用
System.out.println(a.equals(b)); //比较字符串
System.out.println(a == b.intern());//比较引用
}
结果
false
true
true
b.intern() 指向字符串常量区。intern说明,请看下方
第4题,烧脑的 intern
上面我们在研究字符串对象的引用如何驻留到字符串常量池中时,还留下了调用intern
方法的方式,下面我们来具体分析。
从字面上理解intern
这个单词,作为动词时它有禁闭、关押的意思,通过前面的介绍,与其说是将字符串关押到字符串常量池StringTable
中,可能将它理解为缓存它的引用会更加贴切。
String的intern()
是一个本地方法,可以强制将String驻留进入字符串常量池,可以分为两种情况:
- 如果字符串常量池中已经驻留了一个等于此String对象内容的字符串引用,则返回此字符串在常量池中的引用
- 否则,在常量池中创建一个引用指向这个String对象,然后返回常量池中的这个引用
这个方法应该是这样的逻辑:
String intern() {
if(常量池找到该字面量的字符串) {
return 常量池该字面量的字符串
}
if(常量池已经记录字符串字面量相等的引用) {
return A
}
记录 堆上第一个与该字符串字面量相等的引用
return 记录的引用
}
好了,我们下面看一下这段代码,它的运行结果应该是什么?
public static void main(String[] args) {
String s1 = new String("Hydra");
String s2 = s1.intern();
System.out.println(s1 == s2);
System.out.println(s1 == "Hydra");
System.out.println(s2 == "Hydra");
}
输出打印:
false
false
true
用一张图来描述它们的关系,就很容易明白了:
其实有了第三题的基础,了解这个结构已经很简单了:
- 在创建
s1
的时候,其实堆里已经创建了两个字符串对象StringObject1
和StringObject2
,并且在字符串常量池中驻留了StringObject2
- 当执行
s1.intern()
方法时,字符串常量池中已经存在内容等于"Hydra"
的字符串StringObject2
,直接返回这个引用并赋值给s2
s1
和s2
指向的是两个不同的String对象,因此返回 fasles2
指向的就是驻留在字符串常量池的StringObject2
,因此s2=="Hydra"
为 true,而s1
指向的不是常量池中的对象引用所以返回false
上面是常量池中已存在内容相等的字符串驻留的情况,下面再看看常量池中不存在的情况,看下面的例子:
public static void main(String[] args) {
String s1 = new String("Hy") + new String("dra");
s1.intern();
String s2 = "Hydra";
System.out.println(s1 == s2);
}
执行结果:
true
简单分析一下这个过程,第一步会在堆上创建"Hy"
和"dra"
的字符串对象,并驻留到字符串常量池中。
接下来,完成字符串的拼接操作,前面我们说过,实际上jvm会把拼接优化成StringBuilder
的append
方法,并最终调用toString
方法返回一个String对象。在完成字符串的拼接后,字符串常量池中并没有驻留一个内容等于"Hydra"
的字符串。
所以,执行s1.intern()
时,会在字符串常量池创建一个引用,指向前面StringBuilder
创建的那个字符串,也就是变量s1
所指向的字符串对象。在《深入理解Java虚拟机》这本书中,作者对这进行了解释,因为从jdk7开始,字符串常量池就已经移到了堆中,那么这里就只需要在字符串常量池中记录一下首次出现的实例引用即可。
最后,当执行String s2 = "Hydra"
时,发现字符串常量池中已经驻留这个字符串,直接返回对象的引用,因此s1
和s2
指向的是相同的对象。
第5题,还是创建了几个对象?
解决了前面数String对象个数的问题,那么我们接着加点难度,看看下面这段代码,创建了几个对象?
String s="a"+"b"+"c";
先揭晓答案,只创建了一个对象! 可以直观的对比一下源代码和反编译后的字节码文件:
如果使用前面提到过的debug小技巧,也可以直观的看到语句执行完后,只增加了一个String对象,以及一个char数组对象。并且这个字符串就是驻留在字符串常量池中的那一个,如果后面再使用字面量"abc"
的方式声明一个字符串,指向的仍是这一个,堆中String对象的数量不会发生变化。
至于为什么源代码中字符串拼接的操作,在编译完成后会消失,直接呈现为一个拼接后的完整字符串,是因为在编译期间,应用了编译器优化中一种被称为常量折叠(Constant Folding)的技术。
常量折叠会将编译期常量的加减乘除的运算过程在编译过程中折叠。编译器通过语法分析,会将常量表达式计算求值,并用求出的值来替换表达式,而不必等到运行期间再进行运算处理,从而在运行期间节省处理器资源。
而上边提到的编译期常量的特点就是它的值在编译期就可以确定,并且需要完整满足下面的要求,才可能是一个编译期常量:
- 被声明为
final
- 基本类型或者字符串类型
- 声明时就已经初始化
- 使用常量表达式进行初始化
下面我们通过几段代码加深对它的理解:
public static void main(String[] args) {
final String h1 = "hello";
String h2 = "hello";
String s1 = h1 + "Hydra";
String s2 = h2 + "Hydra";
System.out.println((s1 == "helloHydra"));
System.out.println((s2 == "helloHydra"));
}
执行结果:
true
false
代码中字符串h1
和h2
都使用常量赋值,区别在于是否使用了final
进行修饰,对比编译后的代码,s1
进行了折叠而s2
没有,可以印证上面的理论,final
修饰的字符串变量才有可能是编译期常量。
再看一段代码,执行下面的程序,结果会返回什么呢?
public static void main(String[] args) {
String h ="hello";
final String h2 = h;
String s = h2 + "Hydra";
System.out.println(s=="helloHydra");
}
答案是false
,因为虽然这里字符串h2
被final
修饰,但是初始化时没有使用常量表达式,因此它也不是编译期常量。那么,有的小伙伴就要问了,到底什么才是常量表达式呢?
在Oracle
官网的文档中,列举了很多种情况,下面对常见的情况进行列举(除了下面这些之外官方文档上还列举了不少情况,如果有兴趣的话,可以自己查看):
- 基本类型和String类型的字面量
- 基本类型和String类型的强制类型转换
- 使用
+
或-
或!
等一元运算符(不包括++
和--
)进行计算 - 使用加减运算符
+
、-
,乘除运算符*
、/
、%
进行计算 - 使用移位运算符
>>
、<<
、>>>
进行位移操作 - ……
至于我们从文章一开始就提到的字面量(literals),是用于表达源代码中一个固定值的表示法,在Java中创建一个对象时需要使用new
关键字,但是给一个基本类型变量赋值时不需要使用new
关键字,这种方式就可以被称为字面量。Java中字面量主要包括了以下类型的字面量:
//整数型字面量:
long l=1L;
int i=1;
//浮点类型字面量:
float f=11.1f;
double d=11.1;
//字符和字符串类型字面量:
char c='h';
String s="Hydra";
//布尔类型字面量:
boolean b=true;
再说点题外话,和编译期常量相对的,另一种类型的常量是运行时常量,看一下下面这段代码:
final String s1="hello "+"Hydra";
final String s2=UUID.randomUUID().toString()+"Hydra";
编译器能够在编译期就得到s1
的值是hello Hydra
,不需要等到程序的运行期间,因此s1
属于编译期常量。而对s2
来说,虽然也被声明为final
类型,并且在声明时就已经初始化,但使用的不是常量表达式,因此不属于编译期常量,这一类型的常量被称为运行时常量。
再看一下编译后的字节码文件中的常量池区域:
可以看到常量池中只有一个String类型的常量hello Hydra
,而s2
对应的字符串常量则不在此区域。对编译器来说,运行时常量在编译期间无法进行折叠,编译器只会对尝试修改它的操作进行报错处理。
总结
最后再强调一下,本文是基于jdk8
进行测试,不同版本的jdk
可能会有很大差异。例如jdk6
之前,字符串常量池存储的是String对象实例,而在jdk7
以后字符串常量池就改为存储引用,做了非常大的改变。
参考资料:
《深入理解Java虚拟机(第三版)》
https://www.zhihu.com/question/55994121
https://www.iteye.com/blog/rednaxelafx-774673#
blog.csdn.net/o9109003234/article/details/109523691
String s = 1 + 1 + "1";
System.out.println("s = " + s);
s = 1 + "1" + 1;
System.out.println("s = " + s);
s = "1" + 1 + 1;
System.out.println("s = " + s);
s = "1" + (1 + 1);
System.out.println("s = " + s);
OUT:
s = 21
s = 111
s = 111
s = 12