Skip to content

题海扩展:C# 高频细节

readonly struct 有什么作用?

标准答案

readonly struct 的作用是声明一个“内部状态不可变的值类型”。它还是 struct,还是值类型,但编译器会限制它的实例字段必须是只读的,并且知道它的实例成员不会修改 this,所以在 in 参数、readonly 字段等只读引用场景下,可以减少防御性拷贝。

csharp-readonly-struct

代码示例

c
public readonly struct DamageInfo // 定义一个只读结构体,表示一次伤害信息。
{ // 结构体开始。
    public readonly int Value; // 定义只读字段,保存伤害数值。
    public readonly int SkillId; // 定义只读字段,保存技能 ID。
    public DamageInfo(int value, int skillId) // 定义构造函数,用于初始化只读字段。
    { // 构造函数开始。
        Value = value; // 在构造函数中给只读字段赋值。
        SkillId = skillId; // 在构造函数中给只读字段赋值。
    } // 构造函数结束。
    public bool IsCritical(int threshold) // 定义一个不会修改结构体状态的方法。
    { // 方法开始。
        return Value >= threshold; // 根据伤害值判断是否达到暴击阈值。
    } // 方法结束。
} // 结构体结束。
public static class DamageSystem // 定义一个伤害系统工具类。
{ // 类开始。
    public static bool CheckCritical(in DamageInfo info) // 使用 in 参数按只读引用传递,避免大结构体复制。
    { // 方法开始。
        return info.IsCritical(100); // 调用 readonly struct 的成员时不需要防御性拷贝。
    } // 方法结束。
} // 类结束。

底层原理

普通 struct 是值类型,赋值和传参时默认可能复制。如果一个结构体比较大,比如包含很多字段,频繁复制就会有成本。

in 参数可以把结构体按只读引用传递,避免按值复制。但如果结构体是普通可变 struct,编译器在调用它的属性或方法时可能会担心这个方法修改 this,于是偷偷复制一份再调用,这叫防御性拷贝。

readonly struct 告诉编译器:这个结构体的实例状态不会被成员修改。这样在只读引用场景下,编译器更容易直接调用成员,减少隐藏复制。

Unity/游戏场景

适合用在小而稳定的数据对象上,比如坐标、颜色、伤害信息、技能 ID、配置 Key、范围、碰撞查询结果等。

但它不是万能优化。很小的结构体,比如只有一个 int,收益不明显;很大的结构体才更需要关注复制成本。另外,如果 readonly struct 里有引用类型字段,只能保证字段本身不能重新指向别的对象,不代表引用对象内部也不可变。

面试关键句

NOTE

readonly struct 主要有两个价值:语义上表达不可变值对象,性能上配合 inreadonly 场景减少防御性拷贝。它不是引用类型,也不是禁止变量重新赋值,而是限制结构体实例内部状态不可变。

recordclass 有什么区别?

标准答案

class 默认强调“对象身份”,record 默认强调“数据值”。

也就是说,两个 class 对象即使字段内容一样,只要不是同一个引用,默认也不相等;两个 record 对象如果成员值一样,默认就认为相等。

csharp-record-vs-class

代码示例

c
public class PlayerClass // 定义一个普通 class。
{ // class 开始。
    public string Name { get; init; } // 定义玩家名字属性。
    public int Level { get; init; } // 定义玩家等级属性。
} // class 结束。
public record PlayerRecord(string Name, int Level); // 定义一个 record,自动保存名字和等级。
PlayerClass c1 = new PlayerClass { Name = "Tom", Level = 10 }; // 创建第一个 class 对象。
PlayerClass c2 = new PlayerClass { Name = "Tom", Level = 10 }; // 创建第二个 class 对象,内容和第一个一样。
bool classEqual = c1.Equals(c2); // 默认比较引用身份,所以通常是 false。
PlayerRecord r1 = new PlayerRecord("Tom", 10); // 创建第一个 record 对象。
PlayerRecord r2 = new PlayerRecord("Tom", 10); // 创建第二个 record 对象,成员值一样。
bool recordEqual = r1.Equals(r2); // record 默认按成员值比较,所以是 true。
PlayerRecord r3 = r1 with { Level = 11 }; // 使用 with 复制 r1,并只修改 Level。

底层原理

record 会帮你自动生成一组和“数据对象”相关的方法,比如:

Equals:按成员值比较。

GetHashCode:根据成员值生成哈希。

ToString:输出更可读的成员内容。

with:复制一个对象,并修改少数字段。

如果是位置 record,还支持解构。

普通 class 默认继承自 object 的引用相等逻辑,除非你自己重写 EqualsGetHashCode,否则它更关注“是不是同一个对象”。

Unity/游戏场景

class 适合有身份、有生命周期、有行为的对象,比如角色对象、管理器、运行时系统、MonoBehaviour、服务类。

record 适合纯数据,比如网络消息、配置快照、战斗事件、存档 DTO、不可变状态对象。

但 Unity 里要注意:record 不适合替代 MonoBehaviourScriptableObject。Unity 自带序列化也更偏字段序列化,而 record 常用的是构造参数、属性、init,所以在 Inspector 序列化场景里要谨慎。

常见坑

record 默认是引用类型,写 record Player(...) 本质上更接近 record class。如果想要值类型版本,要写 record struct

record 也不等于“深不可变”。如果 record 里面放了一个 List<T>,record 自己的引用可能不变,但 List 内部内容仍然可以被改。

面试关键句

CAUTION

我会这样总结:class 更适合表达有身份和生命周期的对象,record 更适合表达不可变的数据值。record 的核心价值是自动生成值相等、哈希、ToString 和 with 复制,让数据模型更简洁、更安全。

ref struct 为什么不能装箱?

标准答案

ref struct 不能装箱,是因为装箱会把值类型复制到托管堆上,而 ref struct 被设计成只能在栈上使用。它可能保存指向栈内存的引用,如果允许装箱,堆对象就可能持有一个已经失效的栈引用,造成悬空引用,所以 C# 编译器直接禁止。

csharp-ref-struct-no-boxing

代码示例

c
using System; // 引入 System 命名空间,使用 Span<T>。
public ref struct BufferView // 定义一个 ref struct,表示只能在栈上使用的视图。
{ // ref struct 开始。
    private Span<byte> _buffer; // 保存一段连续内存的视图,这段内存可能来自栈。
    public BufferView(Span<byte> buffer) // 定义构造函数,接收一段 Span<byte>。
    { // 构造函数开始。
        _buffer = buffer; // 保存传入的内存视图。
    } // 构造函数结束。
    public int Length // 定义只读属性,返回缓冲区长度。
    { // 属性开始。
        get { return _buffer.Length; } // 返回 Span 的长度。
    } // 属性结束。
} // ref struct 结束。
public static class Demo // 定义一个演示类。
{ // Demo 类开始。
    public static void Test() // 定义测试方法。
    { // 方法开始。
        Span<byte> data = stackalloc byte[16]; // 在栈上分配 16 个 byte。
        BufferView view = new BufferView(data); // 创建 ref struct,它引用了栈上的数据。
        // object obj = view; // 编译错误:ref struct 不能装箱到 object。
    } // 方法结束后,stackalloc 的栈内存失效。
} // Demo 类结束。

底层原理

普通值类型装箱时,会在托管堆上创建一个对象,把值复制进去。比如 int 装箱成 object 是安全的,因为堆对象里只是保存了一个独立的整数副本。

ref struct 不一样。它属于 byref-like type,典型例子是 Span<T>ReadOnlySpan<T>。这类类型可能内部保存的是“对某段内存的引用”,而这段内存可能来自:

stackalloc 分配的栈内存。

数组的一段切片。

非托管内存。

如果 ref struct 被装箱到堆上,GC 管理的是堆对象,但它里面可能指向某个方法栈帧里的内存。方法一结束,栈帧没了,堆对象还活着,这个引用就悬空了。为了从语言层面保证安全,C# 直接规定它不能装箱。

为什么限制很多用法

很多限制本质上都是为了防止它逃逸到堆上:

不能转换成 object,因为那会装箱。

不能被普通闭包捕获,因为闭包对象通常在堆上。

不能跨 async/awaityield return 保存,因为状态机会被提升成堆对象。

不能作为普通 class 的字段,因为 class 实例在堆上。

一些接口转换也要谨慎,因为一旦需要装箱或作为堆上引用保存,就违背了 ref struct 的栈限定规则。

面试关键句

WARNING

ref struct 不能装箱的根本原因是生命周期不匹配。装箱意味着进入托管堆,生命周期由 GC 决定;ref struct 可能引用栈内存,生命周期只能受当前栈帧约束。为了避免堆对象持有失效栈引用,编译器禁止它装箱和逃逸。

Span<T> 和数组有什么关系?

一句话:数组是“拥有内存的数据容器”,Span<T> 是“看向一段连续内存的临时窗口”。

csharp-span-array-relation

核心关系:

int[] 数组自己拥有真实数据,通常在托管堆上,是引用类型,长度固定。

Span<T> 不拥有数据,它只是临时指向一段连续内存,可以指向数组、数组切片、stackalloc 栈内存,甚至非托管内存。

所以:

c
using System; // 引入 Span<T> 和相关扩展方法。

public static class SpanArrayDemo // 定义一个演示类。
{ // 类开始。
    public static void Run() // 定义演示方法。
    { // 方法开始。
        int[] numbers = { 10, 20, 30, 40, 50, 60 }; // 创建数组,数组拥有真实内存。
        Span<int> all = numbers; // 用 Span 包住整个数组,不复制数据。
        Span<int> middle = numbers.AsSpan(2, 3); // 用 Span 查看索引 2 开始的 3 个元素。
        middle[0] = 99; // 修改 Span 的第一个元素,实际修改 numbers[2]。
        int changed = numbers[2]; // changed 是 99,说明 Span 操作的是底层数组。
        Span<int> temp = stackalloc int[4]; // 在栈上分配临时连续内存,也能用 Span 访问。
        temp[0] = 1; // 修改栈上 Span 的第一个元素。
        int[] copy = middle.ToArray(); // 显式 ToArray 才会复制出一份新数组。
    } // 方法结束。
} // 类结束。

底层可以这样理解:

Span<T> 大概像:

ref T 起始位置 + int 长度

它不是数组,也不是普通引用对象。它是 ref struct,有“栈语义”,所以不能装箱,不能放进普通 class 字段,不能跨 async/awaityield return 保存。

为什么它有用?

以前你想取数组一段,可能会 Array.CopyToArray,这会产生新数组和 GC。Span<T> 可以只开一个“窗口”,不复制数据,性能更好。

Unity / 游戏里怎么用?

适合解析网络包、二进制配置、临时 buffer、字符串切片解析等场景。比如从一个大 byte 数组里取消息头、消息体,不必每次创建新数组。

面试关键词:

IMPORTANT

Span<T> 不是数组,它是连续内存的视图;数组负责存储,Span 负责临时访问。它能减少切片复制和 GC,但因为是 ref struct,使用范围受限制。

ArraySegment<T> 是什么?

标准答案

ArraySegment<T> 是“数组的一段视图”。它本身不是新数组,也不复制元素,只保存三样东西:

原数组引用 Array
起始位置 Offset
片段长度 Count

所以它的核心价值是:把大数组中的一小段传出去,但不产生新的数组分配。

csharp-arraysegment

底层原理

比如有一个数组:

c
buffer = [10, 20, 30, 40, 50, 60]

创建:

c
new ArraySegment<int>(buffer, 2, 3)

表示的是:

c
buffer[2] 开始,长度为 3
也就是 [30, 40, 50]

注意,它不是复制出了 [30, 40, 50],而是仍然指向原来的 buffer。所以你通过这段访问或修改数据,本质上操作的是同一个底层数组。

代码示例

c
using System; // 引入 ArraySegment<T> 和 Array 工具类。

public static class ArraySegmentDemo // 定义一个演示类。
{ // 类开始。
    public static void Run() // 定义演示方法。
    { // 方法开始。
        byte[] buffer = { 10, 20, 30, 40, 50, 60 }; // 创建原始数组,数组拥有真实数据。
        ArraySegment<byte> body = new ArraySegment<byte>(buffer, 2, 3); // 创建数组片段,从索引 2 开始,长度为 3。
        byte first = body.Array[body.Offset]; // 读取片段第一个元素,本质读取 buffer[2]。
        body.Array[body.Offset + 1] = 99; // 修改片段中的第二个元素,本质修改 buffer[3]。
        byte changed = buffer[3]; // changed 会得到 99,说明底层数组被改了。
        byte[] copy = new byte[body.Count]; // 创建新数组,只有真正需要独立数据时才分配。
        Array.Copy(body.Array, body.Offset, copy, 0, body.Count); // 把片段内容复制到新数组里。
    } // 方法结束。
} // 类结束。

Span<T> 的区别

ArraySegment<T> 只能表示“数组的一段”,它是普通 struct,可以放到字段里,也可以跨方法、跨异步流程保存。

Span<T> 更强,它可以表示数组、栈内存、非托管内存的一段,但它是 ref struct,不能装箱,不能放到普通 class 字段里,也不能跨 async/yield 保存。

Unity / 游戏场景

在网络包解析、资源二进制读取、缓冲区复用里很常见。比如 socket 收到一个大 byte[] buffer,消息体只是其中一段,就可以用 ArraySegment<byte> 表示,避免每个包都 new byte[],从而减少 GC。

常见坑

TIP

ArraySegment<T> 不是深拷贝。多个片段可能指向同一个数组,修改其中一个片段,会影响原数组和其他片段。

另外要注意 default(ArraySegment<T>) 里面的 Array 可能是 null,使用前最好确认数据来源是有效的。

Memory<T>Span<T> 区别是什么?

标准答案

Span<T> 是“马上用的临时视图”,Memory<T> 是“可以保存、可以跨异步的内存视图”。

csharp-memory-span-difference

核心区别

Span<T>ref struct,有栈语义,不能装箱,不能放到普通 class 字段里,不能跨 async/awaityield return 保存。它适合在函数内部立刻处理数据,比如解析 byte 数组、切片、循环读写。

Memory<T> 是普通 struct,可以放字段里,可以传给异步方法,可以长期保存一段内存视图。真正要读写元素时,再通过:

c
memory.Span

临时拿到一个 Span<T> 来操作。

底层理解

它们都不拥有数据。真正拥有数据的通常是数组、内存池、非托管内存等。

c
Memory<T>:可以保存的“内存票据”
Span<T>:现场读写的“临时窗口”

Memory<T> 更像生命周期更长的描述对象;Span<T> 更像生命周期很短的访问工具。

代码示例

c
using System; // 引入 Memory<T> 和 Span<T>。
using System.Threading.Tasks; // 引入 Task 和 async/await。

public sealed class PacketCache // 定义一个数据缓存类。
{ // 类开始。
    private Memory<byte> _payload; // Memory<T> 是普通结构体,可以作为 class 字段保存。
    public void Store(byte[] buffer) // 定义保存网络包片段的方法。
    { // 方法开始。
        _payload = buffer.AsMemory(2, 3); // 保存数组中从索引 2 开始、长度为 3 的一段视图。
    } // 方法结束。
    public async Task<int> ReadFirstAsync() // 定义一个异步读取方法。
    { // 方法开始。
        await Task.Delay(1); // 模拟异步等待,Memory<T> 可以跨 await 保存。
        return ReadFirst(_payload); // 把 Memory<T> 交给同步方法处理。
    } // 方法结束。
    private static int ReadFirst(Memory<byte> memory) // 定义同步处理方法。
    { // 方法开始。
        Span<byte> span = memory.Span; // 真正访问数据时,从 Memory<T> 临时取出 Span<T>。
        return span[0]; // 读取这段内存里的第一个元素。
    } // 方法结束。
} // 类结束。

Unity / 游戏场景

网络包、资源二进制解析、文件流异步读取里,可以用 Memory<byte> 表示一段 buffer,并传给异步 IO 或缓存系统。进入真正解析逻辑时,再取 .Span 做高性能读取,减少临时数组和 GC。

常见坑

Memory<T> 也不等于拥有内存。它只是引用一段内存,如果底层数组被别人改了,Memory<T> 看到的数据也会变。

另外,Span<T> 可以指向 stackalloc 栈内存,但 Memory<T> 通常不能表示栈内存,因为 Memory<T> 可以逃逸到堆上,栈内存一旦方法结束就没了。

面试关键词

NOTE

Span<T>:短生命周期、高性能、栈语义、不能逃逸。

Memory<T>:可保存、可跨 async、访问时用 .Span

一句话记:Memory 存起来,Span 拿来用。

IComparableIComparer 区别是什么?

标准答案

IComparable<T> 是“对象自己定义默认排序规则”,IComparer<T> 是“外部定义一套排序规则”。

csharp-icomparable-icomparer

核心区别

IComparable<T> 写在类自己身上,表示这个类型天然怎么比较。

IComparer<T> 写在外部比较器里,表示这一次排序按什么规则比较。

比如玩家数据 PlayerScore

c
using System; // 引入 IComparable<T> 接口。
using System.Collections.Generic; // 引入 IComparer<T> 接口。

public sealed class PlayerScore : IComparable<PlayerScore> // 定义玩家分数类,并实现默认比较规则。
{ // 类开始。
    public string Name; // 保存玩家名字。
    public int Level; // 保存玩家等级。
    public int Score; // 保存玩家分数。
    public int CompareTo(PlayerScore other) // 实现默认排序方法。
    { // 方法开始。
        return other.Score.CompareTo(Score); // 默认按分数从高到低排序。
    } // 方法结束。
} // 类结束。

public sealed class LevelComparer : IComparer<PlayerScore> // 定义外部比较器,用于临时按等级排序。
{ // 类开始。
    public int Compare(PlayerScore x, PlayerScore y) // 实现两个对象之间的比较。
    { // 方法开始。
        return y.Level.CompareTo(x.Level); // 按等级从高到低排序。
    } // 方法结束。
} // 类结束。

底层原理

排序算法并不知道“玩家分数高应该排前面”还是“等级高应该排前面”,它只会不断调用比较函数。

比较函数返回值的含义是:

小于 0:左边排前面
等于 0:两者顺序相等
大于 0:右边排前面

List<T>.Sort() 如果没有传比较器,就会找元素类型自己的默认比较规则,也就是 IComparable<T>

List<T>.Sort(comparer) 如果传了比较器,就使用外部的 IComparer<T>

Unity / 游戏场景

排行榜默认按分数排序,可以让玩家记录实现 IComparable<T>

背包界面需要按品质、等级、类型、获得时间排序,这种规则经常切换,更适合写多个 IComparer<T>

技能目标筛选也类似:最近目标、血量最低目标、威胁值最高目标,都可以用不同 comparer 表达。

常见坑

CAUTION

优先用泛型版本 IComparable<T>IComparer<T>,少用非泛型版本,避免装箱和类型转换。

比较逻辑要稳定、可传递,不能一会儿说 A 大于 B,一会儿又说 B 大于 A,否则排序结果可能混乱。

一句话记:IComparable 是自己比,IComparer 是外部规则比。

EqualityComparer<T>.Default 有什么用?

标准答案

EqualityComparer<T>.Default 的作用是:拿到类型 T 的默认相等比较器,用来统一做两件事:

c
Equals(x, y)        判断两个值是否相等
GetHashCode(x)     计算哈希码

