1. 为什么C#面试题值得系统梳理
1.1 从一道题到一张知识网
很多人准备C#面试的方式是刷题,一道一道刷,刷完就忘。我见过太多候选人,问委托能说清楚,问事件就开始含糊,再追问委托和事件在IL层面的区别,直接卡住。问题出在哪?他们把面试题当成了孤立的考点,而没有意识到这些题目背后是一张互相咬合的知识网。
C#这门语言从2000年发布至今,经历了十几个大版本迭代,从最初的类Java语法,到LINQ、async/await、Span、record、模式匹配,每一次版本更新都在往这张网上编织新的节点。面试官问的每一道题,本质上都是在探测你对这张网的某个局部是否理解到位。比如“值类型和引用类型有什么区别”这道题,表面在问内存分配,实际上牵出了装箱拆箱、GC压力、结构体设计、参数传递、闭包捕获等一系列问题。
我整理这100道题的过程,其实是一次对C#知识体系的反向拆解。不是按教科书章节来分,而是按面试官实际考察的维度来分:语言基础、面向对象、内存管理、并发编程、LINQ与集合、反射与特性、异步编程、常见框架与工具链。每个维度下精选高频题,每道题给出解析思路和延伸方向。
1.2 谁适合看这份总结
这份内容主要面向三类人。第一类是有一定C#基础,准备跳槽或晋升面试的开发人员,需要把零散的知识点串成体系。第二类是刚入行的新手,想通过面试题快速了解C#的核心概念和常见坑点。第三类是从其他语言转C#的开发者,需要快速抓住C#区别于Java、Python等语言的关键特性。
对于第一类人,建议重点关注每道题的“延伸追问”部分,那才是面试中拉开差距的地方。对于第二类人,先把基础概念吃透,不要急着看高级话题。对于第三类人,建议重点看委托、事件、LINQ、async/await这些C#特有的设计。
注意:面试题的价值不在于背答案,而在于理解每个答案背后的设计决策。面试官稍微换个角度追问,背答案的人就会露馅。
2. C#语言基础高频题拆解
2.1 值类型与引用类型的本质区别
这是C#面试中出现频率最高的一道题,没有之一。但很多人回答得并不完整。标准答案会说“值类型存在栈上,引用类型存在堆上”,这个说法在大多数场景下成立,但并不准确。实际上,值类型可能被装箱到堆上,引用类型也可能在栈上分配(比如通过stackalloc或ref struct)。
更准确的理解方式是:值类型变量直接包含数据,赋值时复制整个数据;引用类型变量存储的是对象在堆上的地址,赋值时复制的是地址。这个区别带来的连锁反应才是面试官真正想听的:值类型默认不能为null(除非用Nullable ),值类型不参与GC(除非装箱),值类型作为方法参数传递时默认是复制,引用类型传递的是地址副本。
// 值类型赋值:复制数据 int a = 10; int b = a; // b得到10的副本 b = 20; // a仍然是10 // 引用类型赋值:复制地址 var list1 = new List<int> { 1, 2, 3 }; var list2 = list1; // list2和list1指向同一个对象 list2.Add(4); // list1也会看到4延伸追问经常包括:struct和class怎么选?什么时候用readonly struct?ref struct解决了什么问题?这些问题的答案都指向同一个核心:理解数据在哪里、怎么传递、生命周期多长。
2.2 装箱与拆箱的性能陷阱
装箱是把值类型转换成object或接口类型的过程,拆箱是反向操作。这个过程涉及堆分配和内存复制,在热路径上频繁发生会严重影响性能。我见过一个实际案例,一个循环里对int做了百万次装箱操作,导致GC频繁触发,程序响应时间从毫秒级飙升到秒级。
// 装箱:每次循环都分配一个堆对象 for (int i = 0; i < 1000000; i++) { object obj = i; // 装箱 // ... } // 避免装箱:使用泛型 var list = new List<int>(); // 泛型集合不装箱 for (int i = 0; i < 1000000; i++) { list.Add(i); // 无装箱 }常见的装箱场景包括:把值类型存入非泛型集合(ArrayList、Hashtable)、用object参数的方法、字符串拼接中的值类型、枚举作为字典键(如果字典不是泛型)。排查装箱问题可以用ILSpy看生成的IL代码,搜索box指令。
2.3 字符串的不可变性与驻留机制
string是引用类型,但表现得像值类型。它的不可变性意味着任何修改操作都会创建新字符串。这解释了为什么在循环里用+=拼接字符串性能很差——每次都在堆上分配新对象。
// 性能差:每次循环创建新字符串 string result = ""; for (int i = 0; i < 10000; i++) { result += i.ToString(); // 10000次堆分配 } // 性能好:StringBuilder内部维护可变缓冲区 var sb = new StringBuilder(); for (int i = 0; i < 10000; i++) { sb.Append(i); } string result2 = sb.ToString(); // 一次分配字符串驻留是另一个常考点。CLR维护一个驻留池,相同字面量的字符串会指向同一个对象。但通过new string()或StringBuilder创建的字符串不会自动驻留,需要调用string.Intern()手动驻留。
string s1 = "hello"; string s2 = "hello"; Console.WriteLine(object.ReferenceEquals(s1, s2)); // True,驻留池 string s3 = new string("hello".ToCharArray()); Console.WriteLine(object.ReferenceEquals(s1, s3)); // False Console.WriteLine(s1 == s3); // True,==比较的是值2.4 数组与集合的定义和使用区别
数组是固定长度的连续内存块,集合是基于数组或其他数据结构封装的动态容器。数组的Length属性返回元素总数,集合的Count属性也是元素总数,但集合可以动态扩容。
// 数组:固定长度,声明时确定 int[] arr = new int[5]; // 长度5,不可变 arr[0] = 1; // 集合:动态扩容 var list = new List<int>(); // 初始容量0 list.Add(1); // 自动扩容使用上的核心区别:数组支持多维(int[,])和交错(int[][]),集合通常是单维的;数组的索引从0开始且连续,Dictionary等集合的键不要求连续;数组可以直接用foreach遍历,集合需要实现IEnumerable。性能上,数组访问更快(无边界检查优化后),集合提供了更多操作(排序、查找、去重)。
2.5 字符串截取的常见方法与坑
C#截取字符串主要有Substring、Split、正则、Span切片几种方式。Substring(startIndex, length)是最常用的,但要注意索引越界和length为负的情况。
string text = "Hello, World"; string sub1 = text.Substring(7); // "World" string sub2 = text.Substring(0, 5); // "Hello" // string sub3 = text.Substring(7, 10); // 抛ArgumentOutOfRangeException // 使用Span避免分配(.NET Core 2.1+) ReadOnlySpan<char> span = text.AsSpan(7, 5); // "World",无堆分配常见坑点:Substring的第二个参数是长度不是结束索引;中文字符串截取时要注意编码,UTF-16下中文占2个char,按char截取可能截断半个字符;大量截取操作建议用Span或Memory减少GC压力。
3. 面向对象与类型系统深度解析
3.1 委托与事件的本质关系
委托是类型安全的函数指针,事件是委托的封装。面试官常问“委托和事件有什么区别”,标准答案是事件只能在声明它的类内部触发,外部只能订阅和取消订阅。但更深层的区别在于设计意图:委托是“我可以调用这个方法”,事件是“当某事发生时通知我”。
// 委托:可以直接赋值和调用 public delegate void MyDelegate(string msg); MyDelegate d = Console.WriteLine; d("hello"); // 直接调用 // 事件:只能+=和-=,不能在外部触发 public class Publisher { public event EventHandler<EventArgs> MyEvent; protected virtual void OnMyEvent() { MyEvent?.Invoke(this, EventArgs.Empty); // 只能在类内部触发 } }事件在编译后会生成一个私有的委托字段和add/remove方法,这就是为什么外部不能直接赋值。多播委托的异常处理也是常考点:如果一个订阅者抛异常,后面的订阅者不会收到通知。解决方案是遍历GetInvocationList()逐个调用并捕获异常。
3.2 接口与抽象类的选择策略
接口定义“能做什么”,抽象类定义“是什么”。C# 8.0之后接口可以有默认实现,这让两者的界限变得模糊,但核心区别依然存在:一个类只能继承一个抽象类,但可以实现多个接口。
选择策略:如果需要定义一组相关类的共同行为且共享代码,用抽象类;如果需要定义跨继承体系的能力契约,用接口。比如IDisposable、IComparable这些跨类型的能力用接口,而Stream这样的基类用抽象类。
// 接口:定义能力 public interface ILogger { void Log(string message); } // 抽象类:定义模板 public abstract class RepositoryBase<T> { protected abstract T GetById(int id); public T GetOrThrow(int id) { var item = GetById(id); if (item == null) throw new NotFoundException(); return item; } }3.3 密封类、静态类与部分类的使用场景
密封类(sealed)禁止被继承,用于防止派生类破坏设计或出于性能考虑(JIT可以优化虚方法调用)。静态类(static)不能被实例化,所有成员必须是静态的,常用于工具类。部分类(partial)允许把一个类的定义拆分到多个文件,常用于代码生成场景。
// 密封类:防止继承 public sealed class Configuration { public string ConnectionString { get; set; } } // 静态类:工具方法 public static class StringHelper { public static bool IsNullOrEmpty(string s) => string.IsNullOrEmpty(s); } // 部分类:拆分定义 // File1.cs public partial class UserService { public void CreateUser() { /* ... */ } } // File2.cs public partial class UserService { public void DeleteUser() { /* ... */ } }3.4 泛型约束与协变逆变
泛型约束(where T : class, new())让编译器知道类型参数必须满足的条件,从而在泛型方法内调用特定成员。协变(out)和逆变(in)是泛型接口和委托的变体支持,让类型转换更灵活。
// 泛型约束 public T Create<T>() where T : class, new() { return new T(); // 编译器知道T有无参构造函数 } // 协变:IEnumerable<out T> IEnumerable<string> strings = new List<string>(); IEnumerable<object> objects = strings; // 协变,string是object的子类 // 逆变:Action<in T> Action<object> actObject = obj => Console.WriteLine(obj); Action<string> actString = actObject; // 逆变协变逆变只适用于引用类型,值类型不支持。IEnumerable 、IReadOnlyList 是协变的,Action 、IComparer 是逆变的,List 是不变的。
4. 内存管理与性能优化实战
4.1 垃圾回收机制与代龄策略
.NET的GC采用分代回收策略,把堆对象分为三代:Gen0(新分配对象)、Gen1(幸存一次回收)、Gen2(长期存活)。Gen0回收最频繁也最快,Gen2回收最慢但最少。大对象(超过85000字节)直接进入大对象堆(LOH),LOH不参与压缩,容易产生碎片。
// 查看GC代龄 var obj = new object(); Console.WriteLine(GC.GetGeneration(obj)); // 0 GC.Collect(); // 强制回收 Console.WriteLine(GC.GetGeneration(obj)); // 1优化建议:减少Gen0分配频率(对象池、ArrayPool)、避免大对象频繁分配、及时释放非托管资源(using/Dispose)、用WeakReference做缓存避免阻止回收。
4.2 IDisposable与终结器的正确实现
IDisposable用于释放非托管资源,终结器(Finalizer)是GC回收前的最后机会。正确的实现模式是:Dispose方法释放托管和非托管资源并调用GC.SuppressFinalize,终结器只释放非托管资源。
public class ResourceHolder : IDisposable { private IntPtr _handle; // 非托管资源 private bool _disposed = false; public void Dispose() { Dispose(true); GC.SuppressFinalize(this); // 告诉GC不需要调用终结器 } protected virtual void Dispose(bool disposing) { if (_disposed) return; if (disposing) { // 释放托管资源 } // 释放非托管资源 if (_handle != IntPtr.Zero) { CloseHandle(_handle); _handle = IntPtr.Zero; } _disposed = true; } ~ResourceHolder() { Dispose(false); // 只释放非托管资源 } }4.3 Span与Memory的高性能切片
Span 是栈上分配的连续内存视图,支持数组、字符串、非托管内存的切片,零分配。Memory 是堆上的版本,可以跨async方法使用。这两个类型是.NET Core 2.1引入的性能利器。
// Span:零分配切片 int[] array = { 1, 2, 3, 4, 5 }; Span<int> span = array.AsSpan(1, 3); // {2, 3, 4} span[0] = 10; // 修改会影响原数组 // Memory:可跨async async Task ProcessAsync(Memory<byte> buffer) { await SomeAsyncOperation(); buffer.Span[0] = 1; // 在async方法中使用 }使用限制:Span不能作为类的字段(除非是ref struct),不能在async方法中跨越await使用,不能装箱。
4.4 对象池与ArrayPool减少GC压力
高频创建销毁对象会导致GC频繁触发。对象池(ObjectPool)和ArrayPool是两种常见的复用方案。Microsoft.Extensions.ObjectPool提供了通用的对象池实现,ArrayPool .Shared是内置的数组池。
// ArrayPool使用示例 var pool = ArrayPool<byte>.Shared; byte[] buffer = pool.Rent(1024); // 租用,可能大于请求大小 try { // 使用buffer } finally { pool.Return(buffer); // 归还 } // 对象池示例 var policy = new DefaultPooledObjectPolicy<StringBuilder>(); var objectPool = new DefaultObjectPool<StringBuilder>(policy); var sb = objectPool.Get(); try { sb.Append("hello"); } finally { objectPool.Return(sb); }注意:ArrayPool.Rent返回的数组长度可能大于请求值,且内容不保证清零。归还后不要再持有引用。
5. 并发编程与异步模式
5.1 async/await的状态机原理
async方法在编译后会生成一个状态机结构体,await点对应状态机的状态切换。理解这个机制有助于写出高效的异步代码。async方法返回Task或Task ,编译器生成的代码会在await处注册回调,方法返回时如果未完成就返回Task给调用者。
// 编译器大致生成的逻辑 public Task<int> GetValueAsync() { var stateMachine = new StateMachine(); stateMachine.builder = AsyncTaskMethodBuilder<int>.Create(); stateMachine.state = -1; stateMachine.builder.Start(ref stateMachine); return stateMachine.builder.Task; }常见坑点:async void只应用于事件处理器,异常无法被捕获;不要用.Result或.Wait()阻塞异步方法,容易死锁;ConfigureAwait(false)在库代码中应该使用,避免上下文切换开销。
5.2 锁、信号量与并发集合
lock是最基础的互斥锁,Monitor的语法糖。SemaphoreSlim用于限制并发数,ReaderWriterLockSlim用于读多写少场景。并发集合(ConcurrentDictionary、ConcurrentQueue、BlockingCollection)内部实现了无锁或细粒度锁。
// lock:互斥 private readonly object _lock = new object(); public void Increment() { lock (_lock) { _counter++; } } // SemaphoreSlim:限制并发 private readonly SemaphoreSlim _semaphore = new SemaphoreSlim(3); public async Task ProcessAsync() { await _semaphore.WaitAsync(); try { // 最多3个并发 } finally { _semaphore.Release(); } } // ConcurrentDictionary:线程安全字典 var dict = new ConcurrentDictionary<string, int>(); dict.AddOrUpdate("key", 1, (k, v) => v + 1);5.3 Task与Thread的区别和选择
Thread是操作系统级别的线程,创建销毁开销大。Task是.NET的任务抽象,底层可能用线程池线程,也可能用IO完成端口。Task支持延续、取消、组合,是现代C#并发的首选。
// Thread:直接创建线程 var thread = new Thread(() => Console.WriteLine("running")); thread.Start(); thread.Join(); // Task:任务抽象 var task = Task.Run(() => Console.WriteLine("running")); await task; // Task组合 var task1 = Task.Run(() => 1); var task2 = Task.Run(() => 2); var results = await Task.WhenAll(task1, task2);选择原则:CPU密集型用Task.Run,IO密集型用async/await,需要精细控制线程优先级或公寓状态时用Thread。
5.4 CancellationToken的协作式取消
CancellationToken是.NET的协作式取消机制。调用方创建CancellationTokenSource,把Token传给异步方法,方法内部定期检查IsCancellationRequested或调用ThrowIfCancellationRequested。
public async Task ProcessAsync(CancellationToken token) { for (int i = 0; i < 100; i++) { token.ThrowIfCancellationRequested(); // 检查取消 await Task.Delay(100, token); // 支持取消的等待 } } // 调用方 var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); try { await ProcessAsync(cts.Token); } catch (OperationCanceledException) { Console.WriteLine("已取消"); }6. LINQ与集合操作精要
6.1 延迟执行与立即执行的区分
LINQ查询默认是延迟执行的,只有在遍历(foreach、ToList、Count等)时才真正执行。这个特性既强大又容易踩坑。比如在循环中构建查询,如果查询引用了循环变量,延迟执行会导致所有查询看到最终值。
// 延迟执行:查询在foreach时才执行 var query = list.Where(x => x > 5); list.Add(10); // 会影响查询结果 foreach (var item in query) { /* ... */ } // 立即执行:ToList强制求值 var result = list.Where(x => x > 5).ToList(); list.Add(10); // 不影响result6.2 常用操作符的性能对比
Where、Select、OrderBy、GroupBy、Join是LINQ最常用的操作符。性能上,Where和Select是流式的,OrderBy需要缓冲全部数据,GroupBy需要构建查找表,Join需要构建哈希表。
| 操作符 | 执行方式 | 内存占用 | 适用场景 |
|---|---|---|---|
| Where | 流式 | O(1) | 过滤 |
| Select | 流式 | O(1) | 投影 |
| OrderBy | 缓冲 | O(n) | 排序 |
| GroupBy | 缓冲 | O(n) | 分组 |
| Join | 缓冲 | O(n+m) | 关联 |
| First | 短路 | O(1) | 取首个 |
| Any | 短路 | O(1) | 存在性判断 |
6.3 IEnumerable与IQueryable的区别
IEnumerable在内存中执行,IQueryable把表达式树翻译成查询语言(如SQL)在数据源执行。用Entity Framework时,IQueryable的Where会翻译成SQL的WHERE,IEnumerable的Where会把全部数据拉到内存再过滤。
// IQueryable:翻译成SQL IQueryable<User> query = dbContext.Users.Where(u => u.Age > 18); // 生成:SELECT * FROM Users WHERE Age > 18 // IEnumerable:内存过滤 IEnumerable<User> users = dbContext.Users.AsEnumerable().Where(u => u.Age > 18); // 生成:SELECT * FROM Users,然后在内存过滤6.4 自定义LINQ操作符与yield
yield return让方法变成迭代器,可以自定义流式操作符。理解yield的状态机机制对写出高效的LINQ扩展方法很重要。
public static IEnumerable<T> WhereNotNull<T>(this IEnumerable<T> source) where T : class { foreach (var item in source) { if (item != null) yield return item; } } // 使用 var nonNull = list.WhereNotNull().Select(x => x.ToString());7. 反射、特性与元编程
7.1 反射的性能代价与优化
反射允许运行时检查类型信息、调用方法、访问字段。但反射调用比直接调用慢几十到几百倍。优化方案包括:缓存MethodInfo、用Delegate.CreateDelegate创建强类型委托、用Expression树编译、用Source Generator在编译时生成代码。
// 反射调用 var method = typeof(Calculator).GetMethod("Add"); var result = method.Invoke(calculator, new object[] { 1, 2 }); // 优化:缓存委托 var func = (Func<Calculator, int, int, int>)Delegate.CreateDelegate( typeof(Func<Calculator, int, int, int>), method); var result2 = func(calculator, 1, 2); // 接近直接调用7.2 特性的定义与应用场景
特性(Attribute)是附加在代码元素上的元数据,通过反射读取。常见应用包括:序列化标记([Serializable])、验证规则([Required])、依赖注入([Inject])、测试框架([Fact])、AOP拦截。
[AttributeUsage(AttributeTargets.Method)] public class LogAttribute : Attribute { public string Level { get; } public LogAttribute(string level) => Level = level; } public class Service { [Log("Info")] public void DoWork() { /* ... */ } } // 读取特性 var method = typeof(Service).GetMethod("DoWork"); var attr = method.GetCustomAttribute<LogAttribute>(); Console.WriteLine(attr.Level); // Info7.3 动态类型与dynamic的取舍
dynamic绕过编译时类型检查,运行时解析。适合与COM互操作、动态语言交互、简化反射代码。但性能差、无智能提示、错误在运行时才暴露。
dynamic obj = GetDynamicObject(); obj.SomeMethod(); // 运行时解析 // 替代方案:接口或泛型 public interface ICalculator { int Add(int a, int b); }8. 常见问题与排查技巧实录
8.1 AccessViolationException的排查思路
调用C++代码时出现AccessViolationException(0xC0000005)通常意味着内存访问越界。排查步骤:检查P/Invoke签名的参数类型和调用约定是否匹配;确认C++端没有释放C#还在使用的内存;用调试器附加到进程查看崩溃时的调用栈;检查结构体的字段布局(StructLayout)是否正确。
// 正确的P/Invoke签名 [DllImport("native.dll", CallingConvention = CallingConvention.Cdecl)] public static extern int ProcessData(IntPtr data, int length); // 结构体布局 [StructLayout(LayoutKind.Sequential, Pack = 1)] public struct DataHeader { public int Id; public short Flags; }8.2 文本文件编码检测的实用方案
判断不带BOM的文本文件编码是个经典难题。实用方案:先尝试UTF-8严格解码,失败则尝试GBK/ANSI,再失败用启发式判断。.NET提供了Encoding.GetEncoding和DecoderFallback。
public static Encoding DetectEncoding(string filePath) { byte[] bytes = File.ReadAllBytes(filePath); // 检查BOM if (bytes.Length >= 3 && bytes[0] == 0xEF && bytes[1] == 0xBB && bytes[2] == 0xBF) return Encoding.UTF8; if (bytes.Length >= 2 && bytes[0] == 0xFF && bytes[1] == 0xFE) return Encoding.Unicode; // 尝试UTF-8严格解码 try { new UTF8Encoding(false, true).GetString(bytes); return Encoding.UTF8; } catch (DecoderFallbackException) { return Encoding.GetEncoding("GBK"); } }8.3 TCP连接异常的处理
“无法将数据写入传输连接:远程主机强迫关闭了一个现有的连接”是网络编程常见异常。原因可能是对端关闭、网络中断、超时。处理方案:捕获IOException和SocketException,实现重连逻辑,用心跳包检测连接状态。
public async Task SendWithRetryAsync(TcpClient client, byte[] data, int maxRetries = 3) { for (int i = 0; i < maxRetries; i++) { try { await client.GetStream().WriteAsync(data); return; } catch (IOException ex) when (i < maxRetries - 1) { await Task.Delay(1000 * (i + 1)); // 退避重试 } } }8.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方向 |
|---|---|---|
| 内存持续增长 | 事件未取消订阅 | 检查event的+=和-= |
| UI卡顿 | 主线程阻塞 | 检查async/await和Task.Run |
| 死锁 | lock嵌套或.Result | 检查锁顺序和异步调用 |
| 数据不一致 | 并发访问 | 检查锁和并发集合 |
| 性能下降 | 装箱或反射 | 用性能分析器定位 |
| 连接断开 | 超时或对端关闭 | 检查心跳和重连 |
9. 上位机开发与工业通信要点
9.1 OPC通信的常见模式
C#连接OPC服务器通常用OPC Foundation的.NET库或第三方库。核心步骤:建立会话、添加订阅、读取/写入标签。注意连接超时、重连、数据变化通知的处理。
// 伪代码示例 var session = await Session.CreateAsync(endpointUrl); var subscription = new Subscription(session.DefaultSubscription); var item = new MonitoredItem(subscription.DefaultItem) { StartNodeId = "ns=2;s=Tag1", SamplingInterval = 1000 }; subscription.AddItem(item); session.AddSubscription(subscription); await session.ConnectAsync();9.2 CAN通信与LED屏控制
CAN通信需要硬件适配器,常用库包括PCAN、Kvaser的.NET封装。LED屏控制通常通过串口或网络发送协议帧,灵信等品牌有各自的协议文档。关键点:帧格式、校验和、超时重发。
// 串口发送示例 using var port = new SerialPort("COM3", 9600, Parity.None, 8, StopBits.One); port.Open(); byte[] frame = BuildLedFrame("Hello", x: 0, y: 0); port.Write(frame, 0, frame.Length);9.3 BitmapData的全量拷贝
处理图像时,BitmapData之间的拷贝可以用Marshal.Copy或Buffer.MemoryCopy。注意stride(扫描行宽度)可能大于实际宽度,需要逐行拷贝。
public static void CopyBitmapData(BitmapData src, BitmapData dst) { int rowBytes = src.Width * 4; // 假设32位 for (int y = 0; y < src.Height; y++) { IntPtr srcPtr = IntPtr.Add(src.Scan0, y * src.Stride); IntPtr dstPtr = IntPtr.Add(dst.Scan0, y * dst.Stride); Buffer.MemoryCopy(srcPtr.ToPointer(), dstPtr.ToPointer(), rowBytes, rowBytes); } }10. 面试准备策略与经验分享
10.1 如何组织你的知识体系
我建议按“语言基础→内存模型→并发→框架→领域”五层来组织。每层选3-5个核心话题深入,其他话题了解即可。面试前一周重点复习你简历上写的技术栈,面试官会顺着简历问。
10.2 回答问题的结构
遇到技术问题,用“是什么→为什么→怎么做→有什么坑”的结构回答。比如问委托,先说定义,再说为什么需要(回调、解耦),然后说怎么用(声明、实例化、调用),最后说坑(多播异常、内存泄漏)。
10.3 遇到不会的问题怎么办
诚实说不会,但可以展示你的思考过程。比如“这个我没深入研究过,但根据我的理解,可能是...,我会从...方向去查证”。面试官更看重学习能力和解决问题的思路,而不是背答案的能力。
10.4 我个人的复习节奏
我通常提前两周开始准备,第一周过一遍核心知识点,第二周做模拟面试和查漏补缺。每天花1-2小时,重点看自己薄弱的部分。面试前一天不熬夜,把简历上的项目再过一遍,准备好项目相关的深度问题。
提示:面试是双向选择,除了回答问题,也要观察团队的技术氛围和项目情况。问一些关于代码审查、测试覆盖率、技术债务的问题,能帮你判断这个团队是否值得加入。
最后分享一个我常用的技巧:把每个知识点讲给一个虚拟的“小白”听,如果能讲清楚,说明你真的理解了。讲不清楚的地方,就是你需要补的地方。这个方法比单纯刷题有效得多。