目录
前言
最近在研究string的时候,发现StringBuilder底层实现原理很有意思,故作记录。
什么是StringBuilder
- StringBuilder是一个类,是System.Text命名空间下的一个类。
- StringBuilder主要用于处理字符串拼接。
由于string的不可变性,导致每一次做字符串拼接的时候,都会在托管堆上new一个新的字符串,也就是说它会造成GC。
而字符串拼接可以说是非常常用的,所以为了避免造成过多的GC,可以使用StringBuilder先进行字符串拼接,再使用ToString方法转换为字符串。
StringBuilder的成员
测试环境:Unity
StringBuilder sb = new StringBuilder();
我们可以查看StringBuilder的内部结构
我们目前只需要知道几个常用的成员就行了
成员 | 意义 |
---|---|
Capacity | 字符数组m_ChunkChars的最大容量 |
Length | 当前StringBuilder对象实际管理的字符串长度 |
m_ChunkChars | 保存StringBuilder所管理着的字符串中的字符 |
m_ChunkOffset | 字符定位的偏移量 |
m_ChunkPrevious | 指向上一个StringBuilder对象 |
StringBuilder增加元素原理
sb.Append(1); // 将 1 元素加入到StringBuilder对象里
sb.Append(2); // 将 2 元素加入到StringBuilder对象里
我们很容易看出来,StringBuilder底层其实是管理着一个char数组
当我们使用Append方法向StringBuilder中添加元素时,发现它是向字符数组中添加元素
StringBuilder扩容原理
当我们字符数组存不下元素的时候,也就是元素个数大于Capacity的时候,StringBuilder就会触发扩容机制
为了方便,我们在定义StringBuilder对象的时候,指定Capacity为1
StringBuilder sb = new StringBuilder(1);
for(int cnt = 0; cnt <= 5; cnt++) {
sb.Append(cnt);
}
Capacity:1,元素数量:0
记住此时:m_ChunkPrevious 对象为null
Capacity:1,元素数量:1
Capacity:2,元素数量:2
注意!
我们发现原本Capacity = 1的时候,想要再加入元素1的时候,容量已经不够了,所以这里发生了扩容,并且Capacity是扩大了一倍,也就是变为原来的两倍了
而且我们发现 m_ChunkPrevious 对象不为null了
更仔细一点,我们会发现,m_ChunkPrevious对象中的字符数组,存的数组是我们还没有添加元素为1时候的数组
我们画下图吧
我们继续增加元素
Capacity:4,元素数量:3
由于元素数量 = 3 > Capacity = 2,所以再次发生扩容,Capacity = 4
我们也能够发现,生成了一个新的StringBuilder对象,原来元素为1的数组,变成了新的StringBuilder对象的 m_ChunkPrevious 对象
我们现在应该很清楚了,StringBuilder底层其实是数组存储元素,链表处理扩容,并且是头插法
Capacity:4,元素数量:4
Capacity:8,元素数量:5
由于元素数量 = 5 > Capacity = 4,所以再次发生扩容,Capacity = 8
StringBuilder底层总结
数组存储元素,链表处理扩容
关于Java的拓展
C#和Java的处理不一样,底层都是由数组存储,但是Java扩容是直接新建一个数组,大小为原来的两倍。这里我认为关于扩容方面,C#处理的比Java更好,原因是Java是重新开辟两倍原来的Capacity大小的数组,而C#只开辟一倍原来的Capacity大小的数组,而且Java要把原来的元素完全复制过来,而C#不需要
所以C#底层应该是这样子的结构
使用解读
方法 | 意义 |
---|---|
Append方法及重载 | 添加元素 |
Insert方法及重载 | 向指定位置插入元素 |
Replace方法及重载 | 使用新元素替换老元素 |
Remove方法 | 从指定索引位移除指定数量的字符,它没有重载。方法Insert、Replace和Remove都是对内部字符数组m_ChunkChar和链表中m_ChunkPrevious内的字符数组m_ChunkChar操作,StringBuilder内部实现有点“绕”,感兴趣的可以自行去研究 |
ToString方法 | StringBuilder重写了基类Object的ToString()方法用来获取StringBuilder对象的字符串表示,它是将链表m_ChunkPrevious中的字符数组m_ChunkChars及当前StringBuilder对象的字符数组m_ChunkChar中的字符转成String对象返回,这一步是创建一个新的String对象,所以对这个String对象(ToString()的结果)的操作不会影响到StringBuilder对象内部的字符 |
ToString方法解析
需要着重注意下ToString方法
由于我们是头插法处理链表,也就是我们需要倒序遍历链表
比如我们上一个例子
我们想调用StringBuilder对象的ToString方法
输出的应该是:01234
我们来看看源码
// 核心步骤一,长度相关:
public int Length
{
[__DynamicallyInvokable]
get
{
/******** 新建stringBuilder时,会有两个参数(offset+数组长度)*********/
return m_ChunkOffset + m_ChunkLength;
}
}
public unsafe override string ToString()
{
if (Length == 0)
{
return string.Empty;
}
// 新开辟一个新的数组空间
// FastAllocateString函数负责分配长度为Length的空字符串
string text = string.FastAllocateString(Length);
StringBuilder stringBuilder = this;
// fixed 使用指针的关键字
fixed (char* ptr = text) // 新开辟空间的数组,堆地址赋给指针变量ptr
{
// 整个 do-while 倒序遍历单向链表
// 顺序:4 -> 23 -> 1 -> 0
do
{
if (stringBuilder.m_ChunkLength > 0)
{
char[] chunkChars = stringBuilder.m_ChunkChars;
int chunkOffset = stringBuilder.m_ChunkOffset;
int chunkLength = stringBuilder.m_ChunkLength;
// 长度超出了int最大值或者大于新开辟空间的长度
// 例如数组长度刚刚好是int最大值,这个后Append两个字符
if ((uint)(chunkLength + chunkOffset) > text.Length
||
(uint)chunkLength > (uint)chunkChars.Length)
{
throw new ArgumentOutOfRangeException("chunkLength", Environment.GetResourceString("ArgumentOutOfRange_Index"));
}
// 当前stringBuilder它的char[]的指针,堆地址赋给指针变量smem
fixed (char* smem = chunkChars)
{
// CLR公共语言运行时 原生提供(copy),将当前char[]元素克隆到新开辟的空间去
// ptr + chunkOffset:ptr指的是开头,第三个char[]是放在后面位置的,所以要添加偏移量offset
// smem:当前char[]数组指针(引用地址)
// chunkLength: 当前char[]数组被使用的长度(被占用)
string.wstrcpy(ptr + chunkOffset, smem, chunkLength);
}
}
// stringBuilder = 上一个stringBuilder(也就是第二个stringBuilder)
stringBuilder = stringBuilder.m_ChunkPrevious;
}
while (stringBuilder != null);
}
// 最后都添加到最初新开辟的空间数组里去:text
return text;
}
关于Java的拓展
由于Java底层就是一个字符数组,扩容不像C#用链表处理
所以在ToString的时候,Java可以非常容易的new一个字符串,然后把字符数组复制到新的字符串中,然后返回
所以不能说C#一定处理的比Java好,各有优势,毕竟大家都发展这么久了,肯定都有自己的理由
总结
- StringBuilder底层是字符数组
- 扩容使用链表处理,并且是头插法
PS:尾插法不是更容易理解和处理吗?难道有什么特殊处理?