简介:面向C#求职者的面试题整理文档,聚焦基础语法与高级特性中的高频考点,适合准备笔试、面试的初中级开发者及需要复习核心知识的在校学生。文档以问答形式解析了变量初始化、string与StringBuilder区别、GC垃圾回收机制、const与readonly差异、接口与抽象类选择、struct与class对比、异常处理陷阱、随机不重复数组生成、foreach遍历支持等典型题目,每个问题均给出清晰代码示例和图形化内存分析,便于理解底层原理与作答思路。资源包仅含1个doc文件,压缩包约316KB,内容组织紧凑,可快速查阅背诵。目前已有372人学习下载,可作为C#岗位面试冲刺阶段的精简复习资料。
1. 为什么这份 C# 面试题值得逐题吃透
之前帮一个朋友做模拟面试,第一道题就是string str;、string str="";、string str=null;三者的区别,他绕了半天没讲到内存分配上。这种基础题看着简单,实际上能把栈和堆、引用与值说清楚的人不多。这份 C# 面试题整理了 60 多道高频考点,从变量初始化、字符串不可变性、GC、类型系统、委托事件到常用算法,几乎覆盖了面试里会被反复追问的知识点。适合准备中级 .NET 岗位的开发者做自测,也适合带新人的老手拿来做摸底。它的价值不在答案本身,而在每道题背后都能延伸出一串更深的追问,这也是我把它逐题拆开讲的原因。
2. 字符串与内存管理:三种初始化、StringBuilder 与 GC 的结构化对比
2.1 三种字符串声明的内存差异
原题第一道就是string str;、string str="";、string str=null;三者的区别。先给结论,再看代码。
// 局部变量声明后不使用,编译器直接忽略,不产生任何 IL 代码 string str; // 空字符串:栈上保存堆地址,堆上存放空字符串 string str = ""; // null:栈上有引用变量,但指针的值为 0 string str = null;逻辑说明:string str;在局部变量场景下,编译后不会分配任何内存空间;如果它是类的成员变量,则会被自动赋值为 null,等同于第三种写法。string str="";会在托管堆中创建一个空字符串对象,并把栈上的引用指向它。string str=null;只在栈上放了一个值为 0 的指针,堆上没有对象。
参数说明:这里有个容易忽略的点——字符串是引用类型,所以成员变量不显式赋值就是 null。而string str;这种写法如果在代码里直接使用,编译器会在编译期就拦截,提示“使用了未赋值的局部变量”。判断字符串是否为空值时,不要直接用==比较 null 和空串,而应该用string.IsNullOrEmpty()统一处理。
| 声明方式 | 栈区 | 托管堆 | 典型场景 |
|---|---|---|---|
string str;(局部) | 不分配 | 无 | 声明后马上赋值的中间变量 |
string str;(成员) | 引用为 null | 无 | 成员变量默认值 |
string str=""; | 引用指向堆地址 | 空字符串对象 | 表示“有值但为空” |
string str=null; | 引用为 0 | 无 | 尚未初始化 |
空字符串还有一个特性:它会被字符串驻留机制统一管理。多个""实际上指向堆上的同一块内存,不会为每个空串单独分配空间。面试官追问到这一步,基本就能判断候选人是否真的理解引用类型的内存模型。
2.2 string 不可变性的真正含义
原题说 string 是“不可变的字符串”,这句话只说对了一半。真正不可变的是堆上的字符串对象,而不是引用变量本身。看下面的例子:
string a = "abcd"; a += "efg"; a += "hij"; // 两次 += 产生两块堆内存垃圾 StringBuilder sb = new StringBuilder("abcd", 64); sb.Append("efg"); sb.Append("hij"); // 在预留缓冲区写入,不产生新对象逻辑说明:第一段代码里每次+=都会在堆上重新分配一块内存,把旧字符串和新内容复制进去,然后让引用变量指向新地址。两次追加就产生了两块等待 GC 回收的垃圾。StringBuilder 则是在构造时按容量参数预留了一块缓冲区(这里指定 64 个字符),Append操作直接在缓冲区里写入,容量不够时才自动扩容。
参数说明:StringBuilder 的容量参数决定了初始预留的字符数。选择多大取决于业务场景,一般取预计最长字符串的 1.5 倍左右。注意 StringBuilder 也不是银弹,它本身是可变对象,线程安全方面需要额外考虑。如果只是三五个短字符串拼接,直接用+更简洁,性能差异完全可以忽略。
这道题面试官通常会追加一个问题:什么时候必须用 StringBuilder?我的答案是——在循环里拼接字符串、拼接次数不确定、或者拼接结果可能很大的场景。比如生成 CSV 文件、拼 SQL 语句(当然现在都用参数化了)、拼 JSON 字符串,这类场景用 string 会造成大量的内存分配和 GC 压力,用 StringBuilder 是空间换时间。
2.3 GC 回收时机与 const、readonly 的编译期差异
GC 这道题原答案讲到了“没有任何指针指向堆区时,内存会被回收”,这个方向是对的,但不够细。CLR 的垃圾回收用的是可达性分析,不是引用计数。JIT 会维护一个根集合(全局对象、静态字段、局部变量、CPU 寄存器),从根出发遍历对象图,遍历不到的对象就是垃圾。
public class Config { public const int MaxCount = 100; // 编译期常量,值直接嵌入 IL public readonly string Prefix; // 运行时常量,构造函数赋值 public Config(string prefix) { Prefix = prefix; // 唯一允许的赋值时机 } }逻辑说明:const 是编译期常量,编译器会把引用位置直接替换成字面量 100,所以 const 只能赋编译期可确定的字面量。readonly 是运行时常量,它可以在构造函数里根据参数赋值,但一旦构造函数执行完毕,之后再赋值就会编译报错。
参数说明:readonly 和 const 的选择标准很简单——值是否在编译期已知。数据库连接字符串、版本号这类编译时确定的用 const;需要在运行时根据环境或配置决定的用 readonly。另外注意 readonly 只能修饰字段,不能修饰局部变量;const 既可以是字段,也可以是局部变量。
GC 部分还有两个高频追问点:一是不要在生产代码里调用GC.Collect(),强制回收会打乱 CLR 的分代回收策略,反而降低性能;二是实现了IDisposable的非托管资源应该用using块释放,GC 管的是托管堆内存,文件句柄、数据库连接这些必须手动释放。
提示:面试里答 GC 题,不要只说“自动回收”,把根集合、分代(Gen0/Gen1/Gen2)、非托管资源的释放机制带出来,会明显不一样。
3. 类型系统与多态:struct/class、接口/抽象类、is/as 的选型逻辑
3.1 struct 与 class 的边界
原题第 6 题问 struct 和 class 的区别,给的答案表格已经很全了,但实际编码中真正的差异集中在内存行为和构造规则上。
public struct Point { public int X; public int Y; // struct 的构造函数必须有参数,且必须给所有字段赋值 public Point(int x, int y) { X = x; Y = y; } } public class PointClass { public int X = 0; // class 字段可以声明时赋值 public int Y = 0; }逻辑说明:struct 是值类型,赋值时按值复制整个结构体;class 是引用类型,赋值时只复制引用地址。struct 的成员变量不允许在声明时初始化,比如public int X = 0;写在 struct 里会编译报错。struct 的构造函数必须带参数,并且必须在构造函数体里给所有字段赋值。struct 不能有析构函数,也不能被继承,它是隐式密封的。
参数说明:struct 的字段默认值全部是 0(引用类型字段是 null),系统提供的默认构造函数不会经过用户定义的构造函数。很多人在 struct 里写无参构造函数,这是 C# 10 之前的硬性限制,新版语言虽然放宽了,但面试答这道题时还是按经典规则说更稳妥。
实际选型时,我的经验是:数据量小、复制成本低、逻辑简单的值对象用 struct,比如坐标点、颜色值、区间范围;需要多态、需要继承、生命周期复杂的一律用 class。还有一个隐性的坑——struct 作为集合元素时,修改操作容易踩“只读复制”的坑,比如list[0].X = 5是编译不过的,因为索引器返回的是字段副本。
| 对比项 | struct | class |
|---|---|---|
| 类型 | 值类型 | 引用类型 |
| 内存位置 | 栈上(装箱后进堆) | 托管堆 |
| 字段声明时赋值 | 不允许 | 允许 |
| 构造函数 | 必须有参数且为全部字段赋值 | 无限制 |
| 析构函数 | 不能有 | 可以有 |
| 继承 | 不能继承类,可实现接口 | 可继承类,密封类除外 |
| 默认构造函数 | 系统提供,字段值全为 0 | 系统提供,引用类型字段为 null |
3.2 接口与抽象类的选择依据
原题第 5 题给出了抽象类和接口的区别,最后落到“依据是什么”。这道题的重点不是背区别,而是说出选型理由。
public abstract class Animal { public string Name { get; set; } public abstract void Speak(); public void Sleep() { // 所有子类通用的实现,放在抽象类里复用 } } public interface IFlyable { void Fly(); } public class Bird : Animal, IFlyable { public override void Speak() { } public void Fly() { } }逻辑说明:抽象类可以有抽象成员(无实现)和非抽象成员(有实现),接口只能有抽象成员。一个类只能继承一个抽象类,但可以实现多个接口。代码里 Bird 继承了 Animal,又实现了 IFlyable,这种组合是接口多实现的典型用法。
参数说明:选择依据不是绝对的,但有两条经验很实用。第一,如果多个子类有共同的实现代码,优先用抽象类,把公共逻辑放进非抽象方法里,避免每个子类重复写;第二,如果只是定义行为契约,不需要共享实现,或者一个类需要组合多种能力(飞行、游泳、爬行),用接口。
面试官追问“抽象类可以实现接口吗”时,答案是肯定的——抽象类可以实现接口,接口的抽象成员在抽象类里可以不被实现,留给具体的子类去实现。另外要注意,抽象类不能 sealed,因为抽象类的存在意义就是被继承。
3.3 is/as、装箱拆箱与 CTS/CLS/CLR
原题把 is/as、装箱拆箱、CTS/CLS/CLR 拆成了三道题,其实它们都指向同一个核心:类型在运行时如何识别和转换。
object boxed = 42; // 值类型转引用类型:装箱 int unboxed = (int)boxed; // 引用类型转值类型:拆箱 object value = "hello"; if (value is string s) // C# 7 的模式匹配写法 { Console.WriteLine(s.Length); } string str = value as string; // as 转换失败返回 null,不抛异常逻辑说明:装箱是在托管堆上创建一个 object 副本,把值类型的字段复制进去;拆箱是把堆上的 object 副本转回值类型。is 用来判断实例是否属于某个类型,返回布尔值;as 用于在兼容的引用类型之间做转换,转换失败返回 null 而不是抛异常。
参数说明:value is string s这种写法同时完成类型判断和变量声明,s 的作用域在 if 块内。而as只能用于引用类型和可空类型,不能用于值类型——比如42 as int?可以,42 as int编译不通过。频繁装箱拆箱会分配堆内存,在循环里做大量值类型到 object 的转换,GC 压力会明显上升。
CTS、CLS、CLR 这三个概念建议放在一起记:CTS 是通用类型系统,定义了所有 .NET 语言共享的数据类型;CLS 是公共语言规范,规定了一组语言互操作的边界;CLR 是公共语言运行库,负责执行托管代码、管理线程和内存。面试官问“C# 和 VB.NET 怎么互相调用”,答案就在 CTS 和 CLS 里。
原题第 17 题关于new B()的构造顺序是这里最好的补充材料。创建 B 实例时,先执行字段初始化(x=1, y=0),再执行 A 的构造函数,而 A 构造函数里调用的虚方法会分派到 B 的 override 版本,所以先输出 x=1,y=0,等 B 构造函数体执行后 y 才变成 -1。这个“构造阶段调用虚方法”的陷阱,很多工作一两年的开发者都会答错。
4. 异常、委托与遍历:try-finally 执行顺序与 foreach 实现原理
4.1 try-finally 中 return 的真实时序
原题第 7 题问 try 里有 return,finally 会不会执行,答案的“在 return 后运行”容易误导人。准确说法是:finally 在 return 表达式已经求值、但方法尚未真正返回时执行。
private int Test() { int i = 10; try { return i; // 先把 i 的值 10 保存到返回值寄存器 } finally { i = 100; // 修改局部变量,不影响已保存的返回值 } } // 调用 Test() 得到 10,而不是 100逻辑说明:return i的执行分两步——先计算表达式的值(10)并保存,再执行 finally 块,最后把保存的值返回给调用方。finally 里修改 i 只改了局部变量,返回值寄存器里的 10 不受影响。
参数说明:面试时回答这道题,最好把结论说完整:“finally 总是会执行,除非程序崩溃或进程被杀;在 return 表达式求值之后、真正返回之前执行。”如果面试官追问返回值是否受影响,可以补充说明——如果 finally 里没有重新return,返回值是表达式的值;如果 finally 里有 return,会覆盖 try 里 return 的值,但这是反模式,生产代码不要这么写。
4.2 委托与事件的关系
原题第 15 题问“事件是不是一种委托”,答案是事件是一种特殊的多播委托,但它比普通委托多了一层封装——事件的 += 和 -= 在类外部只能由编译器生成的 add/remove 方法操作,不能直接赋值覆盖,而且只能在声明事件的类内部触发。
public delegate void Notify(string message); public class Button { public event Notify Clicked; protected virtual void OnClick(string msg) { Clicked?.Invoke(msg); // 安全调用,null 条件运算符 } public void SimulateClick(string msg) { OnClick(msg); // 类内部允许触发 } }逻辑说明:委托Notify是类型安全的函数指针,可以指向一个或多个方法(多播)。event Notify Clicked声明了一个事件,外部通过button.Clicked += handler订阅、通过-=取消订阅,但不能在外部用button.Clicked = null清空。Clicked?.Invoke(msg)先判断委托是否为空,再执行订阅的方法列表。
参数说明:委托的典型应用是回调、事件、LINQ 里的Func和Action。事件和委托的本质区别是封装性——事件对外只暴露 add/remove,触发权在声明类内部;委托则是可以直接调用的方法引用。面试里答“事件是特殊的委托,增加了封装和限制”就能过关。
4.3 让类支持 foreach:IEnumerable/IEnumerator 与构造阶段的多态陷阱
原题第 9 题给了完整的 MyList 实现,思路是对的:foreach 不是语法糖那么简单,编译器会把它转换成对GetEnumerator()、MoveNext()、Current的连续调用。
public class MyList : IEnumerable, IEnumerator { private int position = -1; private string[] array = { "a", "b", "c", "d" }; public IEnumerator GetEnumerator() { Reset(); // 遍历开始前把位置归位 return this; } public object Current { get { return array[position]; } } public bool MoveNext() { if (position < array.Length - 1) { position++; return true; } return false; } public void Reset() { position = -1; } }逻辑说明:foreach的工作流程是——先调用GetEnumerator()获得迭代器,然后反复调用MoveNext(),每次移动成功就从Current取值。这个实现把集合和迭代器合成在一个类里,代码简洁,但不是最佳实践。更规范的做法是单独写一个迭代器类,或者用yield return让编译器自动生成迭代器。
参数说明:直接用while (list.MoveNext()) { object o = list.Current; }也能遍历,但要注意Reset()不是每次都会调用的。如果同一个集合对象被 foreach 多次,需要确保GetEnumerator()里先复位位置。泛型版本应实现IEnumerable<T>和IEnumerator<T>,并且Current返回类型应该是T,而非object,这样可以避免拆箱开销。
构造阶段的多态陷阱是这道题的追加考点。前面第 3 章提到的new B()输出问题,面试中经常作为考察点出现:基类构造函数执行时如果调用了虚方法,实际执行的是子类的 override 版本,而子类字段可能尚未完成构造函数体的赋值。理解这个时序,比背输出结果更重要。
5. 避坑手册:静态方法、sealed、ref/out 与线程安全的常见误区
5.1 静态方法访问实例成员:编译期就该拦截的错误
现象:原题第 10 题的TestClass里,静态方法setvalue()直接给实例字段count赋值,编译报错 CS0120:对象引用对于非静态字段、方法或属性是必需的。
public class TestClass { private int count; private static int y; public static void SetValue() { // count = 1; // 编译错误:静态方法不能访问实例成员 y = 1; // 正确:y 是静态字段 } }原因:静态方法属于类型本身,没有 this 引用,它不知道要访问哪个实例的 count。静态成员在程序加载时就已分配,而实例成员需要 new 之后才有内存空间。
解决:如果静态方法必须访问实例成员,两个常见方案——把实例作为参数传入静态方法:
public static void SetValue(TestClass obj) { obj.count = 1; }或者把方法改为实例方法。选择哪个取决于业务语义:不需要对象状态的操作设计成静态方法,需要读取或修改对象状态的必须是实例方法。
5.2 sealed 的修饰范围限制
现象:原题第 11 题里写public sealed int MyProperty编译不通过,但public sealed class Test和public sealed override string ToString()是合法的。
public sealed class FinalClass { } // 正确:密封类不能被继承 public class Base { public virtual string ToString() { return ""; } } public class Derived : Base { public sealed override string ToString() { return "sealed"; } // sealed 修饰方法时必须和 override 一起用 }原因:sealed 修饰类和修饰方法/属性的语义不同。类上的 sealed 表示“不能继承”;方法或属性上的 sealed 表示“派生类不能再重写这个方法”,此时它必须是一个 override 方法,否则没有“防止重写”的对象。
解决:记住两条规则——sealed class 直接阻止继承;sealed override 方法阻止进一步重写。原题里的错误写法把 sealed 用在非 override 的属性上,编译器自然拒绝。实际编码中 sealed 的使用频率不高,主要用于设计层面明确“继承链到此为止”,避免后续维护者无意中破坏封装。
5.3 属性不能作为 ref 实参
现象:原题第 13 题里F(ref t.MyProperty)编译报错 CS0206:属性或索引器不能作为 out 或 ref 参数传递。
public class Test { public int myVar; public int MyProperty { get { return myVar; } set { myVar = value; } } } private void F(ref int i) { i = 100; } // 错误:F(ref t.MyProperty); // 正确做法:先用局部变量中转 int temp = t.MyProperty; F(ref temp); t.MyProperty = temp;原因:属性的本质是 get/set 方法,不是变量。ref 要求传递的是变量的地址,而属性背后是方法调用,拿到的只是方法的返回值,没有可寻址的内存位置。字段是真正的变量,所以F(ref t.myVar)合法。
解决:用局部变量承接属性值,调用 ref 方法后再赋回属性。这个坑在写分页参数、交换两个属性值时特别常见。另外注意 ref 和 out 的区别——ref 要求变量传参前已赋值,out 不要求;ref 是双向数据传递,out 只是让方法内产生新值传给外部。面试时把这两点讲清,比背定义更有说服力。
5.4 递归里的锁与参数传递陷阱
现象:原题第 24 题问Test(int i)里lock(this)然后递归调用,i>10 时会不会死锁。答案是不会死锁,但原答案说“如果把 int 换成 object 引用,死锁会发生”,这个结论需要修正。
public class A { public void Test(int i) { lock (this) { if (i > 10) { i--; Test(i); // int 按值传递,每次递归都是新副本 } } } }原因:第一,int 是值类型,递归调用时传递的是副本,每次修改不影响上一层的数据;第二,同一线程对同一对象的 lock 是可重入的——CLR 的 Monitor 记录的是“当前线程 + 递归深度”,同一线程再次进入同一把锁会直接通过,不会自己锁自己。
原题“换成 object 会死锁”的说法不准确:如果换成 object 引用,递归方法传入同一个实例引用,在单线程下依然不会死锁,因为 Monitor 可重入。真正会死锁的场景是多线程并发、各自持有锁并等待对方持有的锁。只是把参数从 int 换成 object,并不会自动制造死锁。
解决:lock 的对象选择比“会不会死锁”更重要。永远不要lock(this)或lock(typeof(T)),因为外部代码可能持有同一个对象实例的锁,导致不可预料的阻塞。规范做法是声明一个私有只读字段作为锁对象:
private readonly object _lockObj = new object(); public void SafeMethod(int i) { lock (_lockObj) { // 临界区代码 } }参数说明:私有锁对象保证只有当前类能访问,避免外部干涉;readonly 保证锁引用不会被重新赋值,防止锁失效。这道题面试官真正考察的是两点:参数传递的语义(值类型传值、引用类型传引用)和 Monitor 锁的可重入性。答完这两点,再纠正“换成 object 就会死锁”的错误结论,反而是加分项。
6. 高频代码题实操:随机数组、递归优化与排序算法的边界
6.1 不重复随机数组:原答案的边界隐患
原题第 8 题的洗牌算法思路对,但rand.Next(100 - i - 1)的上界不包含最大值,导致每一轮取不到当前范围的最后一个元素,洗牌结果不够均匀。标准写法是 Fisher-Yates 洗牌:
int[] array = new int[100]; for (int i = 0; i < 100; i++) { array[i] = i + 1; } Random rand = new Random(); for (int i = array.Length - 1; i > 0; i--) { int j = rand.Next(i + 1); // 上界包含 i,每个位置都被公平覆盖 int temp = array[i]; array[i] = array[j]; array[j] = temp; }逻辑说明:从数组尾部开始,每次在[0, i]范围内随机选一个位置交换,保证每个元素被选中的概率均等。rand.Next(i + 1)的上界是i + 1,所以能取到 i 本身,不会出现“最后一个元素永远不被选中”的偏差。
参数说明:Next(int maxValue)的返回值范围是[0, maxValue),左闭右开。写随机索引时最容易踩的坑就是上界差一,要么取不到最后一个,要么越界。如果追求更好的随机性,可以用RandomNumberGenerator替代Random,但在面试场景下,说清楚 Fisher-Yates 的均匀性已经足够。
6.2 斐波那契递归:从性能瓶颈看算法选型
原题第 14 题的递归实现能正确算出第 30 位,但存在严重的重复计算问题——Foo(30)会重复计算大量子问题,数据量大时性能断崖式下降。
// 递归版:正确但不高效,n=30 时产生数万次调用 public static int Foo(int i) { if (i <= 0) return 0; if (i <= 2) return 1; return Foo(i - 1) + Foo(i - 2); } // 迭代版:线性时间复杂度,O(n) public static long FooIterative(int n) { if (n <= 0) return 0; if (n <= 2) return 1; long prev = 1, curr = 1; for (int i = 3; i <= n; i++) { long next = prev + curr; prev = curr; curr = next; } return curr; }逻辑说明:递归版对应原题的Foo(i - 1) + Foo(i - 2),第 30 位约 83 万,递归深度不算大,但重复计算让总调用次数达到指数级。迭代版从第 3 位开始滚动计算,没有重复开销。
参数说明:面试中答递归题,最好主动指出递归的边界条件(i<=0 返回 0,i<=2 返回 1)和性能问题,然后给出迭代版或记忆化版本。这是展示算法意识的自然切入点。
6.3 冒泡排序:名字与实现的错位
原题第 16 题的代码其实是选择排序,不是冒泡排序。冒泡排序的语义是“相邻元素两两比较,每一轮把最大值冒泡到末尾”。
// 真正的冒泡排序,带提前结束标志位 int[] array = new int[100]; // 初始化逻辑省略 bool swapped; for (int i = 0; i < array.Length - 1; i++) { swapped = false; for (int j = 0; j < array.Length - 1 - i; j++) { if (array[j] > array[j + 1]) { int temp = array[j]; array[j] = array[j + 1]; array[j + 1] = temp; swapped = true; } } if (!swapped) break; // 某一轮没有交换,说明已经有序 }逻辑说明:内层循环比较j和j + 1,每轮结束后,最大的元素到达数组末尾,下一轮无需再比较末尾已排序区间。swapped标志位用来提前终止——如果整轮都没有发生交换,说明数组已经有序。
参数说明:加了标志位的冒泡排序在最好情况下复杂度降到 O(n),最坏情况 O(n²)。实际开发中很少直接用冒泡排序,但面试官让你手写时,一要能写出相邻比较的版本,二要能解释选择排序和冒泡排序的区别。当时我第一次刷这道题,没细看答案就背下来,后来被追问“为什么你的冒泡实现里 j 从 i+1 开始”,才意识到名字和实现对不上。从那以后,我每次遇到排序类代码,都会先在注释里把“每轮做什么、边界在哪、什么时候结束”写清楚,再动手编码。希望帮到你。
本文还有配套的精品资源,点击获取