C# 八股复习笔记

C# 八股复习笔记


一、速览卡片

# 考点 一句话说明
① 引用类型 vs 值类型 值类型变量本身装数据(赋值复印);引用类型变量装”指向堆对象的引用”(赋值拷引用,共享对象)
② 装箱与拆箱 装箱 = 值类型套 object 壳上堆(产生垃圾);拆箱 = 检查 + 取值(无分配)。GC 压力全在装箱
③ GC 机制 从四类”根”标记存活,清除 + 压缩(搬运 + 更新引用),分 Gen0/1/2,LOH ≥85KB 不压缩
④ List 与 Array List<T> 底层 = T[] + Count + Capacity;扩容 = 新数组翻倍 + 拷贝 + 旧数组变垃圾
⑤ Dictionary 哈希表 = buckets(门牌)+ entries(仓库 {key, value, hashCode, next}),等长、共扩容
⑥ 委托与事件 delegate = 类型安全的函数指针 + 多播;event = 封装 delegate,外部只许 += / -=
⑦ 协程 IEnumerator 状态机 + 主线程每帧调度;调用零执行,MoveNext 跑一段,yield 暂停交指令

二、逐考点详解

① 引用类型 vs 值类型

值类型:struct、int、float、bool、enum。变量本身就是数据,赋值 = 整体复印,之后两个变量互不干扰。局部变量通常在栈上;但作为类的字段时跟着对象住在堆上。

引用类型:class、string、array、delegate。变量里装的是指向堆上对象的引用(可重新指向别的对象,类似 C++ 指针而非 C++ 引用)。赋值 = 拷贝引用,两个变量指向同一个对象。

Q:"值类型在栈上、引用类型在堆上"对吗?struct 里装了 class 成员会变成引用类型吗?

A:两句话都错。"住哪"取决于声明位置而非类型:值类型做局部变量在栈上、做类的字段就在堆上;引用类型的对象永远在堆,但装引用的变量可栈可堆。
struct 永远、始终是值类型,装任何成员都不改变类别。只是复制这个 struct 时,里面的引用字段连引用一起被复印——多个副本的引用字段指向同一个对象;值成员(int 等)各拷各的,互不影响。

类比:值类型 = 复制粘贴一个文件副本;引用类型 = 创建一个指向同一文件的快捷方式。 给快捷方式换个指向目标(p2 = new Player()),不影响别的快捷方式;但顺着快捷方式改文件内容,大家看到的都是同一个文件。

⚠️ 易混淆点:string 是引用类型
string 分类上属于 class(引用类型),但它是不可变的——任何"修改"操作都创建了新字符串。用起来像值类型,答题时别归错类。另外装箱只对值类型有意义;引用类型与 object 之间是引用拷贝,零成本。

② 装箱与拆箱

装箱 = 值类型 → object(套壳上堆)  拆箱 = object → 值类型(验壳 + 脱壳取值)
  • 装箱:在堆上 new 一个 object,把值拷贝进去,返回引用。每次装箱都制造一个短命堆对象。
  • 拆箱:类型检查(不符抛 InvalidCastException)+ 把值从堆对象拷回栈变量。不申请内存、不释放任何东西,堆上原对象纹丝不动。
Q:GC 压力为什么全在装箱一侧?拆箱呢?

A:装箱制造垃圾:调用结束时装箱产物无人引用,成为"从根不可达"的短命对象,频繁触发 Gen0 回收。拆箱只读值,不产生任何垃圾——"先装箱再拆箱"是白忙一轮还倒贴 N 个盒子的 GC 垃圾。高频装箱场景:object 参数、非泛型容器(ArrayList)、值类型拼字符串。
判别法:看 API 边界给的类型是 int 还是 object——给 int 无事发生,给 object 则值类型必装箱。

例 1 · 造无限序列:while (true) { yield return i++; } —— 调用方要几个给几个,永不主动算完。
例 2 · 省内存遍历:大文件逐行 yield return line,不用一次加载全部。
例 3 · 高频坑:ArrayList.Add(5) —— 每次 Add 都装箱;List<int>.Add(5) —— 泛型,零装箱。


③ GC 机制

垃圾的定义:从所有”根”出发沿引用链走不到的 = 垃圾。C# 没有 C++ 那种确定性生命周期——生死由”是否可达”决定,回收时机不确定。

Q:"从所有根出发"——根那么多,怎么统计?

A:不是运行时统计,是 JIT 编译时记账。根就四类,各有一张表:
① 线程栈:JIT 为每个方法生成栈映射表——"执行到偏移 N 时,第几号槽装着引用",GC 按表只认引用槽;
② 静态字段:类型加载时登记,数量有限直接遍历;
③ GCHandle 表:给 CLR 之外的使用者(C 库)开的寄养登记——"外面还有人攥着这个对象的地址,先别收",固定(pinning)形态还会把对象钉在原地不许搬家;
④ 终结/待终结队列:带 finalizer 的对象由 new 自动"注册",GC 眼里它们也算活着。

