飘遥的Blog

C/C++/.NET
  博客园  :: 首页  :: 新随笔  :: 联系 :: 订阅 订阅  :: 管理

彻底研究String

Posted on 2008-09-15 22:45  Zzx飘遥  阅读(5694)  评论(25编辑  收藏  举报
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); // 90172320

    
fixed (char* str = s)
    {
        Console.WriteLine((
long)str); // 21017516
    }

    ch[
2] = 'c';
    
string s1 = new string(ch);

    Console.WriteLine((
long)ch); // 90172320

    
fixed (char* str = s1)
    {
        Console.WriteLine((
long)str); // 21059776
    }
    Console.WriteLine(
object.ReferenceEquals(s, s1)); // false
}
上面的代码可以看出:由字符数组创建字符串时复制字符数组的内容来创建新的字符串,并非原先理解的在字符数组的基础上创建。
继续上面的问题,用下面的代码验证并得出答案。
static void Main(string[] args)
{
    
unsafe
    {
        
string s = "Hello String!";
        
string s1 = s;

        Console.WriteLine(
string.
            ReferenceEquals(s, s1));
// True
        Console.WriteLine(s);        // Hello String!
        Console.WriteLine(s1);       // Hello String!

        ShowAddress(s);              
// 20656236
        ShowAddress(s1);             // 20656236

        ChangeString(s);            
// 20656236

        ShowAddress(s);              
// 20656236
        ShowAddress(s1);             // 20656236

        Console.WriteLine(
string.
            ReferenceEquals(s, s1));
// True
        Console.WriteLine(s);        // HeZlo String!
        Console.WriteLine(s1);       // HeZlo String!

    }

    Console.ReadKey();
}

// 输出字符串的起始地址
public unsafe static void ShowAddress(string s)
{
    
fixed (char* p = s)
    {
        Console.WriteLine((
int)p);
    }
}

// 更改字符串的第三个字符为Z
public unsafe static void ChangeString(string s)
{
    
fixed (char* p = s)
    {
        p[
2] = 'Z';
        Console.WriteLine((
int)p);
    }
}
在与不安全代码互操作是会不会破坏字符串的不变性?通过上面的例子可以看出,答案是会!
 
字符串拷贝操作

对一个对象进行拷贝可以调用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

Author: 飘遥(周振兴)
Blog: http://Zxjay.cnblogs.com/
URL: http://www.cnblogs.com/zxjay/archive/2008/09/15/1291274.html