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++ 引用)。赋值 = 拷贝引用,两个变量指向同一个对象。
A:两句话都错。"住哪"取决于声明位置而非类型:值类型做局部变量在栈上、做类的字段就在堆上;引用类型的对象永远在堆,但装引用的变量可栈可堆。
struct 永远、始终是值类型,装任何成员都不改变类别。只是复制这个 struct 时,里面的引用字段连引用一起被复印——多个副本的引用字段指向同一个对象;值成员(int 等)各拷各的,互不影响。
类比:值类型 = 复制粘贴一个文件副本;引用类型 = 创建一个指向同一文件的快捷方式。 给快捷方式换个指向目标(p2 = new Player()),不影响别的快捷方式;但顺着快捷方式改文件内容,大家看到的都是同一个文件。
string 分类上属于 class(引用类型),但它是不可变的——任何"修改"操作都创建了新字符串。用起来像值类型,答题时别归错类。另外装箱只对值类型有意义;引用类型与 object 之间是引用拷贝,零成本。
② 装箱与拆箱
- 装箱:在堆上 new 一个 object,把值拷贝进去,返回引用。每次装箱都制造一个短命堆对象。
- 拆箱:类型检查(不符抛 InvalidCastException)+ 把值从堆对象拷回栈变量。不申请内存、不释放任何东西,堆上原对象纹丝不动。
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++ 那种确定性生命周期——生死由”是否可达”决定,回收时机不确定。
A:不是运行时统计,是 JIT 编译时记账。根就四类,各有一张表:
① 线程栈:JIT 为每个方法生成栈映射表——"执行到偏移 N 时,第几号槽装着引用",GC 按表只认引用槽;
② 静态字段:类型加载时登记,数量有限直接遍历;
③ GCHandle 表:给 CLR 之外的使用者(C 库)开的寄养登记——"外面还有人攥着这个对象的地址,先别收",固定(pinning)形态还会把对象钉在原地不许搬家;
④ 终结/待终结队列:带 finalizer 的对象由 new 自动"注册",GC 眼里它们也算活着。
标记 → 清除 → 压缩 三步:
- 标记:从四类根出发染色,没染到的 = 垃圾。
- 清除:回收垃圾内存。
- 压缩:存活对象往一端挪动挤紧,消除碎片,并更新所有指向它们的引用。
分代:大部分对象死得很快,所以——
| 代 | 放什么 | 回收频率 |
|---|---|---|
| Gen 0 | 新诞生的对象 | 最高(一满就回收,存活极少、压缩便宜) |
| Gen 1 | 从 Gen 0 幸存一次的 | 中等 |
| Gen 2 | 老对象 | 最低(全堆扫描,最贵) |
LOH(大对象堆):≥ 85KB 的对象单独放,不压缩(搬一次太贵),易碎片化。
A:两笔账:① memcpy 搬运每个存活对象;② 按引用总数更新地址——一个对象被 N 个引用指着,搬家后 N 个引用全要改(能找到它们靠类型元数据 + 栈映射表,但工作量与引用数成正比)。
固定对象 = 不许动的障碍物,堆在它周围被划得七零八落,空隙什么都塞不进去 → 外部碎片。LOH 整体不压缩,碎片问题更重。
结论:频繁创建短命对象不可怕(死在 Gen0,回收便宜);可怕的是老对象引用关系复杂 + 大对象。
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
目标:按键快速找值。键 → 哈希 → 数字 → 下标:
两个数组,永远等长,扩容一起扩:
| 数组 | 内容 |
|---|---|
| buckets[] | 门牌:存”去 entries 第几号拿”的索引(空链存 -1) |
| entries[] | 仓库:每条 = { key, value, hashCode, next },next = 下一个冲突条目的编号 |
插入示例(容量 5,”Apple” hashCode=12 → 12%5=2):
1 | buckets: [ -1, -1, 0, -1, -1 ] entries[0] = { "Apple", ..., next=-1 } |
A:两道检查:先拿查找键的 hashCode 和链上每条存的 hashCode 比(整数比较快筛)→ 相同再 key.Equals 确认(防不同键撞同一 hashCode)。
hashCode 一辈子不变;rehash = 换模数换桶:扩容后 12%10 落点变了,所有条目按新模数重新排列——hashCode 纹丝不动,变的是"模谁"。
扩容:装载因子过高 → 两数组约翻倍 → 全量 rehash(有毛刺,换来链条重新变短)。删除的条目不缩数组,进 freeList 等待复用。
类比:buckets 是带索引卡的抽屉,entries 是仓库货架。货架扩到 200 格,抽屉必须跟着做 200 格——因为落位公式是”编号 % 200”。
⑥ 委托 & 事件
委托:类型安全的函数指针 + 多播清单。
1 | delegate void OnHitHandler(int damage); // 声明一种"函数类型"(合同) |
A:delegate 声明只是定义类型("必须是这种形状的函数"),实现由编译器生成(清单 + 遍历调用)。
h = ShowDamageNumber 是"方法 → 委托"的隐式转换:创建一个委托对象,把方法以 {目标, 函数指针} 形式塞进它的清单。从这一刻起 h 是一个装着方法的委托对象,new 那步被语法糖藏起来了。
A:签名 = 参数类型列表 + 返回类型,是挂进清单的准入门槛。参数名随便(damage / dmg 都行),类型、个数、返回类型必须全匹配,否则编译错误。调用时参数原样分发给清单中每个函数。
对照 C:C 的函数指针是裸地址,可以强转签名乱调(UB);C# 委托把签名变成类型的一部分,不匹配在编译期就被拦死——这就是"类型安全"的含义。
事件:给委托上锁。
1 | public event OnHitHandler OnHit; // 发布者类内声明 |
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(内部状态编号)。
A:调用方法只造状态机对象(函数体零执行,只调用不调 MoveNext 的话一行都不跑);第一次 MoveNext 才从函数体第一行执行到第一个 yield 停住——暂停点是首次推进跑出来的。
StartCoroutine = 帮你做"造机器 + 首次 MoveNext + 登记调度器"三件事。
A:放的是"递出去的值",C# 语言只负责暂停 + Current = X,X 的含义由读者决定。
读者是 foreach:X = 序列的下一个数据(造数列、省内存遍历)。
读者是 Unity 调度器:X 的类型 = "何时恢复"的指令——
null 下一帧、WaitForSeconds(t) 到时、WaitUntil(()=>条件) 满足那天、StartCoroutine(另一个) 等它跑完。
等待期间零执行:MoveNext 不被调用,那段代码冻结、不占 CPU(不是空转)。代价:协程里写死循环照样卡死主线程。
1 | IEnumerator AttackRoutine() { |
() => 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 跳过本步)
挪待终结队列
下轮才收(两轮 GC)
蓝 = 固定环节,绿 = 主流程,黄 = 开销大头,红 = 延迟回收分支
图 2 · Dictionary 查找
先比 hashCode(快筛)
相同再 Equals 确认
图 3 · 协程每帧调度循环
全程主线程;条件不满足期间 MoveNext 根本不被调用(零 CPU,不是空转)
五、面试速记
- 值类型 = 复制文件,引用类型 = 创建快捷方式;快捷方式换目标不影响别人,顺着改文件内容大家都看见。
- 装箱 = 值类型套壳上堆(GC 压力来源),拆箱 = 验壳取值(无分配)。
- GC 的根不是运行时统计的,是 JIT 编译时写好的四张表:栈映射、静态字段、GCHandle、终结队列。
- 频繁短命对象不可怕(死在 Gen0),可怕的是老对象引用复杂 + 大对象(LOH 不压缩)。
- 压缩两笔账:memcpy 搬运 + 按引用总数更新地址;固定对象 → 障碍物 → 碎片。
- List
= T[] + Count + Capacity;无原地扩容,永远是”退旧房买新房”。 - Dictionary:buckets 是门牌、entries 是仓库,等长、共扩容;hashCode 不变,rehash 变的是模数。
- 委托 = 类型安全函数指针 + 多播;event = 上锁的清单(只许订阅不许篡改)。
- 协程调用零执行,首次 MoveNext 才开工;StartCoroutine = 造机器 + 首次推进 + 登记。
- yield return 后面放啥,取决于谁在读 Current——foreach 读数据,Unity 调度器读”何时恢复”的指令。
- lambda 捕获值类型 = 变量搬进堆上的闭包对象(储物柜),int 不变引用,共享的是同一个格子。
- 协程与主线程共享变量不用加锁——交错只发生在 yield 边界;并发 ≠ 并行 ≠ 多线程。
整理方式:以”提问 → 纠错 → 追问”的复习对话为底稿,按笔记编号归档;每个 Q/A 框对应一次真实的理解拐点。