标记 → 清除 → 压缩 三步:

  1. 标记:从四类根出发染色,没染到的 = 垃圾。
  2. 清除:回收垃圾内存。
  3. 压缩:存活对象往一端挪动挤紧,消除碎片,并更新所有指向它们的引用。

分代:大部分对象死得很快,所以——

代 放什么 回收频率
Gen 0 新诞生的对象 最高(一满就回收,存活极少、压缩便宜)
Gen 1 从 Gen 0 幸存一次的 中等
Gen 2 老对象 最低(全堆扫描,最贵)

LOH(大对象堆):≥ 85KB 的对象单独放,不压缩(搬一次太贵),易碎片化。

Q:压缩的成本是什么?固定(pinning)为什么造成碎片?

A:两笔账:① memcpy 搬运每个存活对象;② 按引用总数更新地址——一个对象被 N 个引用指着,搬家后 N 个引用全要改(能找到它们靠类型元数据 + 栈映射表,但工作量与引用数成正比)。
固定对象 = 不许动的障碍物,堆在它周围被划得七零八落,空隙什么都塞不进去 → 外部碎片。LOH 整体不压缩,碎片问题更重。
结论:频繁创建短命对象不可怕(死在 Gen0,回收便宜);可怕的是老对象引用关系复杂 + 大对象。
Q:带 finalizer 的对象怎么回收?"终结队列"为何不直接清除?

A:第一轮 GC:标记发现它是垃圾,但在终结队列里挂着 → 本次不收,挪到"待终结队列"(也算根),对象继续活着;终结线程执行完 ~Class() 后,下一轮 GC 才真正回收。
所以带 finalizer 的对象至少经过两轮 GC,且时机、顺序都不保证 → 确定性清理用 Dispose/using,finalizer 只当保险丝。

④ List & Array

List<T> 底层 = T[] 数组 + Count(元素个数)+ Capacity(容量)。

扩容流程:Add 时发现 Count == Capacity → 申请新数组(容量约翻倍)→ 元素逐个拷过去 → 内部指针改指新数组 → 旧数组变 GC 垃圾。

  • 几何翻倍使连续 Add 的总拷贝代价为 O(n);”加 1 扩 1”会退化成 O(n²)。
  • 扩容瞬间有性能毛刺(分配 + 全量拷贝);能预估就用 new List<T>(capacity) 避开。
  • 没有原地扩容:托管堆对象大小创建时定死,realloc 式”续租”在 GC 堆上不存在——永远是”退旧房、买新房、搬家”。

例 1 · 预估容量:战斗开始前 new List<Enemy>(预估波次数量)。
例 2 · 复用容器:每帧 LINQ/临时 List 用对象池复用,避开 GC。


⑤ Dictionary

目标:按键快速找值。键 → 哈希 → 数字 → 下标:

桶下标 = hashCode % capacity

两个数组,永远等长,扩容一起扩:

数组 内容
buckets[] 门牌:存”去 entries 第几号拿”的索引(空链存 -1)
entries[] 仓库:每条 = { key, value, hashCode, next },next = 下一个冲突条目的编号

插入示例(容量 5,”Apple” hashCode=12 → 12%5=2):

1
2
3
4
5
buckets: [ -1, -1,  0, -1, -1 ]     entries[0] = { "Apple", ..., next=-1 }
插入 "Banana"(7%5=2,冲突):
新条目进 entries 空位(1 号),串到链头,2 号桶改指新条目
buckets: [ -1, -1, 1, -1, -1 ]
entries[1] = { "Banana", ..., next=0 } ← 链:1号 → 0号
Q:查找时"比对相等"在比什么?rehash 改的是 hashCode 吗?

A:两道检查:先拿查找键的 hashCode 和链上每条存的 hashCode 比(整数比较快筛)→ 相同再 key.Equals 确认(防不同键撞同一 hashCode)。
hashCode 一辈子不变;rehash = 换模数换桶:扩容后 12%10 落点变了,所有条目按新模数重新排列——hashCode 纹丝不动,变的是"模谁"。

扩容:装载因子过高 → 两数组约翻倍 → 全量 rehash(有毛刺,换来链条重新变短)。删除的条目不缩数组,进 freeList 等待复用。

类比:buckets 是带索引卡的抽屉,entries 是仓库货架。货架扩到 200 格,抽屉必须跟着做 200 格——因为落位公式是”编号 % 200”。


⑥ 委托 & 事件

委托:类型安全的函数指针 + 多播清单。

1
2
3
4
5
6
7
8
9
10
11
12
delegate void OnHitHandler(int damage);   // 声明一种"函数类型"(合同)