csharp-equality-comparer-default

底层原理

它不是简单粗暴地永远调用 object.Equals

运行时会根据 T 选择更合适的比较器。比如:

T 实现了 IEquatable<T>,就优先调用强类型的 Equals(T other),这样对值类型更友好,能减少装箱。

T 是枚举、可空类型等,通常会使用专门的比较器。

普通引用类型没有特殊规则时,才会回退到对象自己的 EqualsGetHashCode

为什么 Dictionary 和 HashSet 需要它

Dictionary<TKey, TValue>HashSet<T> 的核心都离不开两个动作:

先用 GetHashCode 找桶
再用 Equals 判断是否真的是同一个 key

所以如果你没有给 Dictionary 传自定义 comparer,它内部就会用:

c
EqualityComparer<TKey>.Default

代码示例

c
using System; // 引入 Console 和 IEquatable<T>。
using System.Collections.Generic; // 引入 EqualityComparer<T> 和 HashSet<T>。

public readonly struct ItemId : IEquatable<ItemId> // 定义一个道具 ID,并实现强类型相等接口。
{ // 结构体开始。
    private readonly int _value; // 保存真实 ID 数值。
    public ItemId(int value) // 定义构造函数。
    { // 构造函数开始。
        _value = value; // 保存传入的 ID。
    } // 构造函数结束。
    public bool Equals(ItemId other) // 实现强类型相等比较。
    { // 方法开始。
        return _value == other._value; // 两个 ID 数值相同就认为相等。
    } // 方法结束。
    public override bool Equals(object obj) // 重写 object.Equals。
    { // 方法开始。
        return obj is ItemId other && Equals(other); // 如果类型正确,就调用强类型 Equals。
    } // 方法结束。
    public override int GetHashCode() // 重写哈希码方法。
    { // 方法开始。
        return _value.GetHashCode(); // 使用 ID 数值的哈希码。
    } // 方法结束。
} // 结构体结束。

public static class EqualityDemo // 定义演示类。
{ // 类开始。
    public static void Run() // 定义演示方法。
    { // 方法开始。
        ItemId a = new ItemId(1001); // 创建第一个道具 ID。
        ItemId b = new ItemId(1001); // 创建第二个道具 ID。
        bool same = EqualityComparer<ItemId>.Default.Equals(a, b); // 使用默认比较器判断是否相等。
        HashSet<ItemId> set = new HashSet<ItemId>(); // 创建 HashSet,内部默认使用 EqualityComparer<ItemId>.Default。
        set.Add(a); // 添加第一个 ID。
        bool contains = set.Contains(b); // 查询第二个 ID,内部会用 hash 和 Equals 判断。
        Console.WriteLine(same && contains); // 输出结果,验证两个 ID 被认为相等。
    } // 方法结束。
} // 类结束。

Unity / 游戏场景

在游戏里经常会用 HashSet<int> 去重怪物 ID,用 Dictionary<ItemId, ItemConfig> 查配置,用 HashSet<Vector2Int> 存格子坐标。它们默认都会依赖 EqualityComparer<T>.Default

如果你自己定义了 struct 作为 key,比如 GridCoordItemIdSkillKey,最好实现 IEquatable<T>,并正确重写 GetHashCode。否则哈希集合可能性能差,甚至出现“看起来一样但查不到”的问题。

常见坑

如果重写了 Equals,必须同步重写 GetHashCode。因为哈希集合要求:

c
两个对象 Equals 为 true,它们的 GetHashCode 必须相同

另外,string 默认比较是区分大小写的。如果你想让 "abc""ABC" 相等,不要依赖 Default,要显式传:

c
StringComparer.OrdinalIgnoreCase

CAUTION

一句话记:EqualityComparer<T>.Default 是泛型集合默认用的“相等 + 哈希”规则入口。

GetHashCode 为什么要和 Equals 保持一致?

标准答案

GetHashCode 必须和 Equals 保持一致,是因为 DictionaryHashSet 这类哈希集合的查找流程是:

先用 GetHashCode 找桶
再在桶里用 Equals 判断是否相等

如果两个对象 Equals 返回 true,但 GetHashCode 不同,它们会被分到不同桶里,查找时可能根本遇不到,结果就是:明明逻辑上相等,却查不到 key,或者 HashSet 里出现重复元素。

csharp-gethashcode-equals-consistency

底层原理

必须满足这条规则:

c
如果 a.Equals(b) == true
那么 a.GetHashCode() 必须等于 b.GetHashCode()

但反过来不要求成立:

hash 相同,不代表 Equals 一定相等

因为哈希冲突是允许的。哈希表只是用 hash 快速缩小范围,最终是否相等,还要靠 Equals

代码示例

c
using System; // 引入 IEquatable<T> 接口。
using System.Collections.Generic; // 引入 Dictionary<TKey, TValue>。

public readonly struct ItemId : IEquatable<ItemId> // 定义道具 ID,并实现强类型相等比较。
{ // 结构体开始。
    private readonly int _value; // 保存道具 ID 的真实数值。
    public ItemId(int value) // 定义构造函数。
    { // 构造函数开始。
        _value = value; // 保存传入的 ID。
    } // 构造函数结束。
    public bool Equals(ItemId other) // 定义两个 ItemId 是否相等。
    { // 方法开始。
        return _value == other._value; // ID 数值相同就认为相等。
    } // 方法结束。
    public override bool Equals(object obj) // 重写 object.Equals。
    { // 方法开始。
        return obj is ItemId other && Equals(other); // 类型正确时调用强类型 Equals。
    } // 方法结束。
    public override int GetHashCode() // 重写 GetHashCode。
    { // 方法开始。
        return _value.GetHashCode(); // Equals 用 _value,hash 也必须基于 _value。
    } // 方法结束。
} // 结构体结束。

public static class HashDemo // 定义演示类。
{ // 类开始。
    public static void Run() // 定义演示方法。
    { // 方法开始。
        Dictionary<ItemId, string> map = new Dictionary<ItemId, string>(); // 创建字典。
        ItemId key1 = new ItemId(1001); // 创建第一个 key。
        ItemId key2 = new ItemId(1001); // 创建逻辑上相等的第二个 key。
        map[key1] = "Sword"; // 用第一个 key 存入数据。
        bool found = map.ContainsKey(key2); // 用第二个 key 查询,依赖 hash 和 Equals 一致。
        Console.WriteLine(found); // 正确实现时输出 true。
    } // 方法结束。
} // 类结束。

Unity / 游戏场景

在 Unity 项目里,如果你用自定义结构体当 DictionaryHashSet 的 key,比如 ItemIdGridCoordSkillKeyEntityId,就必须保证:

Equals 用哪些字段判断相等
GetHashCode 就必须基于同一批字段计算

比如格子坐标 GridCoord(x, y),如果 Equals 判断 xy 都相同才相等,那 GetHashCode 也应该同时使用 xy。不能只用 x,更不能每次随机生成 hash。

常见坑

WARNING

最危险的是:对象作为 key 放进字典后,又修改了参与 Equals / GetHashCode 的字段。这样它原来在桶 A,修改后 hash 指向桶 B,再查就可能查不到了。

所以哈希 key 最好设计成不可变,比如 readonly struct 或只读字段。

一句话记:Equals 决定“是不是同一个”,GetHashCode 决定“去哪找”;两个逻辑相等的对象必须去同一个桶。

两个对象 Equals 为 true,hashCode 必须相同吗?

标准答案

必须相同。

如果两个对象按同一套相等规则:

c
a.Equals(b) == true

那么必须满足:

c
a.GetHashCode() == b.GetHashCode()

csharp-equals-true-same-hash

但反过来不成立

如果两个对象 hashCode 相同,不代表它们一定 Equals 为 true。

因为哈希冲突是允许的:

hashCode 相同:只是说明可能在同一个桶
Equals 为 true:才说明逻辑上相等

为什么必须这样

DictionaryHashSet 查找时不是一上来就遍历全部元素,而是:

1. 先调用 GetHashCode,决定去哪个桶找
2. 再在桶里调用 Equals,确认是不是目标对象

如果两个对象 Equals 为 true,但 hashCode 不同,它们会进入不同的桶。这样查找时可能根本碰不到,自然也没机会调用 Equals

代码示例

c
using System; // 引入 IEquatable<T>。
using System.Collections.Generic; // 引入 HashSet<T>。

public readonly struct GridCoord : IEquatable<GridCoord> // 定义网格坐标,并实现相等比较。
{ // 结构体开始。
    private readonly int _x; // 保存 x 坐标。
    private readonly int _y; // 保存 y 坐标。
    public GridCoord(int x, int y) // 定义构造函数。
    { // 构造函数开始。
        _x = x; // 保存 x。
        _y = y; // 保存 y。
    } // 构造函数结束。
    public bool Equals(GridCoord other) // 定义两个坐标是否相等。
    { // 方法开始。
        return _x == other._x && _y == other._y; // x 和 y 都相同才相等。
    } // 方法结束。
    public override bool Equals(object obj) // 重写 object.Equals。
    { // 方法开始。
        return obj is GridCoord other && Equals(other); // 类型正确时调用强类型 Equals。
    } // 方法结束。
    public override int GetHashCode() // 重写 GetHashCode。
    { // 方法开始。
        return HashCode.Combine(_x, _y); // Equals 用 x 和 y,hash 也必须基于 x 和 y。
    } // 方法结束。
} // 结构体结束。

public static class HashRuleDemo // 定义演示类。
{ // 类开始。
    public static void Run() // 定义演示方法。
    { // 方法开始。
        HashSet<GridCoord> visited = new HashSet<GridCoord>(); // 创建坐标集合。
        visited.Add(new GridCoord(2, 3)); // 添加一个坐标。
        bool exists = visited.Contains(new GridCoord(2, 3)); // 查询逻辑上相等的坐标。
        Console.WriteLine(exists); // 正确实现时输出 true。
    } // 方法结束。
} // 类结束。

Unity / 游戏场景

如果你把 GridCoordItemIdEntityIdSkillKey 这种自定义类型放进 DictionaryHashSet,就一定要保证 EqualsGetHashCode 规则一致。

比如 Equals 用了 xy 判断格子是否相同,那 GetHashCode 也要基于 xy。不能 Equals 看两个字段,hash 只看一个字段,更不能返回随机数。

补充坑点

IMPORTANT

作为 key 放进 Dictionary 后,不要修改参与 EqualsGetHashCode 的字段。否则对象原本在桶 A,修改后 hash 指向桶 B,就会出现“明明在字典里却查不到”的问题。

一句话记:Equals 相等必须同 hash;同 hash 不一定 Equals 相等。

两个对象 hashCode 相同,Equals 必须为 true 吗?

标准答案

不必须。

两个对象 hashCode 相同,只能说明它们可能被分到哈希表的同一个桶里;最终是不是相等,还要继续调用 Equals 判断。

csharp-same-hash-not-equal

正确关系

必须成立的是:

c
如果 a.Equals(b) == true
那么 a.GetHashCode() == b.GetHashCode()

不一定成立的是:

c
如果 a.GetHashCode() == b.GetHashCode()
那么 a.Equals(b) == true

因为哈希冲突是允许的。

底层原理

Dictionary / HashSet 的流程是:

1. 先用 GetHashCode 定位桶
2. 如果桶里有多个元素,再用 Equals 逐个判断

所以 hashCode 只是“粗筛”,Equals 才是“最终确认”。

代码示例

c
using System; // 引入 IEquatable<T>。
using System.Collections.Generic; // 引入 HashSet<T>。

public readonly struct ItemId : IEquatable<ItemId> // 定义道具 ID,并实现相等比较。
{ // 结构体开始。
    private readonly int _value; // 保存真实 ID。
    public ItemId(int value) // 定义构造函数。
    { // 构造函数开始。
        _value = value; // 保存传入的 ID。
    } // 构造函数结束。
    public bool Equals(ItemId other) // 定义两个 ItemId 是否相等。
    { // 方法开始。
        return _value == other._value; // ID 相同才认为相等。
    } // 方法结束。
    public override bool Equals(object obj) // 重写 object.Equals。
    { // 方法开始。
        return obj is ItemId other && Equals(other); // 类型正确时调用强类型 Equals。
    } // 方法结束。
    public override int GetHashCode() // 重写 GetHashCode。
    { // 方法开始。
        return 1; // 故意让所有 ItemId 都返回相同 hash,用来演示哈希冲突。
    } // 方法结束。
} // 结构体结束。

public static class HashCollisionDemo // 定义演示类。
{ // 类开始。
    public static void Run() // 定义演示方法。
    { // 方法开始。
        HashSet<ItemId> set = new HashSet<ItemId>(); // 创建 HashSet。
        set.Add(new ItemId(1001)); // 添加 ID 为 1001 的对象。
        set.Add(new ItemId(1002)); // 添加 ID 为 1002 的对象,hash 相同但 Equals 为 false。
        Console.WriteLine(set.Count); // 输出 2,说明 hash 相同不代表 Equals 相等。
    } // 方法结束。
} // 类结束。

Unity / 游戏场景

比如你用 GridCoordItemIdEntityIdHashSetDictionary 的 key。即使两个 key 的 hashCode 碰巧一样,集合也不会直接认为它们相等,而是继续用 Equals 比较。

如果 hash 冲突很多,正确性通常还能保证,但性能会下降,因为同一个桶里要比较更多元素。

一句话记忆

NOTE

hashCode 相同只是“同一个桶”;Equals 为 true 才是“同一个逻辑对象”。

Dictionary 扩容时会发生什么?

标准答案

Dictionary 扩容时,本质是:申请更大的内部数组,把已有元素重新分桶,然后替换旧数组

csharp-dictionary-resize

底层会发生什么

Dictionary<TKey, TValue> 内部通常有两块核心结构:

c
buckets:桶数组,负责根据 hash 定位入口
entries:元素数组,保存 hashCode、key、value、next

当继续 Add 时,如果没有可用的 entry 槽位,就会触发扩容。常见实现会选择一个更大的容量,通常接近原容量的 2 倍再取合适大小,然后:

1. 创建更大的 buckets 数组
2. 创建更大的 entries 数组
3. 把旧 entries 复制过去
4. 根据新 buckets.Length 重新计算桶位置
5. 重建每个桶里的 next 链
6. 用新数组替换旧数组

注意:如果 key/value 是引用类型,扩容不会重新 new 这些对象,只是复制引用和 entry 结构。旧的 buckets / entries 数组之后会等待 GC 回收。

为什么要重新分桶

因为桶位置通常类似:

c
bucketIndex = hashCode % buckets.Length

扩容后 buckets.Length 变了,同一个 key 的 hashCode 没变,但 % 新桶数量 的结果可能变,所以必须重新挂到新的桶里。

性能影响

平时插入平均接近 O(1),但扩容那一次是 O(n),因为要搬迁已有元素。

而且扩容瞬间会有额外内存峰值:

c
旧 buckets + 旧 entries + 新 buckets + 新 entries

所以在 Unity 里,如果战斗中、加载中、UI 刷新中频繁让 Dictionary 扩容,就可能出现 GC Alloc 或帧尖峰。

代码示例

c
using System.Collections.Generic; // 引入 Dictionary<TKey, TValue>。

public static class DictionaryCapacityDemo // 定义字典容量演示类。
{ // 类开始。
    public static Dictionary<int, string> CreateItemNameMap(int expectedCount) // 创建道具 ID 到名字的映射表。
    { // 方法开始。
        Dictionary<int, string> map = new Dictionary<int, string>(expectedCount); // 提前给容量,减少后续扩容。
        for (int i = 0; i < expectedCount; i++) // 模拟批量插入 expectedCount 个元素。
        { // 循环开始。
            map.Add(i, "Item_" + i); // 添加元素,如果容量足够,通常不会中途扩容。
        } // 循环结束。
        return map; // 返回构建好的字典。
    } // 方法结束。
} // 类结束。

Unity 工程实践

配置表、资源索引、对象缓存、怪物 ID 映射这类字典,如果大概知道数量,建议初始化时传容量:

c
new Dictionary<int, EnemyConfig>(enemyConfigCount)

这样可以避免运行时边插入边扩容。

另外,Clear() 通常只是清空元素,不一定释放内部数组,所以它适合复用字典;如果你想释放内存,要考虑重新创建字典或使用对应运行时支持的压缩容量方法。

常见追问

CAUTION

删除元素后再添加,不一定马上扩容,因为 Dictionary 可能复用删除留下的空槽。

扩容属于结构修改,所以遍历中添加元素会导致枚举器失效,常见表现就是 foreach 中修改字典直接抛异常。

一句话记:Dictionary 扩容不是多加一个桶,而是换一张更大的表,把旧元素按新桶数量重新分布。

Dictionary 删除元素后空间会立刻缩小吗?

标准答案

不会立刻缩小。

Dictionary 删除元素后,Count 会减少,但内部的 bucketsentries 数组容量通常不会马上变小。被删除的 entry 槽位会进入空闲链表,后续再添加元素时可以复用。

csharp-dictionary-remove-capacity

底层原理

Dictionary 内部大概有:

c
buckets:桶数组
entries:元素数组
freeList:空闲槽位链表
freeCount:空闲槽位数量

删除一个 key 时,它通常会做这些事:

c
1. 根据 hash 找到桶
2. 在桶链里找到对应 entry
3. 把这个 entry 从链表中摘掉
4. 清理 key / value 引用
5. 把这个 entry 挂到 freeList
6. Count 对外表现减少

但它不会因为删了一个元素,就马上重新申请一个更小的数组。

为什么不立刻缩小

因为缩容也有成本。要缩小容量,就需要重新分配数组、重新拷贝元素、重新分桶,这本身是 O(n)

如果每次删除都缩容,频繁增删时会非常慢,还会产生更多 GC 压力。所以 Dictionary 更倾向于保留容量,方便后续复用。

代码示例

c
using System.Collections.Generic; // 引入 Dictionary<TKey, TValue>。

public static class DictionaryRemoveDemo // 定义字典删除演示类。
{ // 类开始。
    public static void Run() // 定义演示方法。
    { // 方法开始。
        Dictionary<int, string> map = new Dictionary<int, string>(100); // 创建初始容量为 100 的字典。
        map.Add(1, "Sword"); // 添加一个元素。
        map.Add(2, "Shield"); // 添加第二个元素。
        map.Add(3, "Potion"); // 添加第三个元素。
        map.Remove(2); // 删除 key 为 2 的元素,内部槽位会变成可复用空槽。
        map.Add(4, "Bow"); // 再添加新元素时,可能复用刚才删除留下的空槽。
        map.Clear(); // Clear 会清空元素,但通常仍保留内部容量。
    } // 方法结束。
} // 类结束。

Unity / 游戏场景

比如战斗中有一个 Dictionary<int, Enemy> 保存怪物索引。怪物死亡后 Remove,字典容量不会立刻降下来。这样下一波怪物生成时,可以复用内部空间,减少重新分配。

这是好事:频繁增删时更稳定。

但如果一个很大的临时字典只在加载阶段用一次,用完后一直留着,就可能造成内存常驻。此时可以考虑把引用置空重新创建,或者在运行时版本支持时使用 TrimExcess() 压缩容量。

常见追问

WARNING

Clear() 会不会释放容量?通常不会。它只是清空元素,保留内部数组,方便下次复用。

