C#某公司面试题(含题目和解析)--1

发布于:2025-09-06 ⋅ 阅读:(16) ⋅ 点赞:(0)

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# 委托和事件密切相关的进阶问题。

  1. 解析内置委托: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。这需要针对具体的序列化库进行配置。
**​​终极建议:**避免序列化单例对象本身。​​ 如果单例对象包含需要持久化的状态数据,考虑单独序列化这些状态数据,而不是序列化整个单例对象。在反序列化时,将这些数据加载到内存中的单例实例里。


网站公告

今日签到

点亮在社区的每一天
去签到