有些程序员会诟病一些公司的面试,近似于扣字眼一样。有些题真是为了面试而出,像孔乙己茴字几种写法般的可笑。然而从面试者的角度来说,考察一个人,无非几个方面、一个是基本功扎实与否、第二个是潜力怎么样、第三个是经验是否丰富。其中基本功扎不扎实就考察对主语言理解的深度。而这些看似好笑的面试题,就是在考察你对主语言的理解深度。
今天我们分析一下String,后面我们还会分析一些关键字( final、static、volatile、synchronized等)、泛型、枚举、反射、io、集合等内容。言归正传。
因为目前主流是1.8以上版本,所以本文jdk版本为1.8
为什么String类会在面试内造成这么多困扰呢?我们先看String的特点:
1、String不是基本数据类型,而是引用类型。
2、String不可变性。
3、String实例化方式
4、intern()
这几个点就是造成困扰的主要几个方面。
引用类型确表现为基本类型的用法,说实话,大多数情况下,你把字符串当作是基本数据类型也没啥大的感觉,开发中很多人都是这么用的。
我们构造一个面试题,题目是:
指向同一个对象的引用,相等么?一样么?是同一个么?
我们先看一个例子:
代码1
输出:
有些人可能会觉得疑惑,为什么一个是引用类型,一个是基本类型。但是表现却一致呢。
有人总结说,java其实都是值传递,即使传递的引用,也是算是值传递。为什么这么说呢,咱们之前讲过一个线程调用的方法都栈中,每个方法对应一个栈帧,栈帧中最重要的结构是局部变量表,局部变量表内有基本类型和引用类型。
那方法的传参就会被放在局部变量表内,复制原来的值,放在栈帧的slot内。无论是基本类型、还是引用类型,存储原来值的slot都变了,但是他们的值是原来的那个值。所以准确的说法应该是参数传值。基本类型的值是真的内容,而引用类型的值是对象的地址。
所以,方法的参数是引用类型,那这个引用也不是之前的引用了。虽然存的对象的地址是同一个。不同的引用指向同一个对象,那这两个引用本身是完全一样的么?我们来一段代码:
代码2
很明显,输出结果都是true。这又牵扯出了另一个问题,"=="这个符号,到底比较的是什么。以上面的思路来说,==的功能很纯碎,就是比较符号所代表的值。引用类型的值是地址,那比较的就是地址。基本类型的值就是内容,所以比较的就是内容。从来不比较符号的本身。那反思一下符号"="的作用,赋值,作用就是赋予符号代表的值,而不是符号本身。
咱们回到代码1中,现在我们知道,方法内的引用,已经是个新的引用了,只不过它的值和实例变量的值是一样的,都是"aaa"这个字符串对象的内存地址。
如果是一般的对象的话,我们会 str.set(xxx)或者 str.value = 来改变其内的值,而这个方法呢,执行了
我们知道,字符串有两种实例化方式,这是其中一种。实例化是干啥的,是产生新的对象的。那这个动作的效果就是,将str指向了新的String对象。到此为止,代码1应该好解释了。str跟实例变量虽然是一样的符号,但是它是一个新的引用,开始的时候指向同一个字符串对象,执行实例化方法之后,它指向了新的String对象。这个时候二者没有任何一毛钱关系了,也不可能互相之间有影响。如果还是没理解,我们再举个例子。
代码3
代码3和代码1,在写法上是类似的。输出结果是2。就是说,在change方法内做的操作,对原本的实例变量没有产生影响。因为方法内的引用所指向的对象已经变了,如果这样写
就会输出4。原因是,虽然引用是新生成的引用,但是指向的对象和实例变量指向的对象是同一个,改变一个,另一个也会体现出来。
现在回答标题的问题。指向同一个对象的引用,相等么?一样么?是同一个么?相等、不一样、不是同一个。
String实例化的两种方式
我们最熟悉的一种:String str = "aaa";
这种方式的执行过程是这样的。1、查看字符串常量池内有没有"aaa"。2、如果没有,那么在常量池直接创建字符串对象,并把地址返回给引用。3、如果有则直接返回给引用。
第二种:String str = new String("aaa");
这种创建过程是这样的,1、查看字符串常量池内有没有"aaa"。2、如果没有,那么在常量池直接创建字符串对象,并在堆里也创建一个字符串对象,将堆里的字符串对象的地址返回给引用。3、如果有则只在堆里创建一个字符串对象,将堆里的字符串对象的地址返回给引用。
需要注意的一点是:堆中的对象中的value数组的地址是常量池中的地址。
这里提到了字符串常量池的概念。我们从先讲一下常量池。
在HotSpot VM里实现的string pool功能的是一个StringTable类,它是一个Hash表,这个StringTable在每个HotSpot VM的实例只有一份,被所有的类共享。
大小演变
从字符串的实例化方式可以看到,两种实例化都可能会在常量池中生成字符串对象。String还提供了intern方法存取常量池。在字符串重复率高的情况下,非常节省内存。
说到这儿了,咱们先说一下不可变吧,最后在深入总结一下各种情况。
怎么保证一个类是可不变的,想要改变只能让他的引用指向新的对象,而不是改变对象本身?
String为什么要保证不可变?String是怎么保证不可变的?它真的不可变么?
String为什么要保证不可变(代价创建大量的String对象)
1.字符串常量池的需要.
字符串常量池可以将一些字符常量放在常量池中重复使用,避免每次都重新创建相同的对象、节省存储空间。但如果字符串是可变的,此时相同内容的String还指向常量池的同一个内存空间,当某个变量改变了该内存的值时,其他遍历的值也会发生改变。所以不符合常量池设计的初衷。
2. 线程安全考虑。
同一个字符串实例可以被多个线程共享。这样便不用因为线程安全问题而使用同步。字符串自己便是线程安全的。
3. 类加载器要用到字符串,不可变性提供了安全性,以便正确的类被加载。譬如你想加载java.sql.Connection类,而这个值被改成了myhacked.Connection,那么会对你的数据库造成不可知的破坏。
4. 支持hash映射和缓存。
因为字符串是不可变的,所以在它创建的时候hashcode就被缓存了,不需要重新计算。这就使得字符串很适合作为Map中的键,字符串的处理速度要快过其它的键对象。这就是HashMap中的键往往都使用字符串。
String是怎么保证不可变的
有的文章把String的不可变归结于final修饰,这是不求甚解的说法。实际上,要知道String为什么不可变,应该从源码来解释:
String类被final修饰,不可继承(这样做是为了防止子类继承重写父类方法)
string内部所有成员都设置为私有变量(这样做是防止直接访问并修改变量)
不存在value的setter(防止修改value)
并将value和offset设置为final。(不能直接替换地址)
当传入可变数组value[]时,进行copy而不是直接将value[]复制给内部变量(内部深拷贝,保证不与外部地址相同,防止通过外部持有的引用而修改内容)
获取value时不是直接返回对象引用,而是返回对象的copy.(防止持有引用修改内容)
它真的不可变么
我们从它保证不变性的手段来看,设置数组为final、private;不设置外部访问方法;因为value数组是引用类型,设置final并不会保证其中的元素不会发生改变,private和不设置setter没法预防反射,也就是说,通过反射设置setAccessible是可以修改value内的元素,所以String并不是一定不可变的,只不过常规手段不可变罢了。
intern方法
String的intern方法很简单,获取字符串常量池内,内容跟调用对象内容一致的字符串对象,并返回字符串常量池内的对象地址。如果常量池内没有,则将调用对象的引用放入常量池内。
intern的正确打开方式
intern的使用场景是跟字符串常量池的作用紧密相关的。那么咱们先考虑一下字符串常量池的作用。典型的适合场景,当有全局的静态数组或者集合持有字符串的引用,那么如果一个字符串反复出现,但是在堆中的对象,每个都不同。那么如果直接持有堆内的引用,那么只要这些字符串对象的生命周期和数组集合等的生命周期一致。那么这个时候适合持有常量池内的对象引用。这样,即使在堆中产生了大量的对象,那么一个gc周期内也就回收了,而且在常量池内常量较少时,读取的速度也很快,一定程度上提高了性能。
不过字符串常量池是个类似hashtable的实现,当其内元素过多时,会引起hash碰撞,导致性能严重下降。所以intern选取的场景很重要。如果是大量不重复的字符串,本不会进入常量池的,使用intern强行放入常量池,会严重影响性能。
理解了字符串常量池的作用,那么我们也就能理解,为什么面试者会关注创建了多少对象了,对象的位置在哪,因为只有这样,才不会弄巧成拙。
创建多少对象,各在什么位置
在分析具体问题之前,我们先对我们之前的结论进行证明,并理解其本质
验证字面量赋值:
结果为true。验证了字面量赋值在常量池创建对象,且只在常量池创建对象。这个验证并不严谨,因为可能存在一种情况,假设它是在堆中创建对象,通过intern放入到常量池中a的引用,结果也是true。不过,通过j结合下面验证复用的操作,可以说明不会出现咱们假设的情况。
结果为true。验证了对常量池内对象的复用。
结果为false.因为b指向的一定是常量池内的对象,a一定不在常量池内。所以new String("aaaa")一定在堆里创建了对象,而且返回的也是堆内对象的引用。那怎么证实这个动作也在常量池内创建了对象呢。我们来debug一下。
我们发现a的value数组的地址和b的是同一个。a的初始化动作在前,b跟a的一样,而b一定是指向在常量池中的对象,那么证明 a肯定事先在常量池内创建了对象,不然b不会跟a有相同的数组。但是上面我们确定了a 和 b 所指向的对象不是同一个,那么只有是堆和常量池各生成了一个对象,且堆中的数组指向了常量池中的数组。
我们通过看源码发现,其实在常量池中创建对象的动作是字面量做的。即在字节码中出现的字面量,一定会在常量池中创建一个对象。所以new String("aaaa")这个动作,"aaaa"负责在常量池中创建对象,而new动作在堆中产生另一个对象,并将value地址赋值为常量池中的value地址。
明白了这个道理之后,后面遇到的各种问题都会迎刃而解:
实例1:
解释看注解
实例2:
由此例子可以得出结论,对象拼接得来的字符串,因为没有出现字面量"23"所以并没有触发在常量池中创建对象的操作。验证了我们之前所说,除了intern方法,只有字面量才会导致在常量池内创建对象。
实例3:
实例4:
实例5:
注意此例子在其他文章中答案是错的。该结果返回值应该是 false,这个例子再次验证了只要字节码内有字面量出现,那么一定会在常量池内创建对象。
实例6:
这个示例貌似是对我们之前结论的推翻,其实不然,我们使用idea查看class文件,发现代码是这样的:
在编译后的class文件中,实际上并没有出现"AA"这个字面量,所以常量池内的AA是由intern方法放入的a2的引用。
实例7:
咱们看看这个final修饰的变量,编译之后会怎么样:
字面量直接在编译期间确定出来。
实例8:
编译后的代码:
通过这8个例子,我们总结,只有字面量和intern方法才可以在字符串常量池中创建对象,谁在编译后的字节码中先出现,地址就是谁的。后面出现的一个,只取值不产生对象。
另外,经常看到 String s = new String("aaa").intern();这个写法的目的在于,让s的引用指向常量池中的对象而非堆中新产生的对象。这样方便堆内对象的回收,因为没有引用指向它。但是这么写是没有实际意义的,因为效果可以用String s = "aaa"代替,还有副作用在堆中产生了对象。只有当aaa是动态生成的才有意义,因为编译期无法确定字面量,就无法放入常量池内,所以使用intern放入,并返回常量池内的地址而非堆内的地址。例如arr[i] = new String(String.valueOf(DB_DATA[i % DB_DATA.length])).intern();这个例子取自美团技术团队的文章。有两个要点,第一值是动态的,第二数组是全局静态的,回收的时候会作为root。