什么时候应该释放?如果确定这个大字典很久不用,尤其在切场景、退出玩法、卸载模块时,可以重新 new 一个小字典,或者让旧字典失去引用等待 GC。

一句话记:删除元素只是腾出座位,不会立刻拆掉房子;下一次添加会优先复用座位。

List<T>.CapacityCount 区别是什么?

标准答案

CountList<T> 当前实际存了多少个元素。

CapacityList<T> 底层数组当前最多能容纳多少个元素。

csharp-list-capacity-count

底层原理

List<T> 本质是一个动态数组,内部大概有:

c
T[] _items     底层数组
int _size      当前元素数量,也就是 Count

比如:

c
Count = 3
Capacity = 8

表示底层数组有 8 个位置,但只有前 3 个位置是有效元素。后面的 5 个位置只是预留空间,不能当成有效数据。

扩容时发生什么

当你继续 Add,如果:

c
Count < Capacity

就直接把元素放进已有空位。

如果:

c
Count == Capacity

Add 就会触发扩容:申请更大的数组,把旧元素复制过去,然后再添加新元素。这个过程会产生数组分配和复制成本。

代码示例

c
using System.Collections.Generic; // 引入 List<T>。

public static class ListCapacityDemo // 定义 List 容量演示类。
{ // 类开始。
    public static void Run() // 定义演示方法。
    { // 方法开始。
        List<int> numbers = new List<int>(8); // 创建 List,并提前设置 Capacity 为 8。
        numbers.Add(10); // 添加第一个元素,此时 Count 变成 1。
        numbers.Add(20); // 添加第二个元素,此时 Count 变成 2。
        numbers.Add(30); // 添加第三个元素,此时 Count 变成 3。
        int count = numbers.Count; // Count 是 3,表示实际有 3 个元素。
        int capacity = numbers.Capacity; // Capacity 是 8,表示底层数组能装 8 个元素。
        numbers.Clear(); // Clear 会把 Count 变成 0。
        int afterClearCapacity = numbers.Capacity; // Capacity 通常仍然是 8,方便后续复用。
        numbers.TrimExcess(); // 尝试压缩多余容量,适合确认后续不再大量添加时使用。
    } // 方法结束。
} // 类结束。

Unity / 游戏场景

如果你在战斗中维护子弹列表、怪物列表、特效列表,最好提前预估数量:

c
new List<Bullet>(maxBulletCount)

这样可以减少运行中扩容带来的 GC Alloc 和帧尖峰。

比如弹幕游戏中,如果一开始没设容量,战斗中不断 Add 子弹,List 可能多次扩容,每次扩容都要新建数组并复制旧元素,低端机上可能就会抖一下。

常见坑

IMPORTANT

Capacity 大不代表有元素。遍历业务数据应该用 Count,不是 Capacity

Clear() 会清空元素,但通常不会释放底层数组容量。这个行为适合复用列表,但如果一个大列表之后很久不用,可能导致内存常驻,可以考虑重新创建或 TrimExcess()

一句话记:Count 是已经坐了几个人,Capacity 是这辆车一共有几个座位。

List<T>.Clear() 会释放底层数组吗?

标准答案

List<T>.Clear() 通常不会释放底层数组

它主要做的是:

Count 变成 0
元素引用被清理
Capacity 通常保留

csharp-list-clear-capacity

底层原理

List<T> 内部本质是一个数组:

c
T[] _items
int _size

Clear() 会把 _size 置为 0,也就是 Count = 0

如果 T 是引用类型,或者结构体里包含引用字段,Clear() 通常还会把原来有效范围内的元素清成 null/default,这样这些元素对象如果没有其他引用,就可以被 GC 回收。

但是,_items 这个底层数组本身通常还保留着。

代码示例

c
using System.Collections.Generic; // 引入 List<T>。

public static class ListClearDemo // 定义 List 清理演示类。
{ // 类开始。
    public static void Run() // 定义演示方法。
    { // 方法开始。
        List<string> names = new List<string>(8); // 创建 List,并让底层数组容量为 8。
        names.Add("A"); // 添加第一个元素,Count 变为 1。
        names.Add("B"); // 添加第二个元素,Count 变为 2。
        names.Add("C"); // 添加第三个元素,Count 变为 3。
        int beforeCount = names.Count; // Clear 前 Count 是 3。
        int beforeCapacity = names.Capacity; // Clear 前 Capacity 是 8。
        names.Clear(); // 清空有效元素,Count 会变成 0。
        int afterCount = names.Count; // Clear 后 Count 是 0。
        int afterCapacity = names.Capacity; // Clear 后 Capacity 通常仍然是 8。
        names.TrimExcess(); // 如果确认后续不用这么大容量,可以尝试压缩底层数组。
    } // 方法结束。
} // 类结束。

Unity / 游戏场景

这在 Unity 里很常见。比如子弹列表、怪物列表、临时碰撞结果列表,每帧或每波战斗结束后调用 Clear(),可以复用底层数组,减少反复 new List 带来的 GC。

例如:

c
List<Enemy> visibleEnemies
List<Collider> hitResults
List<Bullet> activeBullets

如果这些列表会反复使用,Clear() 是合适的,因为它保留容量,下一次添加元素不容易触发扩容。

什么时候要释放容量

如果这个列表只是加载阶段临时用一次,里面曾经装过很多元素,之后很久不用,那一直保留大数组就会占内存。

这时可以考虑:

c
list.TrimExcess()
list.Capacity = 0
list = new List<T>()

具体选哪个要看运行时版本和项目习惯。最直接的是让旧 List 失去引用,重新创建一个小的。

常见坑

TIP

Clear() 不是 Destroy()。如果列表里放的是 Unity 对象引用,比如 GameObjectTextureAudioClipClear() 只是把引用从列表里移除,不会销毁 Unity 对象本身。

一句话记:Clear 是把乘客请下车,不是把车报废;车位还留着,方便下次继续用。

Array.Resize 本质做了什么?

标准答案

Array.Resize 本质不是“把原数组变长或变短”,而是:

创建一个新数组
把旧数组内容复制过去
再让原来的数组变量指向新数组

因为 C# 数组一旦创建,长度就是固定的。

csharp-array-resize

底层流程

假设原数组是:

c
[A, B, C]

执行:

c
Array.Resize(ref arr, 5)

结果大概是:

c
newArray = [A, B, C, default, default]
arr = newArray

旧数组不会被原地拉长。如果没有其他引用指向旧数组,它之后会等待 GC 回收。

代码示例

c
using System; // 引入 Array.Resize 方法。

public static class ArrayResizeDemo // 定义数组扩容演示类。
{ // 类开始。
    public static void Run() // 定义演示方法。
    { // 方法开始。
        int[] numbers = { 1, 2, 3 }; // 创建长度为 3 的原数组。
        int[] oldReference = numbers; // 保存旧数组引用,用来观察 Resize 不是原地修改。
        Array.Resize(ref numbers, 5); // 创建长度为 5 的新数组,复制旧元素,并让 numbers 指向新数组。
        numbers[3] = 4; // 给新数组第 4 个位置赋值。
        numbers[4] = 5; // 给新数组第 5 个位置赋值。
        bool sameArray = ReferenceEquals(numbers, oldReference); // 返回 false,说明 numbers 已经指向新数组。
        int oldLength = oldReference.Length; // oldLength 仍然是 3,旧数组长度没有变化。
        int newLength = numbers.Length; // newLength 是 5,新数组长度是 5。
    } // 方法结束。
} // 类结束。

扩大和缩小的区别

扩大数组时,旧元素会被复制,新位置填 default(T)

int 默认是 0
bool 默认是 false
引用类型默认是 null

缩小数组时,只复制前 newSize 个元素,后面的元素就被截断了。

如果传入的数组变量是 nullArray.Resize 会直接创建一个新数组。

Unity / 游戏场景

运行时频繁 Array.Resize 不适合游戏高频逻辑,因为每次真正改变长度时都可能产生:

新数组分配
旧数组复制
旧数组等待 GC

在 Unity 里,如果子弹、怪物、技能目标、碰撞结果数量经常变化,通常更适合用:

c
List<T>
对象池
预分配数组
ArrayPool<T>

而不是频繁 Array.Resize

常见坑

NOTE

Array.Resize 的参数是 ref T[] array。这说明它要修改调用者手里的数组变量,让它指向新数组。

如果还有别的变量引用旧数组,那些变量不会自动跟着变,它们仍然指向旧数组。

一句话记:Array.Resize 是换一个新数组,再把变量指过去,不是原地改变数组长度。

LinkedList<T> 适合什么场景?

标准答案

LinkedList<T> 适合:已经拿到节点引用后,需要频繁插入、删除、移动节点的场景

csharp-linkedlist-scenarios

底层原理

LinkedList<T> 是双向链表,每个节点大概保存:

c
Value
Previous
Next
List

所以它的优势不是“查找快”,而是:已知节点后,插入和删除只需要改前后指针

复杂度大概是:

c
AddFirst / AddLast:O(1)
AddBefore / AddAfter:已知节点时 O(1)
Remove(node):已知节点时 O(1)
Find(value):O(n)
按下标访问:不支持 O(1)

适合场景

最典型的是 LRU Cache。比如资源缓存里,最近使用的资源放到链表头,最久没用的资源在链表尾。访问某个资源时,如果能通过 Dictionary 直接拿到节点,就可以 O(1) 把它移动到表头。

c
using System.Collections.Generic; // 引入 Dictionary 和 LinkedList。

public sealed class LruOrder // 定义一个简单的 LRU 顺序管理类。
{ // 类开始。
    private readonly LinkedList<int> _order = new LinkedList<int>(); // 链表保存访问顺序,头部表示最近使用。
    private readonly Dictionary<int, LinkedListNode<int>> _nodes = new Dictionary<int, LinkedListNode<int>>(); // 字典保存 key 到链表节点的映射。
    public void Touch(int key) // 访问某个 key,并把它移动到最近使用位置。
    { // 方法开始。
        if (_nodes.TryGetValue(key, out LinkedListNode<int> node)) // 如果 key 已经存在,就直接拿到它的节点。
        { // if 开始。
            _order.Remove(node); // 已知节点时,从链表中删除是 O(1)。
            _order.AddFirst(node); // 把这个节点移动到链表头部,也是 O(1)。
            return; // 已有节点处理完毕,直接返回。
        } // if 结束。
        LinkedListNode<int> newNode = _order.AddFirst(key); // 新 key 创建节点,并放到链表头部。
        _nodes.Add(key, newNode); // 把 key 和节点记录到字典里,方便下次 O(1) 找到节点。
    } // 方法结束。
    public int RemoveOldest() // 移除最久未使用的 key。
    { // 方法开始。
        LinkedListNode<int> last = _order.Last; // 链表尾部就是最久未使用的节点。
        int key = last.Value; // 取出要移除的 key。
        _order.RemoveLast(); // 删除尾节点,复杂度是 O(1)。
        _nodes.Remove(key); // 同步删除字典里的节点映射。
        return key; // 返回被移除的 key。
    } // 方法结束。
} // 类结束。

Unity / 游戏场景

比较适合这些地方:

LRU 资源缓存
可取消的任务队列
下载任务优先级调整
Buff / Tick 节点已缓存的顺序链
需要频繁把某个节点移动到头尾的系统

但在 Unity 高频逻辑里,LinkedList<T> 不一定比 List<T> 好。因为链表节点是分散对象,CPU 缓存不友好,而且每个节点都有额外对象开销和 GC 压力。

不适合场景

如果你经常按下标访问:

c
list[i]

那应该用 List<T> 或数组。

如果你只是普通遍历,尤其在 Update、AI、物理、渲染相关热路径里,List<T> 通常更好,因为底层连续数组更 cache friendly。

面试关键句

CAUTION

LinkedList<T> 的核心优势是:已知节点时插入、删除、移动是 O(1);但如果你只有值,需要先查找节点,那查找仍然是 O(n)。所以它常常要和 Dictionary<TKey, LinkedListNode<T>> 搭配使用。

SortedDictionarySortedList 区别是什么?

标准答案

SortedDictionary<TKey, TValue>SortedList<TKey, TValue> 都会按 Key 排序,但底层结构不同:

SortedDictionary 底层是平衡树,适合频繁插入、删除。

SortedList 底层是两个有序数组,适合读多写少、数据量稳定、希望内存更紧凑的场景。

csharp-sorteddictionary-sortedlist

底层原理

SortedDictionary 可以理解成按 key 排序的树结构。查找、插入、删除通常都是 O(log n),性能比较稳定,但每个节点都有额外引用开销,内存不如数组紧凑。

SortedList 名字里有 List,但它不是链表。它底层更像:

c
TKey[] keys
TValue[] values

key 保持有序。查找时可以二分,通常是 O(log n);但如果在中间插入或删除,后面的元素要整体移动,所以插入删除可能是 O(n)

代码示例

c
using System; // 引入 Console。
using System.Collections.Generic; // 引入 SortedDictionary 和 SortedList。

public static class SortedCollectionDemo // 定义排序集合演示类。
{ // 类开始。
    public static void Run() // 定义演示方法。
    { // 方法开始。
        SortedDictionary<int, string> treeMap = new SortedDictionary<int, string>(); // 创建基于树的有序字典。
        treeMap.Add(30, "Boss"); // 添加 key 为 30 的元素。
        treeMap.Add(10, "Slime"); // 添加 key 为 10 的元素。
        treeMap.Add(20, "Goblin"); // 添加 key 为 20 的元素。
        SortedList<int, string> arrayMap = new SortedList<int, string>(); // 创建基于有序数组的有序表。
        arrayMap.Add(30, "Boss"); // 添加 key 为 30 的元素。
        arrayMap.Add(10, "Slime"); // 添加 key 为 10 的元素,内部会保持 keys 有序。
        arrayMap.Add(20, "Goblin"); // 添加 key 为 20 的元素,可能移动后续数组元素。
        foreach (KeyValuePair<int, string> pair in treeMap) // 遍历 SortedDictionary,输出时按 key 升序。
        { // 循环开始。
            Console.WriteLine(pair.Key + ":" + pair.Value); // 输出排序后的键值对。
        } // 循环结束。
        string secondValue = arrayMap.Values[1]; // SortedList 可以按索引访问 value。
    } // 方法结束。
} // 类结束。

怎么选择

如果数据经常动态插入、删除,比如运行时任务优先级表、动态时间轴、在线玩家积分实时变化,优先考虑 SortedDictionary

如果数据构建一次后主要查询,很少修改,比如加载后的配置表索引、固定排行榜快照、小规模有序映射,SortedList 可能更合适,因为数组更紧凑,也支持按索引访问 Keys[i]Values[i]

Unity / 游戏场景

比如你要维护一个按时间触发的事件表:

key = 触发时间
value = 事件

如果事件运行中频繁新增取消,可以用 SortedDictionary

如果是关卡加载时一次性建立好的时间轴,运行中只是顺序读取,SortedList 会更轻。

常见坑

WARNING

SortedList 插入中间元素可能很贵,因为数组要搬移。不要看到它查找 O(log n) 就以为所有操作都便宜。

SortedDictionary 遍历是有序的,但它不是哈希表,按 key 查询不是 Dictionary 那种平均 O(1),而是树结构的 O(log n)

一句话记:SortedDictionary 是树,插删稳定;SortedList 是有序数组,读多更省。

ConcurrentDictionary 解决什么问题?

标准答案

ConcurrentDictionary<TKey, TValue> 解决的是:多个线程同时读写字典时的线程安全问题

普通 Dictionary<TKey, TValue> 在多线程同时写、边读边写时是不安全的,可能出现数据竞争、异常、读到中间状态,甚至破坏内部结构。ConcurrentDictionary 把常见并发访问封装好了,提供线程安全的添加、删除、读取和更新操作。

csharp-concurrentdictionary

底层原理

可以简单理解为:它内部帮你做了并发控制。常见实现会尽量避免整张表都被一个大锁锁住,而是用更细粒度的同步方式,让多个线程可以更安全地访问不同部分的数据。

它提供的重点不是“更快的 Dictionary”,而是“并发场景下更安全、更不容易写错”。

常用方法有:

TryAdd:尝试添加,key 已存在就失败
TryRemove:尝试删除,并拿到旧 value
GetOrAdd:没有就添加,有就返回已有值
AddOrUpdate:没有就添加,有就更新
TryUpdate:类似带条件的更新

代码示例

c
using System.Collections.Concurrent; // 引入 ConcurrentDictionary。
using System.Threading.Tasks; // 引入 Parallel。

public static class ConcurrentDictionaryDemo // 定义并发字典演示类。
{ // 类开始。
    public static void Run() // 定义演示方法。
    { // 方法开始。
        ConcurrentDictionary<int, int> scores = new ConcurrentDictionary<int, int>(); // 创建线程安全字典。
        Parallel.For(0, 1000, i => // 并行执行 1000 次操作。
        { // Lambda 开始。
            int playerId = i % 10; // 模拟多个线程同时操作相同的玩家 ID。
            scores.AddOrUpdate(playerId, 1, (key, oldValue) => oldValue + 1); // 不存在就加 1,存在就安全累加。
        }); // Lambda 结束。
        int playerZeroScore = scores[0]; // 读取玩家 0 的累计分数。
    } // 方法结束。
} // 类结束。

它解决了什么具体问题

第一,避免普通 Dictionary 多线程写入时结构被破坏。

第二,避免你自己写 lock 时锁范围写错。

第三,避免典型的 check-then-act 竞态,比如:

c
if (!dict.ContainsKey(key))
    dict.Add(key, value)

这段在多线程里不安全,因为两个线程可能同时判断不存在,然后同时添加。用 GetOrAddTryAdd 就能把判断和添加合成一个原子操作。

Unity / 游戏场景

Unity 主线程限制依然存在。ConcurrentDictionary 只能保证字典本身线程安全,不能让 Unity API 变成线程安全。

适合用在:

后台线程收集日志
网络线程缓存消息
异步资源下载状态表
多线程任务结果缓存
服务器工具或编辑器工具的并发统计

不适合用它在子线程里直接操作:

c
GameObject
Transform
Texture
Animator
UnityEngine.Object

这些仍然应该回到主线程处理。

常见坑

IMPORTANT

ConcurrentDictionary 保护的是集合结构,不保护 value 对象内部状态。如果 value 是一个可变对象,多个线程同时改它的字段,仍然可能出问题。

另外,GetOrAdd 的工厂函数在并发情况下可能被多个线程调用,最终只有一个结果进入字典。所以工厂函数最好不要带不可重复的副作用,比如发奖励、扣钱、创建必须唯一的外部资源。

一句话记:ConcurrentDictionary 解决的是多线程安全访问字典,不是解决所有业务对象的线程安全。

lock 锁住字符串有什么风险?

csharp-lock-string-risk

标准答案

lock 不建议锁字符串,因为字符串字面量可能被 CLR 驻留,也就是相同内容的字符串字面量可能指向同一个对象。

所以两个完全无关的地方:

c
lock("Cache")

可能锁住的是同一把锁,导致无关代码互相阻塞,甚至形成死锁。

底层原理

lock(obj) 本质是对某个引用对象进入监视器锁,类似:

c
Monitor.Enter(obj)
try { ... }
finally { Monitor.Exit(obj) }

