字符串特性
定义
字符串(string)实际上就是字符的几何(char[]),其作为基元类型,对应System.String,其类型定义如下:
public sealed class String : IComparable, ICloneable, IConvertible, IComparable<String>,
IEnumerable<char>, IEnumerable, IEquatable<String>
由此可见字符串是一个类结构,而不是结构体,说明字符串是引用类型,同时也可以通过字符串默认值为null这一点可以看出,字符串是一个引用类型,所以针对字符串的操作会牵扯到堆上的内存分配活动
在我们使用的过程中,往往字符串的使用方式与值类型像是,然而字符串被微软设计为一个密封类(且具有不变性),是基于一下的原因:
- 一个线程栈的大小只有1MB空间,其他值类型的长度是固定,但是字符串的长度是不定的,可能是十分巨大的
- 字符串如果被设计为值类型,则方法传入字符串将会拷贝其值而不是引用,如果字符串长度巨大,则会影响性能
- 不变性使得字符串是线程安全的
- 字符串驻留节省内存,而它只能借助不可变性来实现
字符串与普通引用类型的比较
字符串的使用方式与值类型很相似:
- 字符串通过"=="进行互相比较,且比较的是字符串的值,而不是其是否指向同一个堆上的地址
- 字符串被传入另一方法后,在另一方法内部进行修改,执行完毕之后,外部字符串的值不会被改变
对于第一点的实现,字符串的**"==“与”!="操作符以及Equals**方法被重写为比较字符串的值而不是引用
字符串的不变性
什么是字符串的不变性
一般情况下,我们将“一经赋值,其值就不能被更改”视为不变性,字符串的不变性就是指,字符串一经赋值,其值就不能被更改,当通过代码使字符串变量等于一个新的值的时候,堆上会出现一个新的字符串,然后,栈上的变量指向新的字符串,没有办法更改原来字符串的值,可参考下图进行理解
同样我们还可以通过C#代码来验证字符串的不变性
static void ProveImmutable()
{
string a = "a";
string b = a;
// true
Console.WriteLine(ReferenceEquals(a, b));
b = "2";
// false
Console.WriteLine(ReferenceEquals(a, b));
}
同时我们可以查看System.String类型的成员来证明其不变性
字段类型中除了若干常量之外就剩下内容:
- 一个readonly的Empty,值为空
- 私有的m_stringLength,无法被外界更改
属性有: - Chars[Int32],代表字符串成员
- Length,代表字符串长度
public static readonly String Empty;
public char this[int index] { get; }
public int Length { get; }
这些属性都是只读的且变量都是私有无法修改的,所以字符串的值是无法改变的
不变性设计的原因
- 字符串的不变性允许在一个字符串上执行各种操作而不实际地更改它,这就使多个线程操作字符串不会出现同步问题,因为值无法被修改。
- 不可变性结合字符串驻留池可以节省内存,如果字符串是可变的,驻留池中多个字符串指向同一块内存,如果改变其中一个字符串的值,其他指向这个值的字符串也会一起改变
字符串驻留池
CLR管理了一个字符串驻留池,在要建立新的字符串之前会探测这个驻留池。如果发现已经有相同值的字符串存在,则不新建字符串,而是让新旧两个字符串变量在栈上指向同一个堆上的字符串值
可通过如下代码进行验证:
var s1 = "123";
var s2 = "123";
Console.WriteLine(Equlas(s1, s2)); // true
Console.WriteLine(ReferenceEquals(s1, s2)); // true
常量的字符串相加优化
常量的字符串相加会被编译器优化,不会先建立多个字符串,然后再建立一个较长的字符串存储相加的结果,而是进进建立一个字符串存储相加的结果,验证如下:
string st1 = "123" + "abc";
string st2 = "123abc";
Console.WriteLine(st1 == st2); // true
Console.WriteLine(ReferenceEquals(st1, st2)); //false
若是常量与变量相加则有所不同:
string s1 = "123";
string s2 = s1 + "abc";
string s3 = "123abc";
Console.WriteLine(s2 == s3); // true
Console.WriteLine(ReferenceEquals(st1, st2)); //false
因为变量和常量相加的动作不会被编译器优化,此时堆上的的字符串有"123",“abc”,“123abc”,“123abc”(没有优化过无法参加驻留),所以尽量使用常量相加的方式来代替变量与常量相加来达到字符串驻留的效果,驻留池的默认行为包括:
- 常量直接驻留,包括String.Empty
- 常量的相加直接驻留
- 常量和变量相加不驻留,也不探测驻留池
有关驻留池的方法
- string.IsInterned(string):接收一个输入字符串,如果该字符串在驻留池中,如果存在返回指向驻留池的引用,否则返回null
- string.Intern(string):显示地将某一个字符串拉入驻留池
使用案例如下:
static void Intern1()
{
string s1 = "abc";
// 常量默认驻留
Console.WriteLine(string.IsInterned(s1) ?? null);
// 编译器优化为常量相加
string s2 = "ab";
s2 += "c";
Console.WriteLine(string.IsInterned(s2) ?? null);
// 变量和常量相加不驻留,不打印
string s3 = s2 + "c";
Console.WriteLine(string.IsInterned(s3) ?? null);
}
static void Intern2()
{
string s1 = "123";
string s2 = "12";
//变量和常量相加不探测驻留池
string s3 = s2 + "3";
//false
Console.WriteLine(ReferenceEquals(s1,s3));
//将s3拉入主流次后
s3 = string.Intern(s3);
//true
Console.WriteLine(ReferenceEquals(s1,s3));
}
驻留池实现结构
驻留池是一个由CLR维护的哈希表,其键值就是字符串本身,而值则为字符串被分配的内存地址
字符串的相加
我们通过上文知道,对字符串的操作会导致堆上出现大量的字符串实例,而其中大多数都会称为垃圾,为了避免这个问题,.NET提供了StringBuilder,其作为一个可变的对象,对它进行操作不会新建实例
StringBuilder的使用
对StringBuilder的使用需要注意,尽量给它指派一个初始长度,否则它将指定一个较小的长度,然后当长度不够时将自己的长度扩容(容量翻倍),扩容的步骤如下:
- 在内存中分配一个更大的空间
- 将现有的字符串复制过去
这样的话,现有的字符串会称为垃圾,且扩容的过程会消耗性能
性能比较代码:
private static readonly string STR = "1234567890";
private static string UseNormalConcat(int count)
{
var result = "";
for (int i = 0; i < count; i ++)
{
result += STR;
}
return result;
}
private static string UseStringBuilder(int count)
{
var builder = new StringBuilder();
for (int i = 0; i < count; i ++)
{
builder.Append(STR);
}
return builder.ToString();
}
static void Main(string[] args)
{
Stopwatch watch = new Stopwatch();
int count = int.Parse(Console.ReadLine());
watch.Start();
UseNormalConcat(count);
watch.Stop();
Console.WriteLine("Concat:" + watch.ElapsedMilliseconds);
watch.Restart();
UseStringBuilder(count);
watch.Stop();
Console.WriteLine("StringBuilder:" + watch.ElapsedMilliseconds);
Console.ReadLine();
}
通过count控制相加次数可以得出如下结果,单位毫秒:
count | 1 | 10 | 100 | 1000 | 10000 |
---|---|---|---|---|---|
Concat | 0 | 0 | 0 | 6 | 199 |
StringBuilder | 0 | 0 | 0 | 0 | 0 |
可看出当迭代次数较小时,StringBuilder的性能和简单地使用+运算符大致相同,此时可以不用StringBuilder,但当迭代次数较大时,StringBuilder的效率则大大高于Concat
Concat性能瓶颈
使用Concat相加字符串时,都需要在内存中开辟一个新的空间,然后把被加者和加者一起拷贝进去,而非简单的在被加者后面append,所以叠加多少次则需要开辟多少次内存,且由于字符串的不变性,还会造成堆上出现大量的垃圾
Concat使用优化
如果一开始就申请一块很大的内存,那么Concat的性能会大大提高,并且减轻GC压力,Concat可以接收一个数组,并一次性将它们拼接起来
private static string StringConcat(int count)
{
var array = new string[count];
for (int i = 0; i < count; i++)
{
array[i] = STR;
}
return string.Concat(array);
}
count | 1 | 10 | 100 | 1000 | 10000 |
---|---|---|---|---|---|
Concat | 0 | 0 | 0 | 6 | 199 |
StringBuilder | 0 | 0 | 0 | 0 | 0 |
StringConcat | 0 | 0 | 0 | 0 | 0 |
可发现这样优化之后的性能与StringBuilder相差无几,Concat方法如果能够提前计算好整个字符串的长度,则可以省去内存分配的消耗,改进了性能