目录
1. String是一个类,不属于基本数据类型
大家应该知道的是,String是Java中已经定义好的一个类,并不属于八种基本数据类型中的一种,它在java.lang包中,所以使用的时候不需要导包。
Java程序中所有的字符串,例如"abcdefg",都是String类的对象。
字符串一旦被创建,就不能被更改,如果通过其他的方式产生新的字符串,实质上都是创建了一个新的String类对象。
2. 字符串常量池的概念
有一点我们应该清楚,我们创建的所有Java对象,都是在堆内存中存放的,当然String字符串类的对象也不例外。这里有个细节需要知道,在堆内存中,有一块单独的区域,叫做字符串常量池,它是专门用来存放字符串的,只有当我们在程序中使用双引号直接赋值的时候,字符串的值会存放到字符串常量池中(这里要记住,必须是通过 = 赋值才可以,原因下面会讲到),如下所示
// 简单定义一个密码字符串并直接用 = 赋值
String password = "abcdwqq";
如果是通过上面这种代码的形式直接赋值的话,首先程序会去堆内存中的字符串常量池中寻找是否已经存在 “abcdwqq” 这样一个字符串;
如果字符串常量池中已经存在,会把该对象的内存地址直接赋值给password;
如果不存在,会直接创建一个新的字符串 abcdwqq ,然后将该字符串对应的内存地址赋值给password;
当我们下次又创建了一个字符串也等于 abcwqq 时,它再次去字符串常量池中寻找,发现已经存在abcdwqq这个字符串,那么它就不会再次创建,而是直接将该对象的内存地址值赋值给新创建的字符串对象,如下代码所示
public static void main(String[] args) {
// 简单定义一个密码字符串并直接用 = 赋值
String password = "abcdwqq";
/**
* 再次定义一个字符串password2与上面的password相等,
* 那么password2所指向的内存地址值就会等于password所指向的内存地址值
*/
String password2 = "abcdwqq";
System.out.println(password == password2);
}
我们运行main方法,得到如下图所示结果
运行结果为 true ,由此也证实了我们的结论;当我们使用 = 为字符串进行赋值的时候,系统首先会判断要赋的值是否已经在字符串常量池中存在,若存在,则直接指向,不存在则创建指向;当下次又有同样的字符串赋值相同的结果时,便不会再创建新的字符串对象,而是直接引用字符串常量池中的对象地址。
3. == 比较的到底是什么?
== 这个运算符我们大家都不陌生,这里我再从新复述一遍吧。
== 的比较分为基本数据类型和引用数据类型。
当 == 比较的是基本数据类型时,比较的是数据值是否相等。
当 == 比较的是引用数据类型时,比较的是引用是否相等(即所指向的内存地址)。
刚刚我们比较了两个不同的字符串赋予相同的值, == 比较出来的结果是true;但这里有一个前提,那就是两个对象都是通过 = 进行赋值,其实字符串还有另一种创建方法,只是不常用,就是通过 new ,和 new 对象是一样的道理。如下代码所示
public static void main(String[] args) {
// 通过new关键字new一个name1字符串并赋值为zhangsan
String name1 = new String("zhangsan");
// 通过new关键字new一个name2字符串也赋值为zhangsan
String name2 = new String("zhangsan");
System.out.println(name1 == name2);
}
运行main方法,得到如下图所示结果
运行结果为 false ,这就有些奇怪了啊,有没有人有疑问呢?我们通过 = 直接赋值,运算结果为true,为什么就是改变了一下赋值方法,运算结果就变成 false 了呢?
其实这里面是有原因的。刚才我也说了,通过 = 赋值,所创建的字符串对象会保存在堆内存中的字符串常量池中;而通过 new 关键字创建出来的对象,是不会存放在字符串常量池中的,而是直接存放在堆中,因此上面的代码,我们的 name1 与 name2 虽然值相等,但却是两个对象,所指向的内存地址也自然不同,那么 == 运算得出的结果自然也就是 false 。
经过了上面的两种比较,我们发现
常量池中两个值相等的字符串比较,结果为 true ;
堆内存中两个值相同的字符串比较,结果为false;
那么第三种情况,堆中的一个字符串对象与常量池中的一个字符串对象值相等时作比较会是什么样的结果呢?我们来试一下吧!
public static void main(String[] args) {
// 通过new关键字new一个name1字符串并赋值为zhangsan,该值会存放在字符串常量池中
String name1 = "zhangsan";
// 通过new关键字new一个name2字符串也赋值为zhangsan,该值会存放在堆中
String name2 = new String("zhangsan");
System.out.println(name1 == name2);
}
运行main方法得到如下图所示结果
结果也是不出所料,false 。
为什么会这样呢?相信细心的小伙伴已经知道原因了,name1 的值存放在常量池中,name2 的值存放在堆中,内存地址肯定不一样吧!那么对它们两个作比较,结果肯定为 false 啊。
总结:通过 String 变量名 = "变量值" 的方式创建字符串时,该字符串会存放在字符串常量池中;通过 String 变量名 = new String("变量值") 的方式创建字符串时,该字符串会直接存放在堆中不存放在常量池中;
4. StringBuilder概述
刚才在第一点我们也说了,String字符串一经创建,值就不可变,当我们在程序编程时,如果对字符串进行拼接,实际上会产生新的字符串,这样不仅会降低程序的执行效率,还会创建出多余无用的字符串垃圾,造成内存过度消耗,严重时还会造成内存泄漏。
这样非常不好。那么有没有更好的解决方案来应对字符串可变化的这一需求呢?当然是有的,它就是我们要说的 StringBuilder。
StringBuilder 我们可以把它看作是一个容器,创建之后内容是可变的,它可以很大的提高我们对字符串的操作效率。
StringBuilder有两个常用的构造方法,一个无参构造,一个带参构造(在创建时可以添加一个字符串进去),如下图所示
StringBuilder 的常用方法如下图所示,其中较为常用的是 append() 添加方法与 reverse() 反转方法
如下图,我创建了一个StringBuilder对象,并可以调用了append方法对内容进行添加修改
输出结果为 “aaabbbccddd”,与预期结果一致;
这里需要注意一点,StringBuilder 所创建的对象并不是字符串,因此 StringBuilder 所创建的对象不能调用 String 类的方法,它只是一个可变的容器,可以简单地理解成一个装字符串的数组,我们看下图
通过 . 的方式调用,IDEA编辑器并没有给出相应的关于 String 类的操作方法,如果想要使用字符串的方法,我们需要对 StringBuilder 的对象进行转化,调用 toString 方法即可将StringBuilder 的对象转化为字符串对象。如下图所示
(这里需要注意,调用了 toString() 方法,并不是将原来的 StringBuilder 对象变成了字符串,而是在底层又 new 了一个新的字符串,下面将源码时会细说)
转化完成之后,我们可以看到,现在已经可以调用 String 字符串的相关方法了。
5. StringBuilder 的使用场景
通过上面的讲述,我们知道了 通过 StringBuilder 可以达到我们对字符串内容修改的目的,那么同样就引申出来了StringBuilder 的使用场景,可以看到在 4 中我列举了StringBuilder的常用方法,基于这些方法,我们通常会使用StringBuilder完成以下几种目标
(1)基于 append 方法完成字符串的拼接;
(2)基于 reverse 方法完成字符串的反转;
6. 字符串存储的内存原理
通过上面我对字符串常量池的讲解,相信这个时候大家再看这个问题,就已经非常简单了吧。
创建字符串我们有两种方式,创建方式的不同也标志着它们在内存中的原理不同。
6.1 方式一
使用 = 直接赋值的方式创建,这种方式创建的字符串会存放在堆内存中的字符串常量池中,可以重复使用,在底层不会重复创建 String 对象,提高了变成效率,还降低了内存的使用,是我们推荐的使用方法。
6.2 方式二
使用 new 关键字的方式 new 一个字符串对象,这种方式创建的字符串会存放到堆中,不会进入字符串常量池,无法重复使用,这种方式还会增大内存消耗,是开发过程中不建议使用的一种方式。
7. 字符串拼接的底层原理
我们都知道,有时候我们会对字符串做一些拼接操作,在拼接字符串时,大多可以分为以下两种情况。
情况一:等号右边没有已经定义好的字符串变量,如下图所示
这种情况相对来讲比较简单,它在编译的时候会自动触发优化机制,虽然我们定义的时候是
String s = "a" + "b" + "c";但在编译过程中就已经得到最终结果了,它在编译时期会自动将 "a","b","c"合并,也就是说,在编译成 class 文件的时候,它会观察是否有变量参与,如果没有已经定义好的字符串参与拼接情况下,那么它就会直接得出最终结果 String s = "abc";
情况二:等号右边有已经定义好的字符串参与拼接运算,这个情况比较复杂,我尽可能说得明白一下,如下图所示
可以看到,字符串 s2 与 s3 都是基于 s1 的,在内存中,会发生了下面这种结果
这里我需要明确一点,在JDK8之前与JDK8之后,对于字符串的拼接底层方式是略有不同的,我着重来说一下JDK8之前底层是如何拼接的,面试有可能会问道,还希望各位可以坚持看完看懂,如果不感兴趣可以直接跳转到JDK8之后。
7.1 JDK8之前
第一步:main 方法先进栈;
第二步:定义字符串 s1 ,并加入到字符串常量池中,将 "a" 所在的内存地址赋值给 s1 ;
第三步:执行 String s2 = s1 + "b",在字符串常量池中加入字符串 "b",然后在堆内存中创建一个StringBuilder对象,然后通过 append 方法将 s1 的内容与 "b" 进行相加拼接,然后再调用toString 方法形成字符串,如下图所示;
在第三步这里我需要着重给你们说一下 toString 方法,我们来看一下 toString 方法的源码,在idea界面任意位置都行,按住键盘上的 Ctrl + N 键,我们输入StringBuilder ,点入它的源码类如下;
各位小伙伴如果有条件,可以自己在自己的电脑上跟着我一起做,学习如何看Java源码,相信你会更加明白,在以后对你看其他源码也有很大帮助的
点入源码类之后,我们点击 Ctrl + F12 打开方法一览表,找到 toString 方法,点击直接跳转到 toString 方法对应处,如下图所示
别的都不需要看,就看我画红线的部分,各位发现了什么,是不是StringBuilder 源码中 toString 方法去 new 了一个 String 啊,这说明了什么各位应该很清楚了吧,说明我们的 StringBuilder对象在调用 toString 方法的时候会在堆内存中 重新创建一个 String 字符串对象吧!!!
那么我现在来提问各位,在上述的第三步拼接字符串 s2 的过程中,在堆内存中一共创建了几个对象?
显而易见,是两个吧!
首先在拼接之前我们要创建一个 StringBuilder 对象;
拼接完成之后调用 toString 方法会再在堆内存中 new 一个新的 String 对象。
第四步:执行完第三步字符串拼接操作之后,并调用了 toString 方法形成了新的字符串,新字符串的内存地址就会赋值给变量 s3。 自此,Java底层字符串的拼接操作才算是全部完成了。
7.2 JDK8之后
其实JDK8之后虽然做出了改进,但相比于JDK8之前,效率提升的并不算多,我来简单说一下,如下图,定义 s1,s2,s3 三个字符串,s4对它们三个进行拼接
在JDK8之后进行字符串拼接时,底层会对拼接之后的字符串大小做一个预估,把要拼接的字符串放入一个数组中,全部拼接完成之后,再调用 toString 方法将数组转变成字符串;
如果换做JDK8之前的版本,我们应该能看出,s1 + s2 时,会创建一个 StringBuilder 和 String 对象;s1与 s2相加完成之后的字符串再与 s3 进行拼接时,会再次创建一个 StringBuilder 和 String 对象,如此一来,便创建了四个对象,非常消耗性能,特别是如果多行字符串都进行拼接,消费的性能就特别特别高,是非常不友好的;
在JDK8之后,虽然不需要再这么麻烦,但在创建之前,对字符串的预估还是需要消费时间的,因此性能并未提升多少,但总的来说也提升了。
7.3 结论
(1)如果我们需要拼接字符串的时候,尽量避免使用 + 直接进行拼接,因为这样非常消耗性能,也很浪费时间;
(2)如果需要进行字符串拼接式,尽量采用 StringBuilder 来进行字符串的拼接。
7.4 面试题小练习
如上图,我们定义一个 s1 = "abc",定义一个 s2 = "ab",定义一个 s3 = s2 + "c",运行结果为什么?
相信通过我上面的讲述,各位一眼就能猜出答案了吧。
结果应该为 false ,那我们来验证一下吧!
如上所示,在main 方法中输入测试代码,运行可以发现控制台中输入 false ,具体结果就不需要我再过多解释了吧,看懂了我上面我举得例子,这个小题就是小菜一碟。
8. StringBuilder提高效率的原因?
其实这里主要是对上面的总结,通过上面我对StringBuilder 这个类的讲解以及举例,我们应该知道了,StringBuilder在继续字符串拼接时,会创建一个容器,当我们要进行拼接时,直接将要拼接的内容添加到容器内部即可,不需要创建多余的对象,从而提高了效率,也降低了内存消耗。
9. StringBuilder源码分析
9.1 底层实现
StringBuilder 在创建之后,默认会创建一个容量为 16 的字节数组,如下所示
这里边有长度和容量两个概念,容量就是数组最多可以存贮的的字符数量,长度则是指已经存入的字符串数量,例如我创建了一个 StringBuilder 对象 ,调用 append 方法 添加了 "abc" ,那么现在数组的长度就是3。
9.2 扩容机制
当我们继续添加字符串,当容量16之后,该数组就会进行扩容,这里扩容分为两种情况。
情况一:也是默认扩容情况,会扩容之原来长度的 2倍 + 2,也就是 16 * 2 + 2 = 34。后续如果还需要扩容依旧如此,就是 34 * 2 + 2 = 70,以此类推。
情况二:当扩容之后的长度仍然无法满足需求的容量时,扩容后的容量就会以实际容量需求为准,这一点与 ArrayList 数组的扩容机制有些类似。
9.3 源码分析
9.3.1 无参构造源码分析
说了那么多,我们来看看它底层真的是我所说的那样吗?还用上面我教你们的那看源码的方法,Ctrl + N,Ctrl + F12即可查看源码
我们先来看 StringBuilder 类的无参构造方法,这里指定了 capacity = 16,先记住这个16;我们点入 super 跟入查看
这里又定义了一个 char 数组,在上图方法中,又将 capacity = 16 传给了value,由此不难看出,无参构造底层就是创建了一个长度为16的字符数组,名为 value 。
9.3.2 append 追加方法源码分析
这里我们找到 append 方法的源码,点进跟入查看,
我们发现在 append 方法中首先做了非空判断,我们看一下
这里发现,它其实是把 null 当作了 n,u,l,l 四个字符存进去了,这里的 count 是一个计数器,默认为0,赋值给c,每当存入一个,c++ 值增加一,确定下一次要存入的字符的位置,这一点与数组ArrayList 存贮数据的方式非常类似。
我们再回到 append 方法,继续看 ensureCapacityInternal,这里其实是做一次运算,将原本字符数组中的长度与要添加的字符串的长度求和,我们点进该方法跟入查看
这里的 if 是对添加后的数组长度做判断,前面我们知道,value 是一个长度为16的字符数组,这里如果运算结果大于0,说明添加之后的长度大于现有字符数组的长度,说明需要扩容,下方调用了 Arrays.copyOf 复制方法和 newCapacity 创建新数组方法,
我们点击 newCapacity 深入查看,在这里,它首先 int newCapacity = (value.length << 1)+ 2;
这里就定义了新数组的长度,默认扩容为原来的2倍加2;
再看下方的 if 判断,这里判断条件为 (如果新数组的长度 - 实际最小的数组长度 仍然小于0),说明扩容之后容量仍然不够,这个时候就以实际所需长度为准。
看完了上面可以得出一下结论,
当我们向字符数组中添加数据时,如果添加之后仍然小于默认容量16,则不变,不触发扩容机制;
如果添加之后大于16,则会触发扩容机制,扩容之原来的2倍加2的容量;
如果采用扩容方法之后,仍无法满足最小容量的需求,则容量会以实际所需的容量为准;