问题在于,字符串不是一个可靠的私有锁对象。

字符串字面量可能进入 intern pool:

c
"Cache"

不同类、不同模块里写同一个字符串字面量,可能拿到的是同一个字符串对象。这样一个模块锁住 "Cache",另一个模块也锁 "Cache",它们就会互相影响。

错误写法和正确写法

c
using System.Collections.Generic; // 引入 Dictionary<TKey, TValue>。

public sealed class BadCache // 定义一个错误示例类。
{ // 类开始。
    private readonly Dictionary<int, string> _data = new Dictionary<int, string>(); // 保存共享数据。
    public void Add(int id, string value) // 定义添加缓存的方法。
    { // 方法开始。
        lock ("Cache") // 错误:字符串可能被驻留,锁对象可能被无关代码共享。
        { // lock 代码块开始。
            _data[id] = value; // 修改共享字典。
        } // lock 代码块结束。
    } // 方法结束。
} // 类结束。

public sealed class GoodCache // 定义一个正确示例类。
{ // 类开始。
    private readonly object _syncRoot = new object(); // 正确:创建私有专用锁对象,外部拿不到。
    private readonly Dictionary<int, string> _data = new Dictionary<int, string>(); // 保存共享数据。
    public void Add(int id, string value) // 定义添加缓存的方法。
    { // 方法开始。
        lock (_syncRoot) // 正确:只锁当前对象内部的私有锁。
        { // lock 代码块开始。
            _data[id] = value; // 修改共享字典。
        } // lock 代码块结束。
    } // 方法结束。
} // 类结束。

Unity / 游戏场景

比如后台线程写日志、网络线程缓存消息、资源下载线程更新状态,如果你用了字符串当锁,另一个系统刚好也锁了同样的字符串,就可能出现莫名其妙的卡顿。

更糟的是,如果主线程也在等这把锁,Unity 就可能出现主线程卡住、帧率突然掉下去的问题。

推荐锁什么

推荐锁:

c
private readonly object _syncRoot = new object();

不推荐锁:

c
string
this
typeof(SomeClass)
公开可访问的 object
UnityEngine.Object

原因都类似:锁对象可能被外部代码拿到,锁边界就失控。

补充坑点

TIP

lock 只能保护 C# 数据结构的并发访问,不代表 Unity API 就能跨线程调用。即使用了 lock,子线程也不应该直接操作 GameObjectTransformTexture 这些 Unity 对象。

一句话记:锁对象必须私有、稳定、专用;字符串可能被共享,所以不要拿字符串当锁。

MonitorMutexSemaphore 区别是什么?

标准答案

MonitorMutexSemaphore 都是线程同步工具,但解决的问题不同:

Monitor:进程内互斥锁,lock 本质就是它的语法糖。

Mutex:系统级互斥锁,可以跨进程,比较重。

Semaphore:计数信号量,允许最多 N 个线程同时进入。

csharp-monitor-mutex-semaphore

底层区别

Monitor 绑定在一个托管对象上,只在当前进程内用于线程互斥。平时写的:

c
lock (_syncRoot) { ... }

编译后大致就是:

c
Monitor.Enter(_syncRoot)
try { ... }
finally { Monitor.Exit(_syncRoot) }

Mutex 是操作系统内核对象,可以创建命名 Mutex,让不同进程争同一把锁。它能力更强,但也更重,适合跨进程互斥,比如防止程序多开。

Semaphore 不一定只允许一个线程进入,它有一个计数。比如计数为 3,就表示最多 3 个线程同时进入,常用于限流、连接池、下载并发数控制。

代码示例

c
using System; // 引入 Console。
using System.Threading; // 引入 Monitor、Mutex、SemaphoreSlim。
using System.Threading.Tasks; // 引入 Task。

public sealed class SyncDemo // 定义同步演示类。
{ // 类开始。
    private readonly object _syncRoot = new object(); // 定义 Monitor/lock 使用的私有锁对象。
    private int _counter; // 定义需要被保护的共享数据。
    private readonly SemaphoreSlim _downloadLimit = new SemaphoreSlim(3); // 定义最多允许 3 个任务同时进入的异步信号量。
    public void AddOne() // 定义线程安全地增加计数的方法。
    { // 方法开始。
        lock (_syncRoot) // 使用 lock,本质是 Monitor.Enter 和 Monitor.Exit。
        { // lock 代码块开始。
            _counter++; // 修改共享数据,同一时间只允许一个线程执行。
        } // lock 代码块结束。
    } // 方法结束。
    public static bool TryRunSingleInstance() // 定义使用 Mutex 防止多进程同时运行的方法。
    { // 方法开始。
        using Mutex mutex = new Mutex(true, "MyGameTool_SingleInstance", out bool createdNew); // 创建命名 Mutex,用于跨进程互斥。
        return createdNew; // 如果 createdNew 为 true,说明当前进程拿到了这把跨进程锁。
    } // 方法结束。
    public async Task DownloadAsync() // 定义一个受限并发的异步下载方法。
    { // 方法开始。
        await _downloadLimit.WaitAsync(); // 等待信号量,有空位才允许继续执行。
        try // 开始 try,保证后面一定释放信号量。
        { // try 开始。
            await Task.Delay(100); // 模拟异步下载任务。
        } // try 结束。
        finally // finally 保证异常时也能释放。
        { // finally 开始。
            _downloadLimit.Release(); // 释放一个信号量名额。
        } // finally 结束。
    } // 方法结束。
} // 类结束。

怎么选

进程内保护共享数据,用 lock / Monitor

跨进程互斥,比如工具只允许打开一个实例,用 Mutex

限制并发数量,比如最多 3 个下载任务、最多 5 个后台 IO,用 Semaphore 或更常用的 SemaphoreSlim

异步代码里优先用 SemaphoreSlim.WaitAsync(),不要用阻塞式等待卡住线程。

Unity / 游戏场景

Unity 里常见的是后台线程处理日志、网络、文件 IO,然后主线程消费结果。保护普通 C# 队列或缓存可以用 lock

资源下载并发数限制可以用 SemaphoreSlim,比如最多同时下载 3 个 AssetBundle。

但要注意:加锁不能让 Unity API 变成线程安全。子线程仍然不能直接操作 GameObjectTransformTextureAnimator 等 Unity 对象。

常见坑

NOTE

MonitorMutex 都要成对释放,否则其他线程可能一直等。

Semaphore / SemaphoreSlim 也必须 Release(),通常放在 finally 里。

一句话记:Monitor 是房间门锁,Mutex 是跨楼栋门禁,Semaphore 是有 N 个车位的停车场。

volatile 有什么作用?

csharp-volatile

标准答案

volatile 的作用是:告诉编译器和运行时,这个字段可能会被多个线程同时访问,读写时要更重视“可见性”和“顺序性”。

它解决的是:

一个线程改了字段,另一个线程不要一直读旧值

但它不解决:

count++ 这种复合操作的原子性

底层原理

普通字段在多线程下,编译器、JIT、CPU 都可能做优化,比如缓存值、调整读写顺序。单线程下这些优化通常没问题,但多线程共享字段时,另一个线程可能看不到最新值。

volatile 会限制这类优化,让读写更接近“每次都认真观察共享字段”。

在 C# 里可以粗略理解为:

c
volatile 读:更像 acquire read
volatile 写:更像 release write

也就是说,它能帮助保证某些读写顺序和可见性。

代码示例

c
using System.Threading; // 引入 Thread。

public sealed class WorkerFlag // 定义一个后台工作标记类。
{ // 类开始。
    private volatile bool _running = true; // volatile 让其他线程更及时看到这个标记变化。
    public void Stop() // 定义停止方法。
    { // 方法开始。
        _running = false; // 一个线程把运行标记改成 false。
    } // 方法结束。
    public void WorkLoop() // 定义工作循环。
    { // 方法开始。
        while (_running) // 另一个线程循环读取 volatile 标记。
        { // 循环开始。
            Thread.SpinWait(1000); // 模拟后台工作。
        } // 循环结束。
    } // 方法结束。
} // 类结束。

它不能做什么

volatile 不能保证复合操作原子性。

比如下面这个仍然是线程不安全的:

c
using System.Threading; // 引入 Interlocked。

public sealed class CounterDemo // 定义计数器演示类。
{ // 类开始。
    private volatile int _count; // volatile 只能保证可见性,不能保证自增原子。
    public void WrongAdd() // 定义错误自增方法。
    { // 方法开始。
        _count++; // 错误:这包含读取、加一、写回三步,多线程会丢增量。
    } // 方法结束。
    public void RightAdd() // 定义正确自增方法。
    { // 方法开始。
        Interlocked.Increment(ref _count); // 正确:使用原子操作完成自增。
    } // 方法结束。
} // 类结束。

Unity / 游戏场景

在 Unity 里,volatile 可以用于非常简单的后台线程停止标记,比如下载线程、日志线程、网络辅助线程的退出标志。

但实际项目中,如果是取消异步任务,我更推荐:

c
CancellationToken

如果是计数、状态累加,用:

c
Interlocked

如果是多个字段要一起保持一致,用:

c
lock

另外,volatile 不能让 Unity API 变得线程安全。子线程仍然不能直接操作 GameObjectTransformTexture 等 Unity 对象。

常见坑

NOTE

不要把 volatile 当成“线程安全万能药”。

它适合简单状态标记,不适合复杂共享数据结构。比如一个对象里有多个字段要保持一致,单靠 volatile 不够,应该用 lock 或其他同步机制。

一句话记:volatile 管“看得见”,不管“一步完成”。

ThreadLocal<T> 是什么?

csharp-threadlocal

一句话定义

ThreadLocal<T> 是“线程本地变量”:同一个 ThreadLocal<T> 对象,不同线程访问 .Value 时,拿到的是各自独立的一份 T

底层理解

它不是把一个变量共享给所有线程,而是为“当前线程”维护一份独立值。线程 A 第一次访问 .Value,会创建 A 自己的值;线程 B 第一次访问 .Value,会创建 B 自己的值。A 改自己的 .Value,B 看不到。

适合用在每个线程都需要一份临时对象的场景,比如日志格式化缓冲区、解析缓存、随机数对象、线程内统计数据。这样可以减少共享数据,也减少 lock 竞争。

代码示例

c
using System; // 引入 IDisposable。
using System.Text; // 引入 StringBuilder。
using System.Threading; // 引入 ThreadLocal<T>。
public sealed class ThreadLocalBuffer : IDisposable // 定义一个每线程缓冲区类,并支持释放。
{ // 类开始。
    private readonly ThreadLocal<StringBuilder> _builder = new ThreadLocal<StringBuilder>(() => new StringBuilder(256)); // 每个线程第一次访问时创建自己的 StringBuilder。
    public string FormatMessage(int id, string text) // 定义格式化消息方法。
    { // 方法开始。
        StringBuilder builder = _builder.Value; // 当前线程拿到属于自己的 StringBuilder。
        builder.Clear(); // 清空当前线程自己的缓冲区,不影响其他线程。
        builder.Append("id="); // 写入固定前缀。
        builder.Append(id); // 写入消息 ID。
        builder.Append(", text="); // 写入分隔文本。
        builder.Append(text); // 写入消息内容。
        return builder.ToString(); // 返回格式化后的字符串。
    } // 方法结束。
    public void Dispose() // 定义释放方法。
    { // 方法开始。
        _builder.Dispose(); // 释放 ThreadLocal 保存的线程本地值引用。
    } // 方法结束。
} // 类结束。

面试坑点

NOTE

ThreadLocal<T> 不适合表达“异步上下文变量”。因为 async/await 恢复后可能换线程,如果你想让一个值跟着异步调用链走,通常用 AsyncLocal<T>

在线程池里也要小心:线程会复用,线程本地值可能比你想象中活得更久,所以不用时要 Dispose()。Unity 中它可以用于后台线程的日志、网络解析、资源校验缓存,但不能因此在子线程调用 Unity API。

TaskThread 区别是什么?

csharp-task-thread

标准答案

Thread 是线程本身,表示一个真实的执行流;Task 是任务抽象,表示“有一件事要异步完成”,至于用哪个线程跑,通常交给 TaskScheduler 和线程池决定。

简单记:Thread 关心“谁来跑”,Task 关心“这件事完成了吗、结果是什么、异常是什么”。

底层原理

Thread 通常对应操作系统线程,创建、销毁、上下文切换成本都比较高。你 new Thread(...).Start(),就是明确创建一个线程去执行。

Task 本身不是线程。Task.Run 一般会把工作丢给线程池,线程池复用已有工作线程,避免频繁创建线程。并且 Task 能保存完成状态、返回值、异常、取消状态,还能被 await 等待。

特别注意:await 不等于开线程。对于异步 IO,比如网络、文件等待,等待期间可能没有线程一直阻塞在那里,完成后再执行后续 continuation。

Unity 工程实践

Unity 里大多数 API 只能在主线程调用,所以 Task 可以用来做后台计算、下载、解压、配置解析、日志处理,但不能在后台线程里直接改 TransformGameObjectTexture 等 Unity 对象。

常见做法是:后台 Task 算完数据,然后回到主线程应用结果。否则可能出现崩溃、随机异常、编辑器没事但真机出问题。

代码示例

c
using System.Threading; // 引入 CancellationTokenSource 和 CancellationToken。
using System.Threading.Tasks; // 引入 Task 和 async/await。
using UnityEngine; // 引入 Unity 的 MonoBehaviour 和 Debug。
public sealed class TaskVsThreadExample : MonoBehaviour // 定义一个 Unity 示例脚本。
{ // 类开始。
    private CancellationTokenSource _cts; // 保存取消令牌源,用于对象销毁时取消后台任务。
    private void Awake() // Unity 初始化阶段调用。
    { // 方法开始。
        _cts = new CancellationTokenSource(); // 创建取消令牌源。
    } // 方法结束。
    private async void Start() // Unity 第一次 Update 前调用,这里演示异步任务。
    { // 方法开始。
        int result = await Task.Run(() => HeavyCalculate(_cts.Token)); // 把耗时计算交给线程池线程执行,并等待结果。
        Debug.Log(result); // await 后回到 Unity 主线程上下文时,再安全地调用 Unity API。
    } // 方法结束。
    private int HeavyCalculate(CancellationToken token) // 定义一个耗时计算方法。
    { // 方法开始。
        int sum = 0; // 创建累加变量。
        for (int i = 0; i < 1000000; i++) // 模拟大量循环计算。
        { // 循环开始。
            token.ThrowIfCancellationRequested(); // 如果对象销毁或任务被取消,就立刻抛出取消异常。
            sum += i; // 执行计算逻辑。
        } // 循环结束。
        return sum; // 返回计算结果。
    } // 方法结束。
    private void OnDestroy() // Unity 对象销毁时调用。
    { // 方法开始。
        _cts.Cancel(); // 通知后台任务应该取消。
        _cts.Dispose(); // 释放取消令牌源占用的资源。
    } // 方法结束。
} // 类结束。

面试加分点

TIP

Task 更适合短任务、异步 IO、并行计算、组合多个异步结果;Thread 更适合你确实需要长期独立线程,并且要明确控制线程生命周期的场景。

不要说“Task 就是线程”。更准确的说法是:Task 是任务和结果的抽象,底层可能使用线程池线程,也可能只是表示一个异步 IO 的完成通知。

ValueTask 适合什么场景?

csharp-valuetask

标准答案

ValueTask / ValueTask<T> 适合“这个异步方法经常可以同步完成”的高频场景,比如缓存命中、对象池里直接拿到结果、内存数据已经准备好,只有少数情况才需要真正异步等待。

默认回答可以这样说:一般业务代码优先用 Task,只有在高频调用、且大多数时候同步返回、并且通过 Profiler/GC Alloc 证明 Task 分配有压力时,才考虑 ValueTask

底层原理

Task<T> 是引用类型,返回一个异步结果通常意味着有对象分配。ValueTask<T> 是值类型包装器,它可以直接包住一个 T 结果,也可以包住一个 Task<T>

所以缓存命中时:

Task<T>:可能要给结果包一层 Task 对象
ValueTask<T>:可以直接把 T 放进去返回

缓存未命中时:

ValueTask<T>:仍然可以包装真正的 Task<T>

它解决的是热路径上的小对象分配问题,不是让异步逻辑变得更快,也不是替代线程。

代码示例

c
using System.Collections.Generic; // 引入 Dictionary,用来模拟配置缓存。
using System.Threading.Tasks; // 引入 Task 和 ValueTask。
public sealed class ConfigService // 定义一个配置服务类。
{ // 类开始。
    private readonly Dictionary<int, string> _cache = new Dictionary<int, string>(); // 保存已经加载过的配置内容。
    public async ValueTask<string> GetConfigAsync(int id) // 定义一个可能同步完成也可能异步完成的方法。
    { // 方法开始。
        if (_cache.TryGetValue(id, out string cached)) // 如果缓存里已经有这个配置。
        { // if 开始。
            return cached; // 直接返回结果,ValueTask 可以少一次 Task 分配。
        } // if 结束。
        string loaded = await LoadConfigFromDiskAsync(id); // 缓存未命中时,才真正异步加载。
        _cache[id] = loaded; // 把加载结果写入缓存,方便下次同步返回。
        return loaded; // 返回最终配置内容。
    } // 方法结束。
    private Task<string> LoadConfigFromDiskAsync(int id) // 定义一个模拟异步加载配置的方法。
    { // 方法开始。
        return Task.FromResult("config_" + id); // 示例中直接返回一个完成的 Task,实际项目里可能是文件或网络 IO。
    } // 方法结束。
} // 类结束。

Unity 工程实践

Unity 里可以用在资源管理器、配置表管理器、缓存查询接口里。比如某个资源已经在内存缓存中,就用 ValueTask<T> 直接返回;如果没加载,再走异步加载流程。

但它不适合所有异步接口。比如网络请求、远程下载、Addressables 真正异步加载,如果大多数时候都需要等待,那么直接用 Task 更清晰。

常见坑点

IMPORTANT

ValueTask 的使用规则比 Task 更复杂。通常不要多次 await 同一个 ValueTask,不要随便缓存它、传来传去、放进 WhenAll。如果确实要多次等待或组合,可以 .AsTask(),但这样可能又产生分配,失去它的意义。

面试里最好补一句:ValueTask 是优化工具,不是默认选择。没有测出分配瓶颈之前,我会优先用 Task 保持 API 简单。

CancellationToken 如何使用?

csharp-cancellation-token

标准答案

CancellationToken 是 C# 里做“协作式取消”的机制。它不是强制杀掉线程,而是由调用方发出取消信号,异步任务在合适的位置主动检查这个信号,然后安全退出。

常见三件套:

CancellationTokenSource:取消信号源,负责 Cancel()

CancellationToken:传给任务的只读信号。

OperationCanceledException:任务响应取消时常见的异常形式。

底层原理

CancellationTokenSource.Cancel() 会把内部状态标记为“已取消”,并通知注册过的回调。任务如果调用 token.ThrowIfCancellationRequested(),发现已取消,就会抛出 OperationCanceledException,让异步流程进入取消分支。

所以关键点是:任务必须主动检查 token。你只调用 Cancel(),但任务内部完全不检查,它就不会马上停。

代码示例

