做 .NET 开发的这几年,我经常遇到一种情况:新同事看代码读得懂,但一聊到 "CLR 怎么回收内存""装箱拆箱为什么影响性能""var 和 dynamic 到底差在哪" 就卡壳了。问题不在代码能力,而在术语。C# 的术语密度在主流语言里算高的,文档又喜欢把人话翻译成技术黑话,导致很多朋友背了一堆名词,却不知道它们之间是什么关系。所以我想把 C# 常见术语整理成一张表——不是字典式的罗列,而是把相关术语放进同一个上下文里一起讲,这样记起来才牢。
这篇文章适合刚入门 C# 的开发者、从其他语言转过来的朋友,也适合在面试前快速梳理知识框架的人。我按 "从编译到运行、从类型到对象、从基础到进阶、从代码到内存" 的顺序展开,每讲一组术语就配一个简单的说明或生活类比,最后再给一份速查总表。你可以顺着读一遍当知识体检,也可以收藏起来当工作手册,碰到不确定的词再回来查。
1. 为什么 C# 术语需要一张"说人话"的表
1.1 术语墙是怎么形成的
C# 术语多的原因,跟它的血统有关。这门语言诞生于一个"既要又要"的时代:既要像 C++ 一样强大,又要像 VB 一样好上手;既要能直接操作底层,又要提供自动内存管理;既要兼容 Windows 生态,又要能跨平台运行。为了满足这些目标,.NET 平台设计了一整套中间层,每个中间层都是一个术语堆:CLR、CTS、CLS、IL、JIT、BCL、FCL……光是缩写就够让人头疼了。
这些术语本身不难,难的是它们互相引用。你想搞懂 GC,得先知道托管堆是什么;想搞懂托管堆,又得先明白值类型和引用类型的区别;想分清这两种类型,又绕不开栈和堆。术语是网状的,不是线性的。这有点像学车:教练说"半联动",你得先知道离合器、油门、刹车各自干嘛,才知道"联动"到底联的是什么。
所以我不按字母表排,而是按 "你在学习和开发时碰到它们的先后顺序" 来排。先讲编译运行链,再讲类型系统,再讲面向对象,然后是进阶特性,最后是内存管理。读一遍之后,术语之间的关系也就顺下来了。
1.2 这张表怎么读、能给谁用
我把每个术语都按 "一句话定义 + 为什么存在 + 常见误区" 的结构来讲。一句话定义负责让你快速知道它是什么;为什么存在负责让你记住它的出现动机;常见误区负责帮你避开那些最容易被同事和面试官问倒的坑。
三个典型的使用场景:第一,新项目启动时组内对设计方案有分歧,大家说的 "用 abstract class 还是 interface" 其实是两种不同的抽象手段,术语不清会导致讨论变成鸡同鸭讲;第二,看开源代码时遇到没见过的关键字,比如ref、in、record,查表比翻整本规范快;第三,面试前快速过一遍词条,重点看我标注"容易混淆"的部分。顺便说一句,术语表是死的,代码是活的——所有术语最终都要回到具体代码里去理解。
2. "程序还没跑起来"就先碰到的术语:编译与运行链上的关键名词
2.1 CLR、IL、JIT:从源码到机器的三段路
C# 源码(.cs 文件)并不会像 C 那样直接编译成机器码,它先被编译成一种中间语言,叫 IL(Intermediate Language,中间语言)。IL 是半成品代码,不识别具体 CPU 的指令,只认识一套抽象的指令集。这一步由 C# 编译器完成。
IL 存放在程序集(Assembly)里。程序集就是编译出来的 .dll 或 .exe 文件,里面既有 IL 代码,还有元数据(Metadata)。元数据可以理解成"代码的说明书",记录了每个类、方法、属性的名称和签名——这正是 .NET 能实现反射(Reflection)的基础。反射就是程序在运行时审视自己的元数据,动态地去创建对象、调用方法。
程序真正跑起来时,CLR(Common Language Runtime,公共语言运行时)登场。CLR 是 .NET 的虚拟机,负责加载程序集、做安全校验、执行 JIT 编译、管理内存和线程。JIT(Just-In-Time,即时编译)是 CLR 的一部分,在方法第一次被调用时把 IL 实时翻译成当前 CPU 能执行的机器码。
有个很有意思的细节:JIT 不是把所有代码一次性编译完,而是"用到哪个方法就编译哪个方法"。所以程序刚启动时会慢一点,因为 JIT 正在忙着翻译;跑起来之后速度就稳定了,因为该翻译的都翻译完了。调试器里看到的"首次调用慢",很多就是这个原因。
2.2 托管代码与非托管代码:谁在管你的内存
托管代码(Managed Code)指运行在 CLR 监管下的代码。CLR 会替它做垃圾回收、类型安全检查、异常处理等。非托管代码(Unmanaged Code)不受 CLR 管理,比如通过 P/Invoke 调用的底层系统 API、用 C++ 写的原生 DLL,这类资源要自己申请、自己释放。
开发中最常碰到非托管资源的地方是文件句柄、网络连接、数据库连接。系统不会自动帮你回收它们,需要显式释放。这也是为什么后面要专门讲 IDisposable 和 using——它们是用来"管好非托管资源"的补丁。
刚入门的人有个误解:以为 C# 里不存在内存泄漏。其实托管代码里也有泄漏,只是形式变了——你把不再使用的对象一直挂在某个静态字段或事件上,GC 就永远觉得它"还活着",内存照样只涨不降。这类泄漏比 C++ 的野指针更隐蔽,排查起来也更头大。
2.3 CTS 与 CLS:跨语言的约定
CTS(Common Type System,公共类型系统)定义了 .NET 中所有类型的基本规则:类型分值类型和引用类型、每个类型的继承关系、访问权限等。只要一门语言能编译成 IL 并遵守 CTS 的规则,它写出来的类型就能和其他 .NET 语言互通。
CLS(Common Language Specification,公共语言规范)则是更严格的"通用子集"。CTS 是全部规则,CLS 是建议所有人遵守的公共约束。为什么需要子集?因为有些语言特性没法在所有语言里统一表达:C# 有属性(Property),但某些语言没有。如果某个公共 API 只用了自己语言独有的特性,其他语言就不好调用了。CLS 规定了公共接口的"最小公约数",让 C#、VB.NET、F# 之间能互相调用。
多数开发者平时不需要背 CLS 的具体条款,但有一个场景会碰到:写公共类库时编译器警告"违反 CLS",通常意味着某个公开成员的签名用了不兼容类型,改一下就能消除警告。这类警告被很多人当成噪音忽略了,其实它是帮你守住跨语言兼容性的线。
2.4 程序集与命名空间:组织代码的两种粒度
程序集(Assembly)是物理层面的打包单位,一个 .dll 或 .exe 就是一个程序集。命名空间(Namespace)是逻辑层面的组织单位,用来避免类名冲突,可以跨越程序集存在。比如System.Collections.Generic这个命名空间里的List<T>,实际代码位于某个具体的程序集文件里。
写代码时的经验是:命名空间尽量跟着"功能模块 + 分层"走,项目里常见的 Services、Repositories、Models 就是按职责划分命名空间。程序集划分则要考虑依赖关系——被引用最多的核心层应该最少依赖别人,否则整个解决方案会变成一团互相引用的乱麻。我见过一个项目把实体类、工具类、业务接口全塞进同一个程序集,结果任何改动都牵一发动全身,这就是物理边界没划好。
3. 类型系统里的高频词:值类型、引用类型与泛型
3.1 值类型与引用类型:数据存储方式的分水岭
C# 里所有类型分成两大类:值类型(Value Type)和引用类型(Reference Type)。判断一个类型属于哪类,最简单的方法是看它是不是继承自System.ValueType。通常结构体(struct)、枚举(enum)、数值类型(int、double、bool)都是值类型;类(class)、接口(interface)、委托(delegate)、数组(array)、字符串(string)都是引用类型。
两者的核心区别在存储方式。值类型的变量直接保存数据本身;引用类型的变量保存的是一个"地址",真正的数据在堆上,变量只拿着指向它的"门牌号"。打个比方:值类型是把笔记本直接放在桌上,引用类型是桌上只放一张写着"去第 3 排柜子找"的便签。
这个区别带来一个行为差异:值类型赋值时是"复制内容",引用类型赋值时是"复制地址"。改了其中一个引用类型变量指向的对象的属性,另一个变量"看"到的也是改动后的,因为大家拿着同一张便签,指向同一个柜子。值类型不会这样,复制一份就是两份独立数据。很多 bug 都源于没分清这个区别。
| 对比项 | 值类型 | 引用类型 |
|---|---|---|
| 典型代表 | int、double、bool、struct、enum | class、string、array、delegate |
| 变量保存的内容 | 数据本身 | 指向堆上数据的地址 |
| 赋值行为 | 复制内容 | 复制引用 |
| 默认值 | 0、false 等具体默认值 | null |
| 常见存放位置 | 栈或作为类字段存在堆中 | 数据在堆上,引用在栈上 |
至于"值类型存栈、引用类型存堆"这句老话,我建议别当成铁律。准确的说法要看数据是"作为谁的成员存在":一个值类型字段在类里,它就跟着类对象待在堆上;一个引用类型的局部变量,它的引用在栈上,但指向的数据在堆上。遇到具体问题再分析,别被简化说法误导。
3.2 装箱与拆箱:值类型与 object 之间的桥梁
装箱(Boxing)是把值类型转换成引用类型 object(或它实现的接口)的过程。拆箱(Unboxing)则相反,把 object 转回原来的值类型。每次装箱都会在堆上新分配一个对象,把值类型的数据复制进去;拆箱要做类型检查,再复制回来。
为什么会有这种操作?因为像ArrayList这样的老集合元素类型是 object,你把 int 塞进去,编译器就得装箱。现在有了泛型集合List<int>,大部分装箱场景已经消失。但下面这些场景仍然会装箱:把值类型传给以 object 为参数的方法、值类型拼接到字符串(string.Format、$"{value}")、值类型被赋给 object 类型的变量。
装箱拆箱在数据量小时影响不明显,但在循环里反复发生,性能差距非常扎眼。我曾见过某段代码在 10 万次循环里因为装箱多花了几百毫秒。优化手段通常是:能用泛型就别用 object;高频拼接字符串用 StringBuilder;循环里避免把值类型往接口或 object 类型转。这不是玄学,是实打实能感知的差异。
3.3 泛型:为类型写"模板"
泛型(Generic)的本质是让"类型"本身变成可参数化的东西。List<T>里的 T 是类型参数,使用时传入 int 得到List<int>,传入 string 得到List<string>。编译器会为每种实际用到的类型生成对应的专用版本,所以没有装箱,也不会丢失类型安全。
泛型不只是集合的专利,自定义泛型类和方法能帮你消除大量重复代码。比如写一个通用缓存服务Cache<T>,任何类型都能复用同一套逻辑。设计泛型时可以用约束(Constraint)限制 T 必须满足的条件,比如where T : class、where T : struct、where T : new()。这些约束既是给编译器的承诺,也是给后续维护者看的文档。
泛型里协变(Covariance)和逆变(Contravariance)常让初学者晕。可以简化理解:out表示类型参数只出现在"产出"位置,比如IEnumerable<out T>只能被读取,所以List<string>能当作IEnumerable<object>用;in表示只出现在"消费"位置,比如IComparer<in T>。设计自己接口时用不上就先不加 in/out,不影响正确性,用上了再学也不迟。
3.4 可空值类型与可空引用类型:null 的两张面孔
可空值类型(Nullable<T>,语法写作int?、bool?)解决的是"值类型没有 null"的历史问题。数据库里一个 int 字段可能是空值,在 C# 里不处理就很别扭。int?相当于 int 加了一个"是否为 null"的标记,使用时可以用.HasValue判断、.Value取值,也可以和??操作符配合提供默认值。
另一种是 C# 8 引入的可空引用类型。string和string?在运行时没有区别,它们只是编译期的"意图标记"。开启可空检查后,编译器会用警告提醒你可能为 null 的引用。很多人刚接触觉得烦,但用习惯之后能减少一大片空引用异常。原则就一条:参数和返回值能不为空就声明为非空,确实可能为空的才加问号。别为了消警告到处乱加!,那个感叹号是告诉编译器"我保证这里不为空",乱用等于自己卸掉安全绳。
4. 面向对象绕不开的术语:类、接口、继承与访问控制
4.1 封装、继承、多态、抽象:四个词定位类设计
封装(Encapsulation)把数据和操作数据的方法绑定在一起,对外隐藏内部细节。C# 里通过 private 字段 + public 属性/方法实现,属性(Property)的 getter/setter 就是封装思想的直接体现。为什么重要?因为一旦外部能随便改内部字段,你就无法保证对象的合法性——比如年龄字段被改成 -1,数据完整性就崩了。
继承(Inheritance)让一个类复用另一个类的成员。子类继承父类,除了构造函数和私有成员,大部分成员都能访问。继承表达的是"是一种"的关系:Dog 继承 Animal,"狗是一种动物"。这个关系必须站得住脚,否则就是在生造继承,后来改需求时你就会发现"交通工具"突然不是"车"的合理父类了。
多态(Polymorphism)指"同一个接口,不同的实现"。C# 主要通过虚方法(virtual/override)和接口实现。基类定义虚方法,子类重写,运行时调用的是实际对象类型对应的方法,而不是声明类型的方法。这就是各种设计模式里"依赖抽象"的前提——调用方只认得基类,具体行为由运行时决定。
抽象(Abstraction)是提炼共性、隐藏具体细节的过程。抽象类(abstract class)可以有字段和构造函数,也包含只有签名没有实现的抽象方法,由子类去实现。抽象类和接口的区别是经典面试题:抽象类适合"家族体系",能共享代码;接口适合"契约能力",不关心是谁来履行。
4.2 接口与抽象类:两种抽象的适用边界
接口(Interface)定义一组"必须实现"的成员,不提供实现。类可以同时实现多个接口,所以接口适合给"能力打标签":IDisposable、IComparable都是能力标签。从设计角度看,接口回答的是"这个对象能不能做某件事",而不是"这个对象是谁"。
抽象类的定位是"一部分已经实现、一部分留给子类"。多个子类有大量相同逻辑时,抽到抽象类的普通方法里;只有个别步骤不同,就用抽象方法留给子类重写——这是模板方法模式的基础。
我选型时的经验:优先从"外部如何看待这个类型"出发。如果调用方只需要知道"它能被释放"或"它能被比较",用接口;如果一堆类之间有实实在在的共同代码,且天然构成"一种"关系,用抽象类。千万不要为了炫技把所有公共代码都塞到接口的默认实现里,那只会让接口变成杂物间。接口的职责越单一越好,这也是接口隔离原则的核心。
4.3 访问修饰符:谁能看到谁的成员
访问修饰符(Access Modifier)决定成员对外可见的程度。C# 提供了多档可见性,从宽到窄列一下:
| 修饰符 | 可见范围 | 常见用途 |
|---|---|---|
| public | 所有代码可见 | 对外 API、公开接口 |
| internal | 同一程序集内可见 | 内部模块间共享 |
| protected | 当前类及子类可见 | 给子类留扩展点 |
| protected internal | 子类或同程序集可见 | 两者满足其一即可 |
| private protected | 同程序集内的子类可见 | 两者必须同时满足 |
| private | 仅当前类可见 | 内部实现细节 |
很多人在面试时被问过private protected和protected internal的区别:一个是"或",一个是"且"。protected internal的意思是"子类也行,或者同程序集也行";private protected则要求"必须是子类,而且必须在同一个程序集内"。
在团队协作里,访问修饰符不只是语法,它定义了类的边界。public 成员越多,维护负担越重,因为改任何 public 成员都可能影响外部调用方。我的默认姿势:字段一律 private,通过属性暴露;能设 private 就先 private,需要暴露再逐渐放宽。
4.4 静态成员与实例成员:归属问题
静态(static)成员属于类型本身,而不是某个对象;实例成员属于每个对象。静态字段在同一进程中所有对象共享一份;实例字段每个对象各持一份。静态方法只能访问静态成员,因为它们没有"当前对象"的概念。
静态构造函数(static constructor)用来初始化静态成员,它只执行一次,在第一次使用类型时由 CLR 触发。很多人不知道静态成员的另一面:静态字段的生存期是整个进程。如果它持有大集合且不断增长,就等于给你加了一个"隐形内存泄漏源"。所以静态字段要么存常量级数据,要么做线程安全的懒加载,别什么都往静态里塞。
4.5 重载与重写:同名方法的两种变化
重载(Overload)指在同一个类里定义多个同名但参数不同的方法,比如Console.WriteLine有十几个重载。重载是编译期决定的,编译器根据实参的类型和个数选版本。
重写(Override)指在子类重写父类声明为 virtual 的方法,运行时决定调用哪个版本。重写要求方法签名一致,只是实现不同。
容易混淆的是new关键字隐藏父类方法。new隐藏和重写有本质区别:重写会走虚调用,new隐藏则完全按声明类型找方法。在声明为父类的变量上调用子类用new隐藏的方法,执行的是父类版本;如果是重写,执行的是子类版本。这个区别就像个坑,踩过的人才记得住。看代码先看方法声明带没带 virtual/override,没带就当普通方法处理。
5. 进阶名片:委托、事件、LINQ 与 async/await
5.1 委托:把方法当成参数来传
委托(Delegate)可以理解成"方法的类型"。传统函数的参数只能是 int、string 等数据类型,有了委托之后,你可以把"方法本身"当参数传给别人。定义delegate void MyAction(string msg),就是在定义一种"接受一个 string 参数、无返回值的方法类型"。
内置的Action和Func让大多数场景不需要自定义委托。Action表示无返回值方法,Func<TResult>表示有返回值方法,泛型参数从后往前对应参数和返回值,比如Func<int, string, bool>表示接受 int 和 string 两个参数、返回 bool。
委托内部有调用列表,用+=挂多个方法,用-=摘掉。因为委托本质是一个对象,所以它也可以为 null,调用前判空是好习惯,或者用?.Invoke(...)空安全调用。多播委托返回值的规则是:只有最后一个方法的返回值会被保留,前面方法的返回值被丢弃——这个细节经常让人踩坑。
5.2 事件:基于委托的"广播-订阅"模型
事件(Event)是在委托外面加了一层"纪律"。事件只能由声明它的类来触发,外部订阅者只能用+=注册或-=退订,不能直接赋空或主动调用。这种限制保证了发布者能控制何时通知,不会被外部随意篡改触发时机。
发布-订阅模型的典型场景是 UI:按钮的 Click 事件、文本框的 TextChanged 事件。业务代码里也常见:订单服务在订单创建成功时触发 OrderCreated 事件,邮件服务和短信服务各自订阅,订单服务本身不需要知道有哪些订阅者,这大大降低了模块耦合。
和事件强相关的还有回调(Callback)。回调指把方法传给异步或框架代码,让它在合适时机"回过来调用你"。委托、事件、回调本质上都用同一套机制,只是角色不同:委托是"传方法",事件是"受约束的发布-订阅",回调是"提前注册一个钩子等通知"。
我自己的体会是:每次用事件都要警惕一个坑——订阅了忘记退订。发布者持有订阅者的强引用,订阅者就永远不能被 GC 回收,这就回到前面说的"托管代码里的内存泄漏"。凡是"谁订阅了我就活该被拖住"的场景,都得在合适的生命周期节点上-=退订。
5.3 Lambda 表达式与匿名方法:闭包的坑
匿名方法(Anonymous Method)是没有名字的方法,C# 2.0 就有,写法是delegate(int x) { return x * 2; }。Lambda 表达式是它的进化版,写法是x => x * 2。lambda 语法更简洁,能推断的类型、括号、return 都可以省略。
lambda 的一个重要概念是闭包(Closure):lambda 会捕获它外部作用域里的变量,捕获的是"变量本身"而不是"当时的快照"。在循环里用 lambda 特别容易出问题:
for (int i = 0; i < 5; i++) { int j = i; // 复制当前值再捕获 list.Add(() => Console.WriteLine(j)); }如果直接捕获 i,最后所有 lambda 看到的都是同一个 i 的最终值(5)。foreach的迭代变量在新版本里被安全处理过,但 for 循环还是老样子,复制一个局部变量再捕获是最稳妥的解法。
var和 lambda 容易被搞混。var只是让编译器推断类型,推断出来的类型是确定的,不能用它声明一个"任意类型"的变量。dynamic才是运行时类型解析,用多了会丢失编译期检查和智能提示,性能也会下降。我的建议:能用 var 就用,能用静态类型就不用 dynamic。
5.4 LINQ:让数据查询变成"说人话"
LINQ(Language Integrated Query,语言集成查询)让开发者可以用类似 SQL 的语法查询内存集合、XML、数据库。两种写法:查询语法(from x in list where ... select ...)和方法语法(list.Where(...).Select(...))。实际项目里后者更常用。
LINQ 背后的核心是扩展方法(Extension Method)和迭代器(Iterator)。Where、Select都是IEnumerable<T>的扩展方法——给已有类型"偷偷加方法"的机制,本质是静态类里的静态方法,第一个参数用 this 指向被扩展的类型。迭代器则是yield return的魔力:用yield return写的方法,每次返回一个元素时会暂停在当前位置,下次继续。
延迟执行(Deferred Execution)是 LINQ 最重要的概念。Where、Select执行时并不会立刻遍历集合,它们只是构造了"查询计划";只有当你真的去迭代(foreach、ToList、Count 等)时,查询才真正执行。不理解这一点,很容易写出"数据改了两遍,查询结果还是旧的"这种 bug。反过来,把握延迟执行,你就能用同一块数据不断投影出不同视图,避免反复消耗内存。
LINQ 相关的高频术语还有匿名类型(new { Name = x.Name }创建的类型不需要类定义,常用于 Select 投影)、SelectMany(把"集合里的集合"压平)、GroupBy(分组聚合)、Join(连接集合)。初学者别一口气学完,先把Where、Select、FirstOrDefault、Any、OrderBy用熟,就足够应付绝大多数需求了。
5.5 async/await 与 Task:协作式异步
异步编程的核心术语是 Task(任务)和 async/await。Task 表示"未来会完成的操作";async 标记方法内部会用 await;await 是"当操作还没完成时,先让出控制权,等完成后再回来继续往下跑"。
关键认知:await 不等于"阻塞等待"。阻塞是线程卡在那里什么都不干,await 是当前线程先回去做别的事,操作完成再回来。对 UI 程序,await 可以避免界面卡死;对服务端,可以避免线程被无谓占用。很多人把Task.Wait()或.Result当 await 用,结果线程被硬生生卡住,高并发下线程池被耗尽——这就是"同步式异步",性能杀手。
async/await 常见的坑有三类:一是在 async 方法里用.Result获取结果,可能死锁(尤其涉及同步上下文时);二是忘记ConfigureAwait(false),在 UI 线程上造成不必要的等待;三是在 catch 块里再抛异常,早期版本会丢失原始堆栈,后来的 .NET 版本改善了不少。我的建议:从函数签名开始就一直 async,别在中间某层用.Result掐断异步链。
异步还牵扯到几个相关术语:CancellationToken(取消令牌,让耗时操作能被安全取消)、Task.Run(把同步方法扔到线程池线程)、ValueTask(减少某些场景下的分配开销)。这些属于进阶话题,碰到再查不迟。
6. 内存与资源管理术语:GC、栈、堆与 Dispose
6.1 栈与堆:两种存储区域的性格差异
栈(Stack)和堆(Heap)是进程内存里两个区域。栈的特点是后进先出、分配释放极快、容量有限,主要放方法调用的局部变量、参数和返回地址。堆的特点是内存随处分配、容量大、需要垃圾回收,引用类型的数据本体就在堆上。
上面那张表里已经写了值类型和引用类型的对照,这里展开说存储位置。决定数据放哪,看的不是类型本身,而是"这个数据是作为谁的成员存在":
| 对比项 | 栈 | 堆 |
|---|---|---|
| 分配速度 | 快 | 较慢 |
| 容量 | 小 | 大 |
| 管理方式 | 方法调用结束自动释放 | GC 回收 |
| 典型内容 | 局部变量、方法参数、返回地址 | 引用类型对象本体、大对象 |
栈和堆还有性能差异。堆分配比栈分配慢,因为要寻找可用空间、可能有锁竞争、还要参与 GC。所以生命周期短、内聚的数据,尽量用值类型和结构体承载。别误会成"值类型一定比引用类型快",如果结构体太大,频繁复制反而更慢,这个要实测。
6.2 垃圾回收(GC):自动内存管理的核心
垃圾回收(GC, Garbage Collection)负责回收"不再被引用的托管对象"。GC 把托管堆分代(Generation 0、1、2),新对象进 Gen0,活过回收就越往后代。回收时,GC 先找到所有"根"(局部变量、静态字段等),沿引用链标出"活着的对象",其余都是垃圾,可以被回收。这就是标记-清除的基本思想。
分代设计的原因很现实:绝大多数对象活得很短。如果每次 GC 都扫描整个堆,代价太高。所以先扫 Gen0,回收效果不够再把存活对象提升到 Gen1,以此类推。触发 GC 的条件有 Gen0 满、显式调用GC.Collect()、系统内存压力大等。
GC 有个让新手害怕的词:GC 暂停(Stop The World,简写 STW)。回收时,线程可能被暂时挂起,等待 GC 完成。.NET 的服务器 GC 和工作站 GC 在不同场景下平衡延迟和吞吐。还有大对象堆(LOH, Large Object Heap):超过 85KB 的对象直接进 LOH,LOH 默认不压缩,频繁创建大数组可能造成内存碎片。这些偏向性能调优,先了解名词,遇到问题再深挖。
提示:不要调用
GC.Collect()来"预防"内存问题。垃圾回收是自动的,手动触发往往让性能更差。真正的内存问题要从根因排查:是不是有对象被意外引用挂住了。
6.3 IDisposable 与 using:给自动回收打补丁
GC 管的是托管内存,管不了非托管资源。于是 C# 提供了IDisposable接口和 using 语句来手动控制释放时机。IDisposable只有一个方法Dispose(),调用时类内部应释放所有非托管资源,也可以选择释放托管资源。
using 语句是语法糖:
using (var conn = new SqlConnection(connectionString)) { // 使用连接... }它等价于 try-finally 里调用conn.Dispose(),保证异常发生时资源也能被释放,这是最大的价值。C# 8 之后还有 using 声明(using var conn = ...),作用域持续到当前代码块结束。
实现IDisposable时有经典模式:析构函数(Finalizer)+ Dispose 双重机制。析构函数(~ClassName())会被 GC 在对象被回收时调用,作兜底释放资源,但调用时机不确定。正确做法是实现protected virtual void Dispose(bool disposing),让Dispose()和析构函数都调它,并在Dispose()里用SuppressFinalize告诉 GC:我已手动释放过,不用再走析构。
常见的资源管理错误:忘记退订事件导致订阅者被"拉拽"着不回收;Dispose 里抛异常导致 finally 块出问题;using 块外使用已释放对象。这些问题只能靠写代码时的意识和代码审查来拦。凡是实现 IDisposable 的类,命名、语义、释放路径都必须先想清楚再动手。
7. 一张速查总表与三个最容易混淆的细节
7.1 常见术语速查总表
把前面涉及的关键术语汇总成一张速查表,建议收藏:
| 术语 | 一句话定义 | 出现场景 |
|---|---|---|
| CLR | .NET 的虚拟机,负责加载、JIT、GC 和类型安全 | 每个 .NET 程序运行时 |
| IL | 编译生成的中间语言,介于源码和机器码之间 | 编译后的 .dll/.exe 中 |
| JIT | 运行时把 IL 翻译成机器码的编译器 | 方法第一次调用时 |
| 程序集 | 编译产物,一个 .dll/.exe,含 IL 与元数据 | 项目输出目录 |
| 命名空间 | 逻辑上的类型组织单位 | 类库设计时 |
| 托管代码 | 运行在 CLR 管理下的代码,自动 GC | 绝大多数 C# 代码 |
| 值类型 / 引用类型 | 数据直接存储 / 通过地址引用 | 类型设计、参数传递 |
| 装箱 / 拆箱 | 值类型转 object / 反向转换 | 新旧集合互操作、字符串拼接 |
| 泛型 | 类型参数化机制,类型安全且免装箱 | List<T>、Dictionary<K,V> |
| 委托 / 事件 | 方法的类型 / 受约束的发布-订阅 | 回调、UI 事件 |
| Lambda | 匿名函数的简洁语法 | LINQ、回调 |
| LINQ | 语言集成查询 | 集合操作、数据库查询 |
| async/await | 异步方法的语法与协作机制 | I/O 操作、UI 响应 |
| GC | 自动回收托管内存的机制 | 内存管理 |
| 栈 / 堆 | 快速分配区 / 动态分配大内存区 | 数据存储位置判断 |
| IDisposable | 手动释放非托管资源的接口 | 文件、连接、句柄 |
7.2 最容易混淆的三组词:从实战教训里总结的
第一组是 var 与 dynamic。var 是"让编译器替我推断确定类型",编译后类型就锁定了;dynamic 是"类型到运行时才去解析"。把 dynamic 当万能药,换来的是性能损失和编译期检查消失,代码评审时我会要求所有 dynamic 都必须能说出"非它不可"的理由。
第二组是重载与重写。重载是同名不同参数,编译期决定;重写是同签名不同实现,运行期决定。new隐藏的坑再加一层:它既不报错也不走虚表,容易被误认为重写。看代码时先看方法声明有没有 virtual/override 关键字,没有就走普通绑定。
第三组是 Dispose 与析构函数。很多初学者以为析构就是 Dispose。实际上析构由 GC 调用,时机不确定;Dispose 由开发者在确定时机调用。封装资源类时,正确姿势是"Dispose 为主、析构兜底",而不是只写个析构就当清理完成。
C# 术语的体系看上去庞大,但拆开看,无非是"编译器怎么编译、CLR 怎么运行、类型怎么组织、对象怎么协作、资源怎么管理、异步怎么流转"这几条线。把这六条线顺一遍,术语自然就连成网了。先用速查表做索引,再回到正文看上下文,比死记硬背强得多。我自己带新人的时候,从来不让对方背术语表,而是扔几个小 Demo 让他跑——跑出来的印象,比背一百个名词都深。