彻底研究String
String是很常用的类型,但有的同学在使用过程中存在一些误区,导致效率低下,在此对其机制进行一个彻底的讨论,水平有限,如有不同的见解请留言讨论。
String
[SerializableAttribute]
[ComVisibleAttribute(
true
)]
public
sealed
class
String : IComparable,
ICloneable, IConvertible, IComparable
<
string
>
, IEnumerable
<
char
>
,
IEnumerable, IEquatable
<
string
>
String的创建
String 是引用类型,其地址是在托管堆上分配的,而值类型的地址是在计算栈上分配的。String是密封的(sealed),因此不可以直接继承String类来创建另一个版本的String。
MS 对String类型进行了特殊的优化,以提高其效率和方便性。创建String实例可以跟其他基本类型一样,直接赋值即可。
string
str
=
"
Hello String!
"
;
相应的IL代码为:
ldstr
"
Hello String!
"
IL中实例化一个String用的是ldstr(load string)指令,而不是调用通常构造类型的newobj指令。
ldstr指令负责字符串实例的初始化和在托管堆上的地址分配,返回指向该字符串地址的指针。
String类型提供了几个个构造方法,可以使用unsafe的char*或Sbyte*构造String对象,也可以由字符数组构造String对象等。
String的不变性
String的实例在corlib.dll外部来说是只读的(在corlib.dll内部有一些声明为internal的方法可以对String实例进行修改操作,这些方法供StringBuilder等使用),在其生命周期内是恒定不变的,对字符串的改变(ToUpper,SubString,拼接字符串等等)会导致新字符串对象的创建,旧字符串的回收,给GC造成压力。
string
s1
=
"
Hello String!
"
;
string
s2
=
s1.ToUpper();
Console.WriteLine(
string
.ReferenceEquals(s1, s2));
ReferenceEquals方法可以判断两个变量引用的是不是同一个对象,由于String的不变性,上面的代码会输出false。
字符串的不变性不会导致线程同步问题,也就是它是线程安全的。
字符串的长度、字符串的字符索引都是只读的,对其改变会出现编译错误。
string
str
=
"
Hello String!
"
;
str.Length
=
10
;
//
error: Property or indexer 'string.Length' cannot be assigned to -- it is read only
str[
0
]
=
'
h
'
;
//
error: Property or indexer 'string.this[int]' cannot be assigned to -- it is read
字符串驻留
字符串驻留又称为:字符串留用、字符串拘留等。
字符串驻留是指:在应用域(AppDomain)范围内将某些字符串放入驻留池内,此后应用程序创建字符串时,如果相同的字符串存在在驻留池,将直接返回驻留池内该相同字符串的引用,而不需要创建新的字符串实例。可见字符串驻留机制是建立在字符串不变性的基础之上的,如果没有字符串不变性这条属性,将产生不可预料的后果。
string
s1
=
"
Hello String!
"
;
string
s2
=
"
Hello String!
"
;
Console.WriteLine(
string
.ReferenceEquals(s1, s2));
上面代码的输出为:true,正是字符串驻留的体现。String类型有两个静态方法与字符串驻留操作相关。
public static string Intern(string str)
如果 str 的值已经存在在字符串驻留池,则返回该字符串的引用;否则返回含有 str 值的字符串的新引用。
public static string IsInterned(string str)
如果 str 存在在字符串驻留池中,则返回字符串驻留池中该字符串的引用;否则返回null。
string
s1
=
"
Hello
"
;
string
s2
=
s1
+
"
String!
"
;
Console.WriteLine(
string
.IsInterned(s1)
??
"
null
"
);
//
输出:Hello
Console.WriteLine(
string
.IsInterned(s2)
??
"
null
"
);
//
输出:null
要显式关闭字符串驻留机制,FCL提供了一个特性:
public class CompilationRelaxationsAttribute : Attribute
用CompilationRelaxations.NoStringInterning枚举来指定关闭字符串驻留机制。这个特性是应用在程序集级别的,其使用语法为:
[assembly:CompilationRelaxations(CompilationRelaxations.NoStringInterning)]
事实上,C#编译器默认的是关闭了字符串驻留机制的,因为在程序执行过程中或许会产生大量的临时字符串,如果都加入到程序集的字符串驻留池,驻留字符串的查找会耗费大量时间。字符串一旦加入驻留池,其生命周期跟该应用域的生命周期相同,在应用域存在的过程中,驻留的字符串会一直占用大量的内存。因此在运行时,字符串驻留机制弊大于利。在某些时候(如处理数据量大文本时)为了优化性能可以自己控制字符串加入驻留池。
string
s1
=
new
string
(
'
a
'
,
10000
);
string
.Intern(s1);
string
s2
=
new
string
(
'
a
'
,
10000
);
Console.WriteLine(
string
.IsInterned(s2)
??
"
null
"
);
上面的代码输出10000个'a',10000个'a'的字符串在内存中只有一份拷贝,如果把代码行2注释掉,10000个'a'的字符串会在内存中存在两份拷贝,代码会输出null。
有的同学会问,既然编译器关闭字符串驻留,为何前面的例子的字符串会驻留?原因是在编译前定义的字符串直接量会存在在程序集的元数据中,运行时它门反正要进入内存,不如把它们加入应用域的字符串驻留池提高性能。当然,不能过于依赖默认的字符串驻留机制,说不定以后的CLR版本会目前默认的字符串驻留机制进行改变。
在程序代码中出现string s = "Hello" + " String!";编译器会把"Hello" + " String!"作为"Hello String!"来处理,这是编译器优化的结果,也就是编译中已经存在"Hello String!"直接量,并加入到程序集元数据中,因此会看到"Hello String!"已经驻留。
字符串驻留池是应用域内CLR维护控制的,其数据结构是哈希表(Hashtable),其中键是字符串的直接量,值是该字符串的引用,查找一个字符串是否已驻留时,先查找与该字符串长度相同的驻留字符串,其他的忽略,找到相同长度的字符串后再逐字符比较(二进制值),如果相同,返回驻留字符串的引用,否则,返回null。
在使用字符串时有个疑问:在与不安全代码互操作是会不会破坏字符串的不变性?
下面的代码回答了这个问题:
unsafe
{
char
*
ch
=
stackalloc
char
[
100
];
for
(
var
i
=
0
; i
<
100
-
1
; i
++
)
{
ch[i]
=
(
char
)(i
+
1
);
}
ch[
99
]
=
'
\0
'
;
string
s
=
new
string
(ch);
Console.WriteLine((
long
)ch);
//
68479972
ch[
2
]
=
'
c
'
;
string
s1
=
new
string
(ch);
Console.WriteLine((
long
)ch);
//
68479972
Console.WriteLine(
object
.ReferenceEquals(s, s1));
//
false
}
ch的地址可能每次都与上面不一样,但两次输出结果相同。
字符串拷贝操作
对一个对象进行拷贝可以调用object保护的MemberwiseClone()方法,要实现深层次拷贝,可实现ICloneable接口,但string类型实现了ICloneable接口,但实现的却是浅层拷贝。在一些极特殊的情况下,要返回含有相同值的字符串,可以用String.Copy方法。
string
s
=
"
Hello String!
"
;
string
s1
=
(
string
)s.Clone();
string
s2
=
string
.Copy(s);
string
s3
=
s.ToString();
string
s4
=
s.Substring(
0
);
Console.WriteLine(
string
.ReferenceEquals(s, s1));
//
true
Console.WriteLine(
string
.ReferenceEquals(s, s2));
//
false
Console.WriteLine(
string
.IsInterned(s2)
??
"
null
"
);
//
Hello String!
s2
=
string
.Intern(s2);
Console.WriteLine(
string
.ReferenceEquals(s, s2));
//
true
Console.WriteLine(
string
.ReferenceEquals(s, s3));
//
true
Console.WriteLine(
string
.ReferenceEquals(s, s4));
//
true
上面的结果是否在你预料之内呢?
字符串连接操作
字符串连接是非常常见的操作,但每次连接,都导致新对象的产生,其步骤大体如下(.NET 的实现可能有一些差别):
s += s1;
1.分配足够多的临时存储空间temp。
2.将s复制temp的起始处,s1复制到temp的结束处。
3.释放s原来的空间,交给GC处理。
4.为s分配足够的空间,将temp复制到s的新的存储空间。
每次分配都牵涉到存储空间的分配和释放,如果字符串连接过多,会严重影响执行效率,因此最好用StringBuilder来处理(下一篇介绍)。
字符串比较
尽量使用String定义的比较操作的方法。许多种字符串比较的静态方法和实例方法以及这些方法的重载,如果与区域无关的比较建议使用StringComparison.Ordinal或StringComparison.OrdinalIgnoreCase选项。有区域有关的比较建议使用StringComparison.CurrentCulture或StringComparison.CurrentCultureIgnoreCase选项。尽量不要使用StringComparison.InvariantCulture或StringComparison.InvariantCultureIgnoreCase选项,因为这个选项会慢很多。
参考资料:
MSDN
Applied Microsoft .NET Framework Programming