c
using System; // 引入 OperationCanceledException。
using System.Threading; // 引入 CancellationToken 和 CancellationTokenSource。
using System.Threading.Tasks; // 引入 Task 和 Task.Delay。
using UnityEngine; // 引入 MonoBehaviour 和 Debug。
public sealed class CancelLoadExample : MonoBehaviour // 定义一个 Unity 异步取消示例脚本。
{ // 类开始。
    private CancellationTokenSource _cts; // 保存当前对象生命周期内的取消信号源。
    private void OnEnable() // 对象启用时调用。
    { // 方法开始。
        _cts = new CancellationTokenSource(); // 创建新的取消信号源。
        _ = LoadAsync(_cts.Token); // 启动异步加载任务,并把 token 传进去。
    } // 方法结束。
    private async Task LoadAsync(CancellationToken token) // 定义支持取消的异步加载方法。
    { // 方法开始。
        try // 开始捕获取消异常。
        { // try 开始。
            for (int i = 0; i < 5; i++) // 模拟五个加载步骤。
            { // 循环开始。
                token.ThrowIfCancellationRequested(); // 主动检查是否已经请求取消。
                await Task.Delay(500, token); // 等待时也传入 token,让等待过程可以被取消。
                Debug.Log("加载步骤:" + i); // 回到主线程上下文后输出加载步骤。
            } // 循环结束。
        } // try 结束。
        catch (OperationCanceledException) // 捕获取消异常。
        { // catch 开始。
            Debug.Log("加载已取消"); // 处理取消结果,避免当成普通错误。
        } // catch 结束。
    } // 方法结束。
    private void OnDisable() // 对象禁用时调用。
    { // 方法开始。
        _cts.Cancel(); // 发出取消请求,让异步任务尽快退出。
        _cts.Dispose(); // 释放取消信号源内部资源。
        _cts = null; // 清空引用,避免误用旧的取消源。
    } // 方法结束。
} // 类结束。

Unity 工程实践

在 Unity 里,常用于异步加载资源、网络请求、配置表解析、切场景取消旧任务、关闭 UI 时取消未完成请求。

比如玩家打开背包界面时开始异步加载图标,玩家马上关闭背包,就应该取消这批加载,避免加载完成后又去访问已经关闭的 UI 对象。

常见坑点

TIP

Cancel() 不代表任务已经结束,只代表“我请求你结束”。任务要检查 token,才能及时退出。

CancellationTokenSource 用完要 Dispose(),尤其是用了 CancelAfter() 或注册回调时。

不要用取消去替代异常处理。取消是预期流程,不应该当作真正错误上报。

ConfigureAwait(false) 是什么?

csharp-configureawait-false

标准答案

ConfigureAwait(false) 是给 await 用的配置,意思是:await 后面的续代码不一定要回到原来的上下文执行。

默认情况下:

c
await task

通常会尝试捕获当前 SynchronizationContext,比如 UI 主线程、Unity 主线程上下文。异步操作完成后,后续代码会尽量回到这个上下文继续执行。

而:

c
await task.ConfigureAwait(false)

表示“不强制回原上下文”,后续代码可能在线程池线程继续执行。

底层原理

await 不是简单地“停在原地等”。它会把 await 后面的代码拆成一个 continuation,也就是“续体”。

默认 await 会问:当前有没有 SynchronizationContext?如果有,就把续体投递回这个上下文。Unity 中可以理解为:尽量回到 Unity 主线程继续执行。

ConfigureAwait(false) 就是告诉它:不用捕获这个上下文,异步任务完成后,后续代码在哪里方便就在哪里继续,通常可能是线程池线程。

代码示例

c
using System.Threading.Tasks; // 引入 Task 和 async/await。
using UnityEngine; // 引入 Unity 的 MonoBehaviour 和 Debug。
public sealed class ConfigureAwaitExample : MonoBehaviour // 定义一个 Unity 示例脚本。
{ // 类开始。
    private async void Start() // Unity 启动时调用,async void 只适合 Unity 生命周期入口或事件入口。
    { // 方法开始。
        string result = await LoadTextAsync(); // 这里默认会捕获 Unity 主线程上下文,完成后回到主线程继续。
        Debug.Log(result); // 回到 Unity 主线程后,可以安全调用 Unity API。
    } // 方法结束。
    private async Task<string> LoadTextAsync() // 定义一个异步加载文本的方法。
    { // 方法开始。
        string raw = await FakeIoAsync().ConfigureAwait(false); // 内部纯数据等待,不要求回 Unity 主线程。
        string parsed = raw.Trim(); // 这里只处理普通字符串,不访问 Unity API,所以可以不在主线程。
        return parsed; // 把处理后的结果返回给外层调用方。
    } // 方法结束。
    private Task<string> FakeIoAsync() // 定义一个模拟异步 IO 的方法。
    { // 方法开始。
        return Task.FromResult(" config data "); // 示例中直接返回完成的 Task,真实项目可能是网络或文件读取。
    } // 方法结束。
} // 类结束。

Unity 工程实践

在 Unity 里,ConfigureAwait(false) 可以用在“纯 C# 库代码”或“后台数据处理”里,比如 JSON 解析、配置表处理、网络字节流解析。

但如果 await 后面要操作 GameObjectTransformTextureUI,就不要在这一段用 ConfigureAwait(false),因为续代码可能不在 Unity 主线程。

常见坑点

NOTE

ConfigureAwait(false) 不等于开新线程。它只是“不强制回原上下文”。

它也不等于性能一定更快。它的收益主要是减少上下文切换,避免某些 UI/旧 ASP.NET 同步等待导致的死锁。

面试里可以这样收尾:应用层代码如果依赖 UI 或 Unity 主线程,我默认不用;通用库内部如果不依赖上下文,我会考虑使用 ConfigureAwait(false)

async void 为什么危险?

csharp-async-void-danger

标准答案

async void 危险是因为它没有返回 Task。调用方不能 await 它,也拿不到它的完成状态、异常、取消状态,所以它很像“发出去就失联”的异步调用。

面试里可以直接说:普通异步方法尽量写 async Taskasync Task<T>async void 只适合事件处理器、Unity 生命周期入口这类本来就要求 void 的方法。

底层原理

async Task 方法里的异常会被保存到返回的 Task 里,调用方 await 时可以用 try/catch 捕获。

async void 没有 Task 作为承载对象。它内部异步阶段抛出的异常,会被投递到当前 SynchronizationContext 或运行时异常处理流程里,调用方外面的 try/catch 很可能接不住。

所以危险点有三个:

无法等待:不知道它什么时候执行完。

异常不可控:异常不能像 await Task 一样被调用方捕获。

生命周期难管理:对象销毁、界面关闭、场景切换后,它可能还在继续跑。

代码示例

c
using System; // 引入 Exception。
using System.Threading.Tasks; // 引入 Task 和 async/await。
using UnityEngine; // 引入 Unity 的 MonoBehaviour 和 Debug。
public sealed class AsyncVoidExample : MonoBehaviour // 定义一个 Unity 示例脚本。
{ // 类开始。
    private async void Start() // Unity 生命周期入口只能是 void,所以这里可以 async void。
    { // 方法开始。
        try // 在 async void 入口内部兜底异常。
        { // try 开始。
            await LoadDataAsync(); // 调用真正的 async Task 业务方法。
        } // try 结束。
        catch (Exception ex) // 捕获业务异步方法抛出的异常。
        { // catch 开始。
            Debug.LogError(ex.Message); // 在 Unity 主线程记录错误信息。
        } // catch 结束。
    } // 方法结束。
    private async Task LoadDataAsync() // 真正的业务异步方法返回 Task。
    { // 方法开始。
        await Task.Delay(1000); // 模拟异步等待,例如网络或文件 IO。
        throw new Exception("加载失败"); // 模拟异步阶段抛出异常。
    } // 方法结束。
} // 类结束。

Unity 工程实践

Unity 的 Start、按钮点击回调、事件回调有时候必须是 void,这时可以写 async void,但它应该只做入口转发。

推荐结构是:

c
async void Start / OnClick
    -> try/catch
    -> await XxxAsync()
    
XxxAsync 返回 Task
    -> 真正业务逻辑

这样业务层依然可以被测试、等待、取消、组合,异常也能集中处理。

常见坑点

IMPORTANT

不要把资源加载、登录流程、战斗初始化这种核心业务写成 async void。否则上层无法知道它是否完成,也不好做取消和错误恢复。

如果是“故意不等待”的 fire-and-forget 任务,也要内部 try/catch,并考虑 CancellationToken,否则线上异常会很难定位。

异常在 Task 中如何传播?

csharp-task-exception-propagation

标准答案

async Task 方法里抛出的异常,会被异步状态机捕获并保存到返回的 Task 里。这个 Task 的状态会变成 Faulted

当你 await 这个 Task 时,异常会被重新抛出来,所以可以像同步代码一样用 try/catch 捕获。

底层原理

比如这个异步方法:

c
async Task FooAsync()
{
    throw new Exception();
}

它不是把异常直接抛给调用方,因为调用方拿到的是一个 Task。异常会先进入 Task.ExceptionTask.Status 变成 Faulted

传播方式主要看你怎么观察这个 Task

await task:重新抛出原始异常,最推荐。

task.Wait() / task.Result:通常抛 AggregateException,真正异常在 InnerExceptionInnerExceptions 里。

Task.WhenAll:多个任务失败时,整体任务也会 Faulted,可以通过返回的 Task.Exception 看到多个异常。

代码示例

c
using System; // 引入 Exception。
using System.Threading.Tasks; // 引入 Task 和 async/await。
using UnityEngine; // 引入 Unity 的 MonoBehaviour 和 Debug。
public sealed class TaskExceptionExample : MonoBehaviour // 定义一个 Unity 示例脚本。
{ // 类开始。
    private async void Start() // Unity 生命周期入口只能是 void,所以这里作为异步入口。
    { // 方法开始。
        try // 用 try/catch 捕获 await 重新抛出的异常。
        { // try 开始。
            await LoadAsync(); // 等待异步任务,任务失败时这里会重新抛出异常。
        } // try 结束。
        catch (Exception ex) // 捕获异步任务中的异常。
        { // catch 开始。
            Debug.LogError(ex.Message); // 记录异常信息,避免后台异常丢失。
        } // catch 结束。
    } // 方法结束。
    private async Task LoadAsync() // 定义一个返回 Task 的异步方法。
    { // 方法开始。
        await Task.Delay(500); // 模拟异步等待。
        throw new InvalidOperationException("资源加载失败"); // 异步阶段抛出异常,会进入 Task。
    } // 方法结束。
} // 类结束。

Unity 工程实践

在 Unity 项目里,异步资源加载、网络请求、配置解析都应该让业务方法返回 Task,然后在入口处集中 await + try/catch

不要写了一个任务就不管:

c
_ = LoadAsync();

这种 fire-and-forget 如果内部异常没人捕获,线上会很难定位。至少要在任务内部 try/catch,或者封装一个统一的 ForgetWithLog() 记录错误。

常见坑点

IMPORTANT

取消和异常要区分。OperationCanceledException 配合正确的 CancellationToken,通常表示任务进入 Canceled,不是 Faulted

面试里最好说一句:await 是推荐观察异常的方式,尽量少用 .Wait().Result,它们容易导致阻塞、死锁,还会把异常包装成 AggregateException

try/finally 在资源释放中有什么用?

csharp-try-finally-resource-release

标准答案

try/finally 的作用是保证资源释放代码尽量一定执行。无论 try 里正常结束、提前 return,还是抛出异常,只要程序控制流能离开 try,就会进入 finally 做收尾。

面试里一句话:catch 是处理异常,finally 是释放资源,它不关心有没有异常,都要收拾现场。

底层原理

很多资源不是 GC 能立刻处理的,比如文件句柄、Socket、锁、对象池借出的对象、事件订阅、临时状态。GC 只负责托管内存,不保证你什么时候释放这些外部资源。

所以 C# 里 using 本质上就是 try/finally 的语法糖:

c
using(resource)
{
    work;
}

大概等价于:

c
try
{
    work;
}
finally
{
    resource.Dispose();
}

代码示例

c
using System; // 引入 IDisposable。
public sealed class PooledObject : IDisposable // 定义一个需要归还的池对象。
{ // 类开始。
    public void Use() // 定义使用对象的方法。
    { // 方法开始。
        Console.WriteLine("使用对象"); // 模拟业务逻辑。
    } // 方法结束。
    public void Dispose() // 定义释放方法。
    { // 方法开始。
        Console.WriteLine("归还对象池"); // 模拟把对象归还到对象池。
    } // 方法结束。
} // 类结束。
public static class FinallyExample // 定义示例工具类。
{ // 类开始。
    public static void Run() // 定义运行示例的方法。
    { // 方法开始。
        PooledObject obj = new PooledObject(); // 从对象池或资源系统中拿到一个对象。
        try // 开始执行可能失败的业务逻辑。
        { // try 开始。
            obj.Use(); // 使用资源对象。
            throw new Exception("中途失败"); // 模拟业务执行过程中出现异常。
        } // try 结束。
        finally // 无论 try 成功还是失败,都会进入 finally。
        { // finally 开始。
            obj.Dispose(); // 释放资源或归还对象池。
        } // finally 结束。
    } // 方法结束。
} // 类结束。

Unity 工程实践

Unity 里 try/finally 常用于这些场景:

对象池:从池里取出临时对象,逻辑失败也要归还。

事件系统:临时订阅事件,结束时必须取消订阅。

加载流程:设置 isLoading = true 后,失败也要恢复成 false

锁或标记位:避免异常导致状态永远卡住。

比如技能释放中临时打开攻击判定盒,后面异常了,也要在 finally 里关闭判定盒,否则角色可能一直处于可伤害状态。

常见坑点

IMPORTANT

不要在 finally 里写复杂业务,也不要随便抛新异常。因为 finally 里抛出的异常可能会覆盖 try 里的原始异常,导致真正的问题被藏起来。

也不要误解成 finally 百分百执行。比如进程被强杀、断电、环境崩溃,这种极端情况它也没机会执行。正常控制流和普通异常下,它才是可靠收尾机制。

using var 和传统 using 区别是什么?

csharp-using-var-vs-using

标准答案

using var 和传统 using 的核心区别是:释放资源的作用域不同。

传统 using 是一个语句块,资源在这个块结束时释放。

using var 是一个声明,资源在当前作用域结束时释放,比如方法结束、当前 { } 代码块结束。

它们底层本质都接近 try/finally,最终都会在 finally 里调用 Dispose()

底层原理

传统写法:

c
using (var r = Open())
{
    DoWork();
}

资源 rusing 的花括号结束时释放。

using var 写法:

c
using var r = Open();
DoWork();
MoreWork();

资源 r 会活到当前作用域结束才释放。如果这是一个很长的方法,那它可能比传统 using 持有资源更久。

代码示例

c
using System; // 引入 Console。
using System.IO; // 引入 StringReader。
public static class UsingExample // 定义 using 示例类。
{ // 类开始。
    public static void TraditionalUsing() // 定义传统 using 示例方法。
    { // 方法开始。
        using (StringReader reader = new StringReader("hello")) // 创建资源,并限定在 using 块内使用。
        { // using 块开始。
            string text = reader.ReadLine(); // 读取一行文本。
            Console.WriteLine(text); // 输出读取到的文本。
        } // using 块结束时自动调用 reader.Dispose()。
    } // 方法结束。
    public static void UsingVar() // 定义 using var 示例方法。
    { // 方法开始。
        using var reader = new StringReader("hello"); // 创建资源,资源会在当前方法作用域结束时释放。
        string text = reader.ReadLine(); // 读取一行文本。
        Console.WriteLine(text); // 输出读取到的文本。
    } // 方法结束时自动调用 reader.Dispose()。
} // 类结束。

Unity 工程实践

在 Unity 编辑器工具里,经常会读写文件、生成配置、导出资源,这类 FileStreamStreamReaderStringWriter 都适合用 usingusing var 保证释放。

如果资源生命周期很短,我更倾向传统 using,因为释放边界一眼能看出来。如果方法很短、只是简单读取文件,using var 更清爽,少一层缩进。

常见坑点

TIP

using var 不代表“这一行执行完就释放”,它是当前作用域结束才释放。这个点面试官很爱追问。

多个 using var 一般按声明的反向顺序释放,类似栈:后创建的先释放。

如果对象实现的是 IAsyncDisposable,要用 await using,不是普通 using

IDisposable 和终结器 Finalizer 区别是什么?

csharp-idisposable-finalizer

标准答案

IDisposable 是主动释放资源的机制,通常通过 Dispose()using 调用,释放时机可控。

Finalizer,也就是终结器,写法类似 ~ClassName(),是对象被 GC 判定不可达后,由终结器线程兜底清理,释放时机不可控。

一句话:Dispose 是正常关门,Finalizer 是你忘了关门后,系统最后巡场帮你兜底。

底层原理

GC 主要负责托管内存,比如普通 C# 对象。但文件句柄、Socket、Native 内存、操作系统 Handle、Unity Native 资源,不一定能只靠 GC 及时释放。

IDisposable 的目标是确定性释放:我用完就释放。

Finalizer 的目标是兜底:如果用户忘了调用 Dispose(),GC 之后还有机会清掉非托管资源。

但是带 Finalizer 的对象回收更慢。它第一次被 GC 发现不可达时,通常不会马上回收内存,而是进入终结队列,等终结器线程执行完,后续 GC 才能真正回收。所以 Finalizer 有额外性能成本。

代码示例

c
using System; // 引入 IDisposable 和 GC。
public sealed class NativeResourceWrapper : IDisposable // 定义一个包装非托管资源的类。
{ // 类开始。
    private IntPtr _handle; // 保存模拟的非托管资源句柄。
    private bool _disposed; // 记录资源是否已经释放,避免重复释放。
    public NativeResourceWrapper(IntPtr handle) // 定义构造函数。
    { // 构造函数开始。
        _handle = handle; // 保存外部传入的非托管句柄。
    } // 构造函数结束。
    public void Dispose() // 实现 IDisposable 的 Dispose 方法。
    { // Dispose 方法开始。
        Dispose(true); // 主动释放时,允许清理托管和非托管资源。
        GC.SuppressFinalize(this); // 告诉 GC 不需要再调用终结器。
    } // Dispose 方法结束。
    private void Dispose(bool disposing) // 定义统一释放逻辑。
    { // 释放逻辑开始。
        if (_disposed) // 如果已经释放过。
        { // if 开始。
            return; // 直接返回,避免重复释放。
        } // if 结束。
        if (disposing) // 如果是主动 Dispose 调用。
        { // if 开始。
            // 这里可以释放托管资源。 // 主动释放路径允许访问其他托管对象。
        } // if 结束。
        if (_handle != IntPtr.Zero) // 如果非托管句柄还有效。
        { // if 开始。
            _handle = IntPtr.Zero; // 示例中把句柄置空,真实项目这里会调用原生释放函数。
        } // if 结束。
        _disposed = true; // 标记资源已经释放。
    } // 释放逻辑结束。
    ~NativeResourceWrapper() // 定义终结器,作为忘记 Dispose 时的兜底。
    { // 终结器开始。
        Dispose(false); // 终结器路径只清理非托管资源,不依赖其他托管对象。
    } // 终结器结束。
} // 类结束。

Unity 工程实践

Unity 里常见的资源管理思路也是这个方向:能主动释放就主动释放,不要等 GC。