void ShowDamageNumber(int damage) { /* UI 飘字 */ }
void PlayHitSound(int damage) { /* 音效 */ }
void ShakeCamera(int damage) { /* 震屏 */ }

OnHitHandler h;
h = ShowDamageNumber; // 语法糖:new OnHitHandler(ShowDamageNumber),把方法装进清单
h += PlayHitSound; // 追加:Delegate.Combine
h += ShakeCamera;

h(50); // 一次调用,三个函数依次执行;50 原样传给每一个
Q:h = 函数 这行到底发生了什么?委托只有声明没有实现?

A:delegate 声明只是定义类型("必须是这种形状的函数"),实现由编译器生成(清单 + 遍历调用)。h = ShowDamageNumber 是"方法 → 委托"的隐式转换:创建一个委托对象,把方法以 {目标, 函数指针} 形式塞进它的清单。从这一刻起 h 是一个装着方法的委托对象,new 那步被语法糖藏起来了。
Q:签名管什么?清单里的函数参数要一样吗?

A:签名 = 参数类型列表 + 返回类型,是挂进清单的准入门槛。参数名随便(damage / dmg 都行),类型、个数、返回类型必须全匹配,否则编译错误。调用时参数原样分发给清单中每个函数。
对照 C:C 的函数指针是裸地址,可以强转签名乱调(UB);C# 委托把签名变成类型的一部分,不匹配在编译期就被拦死——这就是"类型安全"的含义。

事件:给委托上锁。

1
2
3
4
5
6
7
public event OnHitHandler OnHit;   // 发布者类内声明

// 外部代码只能:
OnHit += ShowDamageNumber; // ✅ 订阅
OnHit -= ShowDamageNumber; // ✅ 退订
// OnHit = null; // ❌ 编译错误:不许清空
// OnHit(50); // ❌ 编译错误:只能在声明事件的类内部触发
⚠️ 易混淆点:delegate 是图纸,event 是房子
delegate 出场 = 造类型(定义函数形状);event 出场 = 造成员(类里放一份受控订阅清单)。两个关键字不会出现在同一行——记忆里"同一行"的那个是 public event EventHandler X;:event 关键字 + 预定义委托类型 EventHandler(签名 object sender, EventArgs e)。日常也可直接用 Action / Func,不必自写 delegate。

⑦ 协程

本质:IEnumerator 状态机 + Unity 主线程调度。不是多线程——和 Update 共享同一条时间线,是单线程上的协作式并发。

状态机三要素:MoveNext()(从上次暂停处执行到下一个 yield,返回 false = 走完)、Current(本次 yield return 交出的值)、state(内部状态编号)。

Q:调用协程方法时"不执行"?那第一个暂停点哪来的?

A:调用方法只造状态机对象(函数体零执行,只调用不调 MoveNext 的话一行都不跑);第一次 MoveNext 才从函数体第一行执行到第一个 yield 停住——暂停点是首次推进跑出来的。StartCoroutine = 帮你做"造机器 + 首次 MoveNext + 登记调度器"三件事。
Q:yield return 后面到底能放什么?凭什么?

A:放的是"递出去的值",C# 语言只负责暂停 + Current = X,X 的含义由读者决定。
读者是 foreach:X = 序列的下一个数据(造数列、省内存遍历)。
读者是 Unity 调度器:X 的类型 = "何时恢复"的指令——null 下一帧、WaitForSeconds(t) 到时、WaitUntil(()=>条件) 满足那天、StartCoroutine(另一个) 等它跑完。

等待期间零执行:MoveNext 不被调用,那段代码冻结、不占 CPU(不是空转)。代价:协程里写死循环照样卡死主线程。

1
2
3
4
5
6
7
IEnumerator AttackRoutine() {
Debug.Log("举刀");
yield return null; // 暂停到下帧
yield return new WaitForSeconds(2f); // 暂停 2 秒
yield return new WaitUntil(() => hp < 30); // 每帧检查条件,满足才继续
Debug.Log("收招");
}
Q:WaitUntil 里的 () => hp < 30 是什么?没参数,它"捕获"了什么?

