C# /JAVA: 字符串构建利器StringBuilder区别
前言
本章笔记直接记录的string、StringBuilder内存存储原理,并没有大幅度、重点的去介绍堆、栈、常量池的相关底层实现原理。
所以,为了帮助大家更好的去理解,可以在阅读本文章前去了解堆、栈、常量池之间的基础关系,对以下的阅读有很大的帮助。
名词解释
栈:存放变量(值类型)
堆:存放对象(引用类型)
常量池:它是一个Hash表。
为了提升性能和减少内存开销,避免字符串的重复创建,所以开辟出来一个单独的内存空间,就是字符串池。
字符串常量池是由String类私有的维护。
八大基本类型
: byte、short、int、long、boolean、float、double、char
四大引用类型
:数组、class、interface、字符串(string
一、String
1.1 示例
案例一: 不同变量赋值( = )
String str1 = "Hello" ;
String str2 = "Hello" ;
System.out.println(str1 ); //结果: Hello
System.out.println(str1 ); //结果: Hello
- 情况1:不存在
变量
str1 会存放
在栈
中,- 首先在
常量池
中进行查找“Hello”是否存在。 - 不存在时,会在常量池中以
键值对
格式创建<key,value>,value则指向堆中的“Hello”对象指针。 - 从而,str1的
引用地址
就是堆中0x0001对应的“Hello”指针
。
- 情况2:存在
变量
str2 会存放在栈
中,- 首先在
常量池
中进行查找“Hello”是否存在。 存在
时,会继续使用常量池中已存在的key,不会再新建。也就是 str2使用常量池中hello指向堆中的0x0001对应的“Hello”指针
。- 与str1的引用地址是一个。
案例二:相同变量赋值( = )
我们都知道String
属于类,它是不可变
的。即一旦一个String对象被创建以后便不能被更改、变长
、修改;直至这个对象被销毁。
不可变 : 文章下方会有专门的讲解
下面写了一个小例子,如下方所示:
String str1 = "Hello" ;
str1 = "Word" ;
//打印出来的str1为: Word
System.out.println(str1);
看到这里,可能就会有疑问:不是不能被修改吗?怎么会对他进行了修改?
针对这个问题,我画了一张底层实现原理图,希望能够帮助到大家。如图所示:
前面我们说到了第一次str1赋值“Hello”,在常量池中创建后,其value指向对象指针
;
从图中可以看出,再次给str1赋值“Word”时,并不是对原来堆中的实例对象进行重新赋值,而是生成一个新的实例对象,并且str1的引用地址变指向了这个新的“Word”这个字符串。
之前的实例对象“Hello”依然存在,只是不再被引用了而已;如果没有被再次引用,则会被垃圾回收。
但是吧,在这里暂时不回被回收(垃圾回收不能释放被Hash表中引用的字符串,因为Hash表中正在容纳对他们的引用。除非进程终止)
案例三:变量追加赋值( += )
前面提到了String
属于类,它是不可变
的。即一旦一个String对象被创建以后便不能被更改、变长
、修改;直至这个对象被销毁。
小案例:
String str1 = "Hello" ;
str1 + = " Word" ;
//打印出来的str1为:Hello Word
System.out.println(str1)
看到这里,可能就会有疑问:不是长度不可以变吗?为什么变量str1的长度会增加?会被修改?
针对这个问题,我又画了一张底层实现原理图,希望能够帮助到大家。如图所示:
公式变化:
str1在追加赋值+=“Word”
时,(图中为了好看,堆地址0x0003对应的字符串对象我添加了空格,不要被误导了哈~)实际上是str1+“Word”
(str1指向Hello) → Hello+Word
→ " HelloWord
"。
底层变化:
- str1 中的“Hello”、“Word” 在常量池中没有,所以需要在常量池中创建对应key,其value(0x0001、0x0002)指向堆中的对象指针(Hello、Word)。
- 前面也提到了,实际上str1 = str1+“Word”,而这一步操作是
隐式操作
,不走字符串常量池的。 - 也就是说:Hello+Word 是在
堆中相加
的,生成了新的对象
。 - 其新生成堆地址0x0003(对象在堆中地址)会赋给str1。
- str1 根据这个地址去找到对象 “ HelloWord ”。(之前的实例对象“Hello”“Word”依然存在,只是不再被引用了而已)。
1.2 常量池扩展(IsInterned、ReferenceEquals)
有关常量池动态机制在此处查看: string常量池/驻留池——动态机制
这两个都是在C#中提供的方法,java的我还没有使用过。之后的java练习中找到了平替将会及时补充本文哈。
Object.ReferenceEquals() 确定实例是否为同一实例
ReferenceEquals 方法是 Object类的静态方法
,不能被改写。该方法可以比较两个引用类型的引用是否指向用一个实例。
案例一:
> string aa = "Hello";
> string bb = "Hello";
> aa == bb
//判断变量是否相等。返回结果为:true
true
案例二:
> object.ReferenceEquals(aa,bb)
//判断两个变量引用地址是否相同
true
在这里我们可以使用object.ReferenceEquals(aa,bb) 来判断aa与bb是否是使用的一个常量池中的字符串,是否指向同一个对象指针。
由于ReferenceEquals()是判断两个对象的引用是否相等
- 对于
值类型
,因为每次判断前都必须进行装箱操作,也就是每次都生成了一个临时的object,因而永远返回false。- 对于2个
引⽤类型
,ReferenceEquals则会⽐较它们是否指向同⼀地址。(特殊情况是两个都是null的话,会返回true)
String.IsInterned() 判断字符串是否存在内部池中
String.IsInterned(验证是否在常量池中),用来判断一个字符串是都已在常量池中。如果存在,返回该字符串;反之则返回null。
案例一:
> string aa = "Hello";
> string.IsInterned(aa)
"Hello" //正确输出“Hello”,说明常量池中存在“Hello”
案例二:
> string.IsInterned(aa.ToUpper())
null //因为常量池中不存在“HELLO”,故返回:null
//因为常量池中不存在“HELLO”,所以其对象、引用地址都为false
> aa == aa.ToUpper()
false
> object.ReferenceEquals(aa,aa.ToUpper())
false
二、StringBuilder 底层实现(C#/JAVA)
StringBuilder来源:
前面提到了String是不可变的对象。这就相当于每次对字符串进行 +
或 +=
操作的时候会产生一个新的String实例。
对于大量进行拼接的场景非常不友好。因此,StringBuilder诞生~~~~ 撒花撒花~~~
2.1 stringBuilder是什么?
StringBuilder是一个可变的字符序列
。此类提供一个与StringBuilder兼容的API,但不保证同步。
该类被设计用作StringBuilder的一个简易替换,用在字符串缓冲区
被单个线程
使用的时候。
StringBuffer就是为了解决大量拼接字符串时产生很多中间对象问题而提供的一个类,提供 append
和 add
方法,可以将字符串添加到已有序列的末尾或指定位置。
它的本质是一个线程安全
的可修改的字符序列,把所有修改数据的方法都加上synchronized
。但是保证了线程安全是需要性能的代价的。
StringBuilder,它和StringBuffer本质上没什么区别,就是去掉了保证线程安全的那部分,减少了开销。
其中StringBuffer是线程安全的。有个小地方需要慎重,就是toString()
方法。
全文例子(下方讲解全都按照这个走,不再重复写)
//方式1 通过构造函数初始化数据
StringBuilder stringBuilder = new StringBuilder("我爱你中国心爱的母亲");
System.out.println(stringBuilder);
//方式2 使用Append追加
StringBuilder builder = new StringBuilder(16);
builder.append("我爱你中国");
builder.append("心爱的母亲");
builder.append(",");
builder.append("我为你流泪");
builder.append("也为你自豪。");
builder.append("我爱你中国");
builder.append("心爱的母亲");
System.out.println(builder);
//方式3 将StringBuilder转换成字符串
String str = builder.toString();
System.out.println(str);
//结果输出
> 我爱你中国心爱的母亲
> 我爱你中国心爱的母亲,我为你流泪也为你自豪。我爱你中国心爱的母亲
> 我爱你中国心爱的母亲,我为你流泪也为你自豪。我爱你中国心爱的母亲
上面例子中简单操作了一个StringBuilder 的简单使用方式,主要操作是使用 Append()
方法和 ToString()
方法。这也是最常用的拼接方式。
2.2 StringBuilder — C#
2.2.1 单项链表
单链表是一种特殊的数据结构
,能够动态的存储
一种结构类型数据。
链表
是通过指针将一组零散的内存块儿串联在一起使用。为了将所有的结点(内存块儿)串联起来。
每个链表除了存储数据data之外,还需要记录链上的下一个结点的地址。如图所示,我们把这个记录下一个结点(后继指针)我们把这个记录下个结点地址的指针叫作后继指针
next。
如图所示,由四个扩容产生的对象 组合在一起,形成了链表。
其中是第一个结点和最后一个结点是比较特殊的。我们习惯性地把第一个结点叫作头结点
,把最后一个结点叫作尾结点
。其中,头结点用来记录链表的基地址。有了它,我们就可以遍历得到整条链表。而尾结点特殊的地方是: 指针不是指向下一个结点,而是指向一个空地址NULL,表示这是链表上的最后一个结点。
下面进入到了对象扩容之间的了(存储数据data + 后继指针next):
该结构可以看成由两个部分组成。分别包含两个部分数据:
- 第一部分data :结点本身的数据
- 第二部分next :指向下一个结点的
指针
(整个stringBuilder对象的地址)
下图是画了一张StringBuilder的大体数据流转情况,希望能够帮助理解。
如图所示:
- stringBuilderB 是stringBuilderA
扩容
出来的char[ ](后面会讲到扩容相关) - 声明变量 builder 通过
堆地址
0x0002(也就是整个stringBuilder对象的地址)找到
stringBuilderB。 - stringBuilderB
后继指针next
存放了stringBuilderA 的引用地址0x0001,也就是结点的指针。(这里就用到了单链表) - 底层实现是循环遍历查找:
- 通过循环,使得stringBuilderB 找到 stringBuilderA 。(会
开辟
一个新的空间
,stringBuilderB放在新数组后方,接着通过stringBuilderB后继指针next
存放引用地址0x0001找到stringBuilderA放到新数组前面,一次类推) - ToString()方法将char[ ] 拼接在一起(会开辟一个总的长的,然后把数组
挨个放进去
),最后输出得到结果。
- 通过循环,使得stringBuilderB 找到 stringBuilderA 。(会
2.2.2 扩容机制
想了解StringBuilder
的扩容机制,还需要从它的Append
方法入手。
只有Append的时候才有机会去判断原有的 char[ ] 长度
是否满足存储Append进来的字符串。
如图所示,从扩容方面讲解:
- 我刚开始初始化了一个char[16],
- 首次Append《我爱你中国 》存放在stringBuilderA 。并未达到16,
未达到
扩容条件。 - 二次Append《心爱的母亲》继续存放在stringBuilderA(因为tringBuilderA 还有11位
空闲着
,遂继续追加)占用下标5~9 。 - …一直这样一个个的追加,直到stringBuilderA 住满了,到达扩容条件。则会重新生成一个新char[ ] stringBuilderB,将
剩余元素
接着存放在其中。后继指针next指向下一个结点的指针
,也就是stringBuilderA对象的地址。 - 其本质就是将
Append
进来的字符串复制
到stringBuilderA数组中去。其字符串字符长度
也代表了stringBuilderA已经使用的长度
; 那么下一次的Append进来的元素,将会接着在上一个Append的后面继续追加
。
如果当前存储块满足存储,则直接使用。
如果当前存储位置不满足存储,那么存储空间也不会浪费,按照当前存储块的可用存储长度去截取需要Append的字符串的长度,放入到这个存储块的剩余位置,剩下的存储不下的字符则存储到扩容的新的存储块stringBuilderB中去,这个做法就是为了不浪费存储空间。
即时要扩容,那么我当前结点的存储块也一定要填充满,不浪费空间,保证了存储空间最大的利用。
如图所示,从底层实现原理方面讲解:
- 声明变量 builder 存放在栈中
- 首次Append(“我爱你中国”) ,将会在
字符串常量池
中搜索是否存在? - 不存在:将在常量池新建
<key,value>
,并在堆中创建string对象。string对象中存放着“我爱你中国”,之后同时会进行两步操作:- 将
指针
,也就是引用地址
0x123返回给字符串常量池进行value绑定。 - 同时返回给Append(也就是stringBuilderA)。
- 将
- stringBuilderA会根据引用地址0x123去找到str1,将里面的
char[ ]拷贝
一份至stringBuilderA中的char[ ]
。 - 以此类推,等到占满了就会进行
扩容机制
,扩容出stringBuilderB。(详情看上方,不再重复作业) - 输出结果:实例对象的
. ToString()
干了什么事?- 会开辟一个
新的空间
char[ ] ,目的是将stringBuilderA+stringBuilderB拼接起来 - 栈中的builder会找到指针(引用地址)0x0002
- 变量builder引用地址指向0x0002;接着继续循环查找出0x0001(通过stringBuilderB
后继指针next
存放引用地址0x0001),根据线索找到stringBuilderA。 - 会开辟一个
新的空间
,循环倒序遍历出来一个char[ ],就挨个放进去,以此类推,直到循环结束。
- 会开辟一个
2.3 StringBuilder — JAVA
Java的实现方式与C#那些常量池什么的几乎一样,只是存储格式(数据结构)+扩容机制有些区别,其他都一样。
区别咱们继续往下看:
2.3.1 char数组(与C#扩展区别)
数据结构
C# 的 StringBuilder 整体结构来说是一个单向链表
。
JAVA的StringBuilder整体结构来说是一个char[ ]
字符数组。
扩展长度
C# 的 StringBuilder 容量和上一个stringBuilder长度有关,每次扩容不固定:max(当前追加字符的剩余长度,min(当前StringBuilder长度,8000))
。
JAVA的StringBuilder容量则是生成一个新的数组
,在原来char[ ]数组长度*2+2
。(可理解为克隆)
底层实现
C# 扩容是单向链表,该结构有结点组成:后继指针next存放 指向下一个结点的指针 ;尾结点存放本身的数据。最后ToString()时,根据后继指针next的指针找到之前的元素,倒序遍历
单项列表,拼接起来。
JAVA 扩容整体都是char[ ]数组:当达到扩容条件,会生成新的数组
(一般是原来的char[ ]长度*2+2),新数组会把旧数组的元素克隆一份给自己
。原来旧的数组没人引用了,就等待着垃圾回收。
2.3.2 扩容机制
刚才大体说了一下JAVA的底层实现机制是在旧数组的基础上,新生成一个新数组。并且克隆旧数组元素给自己。
旧数组没人引用就会等待着垃圾回收。
扩容机制也并不全都是旧char[]数组长度*2+2
。他也会根据追加的长度进行判断,从而减少空间浪费。
对于这个问题,我们可以先进入java内部方法实现中去看(我用的是jdk1.8):
情景回顾:
声明char[16],数组中的16个字符已经被占满了,我现在二次.Append(“…50个字符…”),展开以下扩展路径
> 方法一,Append方法:
public AbstractStringBuilder append (String str)
{
if (str == null) return appendNull();
int len = str.length(); /.****** Append的50个字符长度 ******/
ensureCapacityInternal(count + len); /.****** 16+50=66 原数组长度+现在新Append长度******/
str.getChars(0, len, value, count);
count += len;
return this;
}
> 方法二,扩容条件:
private void ensureCapacityInternal(int minimumCapacity)
{
if (minimumCapacity - value.length > 0) /.****** 66-16>0,也就是66>16,条件成立,进入下一个方法******/
{
value = Arrays.copyOf(value,
newCapacity(minimumCapacity));
}
}
> 方法三,获取扩容之后的数组长度:
private int newCapacity(int minCapacity)
{
int newCapacity = (value.length << 1) + 2; /.****** 16*2+2=34 新扩容长度 ******/
//新扩容的长度 小于 .Append()的长度
if (newCapacity - minCapacity < 0) /.****** 34-66<0,也就是34<66 条件成立 ******/
{
//申请的扩容空间 就等于 你.Append()的长度
newCapacity = minCapacity; /.****** 新扩容长度=66, 取两个最大值 ******/
}
//下方代码判断是否溢出
// 66<=0 || int最大值-8<66
return (newCapacity <= 0 || MAX_ARRAY_SIZE - newCapacity < 0)
? hugeCapacity(minCapacity) //rue 进入溢出代码
: newCapacity; //false 返回刚申请的长度
}
> 方法四,溢出相关:
private int hugeCapacity(int minCapacity)
{
if (Integer.MAX_VALUE - minCapacity < 0) /.******int最大值<大于最大数组长度或者小于等于0******/
{
throw new OutOfMemoryError();// overflow
}
/.******
*如果大于int最大值-8,正好小于int最大值,返回它自己
*反之, 返回int最大值-8,作为该扩展容器长度。(可能也有负数 我也是服了这个老6了)
******/
return (minCapacity > MAX_ARRAY_SIZE) ? minCapacity : MAX_ARRAY_SIZE;
}
三、ToSting()方法
具体的ToString底层实现代码讲解,请移到下方链接进行查看:
链接: stringBuilder.ToString()方法浅谈
结语
要说两种哪一个好,emmmmm不太好说,各有各的好。
C# 的StringBuilder 是单向链表,扩容的时候挺好,充分利用空间,保证了存储空间的最大利用。但是最后ToString()需要循环倒序遍历,最终把结果组装成一个字符串返回。
JAVA 的StringBuilder 是 char[ ] 类型的,扩容的时候,会生成一个新的数组并且克隆旧数组中的元素到自己里面。之前的旧数组没人引用就会等待垃圾回收。
所以,类似数组扩容再copy的逻辑没有链表的方式高效。
最后输出结果的时候因为本身存储在char[ ]中,所以随后输出 Java的StringBuilder优势是非常明显的。
推荐内容
- ToString底层代码解析(C#/JAVA)
分别浅谈 C# / JAVA 中 stringBuilder.ToString()方法底层原理以及区别
附有C# /JAVA 底层源码分析。
- string/stringBuilder常量池(驻留池) java/C#学习
JAVA / C# 详解之:运行时常量池