比如 NativeArray、文件流、网络连接、压缩解压临时对象、原生插件返回的句柄,都应该有明确的释放流程。尤其移动端上,Native 内存不及时释放,可能表现为 Memory Profiler 里托管内存不高,但进程总内存一直涨。

常见坑点

NOTE

不要给所有类都写 Finalizer。只有直接持有非托管资源时才考虑它。普通托管对象不需要 Finalizer。

Dispose() 里主动释放成功后,要调用 GC.SuppressFinalize(this),避免对象还进入终结队列,浪费 GC 成本。

Finalizer 里不要访问其他托管对象,因为它们的生命周期和终结顺序不可控。终结器里通常只释放自己直接持有的非托管资源。

GC.SuppressFinalize 有什么用?

csharp-gc-suppress-finalize

标准答案

GC.SuppressFinalize(this) 的作用是:当对象已经通过 Dispose() 主动释放完资源后,告诉 GC 不要再调用这个对象的终结器 Finalizer。

一句话:资源已经手动清理干净了,就别再让 GC 走一次兜底清理流程。

底层原理

如果一个类有 Finalizer,比如:

c
~MyClass()

这个对象在 GC 回收时会更麻烦。GC 发现它不可达后,通常不会马上回收,而是放进终结队列,等终结器线程执行完 Finalizer,后续 GC 才能真正回收它。

如果你已经在 Dispose() 里主动释放了非托管资源,再让 Finalizer 执行就没必要了,还可能造成重复释放风险。所以标准 Dispose 模式里会写:

c
GC.SuppressFinalize(this)

它不是释放资源,它只是取消这个对象的终结器调用。

代码示例

c
using System; // 引入 IDisposable、IntPtr 和 GC。
public sealed class NativeHandleWrapper : IDisposable // 定义一个包装非托管句柄的类。
{ // 类开始。
    private IntPtr _handle; // 保存模拟的非托管句柄。
    private bool _disposed; // 记录是否已经释放,避免重复释放。
    public NativeHandleWrapper(IntPtr handle) // 定义构造函数。
    { // 构造函数开始。
        _handle = handle; // 保存传入的非托管句柄。
    } // 构造函数结束。
    public void Dispose() // 实现主动释放方法。
    { // Dispose 方法开始。
        Dispose(true); // 主动释放时,可以清理托管和非托管资源。
        GC.SuppressFinalize(this); // 资源已经主动释放,通知 GC 不要再调用终结器。
    } // Dispose 方法结束。
    private void Dispose(bool disposing) // 定义统一释放逻辑。
    { // 释放逻辑开始。
        if (_disposed) // 如果已经释放过。
        { // if 开始。
            return; // 直接返回,避免重复释放。
        } // if 结束。
        if (disposing) // 如果是用户主动调用 Dispose。
        { // if 开始。
            // 这里可以释放托管资源。 // 主动释放路径允许访问其他托管对象。
        } // if 结束。
        if (_handle != IntPtr.Zero) // 如果非托管句柄还没有释放。
        { // if 开始。
            _handle = IntPtr.Zero; // 示例中置空句柄,真实项目这里会调用 Native 释放函数。
        } // if 结束。
        _disposed = true; // 标记当前对象已经释放。
    } // 释放逻辑结束。
    ~NativeHandleWrapper() // 定义终结器,作为忘记 Dispose 时的兜底。
    { // 终结器开始。
        Dispose(false); // 终结器路径只释放非托管资源。
    } // 终结器结束。
} // 类结束。

Unity 工程实践

在 Unity 里,如果你封装原生插件句柄、Native 内存、文件流、Socket 等资源,通常会实现 IDisposable。用户主动 Dispose() 后,就应该 GC.SuppressFinalize(this),避免这个对象还进入终结器流程。

但如果类根本没有 Finalizer,也没有继承链上的终结器,调用它通常没什么意义。真正重要的是:先把资源释放干净,再取消兜底终结。

常见坑点

GC.SuppressFinalize 不会释放资源。它只是告诉 GC 不用调用 Finalizer。

不要把它当成 Dispose() 的替代品。正确顺序是:

c
Dispose 释放资源
GC.SuppressFinalize 取消终结器

面试里说出这句很加分:Finalizer 是兜底机制,不是正常资源管理路径;正常路径应该是 using / Dispose 主动释放。

大对象堆 LOH 是什么?

csharp-large-object-heap-loh

标准答案

LOH 是 Large Object Heap,大对象堆。它是 .NET 托管堆的一部分,专门放比较大的托管对象。常见说法是对象大小达到约 85KB 及以上时,会进入 LOH。

典型对象包括:大数组、大字符串、大 byte[] 缓冲区、大型反序列化结果。

底层原理

普通小对象一般分配在普通托管堆里,经过 Gen0、Gen1、Gen2 的分代回收。

大对象如果也频繁搬来搬去,移动成本很高,所以 CLR 把它们放到 LOH。LOH 通常跟 Gen2 回收相关,也就是说它不像普通小对象那样很快在 Gen0 回收,回收成本更高,停顿也可能更明显。

LOH 的常见问题是碎片。比如你反复创建和释放不同大小的大数组,堆里可能留下很多不连续的空洞。总空闲内存看起来够,但没有足够连续空间放新的大对象,就可能导致堆继续扩张,内存峰值上升。

代码示例

c
using System; // 引入 Console。
public sealed class LohBufferExample // 定义一个演示 LOH 缓冲区复用的类。
{ // 类开始。
    private readonly byte[] _buffer = new byte[90 * 1024]; // 创建一个超过约 85KB 的大数组,可能进入 LOH。
    public void Fill(byte value) // 定义填充缓冲区的方法。
    { // 方法开始。
        for (int i = 0; i < _buffer.Length; i++) // 遍历整个大缓冲区。
        { // 循环开始。
            _buffer[i] = value; // 写入指定的字节值。
        } // 循环结束。
    } // 方法结束。
    public int Length // 定义长度属性。
    { // 属性开始。
        get // 定义 get 访问器。
        { // get 开始。
            return _buffer.Length; // 返回缓冲区长度。
        } // get 结束。
    } // 属性结束。
} // 类结束。
public static class LohDemo // 定义演示入口类。
{ // 类开始。
    public static void Run() // 定义运行方法。
    { // 方法开始。
        LohBufferExample buffer = new LohBufferExample(); // 创建一次大缓冲区对象。
        buffer.Fill(1); // 复用这个大缓冲区,而不是每次都 new byte[90 * 1024]。
        Console.WriteLine(buffer.Length); // 输出缓冲区长度。
    } // 方法结束。
} // 类结束。

Unity 工程实践

Unity 里 LOH 常见来源不是 Texture 像素本体,因为贴图大头通常在 Native 内存;LOH 更常见于托管侧的大对象,比如下载资源时的 byte[]、解压缓冲区、大 JSON 字符串、配置表整体读入、网络包拼接的大数组。

优化思路是:不要频繁创建和丢弃大数组。能复用就复用,能分块读取就分块读取,能流式解析就不要一次性构造超大字符串。

常见坑点

CAUTION

不要把 LOH 和 Native Memory 混在一起。Profiler 里看到 Managed Heap 或 GC Alloc 增长,才更可能和 LOH 有关系;Texture、Mesh、AudioClip 的大头很多时候在 Native 侧。

面试里可以补一句:LOH 不是不能用,而是不能高频临时分配。大对象最好池化、缓存、分块,避免频繁触发 Full GC 和堆碎片。

什么对象会进入大对象堆?

csharp-objects-enter-loh

标准答案

进入大对象堆 LOH 的核心标准主要是“对象本身够大”,不是看类型名字。常见说法是托管对象大小达到约 85KB 及以上时,可能会被分配到 LOH。

最常见进入 LOH 的对象有:大数组、大字符串、大 byte[] 缓冲区、大型反序列化结果。

底层原理

LOH 存的是托管对象。比如:

byte[]:下载资源、网络包、解压缓存很常见。

char[] / 大字符串:大 JSON、大 XML、大日志文本。

int[] / float[] / 自定义结构体数组:元素数量很多时也可能进入 LOH。

List<T>:List 对象本身通常不大,但它内部的数组 _items 如果扩容到很大,底层数组可能进入 LOH。

普通 class 对象一般不容易直接进 LOH,因为对象头和字段本身通常没那么大。真正危险的往往是它持有了一个大数组或大字符串。

代码示例

c
using System; // 引入 Console。
public static class LohObjectExample // 定义 LOH 示例类。
{ // 类开始。
    public static void Run() // 定义运行示例方法。
    { // 方法开始。
        byte[] downloadBuffer = new byte[90 * 1024]; // 创建约 90KB 的 byte 数组,可能进入 LOH。
        char[] textBuffer = new char[50 * 1024]; // char 通常 2 字节,约 100KB,也可能进入 LOH。
        int[] idArray = new int[30 * 1024]; // int 通常 4 字节,约 120KB,也可能进入 LOH。
        Console.WriteLine(downloadBuffer.Length); // 输出 byte 数组长度,避免示例变量未使用。
        Console.WriteLine(textBuffer.Length); // 输出 char 数组长度,避免示例变量未使用。
        Console.WriteLine(idArray.Length); // 输出 int 数组长度,避免示例变量未使用。
    } // 方法结束。
} // 类结束。

Unity 工程实践

Unity 项目里常见 LOH 来源有:下载 AssetBundle 到 byte[]、解压补丁包、一次性读取大配置表、大 JSON 字符串、网络消息拼接成超大缓冲区。

但要注意:Texture2DMeshAudioClip 的大头很多在 Unity Native 内存,不是 LOH。LOH 主要看托管侧,比如 byte[]string、托管数组。

常见坑点

WARNING

不要说“所有大对象都一定进 LOH”,更稳的说法是:在常见 .NET 实现里,超过约 85KB 的托管对象通常会走 LOH,但具体行为可能受运行时实现影响。

优化上不要频繁 new 大数组。大缓冲区尽量复用、池化、分块读取、流式解析,否则会造成 LOH 碎片、Managed Heap 上涨和 GC 停顿。

分代 GC 的 0/1/2 代怎么理解?

csharp-generational-gc标准答案

分代 GC 的 0/1/2 代,可以理解成对象按“年龄”分层管理:

Gen0:新创建的对象,最年轻,回收最频繁。

Gen1:从 Gen0 活过一次 GC 的对象,过渡层。

Gen2:长期存活对象,最老,回收成本最高。

它基于一个经验:大多数对象都很短命。比如临时字符串、临时数组、闭包对象,很多用完马上就没引用了。所以 GC 先频繁清理年轻代,可以用较低成本回收大量垃圾。

底层原理

对象刚 new 出来,一般先进入 Gen0。发生 Gen0 GC 时:

如果对象已经没人引用,就被回收。

如果对象还活着,就可能晋升到 Gen1。

再活过后续 GC,就可能晋升到 Gen2。

Gen2 通常放长期存活对象,比如缓存、单例、全局管理器、长期引用的数据结构。Gen2 回收要扫描更多长期对象,代价更高,停顿也可能更明显。

代码示例

c
using System; // 引入 Console 和 GC。
using System.Collections.Generic; // 引入 List。
public static class GenerationalGcExample // 定义分代 GC 示例类。
{ // 类开始。
    private static readonly List<byte[]> Cache = new List<byte[]>(); // 保存长期引用,模拟会活得很久的对象。
    public static void Run() // 定义运行示例方法。
    { // 方法开始。
        byte[] temp = new byte[1024]; // 创建临时对象,通常先进入年轻代。
        Cache.Add(new byte[1024]); // 创建被静态集合引用的对象,更可能长期存活并晋升。
        Console.WriteLine(GC.GetGeneration(temp)); // 输出临时对象当前处于哪一代。
        Console.WriteLine(GC.GetGeneration(Cache[0])); // 输出长期引用对象当前处于哪一代。
    } // 方法结束。
} // 类结束。

Unity 工程实践

在标准 .NET/CLR 里,分代 GC 是经典模型。但 Unity 的托管运行时和 GC 实现会随 Mono、IL2CPP、Unity 版本有所不同,不一定完全等同桌面 CLR 的分代压缩 GC。

不过工程原则是一样的:游戏循环里要少产生短命垃圾。比如 Update 里字符串拼接、LINQ、闭包、装箱、临时 List、临时数组,都会增加 GC 压力。对象活得越久、引用关系越复杂,GC 扫描和回收成本也越高。

常见坑点

IMPORTANT

不要只背“0 代快、2 代慢”。面试官更想听到你理解“为什么”:因为新对象大多短命,所以先清年轻代收益高;老对象活得久,扫描它们更贵。

Unity 里排查时,优先看 Profiler 的 GC Alloc、Managed Heap、GC 时间。优化手段通常是复用容器、对象池、缓存 StringBuilder、避免每帧分配,而不是手动频繁 GC.Collect()

Unity 中 GC 和 .NET 桌面程序有什么差异?

csharp-unity-gc-vs-dotnet

标准答案

Unity 中的 GC 和 .NET 桌面程序最大的差异是:桌面 .NET 更偏通用运行时和吞吐,Unity 更关心实时帧率。桌面程序偶尔 GC 暂停一下,用户可能没感觉;Unity 里一次 GC 暂停就可能直接表现成掉帧、卡顿、手感断裂。

另外 Unity 内存要分清两块:

Managed Heap:C# 对象、string、数组、List、闭包等,由 GC 管。

Native Memory:Texture、Mesh、AudioClip、AssetBundle、引擎对象等,很多不由 C# GC 直接释放。

底层原理

桌面 .NET,尤其 CoreCLR,常见是分代 GC、压缩、后台 GC、Server/Workstation GC 等策略,整体很成熟,目标可以偏吞吐或低延迟。

Unity 的托管运行时受版本、Mono/IL2CPP、平台影响。很多 Unity 版本常见的是非压缩或增量式 GC 思路,重点不是“像桌面 .NET 那样完整搬移压缩”,而是尽量把 GC 工作拆到多帧里,降低单帧停顿。

所以在 Unity 面试里要谨慎说:不要把桌面 .NET 的分代压缩 GC 细节直接套到 Unity 上。更稳的说法是:“Unity 具体 GC 实现和版本有关,但工程上最重要的是控制每帧分配和 GC 停顿。”

代码示例

c
using System.Collections.Generic; // 引入 List。
using UnityEngine; // 引入 MonoBehaviour 和 Debug。
public sealed class UnityGcExample : MonoBehaviour // 定义一个演示 Unity GC Alloc 的脚本。
{ // 类开始。
    private readonly List<int> _cache = new List<int>(128); // 缓存 List,避免每帧 new。
    private void Update() // Unity 每帧调用。
    { // 方法开始。
        _cache.Clear(); // 清空旧数据,但复用底层数组。
        for (int i = 0; i < 100; i++) // 模拟每帧收集一些数据。
        { // 循环开始。
            _cache.Add(i); // 复用同一个 List 写入数据。
        } // 循环结束。
        Debug.Log(_cache.Count); // 示例输出数量,真实项目不要每帧频繁打日志。
    } // 方法结束。
} // 类结束。

Unity 工程实践

Unity 优化 GC 的重点不是“等 GC 来得更聪明”,而是少制造垃圾。常见 GC Alloc 来源有字符串拼接、LINQ、闭包、装箱、foreach 特定场景、临时数组、每帧 new List、频繁日志输出。

同时,GC 只能管托管对象。比如 UI 关闭后贴图还在、AssetBundle 卸载后内存没降、Texture 占用很高,这些很多要查 Native Memory、资源引用、Addressables/AssetBundle 生命周期,不是只看 GC。

面试加分点

IMPORTANT

Unity 的 GC 问题通常不是单纯内存回收问题,而是实时帧预算问题。60 FPS 每帧只有约 16.6ms,如果 GC 占了几毫秒甚至几十毫秒,玩家就能明显感觉卡顿。

排查时我会看 Profiler 里的 GC AllocGC.Collect、Managed Heap,再结合 Memory Profiler 看 Native/Managed 分布。优化上优先减少每帧分配,用对象池、缓存容器、StringBuilder、资源生命周期管理,而不是频繁手动 GC.Collect()

为什么字符串是不可变的?

csharp-string-immutable

标准答案

C# 的 string 是引用类型,但它的内容不可变。也就是说,变量保存的是字符串对象的引用,但字符串对象内部的字符内容不能被原地修改。

所以:

c
s += "!"

看起来像是在原字符串后面追加,实际是创建了一个新的字符串对象,然后让变量 s 指向新对象。

为什么要设计成不可变

第一,方便安全共享。多个变量可以引用同一个字符串对象,如果字符串能被改,一个地方改了,其他引用这个字符串的地方都会被影响,很容易出 bug。

第二,哈希稳定。字符串经常作为 DictionaryHashSet 的 key。如果字符串内容能变,GetHashCode() 就可能变化,集合内部位置就乱了,查找会出问题。

第三,方便字符串池。字符串字面量可以被复用,比如两个 "hello" 可能引用同一个池中对象。只有不可变,复用才安全。

第四,天然线程安全。多个线程同时读取同一个字符串,不需要担心另一个线程把内容改掉。

代码示例

c
using System; // 引入 Console。
public static class StringImmutableExample // 定义字符串不可变示例类。
{ // 类开始。
    public static void Run() // 定义运行示例方法。
    { // 方法开始。
        string a = "Hello"; // 创建字符串变量 a,引用内容为 Hello 的字符串对象。
        string b = a; // 让 b 引用和 a 相同的字符串对象。
        a += "!"; // 创建新的字符串 Hello!,并让 a 指向新对象。
        Console.WriteLine(a); // 输出 Hello!,说明 a 已经指向新字符串。
        Console.WriteLine(b); // 输出 Hello,说明原字符串没有被修改。
    } // 方法结束。
} // 类结束。

Unity 工程实践

Unity 里字符串不可变最常见的性能坑是频繁拼接。比如在 Update 里做:

c
text.text = "HP:" + hp + "/" + maxHp

每帧都可能创建新字符串,带来 GC Alloc。如果是偶尔更新 UI 没问题;如果是高频、大量、循环拼接,就要考虑缓存、减少刷新频率,或者用 StringBuilder

面试加分点

TIP

string 不可变不是为了“不能改”本身,而是为了让字符串可以安全共享、可以稳定作为哈希 key、可以被字符串池复用,并减少并发读写问题。

代价是拼接会产生新对象,所以大量动态字符串场景要注意 GC。

字符串驻留池是什么?

csharp-string-intern-pool标准答案

字符串驻留池,也叫 String Intern Pool,是运行时维护的一张字符串表。它的作用是:让相同内容的字符串共享同一个实例,减少重复字符串对象。

比如:

c
string a = "hello";
string b = "hello";

ab 很可能引用的是驻留池里的同一个 "hello" 对象。

底层原理

C# 里的字符串字面量通常会被自动驻留。也就是说,代码中写死的 "hello",编译和运行时会尽量复用池中的同一个字符串对象。

但运行时动态生成的字符串不一定自动驻留,比如拼接、读取文件、网络接收、new string(...) 生成的字符串。它们内容就算一样,也可能是不同对象。

如果你想主动把运行时字符串放进池,可以用:

c
string.Intern(s)

它会查驻留池:如果池里已有相同内容的字符串,就返回池里的实例;如果没有,就把这个字符串加入池并返回。

