String字符串相关类底层原理
目录一. String是一个类不属于基本数据类型二. 字符串常量池的概念三. 比较的到底是什么四. StringBuilder概述五. StringBuilder 的使用场景六. 字符串存储的内存原理6.1 方式一6.2 方式二七. 字符串拼接的底层原理7.1 JDK8之前7.2 JDK8之后7.3 结论7.4 面试题小练习八. StringBuilder提高效率的原因九. StringBuilder源码分析9.1 底层实现9.2 扩容机制9.3 源码分析9.3.1 无参构造源码分析9.3.2 append 追加方法源码分析一. String是一个类不属于基本数据类型大家应该知道的是String是Java中已经定义好的一个类并不属于八种基本数据类型中的一种它在java.lang包中所以使用的时候不需要导包。Java程序中所有的字符串例如abcdefg都是String类的对象。字符串一旦被创建就不能被更改如果通过其他的方式产生新的字符串实质上都是创建了一个新的String类对象。二. 字符串常量池的概念有一点我们应该清楚我们创建的所有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 由此也证实了我们的结论当我们使用 为字符串进行赋值的时候系统首先会判断要赋的值是否已经在字符串常量池中存在若存在则直接指向不存在则创建指向当下次又有同样的字符串赋值相同的结果时便不会再创建新的字符串对象而是直接引用字符串常量池中的对象地址。三. 比较的到底是什么 这个运算符我们大家都不陌生这里我再从新复述一遍吧。 的比较分为基本数据类型和引用数据类型。当 比较的是基本数据类型时比较的是数据值是否相等。当 比较的是引用数据类型时比较的是引用是否相等(即所指向的内存地址)。刚刚我们比较了两个不同的字符串赋予相同的值 比较出来的结果是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(变量值) 的方式创建字符串时该字符串会直接存放在堆中不存放在常量池中四. StringBuilder概述刚才在第一点我们也说了String字符串一经创建值就不可变当我们在程序编程时如果对字符串进行拼接实际上会产生新的字符串这样不仅会降低程序的执行效率还会创建出多余无用的字符串垃圾造成内存过度消耗严重时还会造成内存泄漏。这样非常不好。那么有没有更好的解决方案来应对字符串可变化的这一需求呢当然是有的它就是我们要说的 StringBuilder。StringBuilder 我们可以把它看作是一个容器创建之后内容是可变的它可以很大的提高我们对字符串的操作效率。StringBuilder有两个常用的构造方法一个无参构造一个带参构造(在创建时可以添加一个字符串进去)如下图所示StringBuilder 的常用方法如下图所示其中较为常用的是 append() 添加方法与 reverse() 反转方法如下图我创建了一个StringBuilder对象并可以调用了append方法对内容进行添加修改输出结果为 “aaabbbccddd”与预期结果一致这里需要注意一点StringBuilder 所创建的对象并不是字符串因此 StringBuilder 所创建的对象不能调用 String 类的方法它只是一个可变的容器可以简单地理解成一个装字符串的数组我们看下图通过 . 的方式调用IDEA编辑器并没有给出相应的关于 String 类的操作方法如果想要使用字符串的方法我们需要对 StringBuilder 的对象进行转化调用 toString 方法即可将StringBuilder 的对象转化为字符串对象。如下图所示这里需要注意调用了 toString() 方法并不是将原来的 StringBuilder 对象变成了字符串而是在底层又 new 了一个新的字符串下面将源码时会细说转化完成之后我们可以看到现在已经可以调用 String 字符串的相关方法了。五. StringBuilder 的使用场景通过上面的讲述我们知道了 通过 StringBuilder 可以达到我们对字符串内容修改的目的那么同样就引申出来了StringBuilder 的使用场景可以看到在 4 中我列举了StringBuilder的常用方法基于这些方法我们通常会使用StringBuilder完成以下几种目标1基于 append 方法完成字符串的拼接2基于 reverse 方法完成字符串的反转六. 字符串存储的内存原理通过上面我对字符串常量池的讲解相信这个时候大家再看这个问题就已经非常简单了吧。创建字符串我们有两种方式创建方式的不同也标志着它们在内存中的原理不同。6.1 方式一使用 直接赋值的方式创建这种方式创建的字符串会存放在堆内存中的字符串常量池中可以重复使用在底层不会重复创建 String 对象提高了变成效率还降低了内存的使用是我们推荐的使用方法。6.2 方式二使用 new 关键字的方式 new 一个字符串对象这种方式创建的字符串会存放到堆中不会进入字符串常量池无法重复使用这种方式还会增大内存消耗是开发过程中不建议使用的一种方式。七. 字符串拼接的底层原理我们都知道有时候我们会对字符串做一些拼接操作在拼接字符串时大多可以分为以下两种情况。情况一等号右边没有已经定义好的字符串变量如下图所示这种情况相对来讲比较简单它在编译的时候会自动触发优化机制虽然我们定义的时候是String s a b c但在编译过程中就已经得到最终结果了它在编译时期会自动将 abc合并也就是说在编译成 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之前效率提升的并不算多我来简单说一下如下图定义 s1s2s3 三个字符串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 具体结果就不需要我再过多解释了吧看懂了我上面我举得例子这个小题就是小菜一碟。八. StringBuilder提高效率的原因其实这里主要是对上面的总结通过上面我对StringBuilder 这个类的讲解以及举例我们应该知道了StringBuilder在继续字符串拼接时会创建一个容器当我们要进行拼接时直接将要拼接的内容添加到容器内部即可不需要创建多余的对象从而提高了效率也降低了内存消耗。九. 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 NCtrl F12即可查看源码我们先来看 StringBuilder 类的无参构造方法这里指定了 capacity 16先记住这个16我们点入 super 跟入查看这里又定义了一个 char 数组在上图方法中又将 capacity 16 传给了value由此不难看出无参构造底层就是创建了一个长度为16的字符数组名为 value 。9.3.2 append 追加方法源码分析这里我们找到 append 方法的源码点进跟入查看我们发现在 append 方法中首先做了非空判断我们看一下这里发现它其实是把 null 当作了 null 四个字符存进去了这里的 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的容量如果采用扩容方法之后仍无法满足最小容量的需求则容量会以实际所需的容量为准