前情提要
不管你是入行多年的老码农,或者是涉世未深的小白菜,都一定会被人问过下面这样问题。
String、StringBuilder、StringBuffer的区别是什么?
上面的问题,仅仅要求作答还是很简单的。但当面试官接着你的答案往底层问,这个问题就复杂了。一般遇到这样的情况,五桥先生都会大声的质问面试官:下一题吧...
情景模拟
我们来模拟一下这个面试的场景:假定小东是面试官,小西是应聘者。
小东:你好,小西是吧?请做一个简短的自我介绍吧。
小西:您好,我叫小西,今年18岁,来自...
哎?WK,谁TM给我丢砖头?
不知名的观众A:谁让你扯这些的?直奔主题行不。
不知名的观众B:对啊,直接从这题开始不就好了,扯那么远干嘛?
......
不好意思,刚刚发生了一些小插曲,现场有几名观众正在被送往医院的途中...呃,应广大观众朋友们的要求,咱们直接从这个问题开始。前面的那些精彩的场景咱们就不得不跳过了。
...此处忽略前面的一堆细节...
...
小西:一般咱们会从三个方面去对他们进行比较。
可变性:String类中使用final关键字修饰,所以String对象是不可变的;而而StringBuilder与StringBuffer都继承自AbstractStringBuilder类,没有用final关键字修饰,所以这两种对象都是可变的;
线程安全性:因为String中的对象是不可变的,所以线程安全,StringBuffer对方法加了同步锁或者对调用的方法加了同步锁,所以是线程安全的,StringBuilder并没有对方法进行加同步锁,所以是线程不安全;
性能方面:每次改变String类型的对象时,都会生成一个新的String对象。而StringBuffer和StringBuilder都是基于源对象的更改,所以在大量字符串拼接的时候性能都比String高很多,由于StringBuffer实现了线程安全,在性能上略低于StringBuilder。
话不多说,直接模拟面试官的灵魂追问环节。
- 那如何判定两个String类型的对象是同一个对象,如何判定值相等?
- 我可以写一个自定义的class,继承自String类吗?我可以自己写一个和String同名的class,并用在程序里吗?
- String str = new String("abc");上面的代码创建了几个对象,为什么?
- String是包装类型还是基本类型?基本类型和包装类型有什么区别?Integer包装类有对应的int基本类型,为什么String没有对应的基本类型呢?
- 为什么我们总是习惯于用String类型作为HashMap的key,为什么不用其他的Object类型?
- ......
上面的这些问题有的还是比较简单的,有的问题考察的知识点比较复杂,如果对相应的知识点没有基本的理解,确实难以作答。
本章重点内容是关于String,所以对StringBuilder和StringBuffer不会做过多的介绍。如果有需要,会在后面的章节补充StringBuilder和StringBuffer的相关内容。
请注意,该文档中所涉及到的代码都是基于jdk1.8来说的。
简单聊一下String类型
String到底是啥?
在官方API文档上看见关于String的部分描述是这样的:
String类代表字符串。 Java程序中的所有字符串文字(例如"abc" )都被实现为此类的实例。
字符串不变性: 它们的值在创建后不能被更改。
字符串缓冲区支持可变字符串。因为String对象是不可变的,它们可以被共享。
关于String的字符串不可变性
如果有翻过String源码的朋友,应该都知道String其实是一个基于字符数组实现的数据结构,在char[]的定义上加了final关键字,表示其是一个常量。
public final class String
implements java.io.Serializable, Comparable, CharSequence {
/** The value is used for character storage. */
private final char value[];
...
}
“字符数组”没啥好说的,final这个关键字简单解释下。
final关键字的字面意思是最终的,贴近不能被更改的意思;在Java中,可用来修饰成员变量、局部变量、方法、类;
- 在修饰变量时,表示该变量一旦被赋值就不能被修改;
- 修饰方法时,该方法不能被重写;
- 修饰类时,该类不能被继承;
在后面,会单独拿一个章节的内容来谈这个final关键字。关于final关键字参见:此处应该插眼。
回到正题中,从上面的内容可以看出:final关键字保证了char数组一旦被赋值后,引用指向就不能被更改。如下:
final char[] value = {'a','b','c'};
char[] another = {'d','e','f'};
// 编译错误,会提示:Cannot assign a value to final variable 'value'
value = another;
事实上,String的不可变性并不仅仅只是因为String类持有的value对象由final修饰。String的不可变性是因为以下几个条件共同保证的:
第一,value[]被final修饰,在赋值后不可变;
第二,String的value[]未对外界提供任何修改数组值的方法实现;
第三,String类本身也被final所修饰,所以String不可以被任何类继承;
String类的不可变性的关键点依托于上面三个条件,缺一不可。我们常常会忘记其中的第二个必要条件,其实这个很重要。举个例子说明一下第二个条件是如何保证不可变性的:
final char[] value = {'a','b','c'};
// 此处,只要我们能拿到char[]这个对象,我们就可以轻松的改变Array里的元素
value[2] = 'd';
System.out.println(value);
所以,只要我们能拿到String类持有的char数组,就意味着我们可以随意更改里面的内容,这样就没有办法保证String的不可变性。所以,String类在设计的时候,将char[]对象限定为private,并且不给外部提供任何可以操作到char[]的实现。
关于String的缓冲特性
String类在工作中中被大量用到,为了提高效率,引入了String的缓冲区。引入缓冲区的背景意义和具体细节就不展开了,这也不是今天的重点,直接看实现的结果。
一个对象的创建过程
我们在创建一个对象(包括自定义数据类型)的时候,大概有以下几个步骤。
1.尝试在栈上分配内容;(这部分可以忽略,里面的水很深,有兴趣的可以研究下方法逃逸原理)
2.在堆上分配一块足够大内存;
3.设置对象头、初始化零值、对齐填充;(这部分也可以忽略)
4.执行相应的构造方法,按照用户的意愿初始化对象信息;(到这里,一个对象才基本创建完成)
5.将我们定义的变量指向刚创建对象的引用地址;
Tips:这部分内容在虚拟机相关的内容里会详细说明,本章节重点关心2和5两点即可。
由于效率方面的考虑,基本数据类型基本上都是在栈上面分配内存。
以上是对象创建的基本流程,但String的实现并不只是上面这个简单的步骤,在jvm里面提供了一个专门用于String的扩展区域——字符串常量池。
搞个例子看一下:
// 直接常量赋值
String a1 = "五桥";
String a1_1 = "五桥";
String a2 = "先生";
// 常量相加赋值
String b1 = "五桥先生";
String b2 = "五桥" + "先生";
// 变量相加赋值
String c = a1 + a2;
// new
String d1 = new String("五桥");
String d1_1 = new String("五桥");
String d2 = new String(a1);
String d3 = new String(a1 + a2);
System.out.println("(1)...a1==a1_1? "+(a1==a1_1));
System.out.println("(2)...d1==d1_1? "+(d1==d1_1));
System.out.println("(3)...b2=='五桥先生'? "+(b2=="五桥先生"));
System.out.println("(4)...b2==c'? "+(b2==c));
System.out.println("(5)...d1==d2'? "+(d1==d2));
System.out.println("(6)...d3=='五桥先生'? "+(d3=="五桥先生"));
System.out.println("(7)...d3==b2? "+(d3==b2));
System.out.println("(8)...b1==b2? "+(b1==b2));
System.out.println("(9)...b1==c? "+(b1==c));
针对上面的三种创建方式,分别简单看一下创建这些对象过程是怎么样的。
(一)new一个String对象:
1.先在字符串常量池中找(通过equals方法)对应的值,如果找到了,进入第2步,找不到则在字符串常量池中创建一个对象;
2.会在堆栈中产生一个新的对象(只要有new,就会有新对象的产生),然后把引用地址指向字符串常量池中相应值的地址;
(二)直接常量赋值的方式:
1.先在字符串常量池中找对应的值,如果找到了,进入第2步,找不到则在字符串常量池中创建一个对象;
2.返回字符串常量池中相应值的地址引用;
(三)变量相加赋值的方式,返回对象:
1.直接在堆栈中创建一个新的对象,返回该对象的引用地址;
根据上面的这几条简述结合上面的代码,我们大致分析一下代码段中每一条语句到底要干什么事。
// 直接常量赋值
// 常量池中创建一个'五桥'的对象,返回这个对象的引用地址给a1
String a1 = "五桥";
// 常量池中找到了有一个叫'五桥'的对象,返回这个对象的引用地址给a1_1
String a1_1 = "五桥";
// 常量池中创建一个'先生'的对象,返回这个对象的引用地址给a2
String a2 = "先生";
// 常量相加赋值
// 常量池中创建一个'五桥先生'的对象,返回这个对象的引用地址给b1
String b1 = "五桥先生";
// 常量直接相加,jvm会帮忙优化成一个常量——'五桥先生'
// 在常量池中刚好能找到'五桥先生'的对象,返回引用地址给b2
String b2 = "五桥" + "先生";
// 变量相加赋值
// 变量(这个变量的概念是相对于jvm来说的,jvm对它具体的值不能感知所以称为变量)相加,不会维护常量池,直接在堆栈中分配一个对象
String c = a1 + a2;
// new
// 常量池中找到'五桥'的对象
// 堆栈中分配一个对象,这个对象指向了常量池中'五桥'的对象
// 让d1指向堆栈中刚分配的对象
String d1 = new String("五桥");
// 过程同d1,但注意d1和d1_1不是同一个对象,只不过这两个对象都指向了常量池中的'五桥'
String d1_1 = new String("五桥");
// 这个好理解,d2指向一个对象,这个对象又指向a1所指向的对象
String d2 = new String(a1);
// a1 + a2:堆栈中分配一个对象,这个对象的值是'五桥先生'
// 再分配一个对象,这个对象的引用指向了上面分配的对象
// 注意这个过程与常量池无关
String d3 = new String(a1 + a2);
关于上面提到的jvm的优化,可参阅常量折叠技术。大概的意思是jvm在编译代码的过程(语法分析阶段)中,会将常量之间的计算结果存到俗称的'语法树'中,在程序的运行阶段,就可以直接从'语法树'中获取值。该优化不仅仅适用于String对象的创建,类似于int a = 1 + 2这样的语句同样适用。
为什么变量相加不负责维护常量池?因为在String底层多个对象相加是调用的StringBuilder的append方法实现的,该过程在方法中直接实现(事实上,我们通过编译.java文件为.class文件观察到)。我们甚至可以理解为所有在方法中返回的String对象都是直接在堆中分配内存,具体可参考相应的源码,在此不再展开赘述。
到这里,我们应该能给上面的例子答案了。结果如下:
(1)...a1==a1_1? true
(2)...d1==d1_1? false
(3)...b2=='五桥先生'? true
(4)...b2==c'? false
(5)...d1==d2'? false
(6)...d3=='五桥先生'? false
(7)...d3==b2? false
(8)...b1==b2? true
(9)...b1==c? false
到这里,我们还可以大致画一个String的对象布局图(简化后的)帮助理解一下整个对象创建的过程。
补充内容:在jdk1.7之前,字符串常量池被存放在方法区中(具体是在方法区中的运行时常量池中);在jdk1.7的时候,字符串常量池被单独拿到堆中了。
拓展题
五桥先生曾被朋友问到一个问题,当时因为对常量池这块没有研究所以压根不知道这个题是啥意思。题是下面这样的。
String Q1 = "xxx";// 第一句
String M1 = "yyy" + Q1 + "zzz";//第二句
String M2 = "yyy" + "zzz" + Q1;//第三句
// 问:第一句和第二句有什么区别?
废话不多说,直接看结果:
- 首先,第一句会在常量池中创建一个"xxx",由于第二句和第三句都遇到了变量,所以肯定都分别会在堆中分配对象;
- 第二句中,引用变量在三个对象的中间,jvm不会优化,所以会在常量池中分别创建"yyy"和"zzz";
- 第三句中,上来是两个常量相加的结果,再和变量相加,这个语句会被jvm优化成String M2 = "yyyzzz" + Q1;所以只会在常量池中创建一个"yyyzzz";
另外:有朋友说需要考虑jvm指令重排序的问题,这个地方,我简单说一下我的看法:这个地方跟指令重排序没有关系哈,指令重排的一个原则是两条指令没有依赖关系(说白了就是对虚拟机来说先执行哪一条指令对程序的运行没有影响)。而这里是无法指令重排序的,三个内容相加时,前面两个的结果作为一个中间值再去和后面的值相加,属于有前后依赖关系的顺序执行。
情景模拟中部分问题剖析
回到本文一开始情景模拟里面的几个问题中来,我想在这里,已经可以尝试着回答里面的部分问题了。
(1) 如何判定两个String类型的对象是同一个对象,如何判定值相等?
答:判断两个String对象是不是同一个对象,可直接用“==”符号;判断值相等,用equals方法。
对于String对象来说,“==”是比较引用是不是指向内存中的同一块地址,而equals方法在String类中被重写过,有自己的实现:判断两个对象堆中的内容是否相同。
一般来说,除了基本数据类型,所有的class(包括我们自定义的类)都继承自Object类,在Object中有一个equals方法的实现,我们在定义一个class的额时候,如果需要比较两个对象,都要重写equals方法(当然也要重写hashCode方法,在另一章会讲到)。
关于==、equals以及hashCode参见:此处应该插眼。
(2) 可以写一个自定义的class,继承自String类吗?我可以自己写一个和String同名的class,并用在程序里吗?
答:String类加了final关键字修饰,所以不能被继承。
关于后一个问题,笔者也没有去尝试过,只是有一个朋友在面试中被问到了,这个问题其实不用太在意,一般的面试官是问不到这个问题的。这里就按照网上的描述给一个答案吧:我们可以写一个和Java.lang.String同名的class,但是并不能应用在程序中。因为类加载器在加载String类的时候,根据双亲委派模型会加载其默认的类;就算你自定义一个类加载器去加载自己实现的类,也只能得到一个SecurityException的异常。
(3) String str = new String("abc");上面的代码创建了几个对象,为什么?
答:创建了1或者2个对象
根据String类的缓存机制,首先尝试在字符串常量池中找对应的'abc'对象,找不到就创建一个'abc'对象;再在堆栈中创建一个对象并将引用指向'abc'。
(4) String是包装类型还是基本类型?基本类型和包装类型有什么区别?Integer包装类有对应的int基本类型,为什么String没有对应的基本类型呢?
答:Java中有8种基本类型(byte、short、int、long、float、double、char、boolean),String不在其中,属于包装类型。
关于基本类型和包装类型的区别参见:传送门。
为什么String没有对应的包装类型?这其实是个开放性的问题,可以从很多角度去解释。我个人的理解是这样的:
按照细粒度去区分的话,基本数据类型和String有两个完全不同的出发点,例如Integer是int的包装类,包装的是单个int类型的值;而String的设计理念就决定了他一旦被初始化就会指向一串连续的内存单元,这一块内存单元中,存放的数据不止一个(char数组嘛)。所以,String没有对应的基础类型,实在要说的话,我们可以暴力的说char就是String对应的基本类型。
(5) 为什么我们总是习惯于用String类型作为HashMap的key,为什么不用其他的Object类型?
答:从HashMap的设计来讲,任何对象(除基本数据类型,因为基本数据类型不支持泛型)都可以作为Key,但实际情况是,我们通常会用String作为Key,因为String具有天然的优势。
(1)首先,我们需要知道HashMap的内部实现是通过key的hashcode来确定Entry的存储位置;
(2)自定义的对象作为key的缺点:作为HashMap的key的前提就是必须要重写equals和hashcode方法,如果不重写会导致在你的对象属性发生变化后,找不到对应的key;
(3)相比自定义对象String作为key的优点:因为String具有不可变性,在被创建后,hashcode就可以被缓存下来,不需要再次计算,所以在定位Entry的时候非常快。
将在另外一章中会详细说明这个问题。关于HashMap的相关内容参见:此处应该插眼。
扩展区域
扩展区域主体
这是一个没有实现的扩展。
上一篇:前言
下一篇:你知道Java中基本类型和包装类的区别吗