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

jerry北京市2026年4月22日C# 5 次阅读 约 28 分钟
第 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]
└───┘        └──────────────────────────────┘

查找过程:

  1. 计算 key 的 hashCode
  2. hashCode % buckets.Length 得到桶索引
  3. 沿着链表查找匹配的 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 的底层实现?

简答: lockMonitor.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 边界传递

面试准备建议

  1. 理解原理,不要死记硬背:面试官会追问"为什么",理解底层原理才能应对
  2. 准备代码示例:能手写简单的代码示例比口头描述更有说服力
  3. 关注性能数据:知道"慢多少"比"会慢"更有价值
  4. 了解最新特性:.NET 8 的 Keyed Services、FrozenDictionary、SearchValues 等
  5. 实战经验:准备 1-2 个你在项目中解决性能问题或并发问题的真实案例

练习

  1. 选择 5 道题,用自己的话写出答案,不看参考
  2. 对每道题准备一个"追问"的回答(面试官通常会深入追问)
  3. 用 BenchmarkDotNet 验证至少 3 个性能相关的结论
  4. 实现一个迷你 DI 容器,支持三种生命周期

← 上一章:高性能 .NET

评论

登录 后发表评论

暂无评论