代码示例

c
using System; // 引入 Console 和 string 工具方法。
public static class StringInternExample // 定义字符串驻留池示例类。
{ // 类开始。
    public static void Run() // 定义运行示例方法。
    { // 方法开始。
        string a = "hello"; // 字符串字面量通常会进入驻留池。
        string b = "hello"; // 相同字面量通常会复用驻留池里的同一个实例。
        char[] chars = new char[] { 'h', 'e', 'l', 'l', 'o' }; // 创建字符数组,用于运行时构造字符串。
        string c = new string(chars); // 运行时创建新字符串,内容相同但引用不一定相同。
        string d = string.Intern(c); // 主动把 c 的内容驻留,并拿到池中的字符串实例。
        Console.WriteLine(object.ReferenceEquals(a, b)); // 输出 True,说明两个字面量可能引用同一个实例。
        Console.WriteLine(object.ReferenceEquals(a, c)); // 通常输出 False,说明运行时字符串可能不是池中实例。
        Console.WriteLine(object.ReferenceEquals(a, d)); // 输出 True,说明 Intern 后拿到了池中实例。
    } // 方法结束。
} // 类结束。

Unity 工程实践

Unity 项目里,固定配置 key、固定事件名、固定状态名这类字符串,如果来源稳定,驻留池能减少重复字面量对象。但不要把大量动态字符串都 Intern,比如玩家聊天、日志内容、随机 ID、服务器返回的大量临时字段。

因为驻留池里的字符串可能存活很久,乱用 string.Intern 反而会让内存长期占用,尤其在移动端更危险。

常见坑点

NOTE

不要用 ReferenceEquals 判断业务字符串是否相等。字符串内容比较应该用 Equals==,因为两个内容相同的运行时字符串,不一定引用同一个对象。

面试里可以这样总结:字符串驻留池依赖字符串不可变性,核心收益是共享和减少重复;但手动驻留要谨慎,它适合少量、稳定、重复率高的字符串,不适合大量动态内容。

String.Equals== 区别是什么?

csharp-string-equals-vs-operator

标准答案

string 来说,==String.Equals 默认都是比较字符串内容,不是普通引用比较。

区别在于:== 写起来简单,适合普通内容判断;String.Equals 更灵活,可以指定比较规则,比如是否忽略大小写、是否使用文化区规则。

真正比较两个变量是不是引用同一个对象,要用 object.ReferenceEquals(a, b)

底层原理

string 是引用类型,但它重载了 == 运算符。所以:

c
a == b

对于 string 变量来说,比较的是内容。

而:

c
string.Equals(a, b, StringComparison.Ordinal)

可以明确告诉运行时:按什么规则比较。

常见规则里,游戏开发最常用的是:

StringComparison.Ordinal:按字符编码值比较,快、稳定,适合配置 key、资源路径、协议字段。

StringComparison.OrdinalIgnoreCase:忽略大小写,适合不区分大小写的 key。

CurrentCulture 相关规则:和当前文化区有关,更适合 UI 文本,而不是程序内部 key。

代码示例

c
using System; // 引入 Console 和 StringComparison。
public static class StringCompareExample // 定义字符串比较示例类。
{ // 类开始。
    public static void Run() // 定义运行示例方法。
    { // 方法开始。
        string a = "hello"; // 定义第一个字符串变量。
        string b = new string(new char[] { 'h', 'e', 'l', 'l', 'o' }); // 运行时创建内容相同但引用可能不同的字符串。
        bool sameByOperator = a == b; // string 的 == 比较内容,所以这里是 true。
        bool sameByEquals = string.Equals(a, b); // string.Equals 默认也比较内容,所以这里是 true。
        bool sameByReference = object.ReferenceEquals(a, b); // ReferenceEquals 比较引用,所以这里通常是 false。
        bool ignoreCase = string.Equals("Hero", "hero", StringComparison.OrdinalIgnoreCase); // 指定忽略大小写比较。
        Console.WriteLine(sameByOperator); // 输出 == 的比较结果。
        Console.WriteLine(sameByEquals); // 输出 Equals 的比较结果。
        Console.WriteLine(sameByReference); // 输出引用比较结果。
        Console.WriteLine(ignoreCase); // 输出忽略大小写比较结果。
    } // 方法结束。
} // 类结束。

Unity 工程实践

Unity 里比较配置 ID、资源路径、状态机 key、协议字段时,我更倾向写:

c
string.Equals(a, b, StringComparison.Ordinal)

这样语义明确、性能稳定,不受当前语言文化区影响。

普通业务判断,比如两个玩家名字是否完全一致,用 == 也可以,简单直观,而且能处理 null

常见坑点

如果变量类型是 object,即使里面装的是字符串,== 也可能变成引用比较,而不是 string 的内容比较。

比如:

c
object a = "hello";
object b = new string(...);
a == b

这时要特别小心。面试里可以说:只要我想表达“字符串内容相等”,我会用 string.Equals 并指定 StringComparison,尤其是底层 key、路径、协议字段。

StringBuilder 一定比字符串拼接快吗?

csharp-stringbuilder-vs-concat

标准答案

StringBuilder 不一定比字符串拼接快。少量、固定数量的拼接,比如 "HP:" + hp + "/" + maxHp,直接用 + 或字符串插值通常更简单,性能也足够好。

StringBuilder 更适合循环中大量拼接、多步骤拼接、生成大文本、日志、导出配置、拼 SQL/协议文本这类场景。它的优势是内部有可变缓冲区,可以减少中间字符串对象。

底层原理

string 不可变,所以拼接时通常会产生新字符串。比如循环里:

c
s += item;

每次都可能创建一个新的字符串,把旧内容和新内容复制进去。循环次数一多,就会产生很多临时对象和 GC 压力。

StringBuilder 内部维护一个可变缓冲区,多次 Append 时尽量写到同一块缓冲区里,最后 ToString() 再生成最终字符串。

StringBuilder 自己也是对象,也可能扩容。如果只是拼两三个字符串,创建一个 StringBuilder 反而可能更麻烦。

代码示例

c
using System; // 引入 Console。
using System.Text; // 引入 StringBuilder。
public static class StringBuildExample // 定义字符串构建示例类。
{ // 类开始。
    public static string BuildWithConcat(int hp, int maxHp) // 定义少量拼接方法。
    { // 方法开始。
        return "HP:" + hp + "/" + maxHp; // 少量固定拼接,直接使用 + 可读性好。
    } // 方法结束。
    public static string BuildWithStringBuilder(int[] values) // 定义大量拼接方法。
    { // 方法开始。
        StringBuilder builder = new StringBuilder(values.Length * 4); // 预估容量,减少扩容次数。
        for (int i = 0; i < values.Length; i++) // 遍历所有数值。
        { // 循环开始。
            builder.Append(values[i]); // 把当前数值追加到内部缓冲区。
            builder.Append(','); // 追加分隔符。
        } // 循环结束。
        return builder.ToString(); // 最后一次性生成最终字符串。
    } // 方法结束。
} // 类结束。

Unity 工程实践

Unity 里要特别小心每帧字符串拼接。比如血条、倒计时、伤害数字、调试日志,如果每帧都拼接字符串,很容易出现 GC Alloc

优化思路不是“看到字符串就上 StringBuilder”,而是先看刷新频率。UI 文本只在数值变化时刷新,不要每帧刷新。如果确实要大量拼接,再考虑 StringBuilder,并且最好预设 Capacity 或复用实例。

面试加分点

TIP

StringBuilder 赢在大量拼接和缓冲复用;小规模拼接直接用 + 更清晰,编译器和运行时也可能优化成 string.Concat

真正做性能优化时,我会用 Profiler 看 GC Alloc 和热点,而不是凭感觉把所有 + 都改成 StringBuilder

插值字符串会不会分配内存?

csharp-interpolated-string-allocation

标准答案:会,但要分情况。 如果插值字符串最终赋值给 string,例如 string s = $"HP:{hp}",通常一定会分配一个新的 string 对象。插值字符串只是语法更舒服,不代表零分配。

底层原理: 旧版本 C# / 旧 Unity 环境里,插值字符串可能被编译成 string.Format(...),值类型参数可能发生装箱,还可能创建 object[]。现代 C# 会用 string.Concat 或插值字符串处理器减少中间分配,但只要最后要得到一个 string,结果字符串本身通常还是要分配。

Unity 里怎么回答更稳: 偶尔更新 UI 可以用插值字符串,代码清晰。 但如果是 Update 里每帧写:

text.text = $"HP:{hp}/{maxHp}";

就要小心,因为它可能每帧创建新字符串,造成 GC Alloc。更好的做法是:数值变化时才刷新,或者用 TextMeshPro 的 SetText 这类更适合频繁刷新文本的接口。

c
using UnityEngine; // 引入 Unity 的 MonoBehaviour 基类。
using TMPro; // 引入 TextMeshProUGUI 文本组件类型。
public sealed class HpTextExample : MonoBehaviour // 定义一个血量文本刷新脚本。
{ // 类开始。
    [SerializeField] private TextMeshProUGUI _text; // 在 Inspector 中绑定血量文本组件。
    private int _lastHp = -1; // 记录上一次显示的当前血量。
    private int _lastMaxHp = -1; // 记录上一次显示的最大血量。
    public void Refresh(int hp, int maxHp) // 定义刷新血量显示的方法。
    { // 方法开始。
        if (hp == _lastHp && maxHp == _lastMaxHp) // 如果当前数值和上次完全一样。
        { // if 代码块开始。
            return; // 直接返回,避免重复构造文本。
        } // if 代码块结束。
        _lastHp = hp; // 缓存新的当前血量。
        _lastMaxHp = maxHp; // 缓存新的最大血量。
        _text.SetText("HP:{0}/{1}", hp, maxHp); // 使用 TMP 格式化接口,减少临时字符串风险。
    } // 方法结束。
} // 类结束。

NOTE

面试可以这样说: “插值字符串本身是语法糖,是否有额外分配取决于编译器、运行时和目标 API。但如果最终生成 string,结果字符串一般会分配。在 Unity 热路径里,比如每帧 UI、日志、战斗飘字,我不会默认随便用插值字符串,而是先看 Profiler 的 GC Alloc,再考虑缓存、按变化刷新、TMP SetText 或 StringBuilder。”

params 参数有什么用?

csharp-params-parameter

标准答案

params 的作用是让一个方法接收“数量不固定”的参数。调用者可以传 0 个、1 个、多个参数,也可以直接传一个数组;方法内部统一把它当作一个参数集合来处理。

比如:

c
using System; // 引入 Console 所在的命名空间。
public static class ParamsDemo // 定义一个演示 params 的静态类。
{ // 类开始。
    public static int Sum(params int[] numbers) // 定义一个可接收任意数量 int 参数的方法。
    { // 方法开始。
        int result = 0; // 定义累加结果,初始值为 0。
        foreach (int number in numbers) // 遍历 params 收到的每一个数字。
        { // foreach 开始。
            result += number; // 把当前数字累加到结果里。
        } // foreach 结束。
        return result; // 返回最终累加结果。
    } // 方法结束。
    public static void Test() // 定义测试方法。
    { // 测试方法开始。
        Console.WriteLine(Sum()); // 可以不传参数,此时 numbers 长度为 0。
        Console.WriteLine(Sum(1)); // 可以传 1 个参数。
        Console.WriteLine(Sum(1, 2, 3)); // 可以传多个参数。
        int[] array = { 4, 5, 6 }; // 创建一个普通 int 数组。
        Console.WriteLine(Sum(array)); // 也可以直接把数组传给 params 参数。
    } // 测试方法结束。
} // 类结束。

底层原理

params 不是让方法真的拥有“无限个形参”,它本质上还是一个参数。传统写法里最常见的是 params int[] numbers,方法内部拿到的是一个数组。

调用:

c
Sum(1, 2, 3);

可以理解成编译器帮你整理成类似:

c
Sum(new int[] { 1, 2, 3 });

所以它很方便,但不是完全没有成本:如果你传的是多个离散参数,通常会有数组创建;如果是 params object[],传入 intfloat 这类值类型时,还可能发生装箱。

使用规则

params 参数必须放在参数列表最后面,而且一个方法里只能有一个 params。例如:

c
void Log(string tag, params object[] args)

可以。

但这种不行:

c
void Bad(params int[] nums, string name)

因为 params 后面不能再跟普通参数。

Unity 工程里怎么用

params 常见于日志封装、工具函数、格式化输出,比如:

c
Log("Player", hp, mp, level);

写起来很爽。但在 Unity 热路径里,比如 Update、战斗循环、UI 每帧刷新、成百上千个对象的日志输出,就要小心 params object[] 带来的数组分配和装箱。面试里可以说:我会用它提升 API 易用性,但性能敏感路径会用 Profiler 看 GC Alloc,必要时改成固定参数重载、缓存数组、结构化日志或避免每帧调用。

补充版本点

在 C# 13 之前,params 参数必须是单维数组;较新的 C# 已经扩展到更多集合类型,例如 Span<T>ReadOnlySpan<T>、实现特定集合模式的类型等。Unity 项目里具体能不能用,取决于 Unity 版本、C# 语言版本和运行时支持。

参考:Microsoft Learn - method parameters

可选参数和方法重载怎么选择?

csharp-optional-parameters-vs-overloads

标准答案

可选参数适合“同一个方法行为,只是少量参数有默认值”的场景;方法重载适合“参数类型不同、语义不同、内部逻辑明显不同,或者公共 API 需要更稳定演进”的场景。

一句话记:同一语义的小配置,用可选参数;不同语义、不同输入形态、对外 API 更推荐重载。

底层原理

可选参数本质是在参数上写默认值:

c
void Play(AudioClip clip, float volume = 1f)

调用:

c
Play(clip);

编译器会把默认值补进去,近似理解成:

c
Play(clip, 1f);

这里有一个面试很加分的点:可选参数的默认值通常会被写入调用方编译结果里。所以如果你做的是公共库、SDK、插件,后来把默认值从 1f 改成 0.8f,旧调用方如果不重新编译,可能仍然用旧默认值。

方法重载是多个同名方法:

c
void Play(AudioClip clip)
void Play(AudioClip clip, float volume)
void Play(string assetPath)

编译器根据实参类型和数量,在编译期选择最合适的那个方法。

怎么选择

如果只是控制一些不影响核心语义的小配置,比如音量、是否循环、延迟时间,可以用可选参数:

c
using UnityEngine; // 引入 UnityEngine 命名空间。
public sealed class AudioPlayer : MonoBehaviour // 定义一个音频播放示例组件。
{ // 类开始。
    public void PlayOptional(AudioClip clip, float volume = 1f, bool loop = false) // 用可选参数表示同一播放行为的小配置。
    { // 方法开始。
        Debug.Log("Play optional audio"); // 输出日志,模拟播放逻辑。
    } // 方法结束。
} // 类结束。

如果参数代表不同来源、不同语义、不同处理流程,更适合用重载:

c
using UnityEngine; // 引入 UnityEngine 命名空间。
public sealed class SoundService : MonoBehaviour // 定义一个声音服务示例组件。
{ // 类开始。
    public void Play(AudioClip clip) // 定义直接播放 AudioClip 的重载。
    { // 方法开始。
        Play(clip, 1f); // 转发到带音量的重载,避免重复逻辑。
    } // 方法结束。
    public void Play(AudioClip clip, float volume) // 定义带音量的播放重载。
    { // 方法开始。
        Debug.Log("Play clip with volume"); // 输出日志,模拟真正播放音频。
    } // 方法结束。
    public void Play(string assetPath) // 定义通过资源路径播放的重载。
    { // 方法开始。
        Debug.Log("Load audio by path then play"); // 输出日志,模拟先加载资源再播放。
    } // 方法结束。
} // 类结束。

Unity 工程实践

在 Unity 项目里,工具类、编辑器工具、调试方法可以适当用可选参数,因为调用会更简洁:

c
SpawnEffect(pos, duration: 1.5f, autoRelease: true);

但核心运行时代码、热路径代码、公共框架 API,我更倾向于谨慎使用可选参数。原因是:默认值改动有版本兼容问题,参数越来越多时调用可读性会变差,而且多个可选参数和重载混用时,可能让重载解析变得不直观。

常见坑点

可选参数的默认值必须是编译期能确定的值,比如数字、字符串、nulldefault。不能写 DateTime.Now 这种运行时才知道的值。

可选参数不要滥用成十几个参数的超长方法。参数一多,应该考虑拆成配置对象,比如 SkillCastOptionsSpawnOptions

重载也不要无限堆。重载太多会让 API 膨胀,调用者不知道该选哪个。比较稳的做法是:少量高频入口用重载,复杂配置用参数对象。

面试可以这样说

CAUTION

“我会先看这个方法的语义是否一致。如果只是少量稳定默认配置,比如音量默认值、是否循环,我会用可选参数提升调用简洁性。如果是参数类型不同、数据来源不同、行为不同,或者这是对外暴露的框架 API,我更倾向于方法重载。因为可选参数默认值会写进调用方,后续改默认值可能有兼容问题;而重载表达意图更清楚,也更适合 API 演进。”

扩展方法是什么?

csharp-extension-method

标准答案

扩展方法就是:在不修改原类型源码的情况下,给已有类型增加一种“像实例方法一样调用”的静态方法

比如 Vector3 是 Unity 提供的结构体,我们不能随便改它的源码,但可以写一个扩展方法,让它看起来像自己多了一个方法:

c
using UnityEngine; // 引入 UnityEngine,使用 Vector3 类型。
public static class Vector3Extensions // 定义静态扩展方法类。
{ // 类开始。
    public static Vector3 Flat(this Vector3 value) // 定义 Vector3 的扩展方法,this 表示扩展目标类型。
    { // 方法开始。
        return new Vector3(value.x, 0f, value.z); // 返回去掉 y 轴高度后的水平向量。
    } // 方法结束。
} // 类结束。

使用时可以这样写:

c
using UnityEngine; // 引入 UnityEngine,使用 MonoBehaviour 和 Vector3。
public sealed class MoveExample : MonoBehaviour // 定义一个移动示例脚本。
{ // 类开始。
    private void Start() // Unity 在脚本启用后调用 Start。
    { // Start 方法开始。
        Vector3 pos = transform.position; // 获取当前物体的世界坐标。
        Vector3 flatPos = pos.Flat(); // 像调用实例方法一样调用扩展方法。
        Debug.Log(flatPos); // 输出去掉高度后的坐标。
    } // Start 方法结束。
} // 类结束。

底层原理

扩展方法必须满足几个条件:

  1. 必须写在 static class 里。
  2. 方法本身必须是 static
  3. 第一个参数前面必须加 this
  4. this 后面的类型,就是你要扩展的类型。
  5. 使用扩展方法时,要引入它所在的命名空间。

它本质不是给原类型真的加了成员。编译器只是帮你把:

c
pos.Flat();

改写成类似:

c
Vector3Extensions.Flat(pos);

所以扩展方法是编译期语法糖。它不会改变 Vector3 的内存布局,也不能访问 Vector3 的私有成员。

Unity 工程实践

Unity 项目里扩展方法很常用,比如:

  • Vector3Flat(),方便只取水平面方向。
  • TransformResetLocal(),快速重置本地坐标。
  • List<T> 加一些项目内通用的查找、随机取值工具。
  • string 加配置表、路径、命名相关的工具方法。