A:是 lambda(和 C++ lambda 同源,C# 省掉捕获列表)。参数 ≠ 捕获:参数是调用方传入,捕获是 lambda 定义点外层变量直接借用——hp 就是这个 lambda 捕获的变量,所以它每帧读到的都是当前最新血量(捕获的是"活的格子",不是定义时快照)。
值类型被捕获时,编译器把变量搬进一个闭包对象(堆上的隐藏 class,住在储物柜里)——int 仍是 int、值语义不变,只是变量搬了家;lambda 和外层通过同一个对象引用摸到同一个字段,"共享"是这么来的。若捕获的是拷贝,血量定格在启动那刻,条件永远不满足,协程卡死。
证明捕获非拷贝:lambda 内部改、外部跟着变;证明非快照:外部改、lambda 读到新值。两个方向合起来才完整。
⚠️ 易混淆点:协程 ≠ 多线程
协程与 Update 读写同一变量不需要锁——交错只发生在明确的 yield 边界,不可能执行到一半被插进来。并发 ≠ 并行 ≠ 多线程:协程是单线程上的交替执行,同一时刻只有一段代码在跑。
停止机制:自然结束(MoveNext 返回 false)、StopCoroutine(本质是从调度列表移除,不是杀线程)、宿主销毁或 SetActive(false)。注意:只把组件 enabled = false 不停协程。

三、易混淆点对比汇总

易混对 关键区别
值类型 vs 引用类型 变量装数据(复印) vs 装引用(共享对象);string 是引用类型
装箱 vs 拆箱 套壳上堆(产生垃圾) vs 验壳取值(无分配);压力全在装箱
野指针式的”悬垂” vs 泄漏(C# 语境) GC 不存在野指针;但”持有已 Dispose 对象””事件未退订”≈ 等效问题
拆箱 vs 引用类型转换 拆箱脱的是值类型的壳;引用转换只是检查类型
foreach 泛型 vs foreach 非泛型接口 结构体枚举器 + int Current(零分配) vs IEnumerator + object Current(值类型逐元素装箱)
捕获拷贝 vs 捕获活变量 定义时快照(条件永远不满足) vs 共享格子(读到最新值)
搬家 vs 变质 值类型进闭包对象是”住进储物柜”,int 没变引用类型
delegate vs event 造类型 vs 造成员;外部只许 += / -=,触发只能类内
并发 vs 并行 vs 多线程 逻辑多执行流 vs 物理同时跑 vs 多线程;协程只占第一个
构造/析构(C# finalizer) C++ 确定性 vs C# 至少两轮 GC、时机不保证

四、流程图

图 1 · GC 标记-清除-压缩

四类根
栈映射表 / 静态字段
GCHandle / 终结队列
→
标记
沿引用链染色
没染到 = 垃圾
→
清除
回收垃圾内存
→
压缩
存活对象挤紧
按引用总数改地址
(LOH 跳过本步)
→
带 finalizer?
挪待终结队列
下轮才收(两轮 GC)

蓝 = 固定环节,绿 = 主流程,黄 = 开销大头,红 = 延迟回收分支

图 2 · Dictionary 查找

查找 key
→
算 hashCode
→
% capacity → 桶号
→
buckets[桶号] → entries 编号
→
沿 next 链走
先比 hashCode(快筛)
相同再 Equals 确认
→
命中

图 3 · 协程每帧调度循环

每帧(Unity 主循环)
↓
遍历登记的所有协程
↓
检查暂停条件:到时间了吗?条件满足了吗?下一帧到了吗?
↓ 满足
MoveNext():执行到下一个 yield
↓ 返回 false
协程结束,从列表移除

全程主线程;条件不满足期间 MoveNext 根本不被调用(零 CPU,不是空转)


五、面试速记

  1. 值类型 = 复制文件,引用类型 = 创建快捷方式;快捷方式换目标不影响别人,顺着改文件内容大家都看见。
  2. 装箱 = 值类型套壳上堆(GC 压力来源),拆箱 = 验壳取值(无分配)。
  3. GC 的根不是运行时统计的,是 JIT 编译时写好的四张表:栈映射、静态字段、GCHandle、终结队列。
  4. 频繁短命对象不可怕(死在 Gen0),可怕的是老对象引用复杂 + 大对象(LOH 不压缩)。
  5. 压缩两笔账:memcpy 搬运 + 按引用总数更新地址;固定对象 → 障碍物 → 碎片。
  6. List = T[] + Count + Capacity;无原地扩容,永远是”退旧房买新房”。
  7. Dictionary:buckets 是门牌、entries 是仓库,等长、共扩容;hashCode 不变,rehash 变的是模数。
  8. 委托 = 类型安全函数指针 + 多播;event = 上锁的清单(只许订阅不许篡改)。
  9. 协程调用零执行,首次 MoveNext 才开工;StartCoroutine = 造机器 + 首次推进 + 登记。
  10. yield return 后面放啥,取决于谁在读 Current——foreach 读数据,Unity 调度器读”何时恢复”的指令。
  11. lambda 捕获值类型 = 变量搬进堆上的闭包对象(储物柜),int 不变引用,共享的是同一个格子。
  12. 协程与主线程共享变量不用加锁——交错只发生在 yield 边界;并发 ≠ 并行 ≠ 多线程。

整理方式:以”提问 → 纠错 → 追问”的复习对话为底稿,按笔记编号归档;每个 Q/A 框对应一次真实的理解拐点。