第 14 章:.NET 面试高频题精讲

精选 20 道 .NET 高级岗位高频面试题,涵盖类型系统、GC、异步、并发、泛型、DI、EF Core、性能优化等核心知识点。每道题给出简洁答案和深入解析。
题目总览
| 编号 | 主题 | 难度 |
|---|---|---|
| 1 | 值类型 vs 引用类型 | ⭐⭐ |
| 2 | 装箱与拆箱 | ⭐⭐ |
| 3 | String 的不可变性 | ⭐⭐ |
| 4 | Dictionary 的内部实现 | ⭐⭐⭐ |
| 5 | GC 分代回收 | ⭐⭐⭐ |
| 6 | IDisposable 与终结器 | ⭐⭐⭐ |
| 7 | async/await 状态机 | ⭐⭐⭐⭐ |
| 8 | Task vs ValueTask | ⭐⭐⭐ |
| 9 | ConfigureAwait(false) | ⭐⭐⭐ |
| 10 | lock 的底层实现 | ⭐⭐⭐ |
| 11 | volatile 与内存屏障 | ⭐⭐⭐⭐ |
| 12 | 泛型的类型特化 | ⭐⭐⭐ |
| 13 | 协变与逆变 | ⭐⭐⭐ |
| 14 | 委托与事件的区别 | ⭐⭐ |
| 15 | Expression vs Func | ⭐⭐⭐ |
| 16 | DI 生命周期 | ⭐⭐⭐ |
| 17 | Captive Dependency | ⭐⭐⭐⭐ |
| 18 | 中间件管道 | ⭐⭐⭐ |
| 19 | EF Core 变更追踪 | ⭐⭐⭐ |
| 20 | Span<T> 与性能优化 | ⭐⭐⭐⭐ |
Q1:值类型和引用类型的区别?
简答: 值类型存储实际数据(通常在栈上),赋值时复制值;引用类型存储引用(对象在堆上),赋值时复制引用。
深入解析:
// 值类型:复制值
int a = 42;
int b = a; // b 是独立的副本
b = 100; // a 仍然是 42
// 引用类型:复制引用
var list1 = new List<int> { 1, 2 };
var list2 = list1; // 指向同一个对象
list2.Add(3); // list1 也变成 { 1, 2, 3 }
关键点:
- "值类型在栈上"不完全准确——值类型作为类的字段时在堆上
- 值类型继承自
System.ValueType,引用类型继承自System.Object - 值类型默认值是零值,引用类型默认值是
null
Q2:什么是装箱和拆箱?有什么性能影响?
简答: 装箱是将值类型转换为 object(堆分配 + 值复制),拆箱是将 object 转回值类型(类型检查 + 值复制)。
int x = 42;
object obj = x; // 装箱:堆上分配内存,复制值
int y = (int)obj; // 拆箱:类型检查 + 复制值
// 避免装箱的方法
// ❌ 非泛型集合
ArrayList list = new();
list.Add(42); // 装箱
// ✅ 泛型集合
List<int> list = new();
list.Add(42); // 无装箱
Q3:String 为什么是不可变的?有什么好处?
简答: String 对象创建后内容不能修改。任何"修改"操作都会创建新的 String 对象。
string s = "Hello";
s += " World"; // 创建新字符串,原来的 "Hello" 等待 GC 回收
// 字符串驻留(Interning)
string a = "hello";
string b = "hello";
Console.WriteLine(ReferenceEquals(a, b)); // True,指向同一个对象
好处:
- 线程安全:不可变对象天然线程安全
- 字符串驻留:相同内容共享同一个对象,节省内存
- 哈希缓存:哈希值可以缓存,Dictionary 查找更快
- 安全性:防止字符串被意外修改
频繁拼接用 StringBuilder,.NET 6+ 用字符串插值处理器也很高效。
Q4:Dictionary<K,V> 的内部实现?
简答: 基于哈希表,使用数组 + 链表(拉链法)解决冲突。
内部结构:
buckets[] → entries[]
┌───┐ ┌──────────────────────────────┐
│ 2 │───────→│ hashCode | key | value | next │ entries[2]
│-1 │ │ hashCode | key | value | next │ entries[3] → entries[0]
│ 1 │───────→│ hashCode | key | value | -1 │ entries[1]
│ 3 │───────→│ hashCode | key | value | -1 │ entries[0]
└───┘ └──────────────────────────────┘
查找过程:
- 计算 key 的 hashCode
hashCode % buckets.Length得到桶索引- 沿着链表查找匹配的 key
关键点:
- 负载因子过高时自动扩容(翻倍 + 重新哈希)
- key 必须正确实现
GetHashCode()和Equals() - 自定义类型作为 key 时,建议实现
IEquatable<T>
Q5:.NET GC 的分代回收机制?
简答: GC 将堆分为 Gen 0(新对象)、Gen 1(缓冲区)、Gen 2(长期存活),基于"大多数对象生命周期很短"的假设。
Gen 0 回收(最频繁):
→ 存活对象提升到 Gen 1,Gen 0 空间回收
Gen 1 回收:
→ 同时回收 Gen 0 和 Gen 1
Gen 2 回收(Full GC,最慢):
→ 回收所有代 + LOH(大对象堆,≥ 85KB)
减少 GC 压力的方法:
- 使用
ArrayPool<T>复用数组 - 使用
ValueTask代替Task - 使用
Span<T>和stackalloc避免堆分配 - 使用
struct代替class(小对象)
Q6:IDisposable 和终结器的区别?
简答: Dispose() 由开发者显式调用,确定性释放资源;终结器由 GC 调用,时机不确定,对象至少多存活一次 GC。
class Resource : IDisposable
{
private IntPtr _handle;
private bool _disposed;
public void Dispose()
{
if (_disposed) return;
CloseHandle(_handle); // 释放非托管资源
_disposed = true;
GC.SuppressFinalize(this); // 告诉 GC 不需要调用终结器
}
~Resource() // 终结器:安全网
{
if (!_disposed) CloseHandle(_handle);
}
}
// 使用 using 确保 Dispose 被调用
using var resource = new Resource();
Q7:async/await 的底层原理?
简答: 编译器将 async 方法转换为状态机(IAsyncStateMachine),每个 await 是一个状态切换点。
// 你写的代码
async Task<string> GetDataAsync()
{
var data = await httpClient.GetStringAsync(url);
return data.ToUpper();
}
// 编译器生成的(简化)
struct GetDataAsyncStateMachine : IAsyncStateMachine
{
public int _state; // 当前状态
public AsyncTaskMethodBuilder<string> _builder;
private TaskAwaiter<string> _awaiter;
public void MoveNext()
{
switch (_state)
{
case 0:
_awaiter = httpClient.GetStringAsync(url).GetAwaiter();
if (!_awaiter.IsCompleted)
{
_state = 1;
_builder.AwaitUnsafeOnCompleted(ref _awaiter, ref this);
return; // 让出线程
}
goto case 1;
case 1:
var data = _awaiter.GetResult();
_builder.SetResult(data.ToUpper());
break;
}
}
}
关键点:
- async 方法不会创建新线程
- await 时如果任务已完成,同步继续执行
- await 时如果任务未完成,注册回调后让出线程
Q8:Task 和 ValueTask 的区别?
简答: Task 是引用类型(堆分配),ValueTask 是值类型(栈分配),适合同步完成的热路径。
// Task:每次都堆分配
async Task<int> GetFromCacheAsync_Task()
{
if (_cache.TryGetValue(key, out var value))
return value; // 即使同步完成,也会分配 Task<int>
return await LoadFromDbAsync();
}
// ValueTask:同步完成时零分配
async ValueTask<int> GetFromCacheAsync_ValueTask()
{
if (_cache.TryGetValue(key, out var value))
return value; // 零分配!
return await LoadFromDbAsync();
}
ValueTask 的限制:
- 不能多次 await
- 不能并发 await
- 不能用
.Result或.GetAwaiter().GetResult()(除非已完成)
Q9:ConfigureAwait(false) 的作用?
简答: 告诉 await 不需要回到原来的同步上下文(如 UI 线程),可以在任意线程继续执行。
// 库代码中应该使用 ConfigureAwait(false)
async Task<string> GetDataAsync()
{
var data = await httpClient.GetStringAsync(url)
.ConfigureAwait(false); // 不需要回到 UI 线程
return data.ToUpper(); // 在线程池线程上执行
}
使用规则:
- 应用层代码(ASP.NET Core):不需要,因为没有 SynchronizationContext
- 库代码:应该使用
ConfigureAwait(false),避免死锁 - UI 应用(WPF/WinForms):需要更新 UI 时不能用
Q10:lock 的底层实现?
简答: lock 是 Monitor.Enter/Exit 的语法糖,基于对象头的同步块索引(Sync Block Index)。
// lock 编译后
bool lockTaken = false;
try
{
Monitor.Enter(_syncObj, ref lockTaken);
// 临界区
}
finally
{
if (lockTaken) Monitor.Exit(_syncObj);
}
注意事项:
- 不能 lock 值类型(装箱后每次是不同对象)
- 不要 lock(this)、lock(typeof(…))、lock(“string”)
- lock 不支持 async(用
SemaphoreSlim代替)
Q11:volatile 关键字的作用?
简答: 防止编译器和 CPU 对内存访问进行重排序,保证每次读写都直接访问主内存。
class Worker
{
private volatile bool _running = true;
public void DoWork()
{
while (_running) // 每次从主内存读取,不使用 CPU 缓存
{
// 工作...
}
}
public void Stop() => _running = false; // 立即写入主内存
}
volatile vs lock vs Interlocked:
volatile:简单标志位的读写Interlocked:单个变量的原子操作(递增、CAS)lock:复合操作(多个变量的一致性修改)
Q12:CLR 泛型的类型特化是什么?
简答: JIT 为每种值类型生成独立的机器码(避免装箱),所有引用类型共享同一份机器码(都是指针操作)。
List<int> intList; // 独立的 JIT 代码(操作 4 字节整数)
List<double> doubleList; // 独立的 JIT 代码(操作 8 字节浮点数)
List<string> stringList; // ┐
List<User> userList; // ┘ 共享同一份 JIT 代码(都是指针)
与 Java 的区别:Java 泛型使用类型擦除,运行时没有泛型信息,值类型必须装箱。
Q13:什么是协变和逆变?
简答: 协变(out)允许子类型赋值给父类型,逆变(in)允许父类型赋值给子类型。
// 协变:IEnumerable<out T>
IEnumerable<string> strings = new List<string>();
IEnumerable<object> objects = strings; // ✅ string → object
// 逆变:Action<in T>
Action<object> objAction = obj => Console.WriteLine(obj);
Action<string> strAction = objAction; // ✅ object → string
记忆方法:
- out(协变):T 只出现在输出位置(返回值)
- in(逆变):T 只出现在输入位置(参数)
Q14:委托和事件的区别?
简答: 事件是委托的封装,外部只能 += 和 -=,不能直接赋值或调用。
class Publisher
{
public Action? OnChange; // 委托:外部可以 = null 或直接调用
public event Action? OnChangeEvent; // 事件:外部只能 += 和 -=
}
var pub = new Publisher();
pub.OnChange = null; // ✅ 委托可以直接赋值
// pub.OnChangeEvent = null; // ❌ 事件不能直接赋值
Q15:Expression<Func<T>> 和 Func<T> 的区别?
简答: Func 是编译后的可执行代码,Expression 是描述代码的数据结构(AST),可以被分析和翻译(如翻译为 SQL)。
// EF Core 需要 Expression 来生成 SQL
IQueryable<User> query = db.Users;
query.Where(u => u.Age > 18); // Expression → 翻译为 SQL
// 如果用 Func,会加载所有数据到内存再过滤
IEnumerable<User> all = db.Users;
all.Where(u => u.Age > 18); // Func → 内存中过滤(性能灾难)
Q16:DI 的三种生命周期?
简答:
- Transient:每次解析创建新实例
- Scoped:每个作用域(HTTP 请求)一个实例
- Singleton:整个应用一个实例
builder.Services.AddTransient<IEmailSender, SmtpSender>(); // 每次新建
builder.Services.AddScoped<IDbContext, AppDbContext>(); // 每请求一个
builder.Services.AddSingleton<ICache, MemoryCache>(); // 全局唯一
Q17:什么是 Captive Dependency?
简答: Singleton 服务依赖 Scoped 服务,导致 Scoped 服务的生命周期被"提升"为 Singleton,永远不会被释放。
// ❌ 错误
builder.Services.AddSingleton<CacheService>(); // Singleton
builder.Services.AddScoped<AppDbContext>(); // Scoped
class CacheService(AppDbContext db) { } // db 被俘获,永远不释放
// ✅ 正确:使用 IServiceScopeFactory
class CacheService(IServiceScopeFactory factory)
{
public async Task<User?> GetAsync(int id)
{
using var scope = factory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
return await db.Users.FindAsync(id);
}
}
开启 ValidateScopes = true 可以在开发环境检测此问题。
Q18:ASP.NET Core 中间件管道的原理?
简答: 中间件按注册顺序组成管道,请求从外到内穿过每个中间件,响应从内到外返回。每个中间件可以选择是否调用 next()。
app.Use(async (context, next) =>
{
Console.WriteLine("中间件 1 - 请求");
await next(); // 调用下一个中间件
Console.WriteLine("中间件 1 - 响应");
});
app.Use(async (context, next) =>
{
Console.WriteLine("中间件 2 - 请求");
await next();
Console.WriteLine("中间件 2 - 响应");
});
// 输出顺序:
// 中间件 1 - 请求
// 中间件 2 - 请求
// 中间件 2 - 响应
// 中间件 1 - 响应
底层实现是 RequestDelegate 的嵌套调用(俄罗斯套娃)。
Q19:EF Core 的变更追踪(Change Tracking)?
简答: EF Core 通过 ChangeTracker 跟踪实体状态(Added/Modified/Deleted/Unchanged),SaveChanges() 时根据状态生成对应的 SQL。
var user = await db.Users.FindAsync(1); // 状态:Unchanged
user.Name = "新名字"; // 状态:Modified
db.Users.Add(new User { Name = "新用户" }); // 状态:Added
db.Users.Remove(user); // 状态:Deleted
await db.SaveChangesAsync(); // 根据状态生成 INSERT/UPDATE/DELETE
// 只读查询用 AsNoTracking 提升性能
var users = await db.Users
.AsNoTracking() // 不追踪,减少内存和 CPU 开销
.ToListAsync();
Q20:Span<T> 如何实现零分配?
简答: Span<T> 是 ref struct(只能在栈上),内部是一个托管指针 + 长度,直接指向原始内存,切片操作不产生新的堆分配。
string data = "Hello,World,Test";
// 传统方式:Split 分配数组 + 多个字符串
string[] parts = data.Split(','); // 堆分配
// Span 方式:零分配
ReadOnlySpan<char> span = data.AsSpan();
int comma = span.IndexOf(',');
ReadOnlySpan<char> first = span[..comma]; // "Hello",零分配
Span 的限制:
- 不能作为类的字段(ref struct)
- 不能在 async 方法中使用
- 不能装箱
- 需要 Memory<T> 来跨 await 边界传递
面试准备建议
- 理解原理,不要死记硬背:面试官会追问"为什么",理解底层原理才能应对
- 准备代码示例:能手写简单的代码示例比口头描述更有说服力
- 关注性能数据:知道"慢多少"比"会慢"更有价值
- 了解最新特性:.NET 8 的 Keyed Services、FrozenDictionary、SearchValues 等
- 实战经验:准备 1-2 个你在项目中解决性能问题或并发问题的真实案例
练习
- 选择 5 道题,用自己的话写出答案,不看参考
- 对每道题准备一个"追问"的回答(面试官通常会深入追问)
- 用 BenchmarkDotNet 验证至少 3 个性能相关的结论
- 实现一个迷你 DI 容器,支持三种生命周期
评论
登录 后发表评论
暂无评论