但我一般只把它用于通用、轻量、无明显副作用的工具逻辑。不要把复杂业务流程藏进扩展方法里,否则代码看起来像一句简单调用,实际背后做了加载资源、发事件、改状态,就很难维护。

常见坑点

如果原类型本身已经有同名实例方法,实例方法优先级更高,扩展方法不会覆盖它。

扩展方法不能加字段,不能重写虚函数,也不能突破访问权限。它只是静态方法的另一种调用写法。

还有一个实际项目坑:扩展方法太多、命名空间太散,会导致代码提示里一堆方法,反而降低可读性。所以扩展方法要有明确边界,比如统一放在 Game.Extensions 或按模块拆分。

面试可以这样说

CAUTION

“扩展方法本质是静态方法的语法糖。它通过静态类里的静态方法实现,第一个参数用 this 指明扩展目标类型。调用时看起来像实例方法,但编译器最终会转成静态方法调用。它适合给无法修改源码的类型补充通用工具能力,比如 Unity 里的 Vector3Transform、集合类型;但不能滥用,复杂业务逻辑不应该藏在扩展方法里。”

扩展方法能访问 private 成员吗?

csharp-extension-method-private-access

标准答案

不能。扩展方法不能直接访问目标类型的 private 成员。

原因是:扩展方法看起来像实例方法,但它本质上只是定义在外部 static class 里的 static 方法。它并没有真的被加进原类型内部,所以访问权限和普通外部代码一样。

底层原理

比如:

c
public sealed class Player // 定义一个 Player 类。
{ // 类开始。
    private int _hp = 100; // 定义私有字段,只有 Player 内部能直接访问。
    public int Hp => _hp; // 定义公开只读属性,外部可以通过它读取血量。
} // 类结束。
public static class PlayerExtensions // 定义 Player 的扩展方法类。
{ // 扩展方法类开始。
    public static bool IsDead(this Player player) // 定义 Player 的扩展方法。
    { // 方法开始。
        return player.Hp <= 0; // 可以访问 public 属性 Hp。
    } // 方法结束。
} // 扩展方法类结束。

但是下面这样不行:

c
public static class BadPlayerExtensions // 定义一个错误示例扩展类。
{ // 类开始。
    public static bool IsDeadBad(this Player player) // 定义一个错误的扩展方法。
    { // 方法开始。
        return player._hp <= 0; // 编译错误:扩展方法不能访问 private 字段。
    } // 方法结束。
} // 类结束。

编译器会把:

c
player.IsDead();

理解成类似:

c
PlayerExtensions.IsDead(player);

所以它只是静态方法调用,不会获得 Player 类内部权限。

那反射可以吗?

可以用反射强行拿 private 成员,但这不是扩展方法本身的能力,而是反射绕过访问限制。例如 BindingFlags.NonPublic 可能拿到私有字段。

但在 Unity 项目里我一般不建议这么做,原因有几个:

  • 破坏封装,后续字段名一改就炸。
  • 反射性能差,不适合热路径。
  • IL2CPP / AOT / 代码裁剪环境下更容易踩坑。
  • 私有字段本来就是类型内部实现细节,外部不该依赖。

Unity 工程实践

如果扩展方法真的需要某个内部数据,正确做法不是偷 private,而是让原类型暴露稳定的 public 属性、方法或接口。

比如:

c
public interface IHealthOwner // 定义血量拥有者接口。
{ // 接口开始。
    int Hp { get; } // 定义公开血量属性。
} // 接口结束。
public static class HealthExtensions // 定义血量相关扩展方法类。
{ // 类开始。
    public static bool IsDead(this IHealthOwner owner) // 给 IHealthOwner 扩展死亡判断方法。
    { // 方法开始。
        return owner.Hp <= 0; // 通过公开接口读取血量。
    } // 方法结束。
} // 类结束。

这样比访问 private 更稳,也更符合模块边界。

面试可以这样说

WARNING

“扩展方法不能访问 private 成员。它只是静态方法的语法糖,调用形式像实例方法,但编译后还是 StaticClass.Method(obj)。所以它只能访问 public、同程序集可见的 internal 等正常可见成员。如果要访问内部状态,应该通过公开属性、方法或接口暴露,而不是用扩展方法破坏封装。反射虽然可能绕过限制,但在 Unity 尤其 IL2CPP 下不推荐,性能和稳定性都不好。”

部分类 partial 有什么用?

csharp-partial-class

标准答案

partial 叫部分类,作用是:把同一个类、结构体、接口拆到多个 .cs 文件里写,编译时再合并成一个完整类型

它不是继承,也不是组合,运行时也没有“半个类”的概念。它主要解决的是源码组织、代码生成和多人协作的问题。

底层原理

比如两个文件里都写同一个 partial class Player

c
public partial class Player // 定义 Player 的第一部分。
{ // 第一部分开始。
    private int _hp; // 定义血量字段。
    public int Hp => _hp; // 定义血量只读属性。
} // 第一部分结束。

另一个文件:

c
public partial class Player // 定义 Player 的第二部分。
{ // 第二部分开始。
    public void Attack() // 定义攻击方法。
    { // 方法开始。
        System.Console.WriteLine("Attack"); // 输出攻击日志。
    } // 方法结束。
} // 第二部分结束。

编译后它们会被合并成一个完整的 Player 类型,效果类似:

c
public class Player // 编译后可以理解成一个完整 Player 类。
{ // 类开始。
    private int _hp; // 合并后的血量字段。
    public int Hp => _hp; // 合并后的血量属性。
    public void Attack() // 合并后的攻击方法。
    { // 方法开始。
        System.Console.WriteLine("Attack"); // 输出攻击日志。
    } // 方法结束。
} // 类结束。

常见用途

最常见的用途是自动生成代码和手写代码分离

比如配置表工具生成:

c
public partial class PlayerConfig // 工具自动生成的配置类。
{ // 自动生成部分开始。
    public int Id { get; set; } // 自动生成配置 ID 属性。
    public string Name { get; set; } // 自动生成名称属性。
} // 自动生成部分结束。

你自己再写:

c
public partial class PlayerConfig // 人工编写的扩展部分。
{ // 人工部分开始。
    public bool IsValid() // 定义配置合法性检查方法。
    { // 方法开始。
        return Id > 0 && !string.IsNullOrEmpty(Name); // 判断 ID 和名称是否合法。
    } // 方法结束。
} // 人工部分结束。

这样工具下次重新生成 PlayerConfig.Generated.cs 时,不会覆盖你手写的 IsValid()

Unity 工程实践

Unity 项目里 partial 经常用在:

  • 配置表代码生成。
  • 协议消息代码生成。
  • UI 绑定代码生成。
  • 编辑器工具生成代码。
  • 大型类按功能拆分文件。
  • Source Generator 或自动化工具生成代码。

比如 UI 代码可以拆成:

c
InventoryPanel.Generated.cs   // 自动绑定按钮、图片、文本
InventoryPanel.Logic.cs       // 手写背包逻辑
InventoryPanel.Events.cs      // 手写事件注册和取消

这样比把所有东西堆在一个几千行文件里更好维护。

规则和坑点

每个部分都必须写 partial,否则编译器不会把它们当作同一个部分类。

这些部分必须是同名、同命名空间,通常还要在同一个程序集里。比如 Unity 里如果一个文件在普通运行时程序集,另一个文件在 Editor 程序集,就不能随便合成同一个运行时类型。

partial 也不能当成“类太大也没关系”的借口。如果一个类职责过多,应该拆成多个类或模块,而不是用 partial 把大类藏起来。

面试可以这样说

IMPORTANT

partial 是一种源码拆分机制。多个文件里声明同名的 partial class,编译器会把它们合并成同一个类型。它常用于代码生成和手写代码分离,比如 Unity 里的配置表生成、协议生成、UI 自动绑定。它的好处是避免生成代码覆盖人工代码,也能降低多人协作冲突。但它不是模块化万能药,如果一个类职责太多,还是应该做职责拆分,而不是靠 partial 分文件掩盖复杂度。”

unsafe 代码是什么?

csharp-unsafe-code

标准答案

unsafe 代码就是 C# 里允许使用指针、取地址、指针运算、stackalloc 等低层内存操作的代码。它不是“代码一定很危险”,而是说:编译器和运行时不再帮你保证所有内存访问安全,责任交给程序员自己。

普通 C# 是托管、安全的,比如数组访问会做边界检查,引用对象由 GC 管理。unsafe 允许你更接近 C/C++ 的写法:

c
public static class UnsafeSumExample // 定义一个 unsafe 求和示例类。
{ // 类开始。
    public static unsafe int Sum(int[] values) // 定义 unsafe 方法,允许在方法里使用指针。
    { // 方法开始。
        if (values == null) // 判断传入数组是否为空。
        { // if 代码块开始。
            throw new System.ArgumentNullException(nameof(values)); // 如果数组为空就抛出参数异常。
        } // if 代码块结束。
        int sum = 0; // 定义求和结果。
        fixed (int* pointer = values) // 使用 fixed 固定数组,防止 GC 在此作用域移动它。
        { // fixed 代码块开始。
            for (int i = 0; i < values.Length; i++) // 从数组第 0 个元素遍历到最后一个元素。
            { // for 代码块开始。
                sum += pointer[i]; // 通过指针下标读取元素并累加。
            } // for 代码块结束。
        } // fixed 代码块结束,数组不再被固定。
        return sum; // 返回累加结果。
    } // 方法结束。
} // 类结束。

底层原理

C# 的托管对象通常在托管堆上,由 GC 管理。GC 为了整理内存,可能会移动对象的位置。 如果你直接拿一个托管数组的地址,然后 GC 把数组移动了,原来的指针就可能失效。

所以访问托管对象地址时经常要用 fixed

c
fixed (int* pointer = values)

它的意思是:在这个作用域内暂时固定 values,让 GC 不要移动它。出了 fixed 作用域之后,就不要继续保存这个指针。

stackalloc 则是在栈上分配一小块短生命周期内存,不进入托管堆,一般不会造成 GC,但生命周期很短,方法或作用域结束后就不能用了。

unsafe 能提升性能吗?

不一定。unsafe 只是给你低层能力,不等于自动变快。

它可能减少某些边界检查、减少拷贝、方便和 Native 插件交互,但也会带来:

  • 越界访问风险。
  • 悬空指针风险。
  • 内存破坏风险。
  • 可读性下降。
  • 调试难度上升。
  • IL2CPP、AOT、Burst、平台差异下需要额外验证。

所以我不会为了“看起来高级”去用 unsafe,只有在 Profiler 或性能测试证明普通写法确实是瓶颈时才考虑。

Unity 工程实践

Unity 里 unsafe 常见于:

  • Native 插件交互。
  • 音频、图像、网格等大量数据处理。
  • Jobs/Burst 相关的底层高性能代码。
  • NativeArray、非托管内存、指针传递场景。
  • 一些需要避免托管 GC 的高频计算模块。

普通业务逻辑,比如 UI、任务系统、背包系统、技能流程,一般不应该用 unsafe。这些地方更重要的是可维护性和稳定性。

在 Unity 中使用 unsafe 通常还需要在项目设置或 asmdef 里开启允许 unsafe code,否则编译不过。

面试可以这样说

IMPORTANT

unsafe 是 C# 提供的低层内存操作能力,允许使用指针、取地址、fixedstackalloc 等语法。它本质上绕开了一部分托管安全检查,所以可以做更接近 C/C++ 的内存操作,但也要自己负责生命周期、越界和 GC 移动对象的问题。在 Unity 里我只会在 Native 插件、Burst/Jobs 数据处理、图像或网格等性能热点中谨慎使用,并且用 Profiler 或 Benchmark 验证收益。普通业务代码不建议使用 unsafe。”

fixed 关键字有什么用?

csharp-fixed-keyword

标准答案

fixed 关键字最常见的作用是:unsafe 代码中临时固定托管对象的内存地址,防止 GC 移动它,然后在这个作用域内安全地取得指针。

C# 的托管对象,比如数组、字符串,通常由 GC 管理。GC 在压缩内存时可能移动对象位置。如果你拿到了对象内部数据的指针,而对象又被 GC 移动了,指针就可能失效。所以要用 fixed 临时固定。

c
public static class FixedExample // 定义 fixed 示例类。
{ // 类开始。
    public static unsafe int Sum(int[] values) // 定义 unsafe 方法,用指针计算数组和。
    { // 方法开始。
        if (values == null) // 判断数组是否为空。
        { // if 开始。
            throw new System.ArgumentNullException(nameof(values)); // 为空时抛出参数异常。
        } // if 结束。
        int result = 0; // 定义求和结果。
        fixed (int* pointer = values) // 固定数组地址,并取得第一个元素的指针。
        { // fixed 作用域开始。
            for (int i = 0; i < values.Length; i++) // 遍历数组下标。
            { // for 开始。
                result += pointer[i]; // 通过指针读取元素并累加。
            } // for 结束。
        } // fixed 作用域结束,数组不再被固定。
        return result; // 返回求和结果。
    } // 方法结束。
} // 类结束。

底层原理

fixed 做的不是“加锁”,也不是“让对象永远不动”。它只是告诉 GC:在这个 fixed 块里,先不要移动这个对象。

所以这个指针只应该在 fixed 作用域内使用:

c
fixed (int* p = values)
{
    // p 在这里使用才安全
}

离开 fixed 之后,对象又可能被 GC 移动,所以不能把 p 保存到字段里长期使用。

fixed 的第二种用法

fixed 还有一个用法:在 unsafe struct 里声明固定大小缓冲区,类似 C 里的定长数组字段:

c
public unsafe struct PacketHeader // 定义一个 unsafe 结构体。
{ // 结构体开始。
    public fixed byte Magic[4]; // 定义固定大小的 4 字节缓冲区。
    public int Length; // 定义数据长度字段。
} // 结构体结束。

这个常用于和底层二进制协议、Native 结构体布局交互,但普通业务代码很少用。

Unity 工程实践

Unity 里 fixed 常见于:

  • 和 C/C++ Native 插件传递数组指针。
  • 图像、音频、网格等大量原始数据处理。
  • 高性能序列化、网络包处理。
  • unsafe 代码里临时访问托管数组或字符串。

但要注意:长期 pin 住托管对象会影响 GC 整理内存,可能带来碎片问题。所以 fixed 作用域要尽量短,不要跨帧保存指针,不要把托管对象固定很久。

在 Unity 项目里,如果有更安全的选择,我会优先考虑 NativeArray<T>Span<T>MemoryMarshal 或 Unity Jobs/Burst 的数据结构,而不是随便写 fixed 和裸指针。

面试可以这样说

TIP

fixed 主要用于 unsafe 场景下固定托管对象地址。因为托管对象可能被 GC 移动,如果要拿数组或字符串内部数据的指针,就需要在 fixed 块里临时 pin 住它。这个指针只在 fixed 作用域内可靠,出了作用域不能长期保存。它适合 Native 插件、二进制数据处理、性能热点等场景,但长期固定对象会影响 GC 压缩和内存整理,所以要短作用域使用。”

P/Invoke 是什么?

csharp-pinvoke

标准答案

P/Invoke 是 Platform Invocation Services,作用是让 托管 C# 代码调用非托管 C/C++ 动态库函数

简单说:C# 平时运行在 CLR/Mono/IL2CPP 这类托管环境里,而 C/C++ 插件属于 Native 层。P/Invoke 就是在这两层之间搭桥。

典型 C# 写法

c
using System.Runtime.InteropServices; // 引入 DllImport、CallingConvention 等互操作相关类型。
public static class NativeMathBridge // 定义一个 C# 到 Native 的桥接类。
{ // 类开始。
#if UNITY_IOS && !UNITY_EDITOR // 如果是 iOS 真机环境,并且不是 Unity 编辑器。
    private const string LibraryName = "__Internal"; // iOS 静态链接库通常使用 __Internal。
#else // 其他平台走这里。
    private const string LibraryName = "NativeMath"; // Windows 可对应 NativeMath.dll,Android 可对应 libNativeMath.so。
#endif // 条件编译结束。
    [DllImport(LibraryName, CallingConvention = CallingConvention.Cdecl)] // 声明要从 Native 库中导入 Add 函数,并指定 Cdecl 调用约定。
    private static extern int Add(int a, int b); // 声明 Native 函数签名,extern 表示函数实现不在 C# 里。
    public static int SafeAdd(int a, int b) // 封装一个安全的 C# 调用入口。
    { // 方法开始。
        return Add(a, b); // 调用 Native 函数并返回结果。
    } // 方法结束。
} // 类结束。

Native 侧可能长这样:

c
extern "C" int Add(int a, int b) // 使用 C 导出方式,避免 C++ 名字改编导致 C# 找不到符号。
{ // 函数开始。
    return a + b; // 返回两个整数相加的结果。
} // 函数结束。

底层原理

P/Invoke 调用时大概会经历:

  1. C# 通过 DllImport 找到 Native 动态库。
  2. 运行时查找对应的导出函数符号。
  3. 把 C# 参数转换成 Native 能理解的数据格式。
  4. 按指定调用约定进入 Native 函数。
  5. Native 返回结果后,再把结果转换回托管世界。

这个“参数转换”的过程叫 Marshaling,封送

比如 intfloat 这种基础类型比较简单;但 string、数组、结构体、回调函数就复杂得多,要考虑编码、内存归属、结构体对齐、数组长度、谁申请谁释放等问题。

Unity 工程实践

Unity 里 P/Invoke 常见于:

  • 接入 Android/iOS/Windows 平台 SDK。
  • 调用已有 C/C++ 算法库。
  • 接入音频、图像、加密、压缩、网络底层库。
  • 调 Native 插件做性能敏感的数据处理。
  • 和硬件、系统能力、第三方 SDK 交互。

平台上也有差异:

  • Windows 常见是 .dll
  • Android 常见是 .so,并且要按 ABI 放对目录。
  • iOS 常用静态库或 framework,P/Invoke 名称常写 __Internal
  • WebGL、主机平台等限制更多,要单独验证。

性能和坑点

P/Invoke 有跨边界调用成本,不适合每个元素、每帧、超高频地小颗粒调用。比如不要循环 10000 次每次 P/Invoke 一次,更好的方式是把数组批量传给 Native,一次处理完。

最容易出问题的是签名不一致:

  • C# 的 int 对不上 C++ 的类型大小。
  • C# 结构体布局和 C++ 结构体布局不一致。
  • 字符串编码不一致。
  • 调用约定不一致。
  • Native 函数没有正确导出。
  • Native 保存了托管对象指针,结果 GC 移动或释放后崩溃。

面试可以这样说

NOTE

“P/Invoke 是 C# 调用 Native 动态库的机制,本质是托管和非托管之间的调用桥。它通过 DllImport 声明外部函数,运行时负责找库、找符号、做参数封送,然后进入 Native 函数。在 Unity 里常用于接入平台 SDK 或 C/C++ 插件。它的重点风险是签名匹配、结构体布局、字符串编码、内存归属、平台库名和跨边界调用成本,所以我会尽量封装成少量稳定接口,并用批量调用减少频繁跨边界开销。”

文章评价

读完这篇,留下你的看法

暂无审核通过的评价。

登录账号后才能评价。

本站访客数0总站访问量0本页访问量0