1. string str = null 和 string str = “” 和 string str = string.Empty 有什么区别?
答案:
这三者都是声明一个字符串变量,但在内存和语义上有重要区别:
string str = null
含义: 变量 str是一个空引用,它不指向任何字符串对象。它在堆上没有分配任何内存。
操作: 任何对 str调用实例方法(如 str.Length)的操作都会抛出 NullReferenceException。
判断: 使用 str == null判断。
string str = ""
含义: 变量 str指向一个空的字符串对象,是一个有效的 string实例。这个实例在堆上分配了内存,但其内容长度为0。
操作: 可以安全地调用实例方法,如 str.Length会返回 0。
判断: 使用 string.IsNullOrEmpty(str)或 str.Length == 0判断。
string str = string.Empty
含义: 与 "“完全相同。string.Empty是 .NET Framework 中定义的一个静态的、只读的、长度为0的字符串字段。它是一个编译时常量。
最佳实践: 推荐使用 string.Empty而不是 “”,因为:
1.
表达意图更清晰:明确表示“我想要一个空字符串”,而不是一个可能打错的引号。
2.
微小的性能优化:”"会在编译时创建一个新的字符串对象,但CLR(公共语言运行时)会通过字符串驻留机制确保所有空字符串字面量都指向同一个内存地址,所以实际上两者在运行时是等价的。但使用 string.Empty在代码可读性上更胜一筹。
String/StringBuilder 的区别
答案:
String 和 StringBuilder 是 .NET 中用于处理字符串的两个核心类,但它们的实现和适用场景截然不同。
String (不可变)
核心特性: 不可变性。一旦一个string对象被创建,它的值就不能被改变。任何看似修改字符串的操作(如 +=, Replace, Substring),实际上都会在内存中创建一个全新的字符串对象,而原始的字符串保持不变。
优点: 线程安全、易于使用、实现了恒等性和可比性。
缺点: 在进行大量字符串拼接或修改操作时,会产生大量临时对象,导致性能低下和内存碎片。
适用场景: 字符串的初始赋值、少量的字符串操作、或需要只读字符串的地方。
StringBuilder (可变)
核心特性: 可变性。它在内部维护一个字符数组。当进行修改操作(如 Append, Insert, Remove)时,它是在原有的数组上进行操作,只有在容量不足时才会分配新的更大的数组。
优点: 对于频繁的字符串修改操作,性能极高,避免了不必要的内存分配。
缺点: 功能上不如 String 丰富(没有 ToUpper, Split 等方法,需先 ToString() 再操作)。
适用场景: 循环中进行大量字符串拼接、动态构造复杂字符串(如SQL语句、HTML输出)。
简单比喻: String 像一块石板,刻字后就不能修改,要修改只能换一块新石板重刻。StringBuilder 像一个白板,可以随意擦写。
扩展
**字符串驻留:** 是 .NET 公共语言运行时 (CLR) 使用的一种内存优化技术。
目的: 减少具有相同字符序列的字符串在内存中的重复存储,从而节省内存并提高某些字符串比较操作的性能(尤其是引用比较)。
机制: CLR 内部维护一个名为驻留池(Intern Pool) 的全局哈希表。这个表存储了对程序中所有唯一字符串字面量(literal strings) 的引用。
详细解析过程:
1.
编译期处理:
当编译器(如 C# 编译器 csc.exe)处理你的源代码时,它会识别出所有的字符串字面量(即那些在代码中直接用双引号括起来的字符串,例如 “Hello”, “”, “ABC”)。
编译器会将所有这些字符串字面量嵌入(Embed) 到程序集的元数据(Metadata) 中一个叫做 字符串表(String Table 或 #Strings Stream) 的特殊区域。这个表是程序集文件的一部分。
2.
运行时加载与驻留池初始化:
当程序集被加载到内存中执行时,CLR 会读取程序集中的元数据。
对于字符串表(#Strings Stream)中的每一个唯一的字符串字面量,CLR 会:
1.
在托管堆(Managed Heap)上创建一个新的 string对象来存储该字符串的内容。
2.
将这个新创建的 string对象的引用添加到全局的驻留池(Intern Pool) 哈希表中。这个池是 CLR 级别的,存在于应用程序域(AppDomain)中(虽然驻留池通常是 AppDomain 范围的,但某些实现细节可能涉及跨 AppDomain 共享,不过开发者通常无需关心)。
关键点: 驻留池的键(Key)是字符串的内容本身(字符序列),值(Value)是托管堆上对应的 string对象的引用。对于同一个字符串内容,驻留池中只保存一个引用。
3.
""和 string.Empty的处理:
空字符串字面量 “”: 编译器在字符串表中肯定会遇到这个字面量。CLR 在加载程序集时,会为这个空字符串内容在堆上创建一个 string对象(长度为 0),并将它的引用放入驻留池。
string.Empty: 这是 System.String类中定义的一个静态只读字段:
public static readonly string Empty = "";
赋值本质: string.Empty在编译时就被直接替换为字面量 “”。编译器看到 string.Empty,知道它等价于 “”。
运行时行为: 因此,无论是在代码中直接写 “”,还是写 string.Empty:
编译器生成的中间语言 (IL) 代码中,它们都会被表示为同一个字符串字面量 ldstr "" 指令。
当执行到 ldstr ""指令时,CLR 会:去驻留池中查找内容为 “”(空字符串) 的条目。
因为程序集加载时已经创建并驻留了这个空字符串,所以 CLR 直接返回驻留池中那个唯一的空字符串实例的引用。
结果: 所有使用 ""或 string.Empty的地方,最终指向的都是驻留池中同一个全局唯一的、表示空字符串的 string对象实例。
验证它们指向同一个实例:
string s1 = "";
string s2 = string.Empty;
string s3 = String.Empty;
// 引用比较 (检查是否是同一个对象)
bool refEqual1 = object.ReferenceEquals(s1, s2); // true
bool refEqual2 = object.ReferenceEquals(s1, s3); // true
bool refEqual3 = object.ReferenceEquals(s2, s3); // true
// 使用 string.IsInterned 检查是否驻留
string internedEmpty = string.IsInterned(""); // 返回 "" (证明被驻留了)
bool isSameInstance = object.ReferenceEquals(internedEmpty, s1); // true
**5.
驻留的范围:**
自动驻留: 仅适用于编译时已知的字符串字面量(如 “text”, “”)。这些是 CLR 在加载程序集时自动处理的。
手动驻留: 对于运行时动态创建的字符串(如 StringBuilder构造的、string.Format生成的、文件读取的、用户输入的),它们默认不会被自动加入驻留池。你可以使用 string.Intern(string str)方法手动将一个字符串添加到驻留池(如果池中已有相同内容,则返回池中引用;否则添加并返回新引用)。使用 string.IsInterned(string str)可以检查一个字符串是否已在池中
""和 string.Empty是特例: 因为它们是如此基础和常用,CLR 在初始化时(甚至在加载你的程序集之前)很可能就已经创建并驻留了空字符串实例。无论你是否在代码中显式使用 “”,这个实例都存在。你的程序集加载时遇到的 ""字面量,只是复用了这个早已存在的全局实例。
接口和抽象类的区别
答案:
这是一个面向对象编程的核心概念题,区别主要体现在设计理念和用法上。
选择原则:
如果关注多重继承和行为契约,而不关心继承层次,用接口(如 IDisposable, IEnumerable)。
如果需要为一系列关系紧密的类提供共同的基类和代码复用,用抽象类。
委托是什么,什么场景可以用到委托
答案:
是什么: 委托是一个类型安全的函数指针,它定义了方法的签名(返回值类型和参数列表)。它是一种引用类型,允许将方法作为参数传递、作为返回值,或者存储在变量中。委托是.NET中事件和回调机制的基石。
**简单说:**委托是方法的类型。
使用场景:
1.事件处理:
这是委托最广泛的用途。例如,按钮的 Click 事件就是一个委托,你可以将你自己的方法“挂载”到上面。
2.回调机制:
在异步编程中,当一个耗时操作完成后,通过委托来回调通知主程序。例如,BeginInvoke/EndInvoke 模式。
3.策略模式/多态:
将不同的算法(方法)作为参数传递,实现运行时动态切换策略。例如,List.Sort(Comparison comparison) 方法接收一个委托来决定排序规则。
4.LINQ:
LINQ查询表达式背后的很多操作(如 Where, Select)都接收委托参数(通常是Lambda表达式)来定义过滤和投影逻辑。
5.多播委托:
一个委托实例可以包含多个方法,调用一次会依次调用所有方法。
Demo:
// 1. 声明一个委托
public delegate void MyDelegate(string message);
// 2. 符合签名的方法
public void Method1(string msg) { Console.WriteLine($"Method1: {msg}"); }
public void Method2(string msg) { Console.WriteLine($"Method2: {msg}"); }
// 3. 使用委托
MyDelegate del = Method1; // 将方法赋值给委托变量
del("Hello"); // 调用委托,相当于调用Method1("Hello")
// 4. 多播委托
del += Method2; // 添加另一个方法
del("World"); // 会依次调用Method1和Method2
扩展
1.内置委托:Action<>, Func<>, Predicate<>
核心概念:
这些是 .NET Framework (特别是 System命名空间) 中预定义的一系列泛型委托。它们旨在覆盖开发中绝大多数需要委托的场景,从而避免开发者频繁地自定义委托类型。
1.
Action<>(无返回值)
用途: 封装一个执行操作但不返回值的方法。
签名: 接受 0 到 16 个输入参数(通过泛型参数 T1, T2, …, T16指定),返回类型始终为 void。
常见变体:
Action: 无参数,无返回值。() => Console.WriteLine(“Hello”)
Action: 接受 1 个类型为 T的参数,无返回值。(int x) => Console.WriteLine(x)
Action<T1, T2>: 接受 2 个参数,无返回值。(string s, int i) => Console.WriteLine($“{s}: {i}”)
… 一直到 Action<T1, …, T16>
// 按钮点击事件处理 (简化示意)
button.Click += (sender, e) => MessageBox.Show("Clicked!"); // Action<object, EventArgs>
// 遍历列表并对每个元素执行操作
List<string> names = new List<string> { "Alice", "Bob", "Charlie" };
names.ForEach(name => Console.WriteLine(name)); // Action<string>
2.Func<>(有返回值)
用途: 封装一个执行操作并返回指定类型结果的方法。
签名: 接受 0 到 16 个输入参数(通过泛型参数 T1, T2, …, T16指定),最后一个泛型参数 TResult指定返回值类型。
常见变体:
Func: 无参数,返回类型为 TResult。() => DateTime.Now
Func<T, TResult>: 接受 1 个类型为 T的参数,返回类型为 TResult。(int x) => x * x(返回 int)
Func<T1, T2, TResult>: 接受 2 个参数,返回类型为 TResult。(int a, int b) => a + b(返回 int)
… 一直到 Func<T1, …, T16, TResult>
// LINQ 的 Select 投影
List<int> numbers = new List<int> { 1, 2, 3 };
var squares = numbers.Select(x => x * x); // Func<int, int> (输入 int, 输出 int)
// 转换函数
Func<string, int> parse = s => int.Parse(s);
int num = parse("123");
Predicate<>(返回 bool)
用途: 封装一个执行条件判断的方法,通常用于测试某个条件是否满足。
签名: 接受 1 个类型为 T的参数,返回类型固定为 bool。Predicate本质上等价于 Func<T, bool>。
// 查找列表中满足条件的元素
List<int> numbers = new List<int> { 1, 2, 3, 4, 5 };
int firstEven = numbers.Find(x => x % 2 == 0); // Predicate<int> (等价于 Func<int, bool>)
// 过滤集合
var evens = numbers.FindAll(n => n > 3); // Predicate<int>
好的,我们来详细解析这三个与 C# 委托和事件密切相关的进阶问题。
- 解析内置委托:Action<>, Func<>, Predicate<>
核心概念:
这些是 .NET Framework (特别是 System命名空间) 中预定义的一系列泛型委托。它们旨在覆盖开发中绝大多数需要委托的场景,从而避免开发者频繁地自定义委托类型。
详细解析:
Action<>(无返回值)
用途: 封装一个执行操作但不返回值的方法。
签名: 接受 0 到 16 个输入参数(通过泛型参数 T1, T2, …, T16指定),返回类型始终为 void。
常见变体:
Action: 无参数,无返回值。() => Console.WriteLine(“Hello”)
Action: 接受 1 个类型为 T的参数,无返回值。(int x) => Console.WriteLine(x)
Action<T1, T2>: 接受 2 个参数,无返回值。(string s, int i) => Console.WriteLine($“{s}: {i}”)
… 一直到 Action<T1, …, T16>
示例:
// 按钮点击事件处理 (简化示意)
button.Click += (sender, e) => MessageBox.Show("Clicked!"); // Action<object, EventArgs>
// 遍历列表并对每个元素执行操作
List<string> names = new List<string> { "Alice", "Bob", "Charlie" };
names.ForEach(name => Console.WriteLine(name)); // Action<string>
Func<>(有返回值)
用途: 封装一个执行操作并返回指定类型结果的方法。
签名: 接受 0 到 16 个输入参数(通过泛型参数 T1, T2, …, T16指定),最后一个泛型参数 TResult指定返回值类型。
常见变体:
Func: 无参数,返回类型为 TResult。() => DateTime.Now
Func<T, TResult>: 接受 1 个类型为 T的参数,返回类型为 TResult。(int x) => x * x(返回 int)
Func<T1, T2, TResult>: 接受 2 个参数,返回类型为 TResult。(int a, int b) => a + b(返回 int)
… 一直到 Func<T1, …, T16, TResult>
示例:
// LINQ 的 Select 投影
List<int> numbers = new List<int> { 1, 2, 3 };
var squares = numbers.Select(x => x * x); // Func<int, int> (输入 int, 输出 int)
// 转换函数
Func<string, int> parse = s => int.Parse(s);
int num = parse("123");
Predicate<>(返回 bool)
用途: 封装一个执行条件判断的方法,通常用于测试某个条件是否满足。
签名: 接受 1 个类型为 T的参数,返回类型固定为 bool。Predicate本质上等价于 Func<T, bool>。
示例:
// 查找列表中满足条件的元素
List<int> numbers = new List<int> { 1, 2, 3, 4, 5 };
int firstEven = numbers.Find(x => x % 2 == 0); // Predicate<int> (等价于 Func<int, bool>)
// 过滤集合
var evens = numbers.FindAll(n => n > 3); // Predicate<int>
为什么优先使用内置委托?
1.
标准化与一致性: 整个 .NET 框架和社区广泛使用这些委托。使用它们使代码更符合标准,易于其他开发者理解。例如,LINQ 方法大量使用 Func<>和 Action<>。
2.
减少冗余: 避免在项目中定义大量功能相同(只是名字不同)的自定义委托(如 MyDelegate, MyHandler, MyCallback),减少代码量,提高可维护性。
3.
泛型优势: 它们是泛型的,可以适应各种参数类型组合,无需为每种参数组合单独定义委托类型。
4.
可读性 (有时): 看到 Action就知道方法无返回值,看到 Func就知道有返回值,看到 Predicate就知道是做条件判断,意图明确。
5.
性能: 它们是框架内置的,与自定义委托在性能上没有显著差异。
何时可能需要自定义委托?
需要非常特定且描述性强的名称来传达领域特定的语义(例如 PriceChangedHandler)。
需要委托具有特定的特性(Attribute)或元数据(虽然较少见)。
需要定义事件时(事件通常基于自定义委托类型,如 EventHandler,虽然它本质上也是内置的,但遵循特定模式)。
结论: 在绝大多数需要委托的场景下,优先考虑使用 Action<>, Func<>, Predicate<>。它们简洁、标准、高效,能显著减少不必要的代码重复。
3. “事件和委托有什么关系?”
事件(event)在 C# 中是一种基于委托的、更高级别的语言构造。它本质上是委托的一个安全封装器和发布/订阅(Publish-Subscribe)模式的具体实现。可以说,事件是委托的一种特殊应用形式。
详细解析:
1.
事件基于委托:
声明基础: 当你声明一个事件时,必须指定一个委托类型。这个委托类型定义了事件处理程序(订阅者方法)的签名(参数和返回值)。
public delegate void EventHandler(object sender, EventArgs e); // 标准事件委托
public event EventHandler SomethingHappened; // 事件声明基于委托类型
底层存储: 在编译器生成的代码中,事件通常由一个私有的委托类型字段来存储所有订阅了该事件的方法(事件处理程序)的引用列表。这就是多播委托的能力。
2.
事件是委托的封装器:
关键区别: 事件不是委托类型的公共字段!它提供了两个受控的访问器:add( +=) 和 remove( -=)。
封装的目的:
对类的外部(发布者外部):
只能订阅 (+=) 或取消订阅 (-=)。外部代码不能直接调用事件(不能 SomethingHappened(…)),也不能直接覆盖事件(不能 SomethingHappened = null或 SomethingHappened = myMethod)。这防止了外部代码随意清空事件处理程序列表或直接触发事件,保证了事件触发逻辑的控制权牢牢掌握在发布者(声明事件的类)手中。
对类的内部(发布者内部):
可以在类内部安全地检查事件是否为 null (表示没有订阅者) 并调用(触发)事件 (SomethingHappened?.Invoke(this, EventArgs.Empty))。这确保了只有类内部能决定何时、以何种方式通知订阅者。
3.
**事件实现了发布/订阅模式:**
发布者 (Publisher): 声明事件的类。它定义事件并负责在特定条件满足时触发事件。
订阅者 (Subscriber): 包含事件处理程序方法的类。它使用 +=将自己的方法注册到发布者的事件上。
事件处理程序 (Event Handler): 符合事件委托签名的方法。当发布者触发事件时,所有订阅了该事件的处理程序都会被调用。
委托的角色: 委托作为通信管道,将发布者触发事件的动作与订阅者的事件处理程序连接起来。事件利用委托的多播能力,允许一个事件拥有多个订阅者。
4.
标准模式:EventHandler和 EventArgs
.NET 定义了一个标准的事件委托模式:
public delegate void EventHandler(object sender, EventArgs e);
sender: 触发事件的对象(通常是发布者实例)。
e: 包含事件相关数据的对象,通常派生自 EventArgs。如果没有额外数据,使用 EventArgs.Empty。
泛型版本:
public delegate void EventHandler<TEventArgs>(object sender, TEventArgs e) where TEventArgs : EventArgs;
总结关系:
委托是基础: 委托提供了存储和调用方法引用的能力,是实现回调、事件等功能的底层机制。
事件是应用: 事件是在委托基础上构建的一种安全的、面向对象的、用于实现发布/订阅模式的语言特性。它通过封装委托的访问(只允许 +=/ -=),确保了:
封装性: 外部代码不能随意触发事件或清空订阅者列表。
安全性: 防止了意外的委托覆盖。
多播支持: 天然支持多个订阅者。
清晰的模式: 提供了实现观察者模式的标准方式。
简单比喻:
委托就像是一个电话号码列表(可以包含多个号码)。
事件就像是一个客服热线电话系统:
你(订阅者)可以拨打一个特定的号码(+=)把你的电话添加到接听队列(委托列表)。
你也可以挂断(-=)来移除自己。
但只有客服中心(发布者内部)能决定什么时候按下“呼叫所有等待用户”的按钮(触发事件),并且你(外部)无法直接按下那个按钮或清空整个等待队列(直接操作委托列表)。客服热线系统(事件)对电话号码列表(委托)进行了管理和控制。
实现单例模式(懒汉)
答案:懒汉式指实例在第一次被访问时才被创建,延迟加载以节省资源。
public sealed class Singleton // sealed 防止通过继承破坏单例
{
private static Singleton _instance;
private static readonly object _lock = new object(); // 锁对象
// 私有构造函数,防止外部通过 new 创建实例
private Singleton() { }
// 公共静态属性,提供全局访问点
public static Singleton Instance
{
get
{
// 第一重检查:避免已实例化后不必要的锁竞争
if (_instance == null)
{
lock (_lock) // 加锁,确保只有一个线程进入
{
// 第二重检查:防止排队线程在获得锁后重复创建实例
if (_instance == null)
{
_instance = new Singleton();
}
}
}
return _instance;
}
}
// 示例方法
public void SomeBusinessLogic() { }
}
更简洁的现代实现(使用Lazy):
.NET 4.0+ 推荐使用这种方式,它由框架保证线程安全和延迟加载,代码更简洁易懂。
public sealed class Singleton
{
// Lazy<T> 默认是线程安全的
private static readonly Lazy<Singleton> _lazy =
new Lazy<Singleton>(() => new Singleton());
public static Singleton Instance => _lazy.Value;
private Singleton() { }
}
扩展
饿汉式单例实现
饿汉式单例的核心思想是:在类加载时就立即创建实例。这种方式利用了CLR(.NET运行时)对静态字段初始化的保证来实现线程安全。
public sealed class Singleton
{
// 关键点1: 静态私有字段,在类加载时初始化实例
private static readonly Singleton _instance = new Singleton();
// 关键点2: 公共静态属性,提供全局访问点
public static Singleton Instance
{
get
{
return _instance;
}
}
// 关键点3: 私有构造函数,防止外部实例化
private Singleton()
{
// 初始化代码可以放在这里
Console.WriteLine("Singleton instance created!");
}
// 示例业务方法
public void DoSomething()
{
Console.WriteLine("Doing something...");
}
}
关键点解析:
1.private static readonly Singleton _instance = new Singleton();:
static: 表示该字段属于类本身,而不是类的实例。它在类型第一次被使用(如访问 Singleton.Instance或 Singleton的任何其他静态成员,或创建第一个实例)时,由CLR初始化。
readonly: 确保字段只能在声明时或在构造函数中被赋值一次。这里在声明时赋值,之后无法更改。
new Singleton(): 在类加载(类型初始化)时,CLR会执行这行代码,创建 Singleton的唯一实例。 这是“饿汉”的体现——不管后面用不用,先创建好。
2.public static Singleton Instance { get { return _instance; } }:
提供一个全局访问点。因为 _instance在类加载时已经创建好,所以 get方法只需直接返回这个已存在的实例。
3.private Singleton():
私有构造函数是单例模式的基石,它阻止外部代码使用 new Singleton()来创建新的实例。所有获取实例的途径都必须通过 Instance属性。
线程安全性:
CLR保证静态字段的初始化是线程安全的。 当多个线程同时首次尝试访问该类型(例如,同时调用 Singleton.Instance)时,CLR会使用内部锁机制确保 _instance只被初始化一次。因此,饿汉式单例天生就是线程安全的,不需要额外的锁机制。
优缺点:
优点:
实现简单,代码简洁。
线程安全由CLR保证,无需开发者操心锁。
在程序启动时就创建实例,可以尽早发现资源或配置问题。
缺点:
可能造成资源浪费。 如果这个实例非常大或者初始化非常耗时,但在程序的整个生命周期内可能根本不会被用到,那么提前创建就是一种浪费。
失去了延迟加载(Lazy Initialization)的优势。 如果实例的创建依赖于运行时才能确定的信息(比如配置文件),饿汉式就不适用。
2. 为什么需要双重检查?只用第一重检查或只用锁可以吗?
这个问题是针对懒汉式单例(延迟加载) 中的双重检查锁定(Double-Check Locking) 优化提出的。
只用第一重检查(无锁):
public static Singleton Instance
{
get
{
if (_instance == null) // 第一重检查
{
_instance = new Singleton();
}
return _instance;
}
}
原因: 当两个线程 A 和 B 同时执行到 if (_instance == null)时,它们都发现 _instance是 null。接着,两个线程都会进入 if块内部,先后执行 _instance = new Singleton();。这会导致实例被创建两次,破坏了单例原则。最终,后一个线程创建的对象会覆盖前一个线程创建的对象(假设赋值操作是原子的),但前一个线程可能已经持有了那个即将被丢弃的实例的引用,导致不可预测的行为。
只用锁(无双重检查):
private static readonly object _lock = new object();
public static Singleton Instance
{
get
{
lock (_lock) // 每次访问都加锁
{
if (_instance == null)
{
_instance = new Singleton();
}
return _instance;
}
}
}
问题:性能瓶颈。
**原因:** 虽然 lock保证了同一时刻只有一个线程能进入临界区,确保了实例只创建一次,解决了线程安全问题。但是,每次访问 Instance属性时都需要获取和释放锁。在实例已经创建之后,后续的访问完全不需要锁(因为 _instance永远不为 null了)。这种不必要的锁操作在高并发场景下会带来显著的性能开销。
双重检查锁定(Double-Check Locking):
private static Singleton _instance;
private static readonly object _lock = new object();
public static Singleton Instance
{
get
{
if (_instance == null) // 第一重检查 (无锁快路径)
{
lock (_lock) // 加锁
{
if (_instance == null) // 第二重检查 (加锁慢路径)
{
_instance = new Singleton();
}
}
}
return _instance;
}
}
为什么需要双重检查?
第一重检查 (if (_instance == null)): 这是一个无锁的快速路径。如果实例已经创建(绝大多数情况),线程会直接返回 _instance,避免了昂贵的锁操作。这是性能优化的关键。
锁 (lock (_lock)): 当第一重检查发现 _instance为 null时,线程需要进入临界区。锁保证了同一时间只有一个线程能执行创建实例的代码。
第二重检查 (if (_instance == null)): 这是必不可少的。 考虑这种情况:线程 A 和 B 同时通过了第一重检查(因为此时 _instance确实为 null)。线程 A 先获得锁,进入临界区,创建实例,然后释放锁。线程 B 随后获得锁,进入临界区。如果没有第二重检查,线程 B 会再次创建实例!第二重检查在锁的保护下再次确认 _instance是否为 null。由于线程 A 已经创建了实例,线程 B 会发现 _instance不再为 null,从而跳过创建步骤。
总结: 双重检查锁定巧妙地结合了无锁快速路径(提高性能)和锁保护下的安全创建(保证单例),解决了只用第一重检查的线程不安全问题和只用锁的性能问题。第一重检查负责过滤掉绝大多数已存在实例的访问;锁和第二重检查共同负责安全地完成“首次创建”这个关键操作。
3. 上述方法为什么无法防止通过反射或反序列化创建新实例?如何避免?
为什么无法防止?
1.
反射:
.NET 的反射机制非常强大,可以绕过语言的访问控制(如 private构造函数)。
通过 Type.GetConstructor()获取私有构造函数,再使用 ConstructorInfo.Invoke()或 Activator.CreateInstance()(指定 nonPublic为 true)可以强制调用私有构造函数创建新的实例。
标准的单例实现(无论是饿汉还是懒汉)只通过 private构造函数阻止了常规的 new操作,但对反射攻击无能为力。
2.
反序列化:
当使用序列化框架(如 BinaryFormatter, XmlSerializer, Json.NET等)将一个单例对象序列化(写入文件或网络流)然后再反序列化(读取回来)时,序列化框架通常会创建一个新的对象实例,而不是重用内存中已有的单例实例。
框架在反序列化过程中,需要根据序列化数据重新构造对象。即使构造函数是私有的,序列化框架通常也有办法(例如通过反射或特殊的回调接口)来创建新实例并填充数据。
这导致反序列化后,内存中存在两个内容相同但地址不同的对象,破坏了单例的唯一性。
如何避免?
1.
防御反射攻击:
方法:在构造函数中添加标志位检查。
private static bool _isInstanceCreated = false; // 标志位
private Singleton()
{
// 防御反射攻击
if (_isInstanceCreated)
{
throw new InvalidOperationException("Singleton instance has already been created. Use Singleton.Instance to get it.");
}
_isInstanceCreated = true;
// ... 其他初始化
}
原理: 在构造函数中检查一个静态标志位 _isInstanceCreated。第一次通过正常途径(Instance属性内部调用构造函数)创建实例时,将标志位置为 true。之后,如果反射再次调用构造函数,构造函数会检查到标志位已经是 true,则抛出异常,阻止创建第二个实例。
注意: 这个标志位需要在类加载时初始化为 false(private static bool _isInstanceCreated = false;)。
2.
防御反序列化攻击:
方法:实现 ISerializable接口并控制反序列化行为。
[Serializable] // 标记类可序列化
public sealed class Singleton : ISerializable
{
// ... (单例的私有实例、私有构造函数等)
// 实现 ISerializable 接口的 GetObjectData 方法 (用于序列化)
public void GetObjectData(SerializationInfo info, StreamingContext context)
{
// 通常不需要序列化内部状态,或者只序列化必要的数据
// 这里可以什么都不做,或者序列化一些需要持久化的数据
}
// 特殊的反序列化构造函数 (由序列化框架调用)
private Singleton(SerializationInfo info, StreamingContext context)
{
// 在这个构造函数中,我们不做真正的初始化,而是直接返回现有的单例实例
// 但注意:框架要求这个构造函数必须存在且能调用,我们无法阻止它被调用。
// 关键在于:我们在这个构造函数里不做 *新* 实例的初始化,而是想办法拿到或设置全局实例。
// 方案1 (推荐): 直接忽略传入的数据,将当前对象引用指向已存在的单例
// 这需要框架支持,但 .NET 的序列化机制不允许在构造函数中修改 `this`。
// 此方案在 .NET 中通常不可行。
// 方案2: 抛出异常 (不够友好)
// throw new SerializationException("Singleton instances cannot be deserialized. Use Singleton.Instance.");
// 方案3 (常用): 在反序列化回调中修正引用
// 见下面的 `OnDeserialized` 方法
}
// 反序列化完成后的回调方法
[OnDeserialized]
private void OnDeserialized(StreamingContext context)
{
// 反序列化完成后,CLR 会调用此方法
// 在这里,我们将反序列化框架创建的新实例的引用丢弃,
// 并将单例字段强制指向我们唯一的实例
// 注意:这需要将 _instance 字段标记为 [NonSerialized] 或使用其他机制避免它被序列化
// 因为序列化的是数据,我们不希望序列化_instance字段本身
_instance = Singleton.Instance; // 将反序列化出来的对象的 _instance 字段指向真正的单例
// 或者更常见的做法:在 Singleton 类内部不依赖反序列化出来的对象
}
}
方法3: 使用[NonSerialized]。
将 _instance字段标记为 [NonSerialized],告诉序列化框架不要序列化这个字段本身。
在反序列化过程中,框架会创建一个新的 Singleton对象(调用 Serialization构造函数或默认构造函数),并填充其他被序列化的字段(如果有)。
在反序列化完成后,框架会调用标记了 [OnDeserialized]的方法 OnDeserialized。
在 OnDeserialized方法中,我们主动将新创建出来的这个对象的 _instance字段(或者任何需要指向单例的引用)设置为通过 Singleton.Instance获取到的真正单例实例。这样,反序列化出来的对象内部持有的 _instance就不再是它自己,而是那个全局唯一的实例。后续通过这个反序列化出来的对象访问单例方法,实际上使用的是全局实例。
本质上,我们劫持了反序列化过程,让反序列化出来的对象“变成”对真正单例的一个代理或包装(虽然它本身是一个独立对象,但其核心功能委托给了真正的单例)。更彻底的做法是让这个反序列化出来的对象在 OnDeserialized后几乎成为一个空壳,所有方法调用都转发给 Singleton.Instance。
**更简单通用的方法:**使用 SerializationBinder或序列化框架的钩子 (如 Json.NET 的 JsonSerializerSettings.ContractResolver): 在更高级别的序列化/反序列化设置中,拦截对单例类型的创建请求,始终返回 Singleton.Instance。这需要针对具体的序列化库进行配置。
**终极建议:**避免序列化单例对象本身。 如果单例对象包含需要持久化的状态数据,考虑单独序列化这些状态数据,而不是序列化整个单例对象。在反序列化时,将这些数据加载到内存中的单例实例里。