
- [【实战精选面试题】C# 面试题详细解答,按难易程度排序](# 面试题详细解答,按难易程度排序)
-
- 第一部分:基础题(简单)
-
- [1. C# 中常用的命名规范有哪些?(至少说出 3 种)](# 中常用的命名规范有哪些?(至少说出 3 种))
- [2. C# 有哪些访问修饰符?各自的作用?](# 有哪些访问修饰符?各自的作用?)
- [3. 封装、继承、多态分别是什么?请结合 C# 说明。](# 说明。)
- [4. 重载(Overload)和重写(Override)的区别?](#4. 重载(Overload)和重写(Override)的区别?)
- [5. 抽象类(abstract class)和接口(interface)的区别?什么时候用哪个?](#5. 抽象类(abstract class)和接口(interface)的区别?什么时候用哪个?)
- [6. 属性(Property)和字段(Field)的区别?自动属性是什么?](#6. 属性(Property)和字段(Field)的区别?自动属性是什么?)
- [7. 父类中定义了一个静态成员属性,有两个子类都继承该父类,请问打印结果是什么?为什么?](#7. 父类中定义了一个静态成员属性,有两个子类都继承该父类,请问打印结果是什么?为什么?)
- [8. 静态构造函数(静态构造器)有什么特点?](#8. 静态构造函数(静态构造器)有什么特点?)
- [9. 值类型(Value Type)和引用类型(Reference Type)的区别?](#9. 值类型(Value Type)和引用类型(Reference Type)的区别?)
- [10. struct 和 class 如何选择?什么场景用 struct?](#10. struct 和 class 如何选择?什么场景用 struct?)
- [11. is、as 和强制类型转换的区别?](#11. is、as 和强制类型转换的区别?)
- [12. 以下代码的打印结果是多少?](#12. 以下代码的打印结果是多少?)
- [13. C# 中 ulong 类型可以表示最大的整数为多少?C# 中的 BigInteger 类对于我们来说有什么意义?](# 中 ulong 类型可以表示最大的整数为多少?C# 中的 BigInteger 类对于我们来说有什么意义?)
- [14. int, strArray = new int3, 4 这个语句创建了多少个 int 对象?string, strArray = new string3, 4 这个语句创建了多少个 string 对象?](#14. int[,] strArray = new int[3, 4] 这个语句创建了多少个 int 对象?string[,] strArray = new string[3, 4] 这个语句创建了多少个 string 对象?)
- [15. C# 中如何把 int 数组转换为 string 数组?(请至少说出 2 种方式)](# 中如何把 int 数组转换为 string 数组?(请至少说出 2 种方式))
- [16. 在 C# 当中,我们想要用一个字典容器存储场景中的所有敌对对象,比如 怪物、Boss、可被攻击的场景物件等等,应该如何存储?](# 当中,我们想要用一个字典容器存储场景中的所有敌对对象,比如 怪物、Boss、可被攻击的场景物件等等,应该如何存储?)
- [17. C# 中,你认为什么时候应该使用数组,什么时候应该使用 List?](# 中,你认为什么时候应该使用数组,什么时候应该使用 List?)
- [18. 以下代码执行后,List 中还存在哪些内容?](#18. 以下代码执行后,List 中还存在哪些内容?)
- [19. C# 中 List 中的 Sort 方法中,传入的比较器委托函数,返回的 -1、0、1 分别代表什么意思?](# 中 List 中的 Sort 方法中,传入的比较器委托函数,返回的 -1、0、1 分别代表什么意思?)
- [20. C# 中在字符串前面加一个 @符号或者 符号有什么作用?](# 中在字符串前面加一个 @符号或者 符号有什么作用?)
- [21. string 为什么是不可变的?频繁拼接字符串有什么问题?](#21. string 为什么是不可变的?频繁拼接字符串有什么问题?)
- [22. StringBuilder 的原理是什么?为什么比 string 拼接高效?](#22. StringBuilder 的原理是什么?为什么比 string 拼接高效?)
- [23. 字符串比较时 `==` 和 `Equals` 的区别?](#23. 字符串比较时
==和Equals的区别?) - [24. (代码题)手写字符串反转?](#24. (代码题)手写字符串反转?)
- [25. (代码题)判断一个字符串是否是回文?](#25. (代码题)判断一个字符串是否是回文?)
- [26. 在 C# 中,给你一个数字,表示有 n 秒,请问如何在不改变这个数字的前提下,将它转换成格式为 __小时__分__秒?](# 中,给你一个数字,表示有 n 秒,请问如何在不改变这个数字的前提下,将它转换成格式为 __小时__分__秒?)
- [27. 这个函数是判断一个整数是不是 2 的 n 次方。请问 if 语句中应该如何书写?](#27. 这个函数是判断一个整数是不是 2 的 n 次方。请问 if 语句中应该如何书写?)
- [28. (代码题)斐波那契数列的几种实现?](#28. (代码题)斐波那契数列的几种实现?)
- [29. (代码题)手写二分查找?](#29. (代码题)手写二分查找?)
- [30. C# 类中的函数是否占用内存空间?](# 类中的函数是否占用内存空间?)
- [31. C# 中是否可以通过反射获取到类内部的私有成员?如果可以,如何获取?](# 中是否可以通过反射获取到类内部的私有成员?如果可以,如何获取?)
- 第二部分:进阶题(中等)
-
- [32. 以下代码,最终打印结果是多少?为什么?如果解决这种问题?](#32. 以下代码,最终打印结果是多少?为什么?如果解决这种问题?)
- [33. 以下代码最终的打印结果是多少?为什么?](#33. 以下代码最终的打印结果是多少?为什么?)
- [34. 基于上一题,我们要如何修改,才能让最后的结果为 10?请说出 3 种方式。](#34. 基于上一题,我们要如何修改,才能让最后的结果为 10?请说出 3 种方式。)
- [35. 请问这段代码中,test 对象,test.i 以及 test.str 存储在栈上还是堆上?](#35. 请问这段代码中,test 对象,test.i 以及 test.str 存储在栈上还是堆上?)
- [36. 可空类型是什么?它的作用是什么?](#36. 可空类型是什么?它的作用是什么?)
- [37. C# 中数值在调用 ToString() 方法时,若传入字符串参数,有哪些格式转换规则?(至少说出 3 点)](# 中数值在调用 ToString() 方法时,若传入字符串参数,有哪些格式转换规则?(至少说出 3 点))
- [38. decimal、float、double 的区别?](#38. decimal、float、double 的区别?)
- [39. Convert、Parse、TryParse 的区别?](#39. Convert、Parse、TryParse 的区别?)
- [40. 这句 C# 代码是否有问题,为什么?`IList<IList<int>> list = new List<List<int>>();`](# 代码是否有问题,为什么?
IList<IList<int>> list = new List<List<int>>();) - [41. C# 泛型有哪些约束?分别代表什么?](# 泛型有哪些约束?分别代表什么?)
- [42. 什么是泛型的协变(Covariance)和逆变(Contravariance)?](#42. 什么是泛型的协变(Covariance)和逆变(Contravariance)?)
- [43. (代码题)泛型父类中定义了一个静态成员属性,有两个子类都继承该泛型父类,请问打印结果是什么?为什么?](#43. (代码题)泛型父类中定义了一个静态成员属性,有两个子类都继承该泛型父类,请问打印结果是什么?为什么?)
- [44. Action、Func、Predicate 的区别?](#44. Action、Func、Predicate 的区别?)
- [45. 什么是多播委托(Multicast Delegate)?](#45. 什么是多播委托(Multicast Delegate)?)
- [46. 事件(event)和委托(delegate)的区别?](#46. 事件(event)和委托(delegate)的区别?)
- [47. Lambda 表达式的本质?闭包(Closure)是什么?](#47. Lambda 表达式的本质?闭包(Closure)是什么?)
-
- [C# 语言特性](# 语言特性)
- [48. 扩展方法(Extension Method)是什么?有什么限制?](#48. 扩展方法(Extension Method)是什么?有什么限制?)
- [49. 索引器(Indexer)是什么?如何定义?](#49. 索引器(Indexer)是什么?如何定义?)
- [50. 运算符重载的规则?哪些运算符不能重载?](#50. 运算符重载的规则?哪些运算符不能重载?)
- [51. 可选参数和命名参数是什么?](#51. 可选参数和命名参数是什么?)
- [52. 什么是 LINQ?什么是延迟执行(Deferred Execution)?](#52. 什么是 LINQ?什么是延迟执行(Deferred Execution)?)
- [53. IEnumerable 和 IQueryable 的区别?](#53. IEnumerable 和 IQueryable 的区别?)
- [54. First、FirstOrDefault、Single、SingleOrDefault 的区别?](#54. First、FirstOrDefault、Single、SingleOrDefault 的区别?)
- [55. Select 和 SelectMany 的区别?](#55. Select 和 SelectMany 的区别?)
- [56. LINQ 有哪些常见性能陷阱?](#56. LINQ 有哪些常见性能陷阱?)
- [57. C# 中异步是什么意思?请简单说说你了解的 C# 中实现异步的方式。](# 中异步是什么意思?请简单说说你了解的 C# 中实现异步的方式。)
- [58. 游戏开发哪些地方会用到异步编程?(至少说出三种情况)](#58. 游戏开发哪些地方会用到异步编程?(至少说出三种情况))
- [59. Thread、ThreadPool、Task 的区别?(经典必考题)](#59. Thread、ThreadPool、Task 的区别?(经典必考题))
- [60. async/await 的底层原理是什么?(状态机)](#60. async/await 的底层原理是什么?(状态机))
- [61. ConfigureAwait(false) 的作用?什么时候必须用?](#61. ConfigureAwait(false) 的作用?什么时候必须用?)
- [62. async void 有什么问题?什么时候用?](#62. async void 有什么问题?什么时候用?)
- [63. 为什么异步方法中不能使用 ref / out 参数?](#63. 为什么异步方法中不能使用 ref / out 参数?)
- [64. 调用 `.Result` / `.Wait()` 为什么可能导致死锁?](#64. 调用
.Result/.Wait()为什么可能导致死锁?) - [65. CancellationToken 是什么?如何实现任务取消?](#65. CancellationToken 是什么?如何实现任务取消?)
- [66. 请简单描述死锁是什么意思?](#66. 请简单描述死锁是什么意思?)
- [67. 死锁如何排查?有哪些实战预防手段?](#67. 死锁如何排查?有哪些实战预防手段?)
- [68. lock 关键字的底层原理是什么?](#68. lock 关键字的底层原理是什么?)
- [69. Monitor、Mutex、Semaphore、SemaphoreSlim 的区别?](#69. Monitor、Mutex、Semaphore、SemaphoreSlim 的区别?)
- [70. volatile 关键字的作用?为什么不能替代 lock?](#70. volatile 关键字的作用?为什么不能替代 lock?)
- [71. Interlocked 是什么?有哪些常用原子操作?](#71. Interlocked 是什么?有哪些常用原子操作?)
- [72. 线程安全的集合有哪些?各自特点?](#72. 线程安全的集合有哪些?各自特点?)
- [73. 什么是生产者-消费者模式?如何实现?](#73. 什么是生产者-消费者模式?如何实现?)
- [74. 什么是竞态条件(Race Condition)?如何避免?](#74. 什么是竞态条件(Race Condition)?如何避免?)
- [75. throw 和 throw ex 的区别?如何自定义异常?异常过滤器(when)?](#75. throw 和 throw ex 的区别?如何自定义异常?异常过滤器(when)?)
- [76. 单例模式中为什么要加锁?是否是必须的?](#76. 单例模式中为什么要加锁?是否是必须的?)
- [77. 深拷贝和浅拷贝的区别?他们分别有哪些场景使用?](#77. 深拷贝和浅拷贝的区别?他们分别有哪些场景使用?)
- [78. 对象池模式(Object Pool)是什么?游戏开发中如何实现?](#78. 对象池模式(Object Pool)是什么?游戏开发中如何实现?)
- [79. 工厂模式(Factory Pattern)是什么?简单工厂、工厂方法、抽象工厂的区别?](#79. 工厂模式(Factory Pattern)是什么?简单工厂、工厂方法、抽象工厂的区别?)
- [80. 观察者模式(Observer Pattern)是什么?C# 中如何实现?](# 中如何实现?)
- [81. 策略模式(Strategy Pattern)是什么?适用场景?](#81. 策略模式(Strategy Pattern)是什么?适用场景?)
- [82. 装饰器模式(Decorator Pattern)是什么?](#82. 装饰器模式(Decorator Pattern)是什么?)
- [83. 命令模式(Command Pattern)是什么?在游戏开发中的应用?](#83. 命令模式(Command Pattern)是什么?在游戏开发中的应用?)
- [84. 状态模式(State Pattern)是什么?和策略模式的区别?](#84. 状态模式(State Pattern)是什么?和策略模式的区别?)
- [85. 什么是 SOLID 原则?分别代表什么?](#85. 什么是 SOLID 原则?分别代表什么?)
- [86. C# 和 C++ 在内存管理上有什么区别?(至少说出 2 点区别)](# 和 C++ 在内存管理上有什么区别?(至少说出 2 点区别))
- [87. C# 和 Lua 的共同点和区别?](# 和 Lua 的共同点和区别?)
- [88. 为什么 C++ 的内存管理方面的性能表现往往被认为优于 C#?(至少说出 2 点原因)](#?(至少说出 2 点原因))
- [89. 那为什么 C++ 从执行性能上往往也被认为优于 C# 呢?](# 呢?)
- [90. C# 中的 CLR 是什么?](# 中的 CLR 是什么?)
- [91. 使用 C# 制作游戏存档功能,请问有几种做法?(至少说出三种)](# 制作游戏存档功能,请问有几种做法?(至少说出三种))
- [92. 在制作游戏存档功能时,C# 中反射主要可以发挥出哪些作用?(至少说出三点)](# 中反射主要可以发挥出哪些作用?(至少说出三点))
- [93. 协程(Coroutine)的原理是什么?协程和线程的区别?(Unity)](#93. 协程(Coroutine)的原理是什么?协程和线程的区别?(Unity))
- [94. (代码题)value1 和 value2 谁先初始化?](#94. (代码题)value1 和 value2 谁先初始化?)
- 第三部分:高级题(困难)
-
- [95. C# 中 JIT 编译器是什么,它如何工作?](# 中 JIT 编译器是什么,它如何工作?)
- [96. C# 中函数是何时被加载到内存中的呢?](# 中函数是何时被加载到内存中的呢?)
- [97. C# 函数中一般会声明一些临时变量,当函数被加载内存中后,所占的内存空间是否包含函数中的这些临时变量?](# 函数中一般会声明一些临时变量,当函数被加载内存中后,所占的内存空间是否包含函数中的这些临时变量?)
- [98. 什么是 IL(中间语言)和程序集(Assembly)?](#98. 什么是 IL(中间语言)和程序集(Assembly)?)
- [99. .NET Framework 和 .NET Core / .NET 5+ 的区别?](#99. .NET Framework 和 .NET Core / .NET 5+ 的区别?)
- [100. 什么是 AOT 编译?NativeAOT 的优缺点?](#100. 什么是 AOT 编译?NativeAOT 的优缺点?)
- [101. P/Invoke 是什么?C# 如何调用 C/C++ 的 DLL?](# 如何调用 C/C++ 的 DLL?)
-
- [GC 深入](#GC 深入)
- [102. C# 中的垃圾回收机制是使用的引用计数还是其它方式呢?](# 中的垃圾回收机制是使用的引用计数还是其它方式呢?)
- [103. C# 中自动触发 GC 的原因可能有哪些?从分代收集去考虑?](# 中自动触发 GC 的原因可能有哪些?从分代收集去考虑?)
- [104. GC 分代(Generational)收集中,对象的代龄提升机制是什么?](#104. GC 分代(Generational)收集中,对象的代龄提升机制是什么?)
- [105. GC 的 Workstation 和 Server 模式的区别?](#105. GC 的 Workstation 和 Server 模式的区别?)
- [106. 为什么不要手动调用 GC.Collect()?](#106. 为什么不要手动调用 GC.Collect()?)
- [107. C# 中的大对象堆是什么?](# 中的大对象堆是什么?)
- [108. 什么是内存碎片,如何在 C# 中避免内存碎片?](# 中避免内存碎片?)
- [109. C# 中 new 一个引用对象做了什么?](# 中 new 一个引用对象做了什么?)
- [110. 什么是内存对齐?为什么要进行内存对齐?](#110. 什么是内存对齐?为什么要进行内存对齐?)
- [111. C# 中怎么计算对象占用多少内存?](# 中怎么计算对象占用多少内存?)
- [112. C# 中堆内存优化的大方向是什么?再说 3 点具体方案?](# 中堆内存优化的大方向是什么?再说 3 点具体方案?)
- [113. C# 编程中常见的内存管理问题有哪些?](# 编程中常见的内存管理问题有哪些?)
- [114. 什么是内存屏障(Memory Barrier)?和 volatile 的关系?](#114. 什么是内存屏障(Memory Barrier)?和 volatile 的关系?)
- [115. C# 中的弱引用是什么?它的作用是什么?](# 中的弱引用是什么?它的作用是什么?)
- [116. 托管资源和非托管资源的区别?](#116. 托管资源和非托管资源的区别?)
- [117. IDisposable 和 Dispose 模式是什么?为什么要实现它?](#117. IDisposable 和 Dispose 模式是什么?为什么要实现它?)
- [118. C# 中的 ArrayPool 有什么作用?](# 中的 ArrayPool 有什么作用?)
- [119. Span<T> 和 Memory<T> 是什么?解决什么问题?](#119. Span
和 Memory 是什么?解决什么问题?) - [120. stackalloc 是什么?有什么风险和用途?](#120. stackalloc 是什么?有什么风险和用途?)
- [121. fixed 关键字和 GCHandle 的作用?](#121. fixed 关键字和 GCHandle 的作用?)
- [122. C# 中委托的本质是什么?它是如何存储函数的?](# 中委托的本质是什么?它是如何存储函数的?)
- [123. C# 中事件的本质是什么?](# 中事件的本质是什么?)
- [124. 为什么我们尽量避免装箱拆箱?避免装箱拆箱的方案有哪些?](#124. 为什么我们尽量避免装箱拆箱?避免装箱拆箱的方案有哪些?)
- [125. 为什么说闭包可能会带来内存泄漏?](#125. 为什么说闭包可能会带来内存泄漏?)
- [126. 什么是表达式树(Expression Tree)?和委托的区别?](#126. 什么是表达式树(Expression Tree)?和委托的区别?)
- [127. C# 的模式匹配(Pattern Matching)有哪些形式?(C# 7-11)](# 的模式匹配(Pattern Matching)有哪些形式?(C# 7-11))
- [128. record 类型(C# 9)和 init 访问器是什么?](# 9)和 init 访问器是什么?)
- [129. 可空引用类型(NRT,C# 8)是什么?为什么引入?](# 8)是什么?为什么引入?)
- [130. yield return 的原理是什么?(迭代器状态机)](#130. yield return 的原理是什么?(迭代器状态机))
- [131. async/await 状态机深入:编译器生成了什么?](#131. async/await 状态机深入:编译器生成了什么?)
- [132. ThreadLocal<T> 是什么?和普通静态字段的区别?](#132. ThreadLocal
是什么?和普通静态字段的区别?) - [133. 字符串驻留(String Interning)是什么?有什么坑?](#133. 字符串驻留(String Interning)是什么?有什么坑?)
- [134. 为什么反射慢?如何优化反射性能?](#134. 为什么反射慢?如何优化反射性能?)
- [135. Attribute(特性)是什么?如何自定义特性?](#135. Attribute(特性)是什么?如何自定义特性?)
- [136. C# 中提供了序列化和反序列化 2 进制数据的公共类 BinaryFormatter,为什么我们一般还要自己去实现序列化和反序列化 2 进制数据的方法?](# 中提供了序列化和反序列化 2 进制数据的公共类 BinaryFormatter,为什么我们一般还要自己去实现序列化和反序列化 2 进制数据的方法?)
- [137. 异或加密的原理是什么?请简要说明。](#137. 异或加密的原理是什么?请简要说明。)
- [138. 代码混淆是什么意思?有什么作用?](#138. 代码混淆是什么意思?有什么作用?)
- [139. 在游戏开发中的日志系统一般会配合 C# 中的什么一起使用?用于记录报错信息。](# 中的什么一起使用?用于记录报错信息。)
- [140. 在 Unity 中使用 C# 的 foreach 是否会带来额外内存和性能开销?](# 的 foreach 是否会带来额外内存和性能开销?)
- [141. 为什么我们使用单例模式更多,而几乎不使用静态类?](#141. 为什么我们使用单例模式更多,而几乎不使用静态类?)

- [【实战精选面试题】C# 面试题详细解答,按难易程度排序](# 面试题详细解答,按难易程度排序)
-
- 第一部分:基础题(简单)
-
- [1. C# 中常用的命名规范有哪些?(至少说出 3 种)](# 中常用的命名规范有哪些?(至少说出 3 种))
- [2. C# 有哪些访问修饰符?各自的作用?](# 有哪些访问修饰符?各自的作用?)
- [3. 封装、继承、多态分别是什么?请结合 C# 说明。](# 说明。)
- [4. 重载(Overload)和重写(Override)的区别?](#4. 重载(Overload)和重写(Override)的区别?)
- [5. 抽象类(abstract class)和接口(interface)的区别?什么时候用哪个?](#5. 抽象类(abstract class)和接口(interface)的区别?什么时候用哪个?)
- [6. 属性(Property)和字段(Field)的区别?自动属性是什么?](#6. 属性(Property)和字段(Field)的区别?自动属性是什么?)
- [7. 父类中定义了一个静态成员属性,有两个子类都继承该父类,请问打印结果是什么?为什么?](#7. 父类中定义了一个静态成员属性,有两个子类都继承该父类,请问打印结果是什么?为什么?)
- [8. 静态构造函数(静态构造器)有什么特点?](#8. 静态构造函数(静态构造器)有什么特点?)
- [9. 值类型(Value Type)和引用类型(Reference Type)的区别?](#9. 值类型(Value Type)和引用类型(Reference Type)的区别?)
- [10. struct 和 class 如何选择?什么场景用 struct?](#10. struct 和 class 如何选择?什么场景用 struct?)
- [11. is、as 和强制类型转换的区别?](#11. is、as 和强制类型转换的区别?)
- [12. 以下代码的打印结果是多少?](#12. 以下代码的打印结果是多少?)
- [13. C# 中 ulong 类型可以表示最大的整数为多少?C# 中的 BigInteger 类对于我们来说有什么意义?](# 中 ulong 类型可以表示最大的整数为多少?C# 中的 BigInteger 类对于我们来说有什么意义?)
- [14. int, strArray = new int3, 4 这个语句创建了多少个 int 对象?string, strArray = new string3, 4 这个语句创建了多少个 string 对象?](#14. int[,] strArray = new int[3, 4] 这个语句创建了多少个 int 对象?string[,] strArray = new string[3, 4] 这个语句创建了多少个 string 对象?)
- [15. C# 中如何把 int 数组转换为 string 数组?(请至少说出 2 种方式)](# 中如何把 int 数组转换为 string 数组?(请至少说出 2 种方式))
- [16. 在 C# 当中,我们想要用一个字典容器存储场景中的所有敌对对象,比如 怪物、Boss、可被攻击的场景物件等等,应该如何存储?](# 当中,我们想要用一个字典容器存储场景中的所有敌对对象,比如 怪物、Boss、可被攻击的场景物件等等,应该如何存储?)
- [17. C# 中,你认为什么时候应该使用数组,什么时候应该使用 List?](# 中,你认为什么时候应该使用数组,什么时候应该使用 List?)
- [18. 以下代码执行后,List 中还存在哪些内容?](#18. 以下代码执行后,List 中还存在哪些内容?)
- [19. C# 中 List 中的 Sort 方法中,传入的比较器委托函数,返回的 -1、0、1 分别代表什么意思?](# 中 List 中的 Sort 方法中,传入的比较器委托函数,返回的 -1、0、1 分别代表什么意思?)
- [20. C# 中在字符串前面加一个 @符号或者 符号有什么作用?](# 中在字符串前面加一个 @符号或者 符号有什么作用?)
- [21. string 为什么是不可变的?频繁拼接字符串有什么问题?](#21. string 为什么是不可变的?频繁拼接字符串有什么问题?)
- [22. StringBuilder 的原理是什么?为什么比 string 拼接高效?](#22. StringBuilder 的原理是什么?为什么比 string 拼接高效?)
- [23. 字符串比较时 `==` 和 `Equals` 的区别?](#23. 字符串比较时
==和Equals的区别?) - [24. (代码题)手写字符串反转?](#24. (代码题)手写字符串反转?)
- [25. (代码题)判断一个字符串是否是回文?](#25. (代码题)判断一个字符串是否是回文?)
- [26. 在 C# 中,给你一个数字,表示有 n 秒,请问如何在不改变这个数字的前提下,将它转换成格式为 __小时__分__秒?](# 中,给你一个数字,表示有 n 秒,请问如何在不改变这个数字的前提下,将它转换成格式为 __小时__分__秒?)
- [27. 这个函数是判断一个整数是不是 2 的 n 次方。请问 if 语句中应该如何书写?](#27. 这个函数是判断一个整数是不是 2 的 n 次方。请问 if 语句中应该如何书写?)
- [28. (代码题)斐波那契数列的几种实现?](#28. (代码题)斐波那契数列的几种实现?)
- [29. (代码题)手写二分查找?](#29. (代码题)手写二分查找?)
- [30. C# 类中的函数是否占用内存空间?](# 类中的函数是否占用内存空间?)
- [31. C# 中是否可以通过反射获取到类内部的私有成员?如果可以,如何获取?](# 中是否可以通过反射获取到类内部的私有成员?如果可以,如何获取?)
- 第二部分:进阶题(中等)
-
- [32. 以下代码,最终打印结果是多少?为什么?如果解决这种问题?](#32. 以下代码,最终打印结果是多少?为什么?如果解决这种问题?)
- [33. 以下代码最终的打印结果是多少?为什么?](#33. 以下代码最终的打印结果是多少?为什么?)
- [34. 基于上一题,我们要如何修改,才能让最后的结果为 10?请说出 3 种方式。](#34. 基于上一题,我们要如何修改,才能让最后的结果为 10?请说出 3 种方式。)
- [35. 请问这段代码中,test 对象,test.i 以及 test.str 存储在栈上还是堆上?](#35. 请问这段代码中,test 对象,test.i 以及 test.str 存储在栈上还是堆上?)
- [36. 可空类型是什么?它的作用是什么?](#36. 可空类型是什么?它的作用是什么?)
- [37. C# 中数值在调用 ToString() 方法时,若传入字符串参数,有哪些格式转换规则?(至少说出 3 点)](# 中数值在调用 ToString() 方法时,若传入字符串参数,有哪些格式转换规则?(至少说出 3 点))
- [38. decimal、float、double 的区别?](#38. decimal、float、double 的区别?)
- [39. Convert、Parse、TryParse 的区别?](#39. Convert、Parse、TryParse 的区别?)
- [40. 这句 C# 代码是否有问题,为什么?`IList<IList<int>> list = new List<List<int>>();`](# 代码是否有问题,为什么?
IList<IList<int>> list = new List<List<int>>();) - [41. C# 泛型有哪些约束?分别代表什么?](# 泛型有哪些约束?分别代表什么?)
- [42. 什么是泛型的协变(Covariance)和逆变(Contravariance)?](#42. 什么是泛型的协变(Covariance)和逆变(Contravariance)?)
- [43. (代码题)泛型父类中定义了一个静态成员属性,有两个子类都继承该泛型父类,请问打印结果是什么?为什么?](#43. (代码题)泛型父类中定义了一个静态成员属性,有两个子类都继承该泛型父类,请问打印结果是什么?为什么?)
- [44. Action、Func、Predicate 的区别?](#44. Action、Func、Predicate 的区别?)
- [45. 什么是多播委托(Multicast Delegate)?](#45. 什么是多播委托(Multicast Delegate)?)
- [46. 事件(event)和委托(delegate)的区别?](#46. 事件(event)和委托(delegate)的区别?)
- [47. Lambda 表达式的本质?闭包(Closure)是什么?](#47. Lambda 表达式的本质?闭包(Closure)是什么?)
-
- [C# 语言特性](# 语言特性)
- [48. 扩展方法(Extension Method)是什么?有什么限制?](#48. 扩展方法(Extension Method)是什么?有什么限制?)
- [49. 索引器(Indexer)是什么?如何定义?](#49. 索引器(Indexer)是什么?如何定义?)
- [50. 运算符重载的规则?哪些运算符不能重载?](#50. 运算符重载的规则?哪些运算符不能重载?)
- [51. 可选参数和命名参数是什么?](#51. 可选参数和命名参数是什么?)
- [52. 什么是 LINQ?什么是延迟执行(Deferred Execution)?](#52. 什么是 LINQ?什么是延迟执行(Deferred Execution)?)
- [53. IEnumerable 和 IQueryable 的区别?](#53. IEnumerable 和 IQueryable 的区别?)
- [54. First、FirstOrDefault、Single、SingleOrDefault 的区别?](#54. First、FirstOrDefault、Single、SingleOrDefault 的区别?)
- [55. Select 和 SelectMany 的区别?](#55. Select 和 SelectMany 的区别?)
- [56. LINQ 有哪些常见性能陷阱?](#56. LINQ 有哪些常见性能陷阱?)
- [57. C# 中异步是什么意思?请简单说说你了解的 C# 中实现异步的方式。](# 中异步是什么意思?请简单说说你了解的 C# 中实现异步的方式。)
- [58. 游戏开发哪些地方会用到异步编程?(至少说出三种情况)](#58. 游戏开发哪些地方会用到异步编程?(至少说出三种情况))
- [59. Thread、ThreadPool、Task 的区别?(经典必考题)](#59. Thread、ThreadPool、Task 的区别?(经典必考题))
- [60. async/await 的底层原理是什么?(状态机)](#60. async/await 的底层原理是什么?(状态机))
- [61. ConfigureAwait(false) 的作用?什么时候必须用?](#61. ConfigureAwait(false) 的作用?什么时候必须用?)
- [62. async void 有什么问题?什么时候用?](#62. async void 有什么问题?什么时候用?)
- [63. 为什么异步方法中不能使用 ref / out 参数?](#63. 为什么异步方法中不能使用 ref / out 参数?)
- [64. 调用 `.Result` / `.Wait()` 为什么可能导致死锁?](#64. 调用
.Result/.Wait()为什么可能导致死锁?) - [65. CancellationToken 是什么?如何实现任务取消?](#65. CancellationToken 是什么?如何实现任务取消?)
- [66. 请简单描述死锁是什么意思?](#66. 请简单描述死锁是什么意思?)
- [67. 死锁如何排查?有哪些实战预防手段?](#67. 死锁如何排查?有哪些实战预防手段?)
- [68. lock 关键字的底层原理是什么?](#68. lock 关键字的底层原理是什么?)
- [69. Monitor、Mutex、Semaphore、SemaphoreSlim 的区别?](#69. Monitor、Mutex、Semaphore、SemaphoreSlim 的区别?)
- [70. volatile 关键字的作用?为什么不能替代 lock?](#70. volatile 关键字的作用?为什么不能替代 lock?)
- [71. Interlocked 是什么?有哪些常用原子操作?](#71. Interlocked 是什么?有哪些常用原子操作?)
- [72. 线程安全的集合有哪些?各自特点?](#72. 线程安全的集合有哪些?各自特点?)
- [73. 什么是生产者-消费者模式?如何实现?](#73. 什么是生产者-消费者模式?如何实现?)
- [74. 什么是竞态条件(Race Condition)?如何避免?](#74. 什么是竞态条件(Race Condition)?如何避免?)
- [75. throw 和 throw ex 的区别?如何自定义异常?异常过滤器(when)?](#75. throw 和 throw ex 的区别?如何自定义异常?异常过滤器(when)?)
- [76. 单例模式中为什么要加锁?是否是必须的?](#76. 单例模式中为什么要加锁?是否是必须的?)
- [77. 深拷贝和浅拷贝的区别?他们分别有哪些场景使用?](#77. 深拷贝和浅拷贝的区别?他们分别有哪些场景使用?)
- [78. 对象池模式(Object Pool)是什么?游戏开发中如何实现?](#78. 对象池模式(Object Pool)是什么?游戏开发中如何实现?)
- [79. 工厂模式(Factory Pattern)是什么?简单工厂、工厂方法、抽象工厂的区别?](#79. 工厂模式(Factory Pattern)是什么?简单工厂、工厂方法、抽象工厂的区别?)
- [80. 观察者模式(Observer Pattern)是什么?C# 中如何实现?](# 中如何实现?)
- [81. 策略模式(Strategy Pattern)是什么?适用场景?](#81. 策略模式(Strategy Pattern)是什么?适用场景?)
- [82. 装饰器模式(Decorator Pattern)是什么?](#82. 装饰器模式(Decorator Pattern)是什么?)
- [83. 命令模式(Command Pattern)是什么?在游戏开发中的应用?](#83. 命令模式(Command Pattern)是什么?在游戏开发中的应用?)
- [84. 状态模式(State Pattern)是什么?和策略模式的区别?](#84. 状态模式(State Pattern)是什么?和策略模式的区别?)
- [85. 什么是 SOLID 原则?分别代表什么?](#85. 什么是 SOLID 原则?分别代表什么?)
- [86. C# 和 C++ 在内存管理上有什么区别?(至少说出 2 点区别)](# 和 C++ 在内存管理上有什么区别?(至少说出 2 点区别))
- [87. C# 和 Lua 的共同点和区别?](# 和 Lua 的共同点和区别?)
- [88. 为什么 C++ 的内存管理方面的性能表现往往被认为优于 C#?(至少说出 2 点原因)](#?(至少说出 2 点原因))
- [89. 那为什么 C++ 从执行性能上往往也被认为优于 C# 呢?](# 呢?)
- [90. C# 中的 CLR 是什么?](# 中的 CLR 是什么?)
- [91. 使用 C# 制作游戏存档功能,请问有几种做法?(至少说出三种)](# 制作游戏存档功能,请问有几种做法?(至少说出三种))
- [92. 在制作游戏存档功能时,C# 中反射主要可以发挥出哪些作用?(至少说出三点)](# 中反射主要可以发挥出哪些作用?(至少说出三点))
- [93. 协程(Coroutine)的原理是什么?协程和线程的区别?(Unity)](#93. 协程(Coroutine)的原理是什么?协程和线程的区别?(Unity))
- [94. (代码题)value1 和 value2 谁先初始化?](#94. (代码题)value1 和 value2 谁先初始化?)
- 第三部分:高级题(困难)
-
- [95. C# 中 JIT 编译器是什么,它如何工作?](# 中 JIT 编译器是什么,它如何工作?)
- [96. C# 中函数是何时被加载到内存中的呢?](# 中函数是何时被加载到内存中的呢?)
- [97. C# 函数中一般会声明一些临时变量,当函数被加载内存中后,所占的内存空间是否包含函数中的这些临时变量?](# 函数中一般会声明一些临时变量,当函数被加载内存中后,所占的内存空间是否包含函数中的这些临时变量?)
- [98. 什么是 IL(中间语言)和程序集(Assembly)?](#98. 什么是 IL(中间语言)和程序集(Assembly)?)
- [99. .NET Framework 和 .NET Core / .NET 5+ 的区别?](#99. .NET Framework 和 .NET Core / .NET 5+ 的区别?)
- [100. 什么是 AOT 编译?NativeAOT 的优缺点?](#100. 什么是 AOT 编译?NativeAOT 的优缺点?)
- [101. P/Invoke 是什么?C# 如何调用 C/C++ 的 DLL?](# 如何调用 C/C++ 的 DLL?)
-
- [GC 深入](#GC 深入)
- [102. C# 中的垃圾回收机制是使用的引用计数还是其它方式呢?](# 中的垃圾回收机制是使用的引用计数还是其它方式呢?)
- [103. C# 中自动触发 GC 的原因可能有哪些?从分代收集去考虑?](# 中自动触发 GC 的原因可能有哪些?从分代收集去考虑?)
- [104. GC 分代(Generational)收集中,对象的代龄提升机制是什么?](#104. GC 分代(Generational)收集中,对象的代龄提升机制是什么?)
- [105. GC 的 Workstation 和 Server 模式的区别?](#105. GC 的 Workstation 和 Server 模式的区别?)
- [106. 为什么不要手动调用 GC.Collect()?](#106. 为什么不要手动调用 GC.Collect()?)
- [107. C# 中的大对象堆是什么?](# 中的大对象堆是什么?)
- [108. 什么是内存碎片,如何在 C# 中避免内存碎片?](# 中避免内存碎片?)
- [109. C# 中 new 一个引用对象做了什么?](# 中 new 一个引用对象做了什么?)
- [110. 什么是内存对齐?为什么要进行内存对齐?](#110. 什么是内存对齐?为什么要进行内存对齐?)
- [111. C# 中怎么计算对象占用多少内存?](# 中怎么计算对象占用多少内存?)
- [112. C# 中堆内存优化的大方向是什么?再说 3 点具体方案?](# 中堆内存优化的大方向是什么?再说 3 点具体方案?)
- [113. C# 编程中常见的内存管理问题有哪些?](# 编程中常见的内存管理问题有哪些?)
- [114. 什么是内存屏障(Memory Barrier)?和 volatile 的关系?](#114. 什么是内存屏障(Memory Barrier)?和 volatile 的关系?)
- [115. C# 中的弱引用是什么?它的作用是什么?](# 中的弱引用是什么?它的作用是什么?)
- [116. 托管资源和非托管资源的区别?](#116. 托管资源和非托管资源的区别?)
- [117. IDisposable 和 Dispose 模式是什么?为什么要实现它?](#117. IDisposable 和 Dispose 模式是什么?为什么要实现它?)
- [118. C# 中的 ArrayPool 有什么作用?](# 中的 ArrayPool 有什么作用?)
- [119. Span<T> 和 Memory<T> 是什么?解决什么问题?](#119. Span
和 Memory 是什么?解决什么问题?) - [120. stackalloc 是什么?有什么风险和用途?](#120. stackalloc 是什么?有什么风险和用途?)
- [121. fixed 关键字和 GCHandle 的作用?](#121. fixed 关键字和 GCHandle 的作用?)
- [122. C# 中委托的本质是什么?它是如何存储函数的?](# 中委托的本质是什么?它是如何存储函数的?)
- [123. C# 中事件的本质是什么?](# 中事件的本质是什么?)
- [124. 为什么我们尽量避免装箱拆箱?避免装箱拆箱的方案有哪些?](#124. 为什么我们尽量避免装箱拆箱?避免装箱拆箱的方案有哪些?)
- [125. 为什么说闭包可能会带来内存泄漏?](#125. 为什么说闭包可能会带来内存泄漏?)
- [126. 什么是表达式树(Expression Tree)?和委托的区别?](#126. 什么是表达式树(Expression Tree)?和委托的区别?)
- [127. C# 的模式匹配(Pattern Matching)有哪些形式?(C# 7-11)](# 的模式匹配(Pattern Matching)有哪些形式?(C# 7-11))
- [128. record 类型(C# 9)和 init 访问器是什么?](# 9)和 init 访问器是什么?)
- [129. 可空引用类型(NRT,C# 8)是什么?为什么引入?](# 8)是什么?为什么引入?)
- [130. yield return 的原理是什么?(迭代器状态机)](#130. yield return 的原理是什么?(迭代器状态机))
- [131. async/await 状态机深入:编译器生成了什么?](#131. async/await 状态机深入:编译器生成了什么?)
- [132. ThreadLocal<T> 是什么?和普通静态字段的区别?](#132. ThreadLocal
是什么?和普通静态字段的区别?) - [133. 字符串驻留(String Interning)是什么?有什么坑?](#133. 字符串驻留(String Interning)是什么?有什么坑?)
- [134. 为什么反射慢?如何优化反射性能?](#134. 为什么反射慢?如何优化反射性能?)
- [135. Attribute(特性)是什么?如何自定义特性?](#135. Attribute(特性)是什么?如何自定义特性?)
- [136. C# 中提供了序列化和反序列化 2 进制数据的公共类 BinaryFormatter,为什么我们一般还要自己去实现序列化和反序列化 2 进制数据的方法?](# 中提供了序列化和反序列化 2 进制数据的公共类 BinaryFormatter,为什么我们一般还要自己去实现序列化和反序列化 2 进制数据的方法?)
- [137. 异或加密的原理是什么?请简要说明。](#137. 异或加密的原理是什么?请简要说明。)
- [138. 代码混淆是什么意思?有什么作用?](#138. 代码混淆是什么意思?有什么作用?)
- [139. 在游戏开发中的日志系统一般会配合 C# 中的什么一起使用?用于记录报错信息。](# 中的什么一起使用?用于记录报错信息。)
- [140. 在 Unity 中使用 C# 的 foreach 是否会带来额外内存和性能开销?](# 的 foreach 是否会带来额外内存和性能开销?)
- [141. 为什么我们使用单例模式更多,而几乎不使用静态类?](#141. 为什么我们使用单例模式更多,而几乎不使用静态类?)
【实战精选面试题】C# 面试题详细解答,按难易程度排序
第一部分:基础题(简单)
语言与命名基础
1. C# 中常用的命名规范有哪些?(至少说出 3 种)
答案:
C# 遵循微软制定的 .NET 命名规范,以下是常用的命名约定:
1. PascalCase(帕斯卡命名法)
每个单词首字母大写,其他字母小写。用于:
- 类名:
PlayerController、GameManager - 方法名:
CalculateDamage、GetPlayerPosition - 属性名:
Health、PlayerName - 事件名:
GameOver、PlayerDied(官方规范不建议事件名加On前缀,On通常保留给触发事件的方法) - 命名空间:
UnityEngine、System.Collections
2. camelCase(驼峰命名法)
首字母小写,后续单词首字母大写。用于:
- 局部变量:
playerHealth、currentScore - 方法参数:
int damage、string playerName - 私有字段(带下划线前缀是常见惯例):
_playerHealth、_instance
csharp
public class Player
{
private int _health; // 私有字段:camelCase + 下划线
private string _playerName;
public int Health // 属性:PascalCase
{
get { return _health; }
set { _health = value; }
}
public void TakeDamage(int damage) // 方法:PascalCase,参数:camelCase
{
// ...
}
}
3. 常量:PascalCase(官方推荐)
微软官方 .NET 命名规范推荐常量使用 PascalCase ,与字段命名一致,例如 Math.PI、int.MaxValue、MaxConnections。
注意:全大写加下划线(ALL_CAPS,如
MAX_CONNECTIONS)是 C/C++ 的惯例,C# 官方规范并不推荐,但在部分团队(尤其是 Unity、C++ 背景团队)中仍然常见。枚举成员官方同样推荐 PascalCase。
csharp
public const int MaxConnections = 100;
public const string DefaultConfigPath = "./config.json";
4. I 前缀
接口名称以 I 开头,后跟 PascalCase:
IDisposable、IEnumerable、IComparable
5. 其他约定
| 类型 | 命名规范 | 示例 |
|---|---|---|
| 枚举类型 | PascalCase | Color、Direction |
| 委托类型 | PascalCase,后缀 Handler 或 Callback |
EventHandler、Action |
| 属性 | PascalCase | Name、Age |
| 事件 | PascalCase | Click、KeyDown |
| 私有字段 | camelCase + _ 前缀 |
_instance、_cache |
| 受保护字段 | camelCase(不带下划线) | protected int health |
Unity 特定命名规范:
csharp
public class Player : MonoBehaviour
{
[SerializeField] private int _health; // 序列化字段:私有 + SerializeField
private Rigidbody _rigidbody; // 组件引用:私有 + 下划线前缀
public void Awake() { } // Unity 生命周期:PascalCase
public void Update() { }
public int Health // 公共属性:PascalCase
{
get => _health;
set => _health = value;
}
}
2. C# 有哪些访问修饰符?各自的作用?
答案:
| 修饰符 | 可见范围 |
|---|---|
public |
无限制,任何代码可访问 |
private |
仅当前类型内部可访问 |
protected |
当前类型及其派生类可访问 |
internal |
同一程序集(Assembly)内可访问 |
protected internal |
同一程序集内 或 任意程序集的派生类(OR 关系) |
private protected |
仅同一程序集内的派生类可访问(AND 关系,C# 7.2+) |
csharp
public class Base
{
public int A; // 所有人
private int B; // 仅 Base 内部
protected int C; // Base 及其子类
internal int D; // 同一程序集
protected internal int E; // 同一程序集,或任何程序集中的子类
private protected int F; // 仅同一程序集中的子类(C# 7.2+)
}
默认访问级别:
- 类成员默认
private - 类、接口、结构体、枚举、委托(顶层类型)默认
internal - 命名空间成员默认
internal
注意:
protected internal是"保护或 内部",private protected是"私有且受保护"(先 protected 后 private 的组合),两者含义不同,容易混淆。
面向对象基础
3. 封装、继承、多态分别是什么?请结合 C# 说明。
答案:
1. 封装(Encapsulation)
将数据和操作数据的方法绑定在一起,对外隐藏内部实现细节,只暴露受控的访问接口。
csharp
public class BankAccount
{
private decimal _balance; // 私有字段,外部不可直接访问
public decimal Balance => _balance; // 只读属性暴露余额
public void Deposit(decimal amount) // 通过方法修改数据,可加入校验逻辑
{
if (amount <= 0) throw new ArgumentException("金额必须为正数");
_balance += amount;
}
}
好处:保护数据完整性(防止非法赋值)、降低模块耦合、便于维护。
2. 继承(Inheritance)
子类复用基类的成员(字段、方法、属性),形成"is-a"层次结构。
csharp
public class Animal
{
public string Name { get; set; }
public void Eat() => Console.WriteLine($"{Name} 在吃东西");
}
public class Dog : Animal
{
public void Bark() => Console.WriteLine("汪汪");
}
注意:C# 类是单继承(一个类只能有一个直接基类),但可以实现多个接口。
3. 多态(Polymorphism)
同一操作作用于不同对象时表现出不同行为。C# 中通过虚方法重写 、抽象方法 和接口实现来实现。
csharp
public class Animal
{
public virtual void Speak() => Console.WriteLine("动物叫");
}
public class Dog : Animal
{
public override void Speak() => Console.WriteLine("汪汪");
}
public class Cat : Animal
{
public override void Speak() => Console.WriteLine("喵喵");
}
// 使用:编译期类型是 Animal,运行期根据实际对象调用对应实现(动态绑定)
Animal a = new Dog();
a.Speak(); // 输出:汪汪
4. 重载(Overload)和重写(Override)的区别?
答案:
| 对比项 | 重载 Overload | 重写 Override |
|---|---|---|
| 发生位置 | 同一个类或继承体系中 | 子类和父类之间 |
| 方法签名 | 必须不同(参数个数/类型/顺序) | 必须相同 |
| 返回值 | 可以不同(仅参数不同即可) | 必须相同(或协变返回) |
| 关键字 | 无特殊关键字 | 父类方法须为 virtual/abstract,子类加 override |
| 绑定时机 | 编译时绑定(静态多态) | 运行时绑定(动态多态) |
csharp
public class Calculator
{
public int Add(int a, int b) => a + b; // 重载 1
public double Add(double a, double b) => a + b; // 重载 2(参数类型不同)
public int Add(int a, int b, int c) => a + b + c; // 重载 3(参数个数不同)
}
public class Shape
{
public virtual double Area() => 0;
}
public class Circle : Shape
{
public override double Area() => Math.PI * Radius * Radius; // 重写
}
补充:如果子类用
new关键字定义一个与父类同名同签名的方法,称为隐藏(Hide),它不属于多态,调用哪个取决于变量的编译期类型。
5. 抽象类(abstract class)和接口(interface)的区别?什么时候用哪个?
答案:
| 对比项 | 抽象类 | 接口 |
|---|---|---|
| 实例化 | 不能实例化,但可以有构造函数 | 不能实例化,无构造函数 |
| 成员实现 | 可以有已实现的方法、字段、属性 | 传统上只声明签名;C# 8+ 可以有默认实现方法 |
| 实例字段 | 可以定义 | 不允许(只允许静态字段和常量) |
| 继承数量 | 单继承 | 可多实现 |
| 访问修饰符 | 成员可以是任意修饰符 | 成员默认 public(C# 8+ 可显式实现为私有) |
| 语义 | "是什么"(is-a),抽象基类 | "能做什么"(can-do),能力契约 |
| 用途 | 复用公共代码 + 约束子类 | 解耦、多态能力定义、跨体系统一 |
csharp
// 抽象类:公共代码 + 抽象成员
public abstract class Animal
{
public string Name { get; set; } // 公共字段
public void Eat() => Console.WriteLine("吃饭"); // 公共实现
public abstract void Speak(); // 抽象方法,子类必须实现
}
// 接口:能力契约
public interface IFlyable
{
void Fly();
}
public class Bird : Animal, IFlyable // 单继承类 + 多实现接口
{
public override void Speak() => Console.WriteLine("叽叽");
public void Fly() => Console.WriteLine("飞起来了");
}
选择建议:
- 需要共享字段和公共实现代码 → 抽象类
- 只定义契约、需要多类型实现、强调解耦 → 接口
- 两者可结合:抽象类实现接口,提供公共基座
补充:C# 8.0 起接口可以声明默认实现方法(
default interface method),但仍不允许实例字段,所以"接口无状态"这个核心区别依然成立。
6. 属性(Property)和字段(Field)的区别?自动属性是什么?
答案:
属性本质上是方法。 编译器会把属性编译为 get_xxx() 和 set_xxx() 两个方法,访问属性实际上是方法调用。
csharp
public class Person
{
private string _name; // 字段:直接存储数据
public string Name // 属性:通过访问器读写字段
{
get { return _name; }
set { _name = value; }
}
}
| 对比项 | 字段 | 属性 |
|---|---|---|
| 本质 | 存储数据的变量 | 封装字段的 get/set 方法 |
| 封装性 | public 字段破坏封装,无法拦截赋值 | 可在 set 中加校验、通知、计算逻辑 |
| 赋值控制 | 直接赋值 | 可做只读(只有 get)、只写(只有 set) |
| 可参与成员类型 | 不能是 virtual/abstract | 可以是 virtual、abstract、接口实现 |
| 序列化/数据绑定 | 一般不直接绑定 | 常用于序列化、数据绑定(依赖属性) |
自动属性(Auto-Property):
编译器自动生成私有后备字段,代码更简洁:
csharp
public class Person
{
public string Name { get; set; } // 编译器生成私有字段 + get/set
public int Age { get; private set; } // set 为私有,外部只读
public string Email { get; set; } = ""; // 带默认值初始化
}
区别总结: 需要对外暴露数据且有封装需求时用属性;内部私有存储用字段。属性的一个典型优势:可以在 set 中拦截非法值。
csharp
private int _age;
public int Age
{
get => _age;
set
{
if (value < 0 || value > 150)
throw new ArgumentOutOfRangeException(nameof(value));
_age = value;
}
}
静态成员
7. 父类中定义了一个静态成员属性,有两个子类都继承该父类,请问打印结果是什么?为什么?
csharp
class Father
{
public static int static_I = 0;
}
class Son1 : Father { }
class Son2 : Father { }
Son1.static_I = 20;
Son2.static_I = 30;
Console.WriteLine(Father.static_I);
Console.WriteLine(Son1.static_I);
Console.WriteLine(Son2.static_I);
答案:
打印结果:
30
30
30
原因:
静态成员属于类型本身 ,而不是实例。子类访问的实际上是同一个静态字段 Father.static_I。
8. 静态构造函数(静态构造器)有什么特点?
答案:
静态构造函数用于初始化类型的静态成员,具有以下特点:
- 无参数、无访问修饰符、不能重载
- 只执行一次,在整个应用生命周期内最多运行一次
- 触发时机:类型第一次被使用前(首次访问静态成员或创建第一个实例时)由 CLR 自动调用
- 线程安全:CLR 保证静态构造函数不会并发执行(多线程同时触发时只有一个线程执行,其他线程等待)
- 不能显式调用,也不能被继承/重写
- 初始化顺序:静态字段初始化器先于静态构造函数体执行(补充题 33 的考点)
csharp
public class Config
{
public static readonly string Version;
public static Dictionary<string, string> Settings;
static Config() // 静态构造函数
{
Version = "1.0.0";
Settings = LoadFromFile(); // 初始化静态资源
Console.WriteLine("静态构造函数执行了");
}
public static string LoadFromFile() => /* ... */ "";
}
Config.Version; // 首次访问时触发静态构造函数
适用场景: 初始化静态资源(读取配置文件、加载日志器、注册回调等)、生成静态缓存。
类型系统
9. 值类型(Value Type)和引用类型(Reference Type)的区别?
答案:
| 对比项 | 值类型 | 引用类型 |
|---|---|---|
| 代表 | int、float、bool、struct、enum |
class、string、数组、委托、接口 |
| 存储位置 | 局部变量通常在栈上(作为字段/数组元素则在堆上) | 对象在堆上,变量存引用 |
| 赋值行为 | 复制数据本身 | 复制引用,多个变量指向同一对象 |
| 默认值 | 0、false 等(不能为 null) |
null |
| 继承 | 隐式继承 System.ValueType,不能继承其他类型 |
可以继承类、实现接口 |
| 装箱拆箱 | 转 object 时发生装箱 |
不需要 |
| 相等性 | 默认按值比较(结构相等) | 默认按引用比较 |
csharp
int a = 10;
int b = a; // b 是 a 的副本
b = 20;
Console.WriteLine(a); // 10,a 不受影响
var p1 = new Person { Name = "Tom" };
var p2 = p1; // p2 和 p1 指向同一个对象
p2.Name = "Jerry";
Console.WriteLine(p1.Name); // Jerry,共享同一个对象
判断技巧: 用 typeof(T).IsValueType 判断;struct 是值类型,class 是引用类型。
补充:
string虽然是引用类型,但被设计为不可变 (内容不能修改),且重载了==使其按内容比较,容易让人误以为是值类型。
10. struct 和 class 如何选择?什么场景用 struct?
答案:
核心区别: struct 是值类型,class 是引用类型(详见第9题)。
struct 的特点:
- 值类型,作为局部变量通常在栈上,减少 GC 压力
- 不能继承类(但可实现接口)
- 不能有无参数构造函数(C# 10 前);C# 10 起允许显式无参构造函数
- 赋值是复制数据
struct作为类的字段、数组元素、集合元素时仍在堆上(内嵌在容器对象中)
微软官方建议使用 struct 的条件(全部满足):
- 类型逻辑上表示单个值(如坐标、颜色、区间)
- 实例大小较小(一般建议 ≤ 16 字节)
- 不可变(字段不可修改)
- 不会被频繁装箱
csharp
// 适合 struct 的例子
public struct Vector2
{
public float X;
public float Y;
}
public struct Color
{
public byte R, G, B, A;
}
class 更合适:
- 对象较大、有继承需求、有身份标识(如实体对象)
- 需要引用语义(共享、修改一处影响多处)
常见误用: 把大对象(如包含 List、string 的"struct")定义成 struct,会导致每次赋值都深度复制,性能反而更差。
补充:C# 7.2 引入
ref struct(只能分配在栈上,不能装箱、不能作为类字段),C# 7.3 起ref struct可实现接口。典型应用如Span<T>。
11. is、as 和强制类型转换的区别?
答案:
1. is - 类型检查
返回 bool,只做检查不做转换。C# 7.0 起支持模式匹配,可同时声明变量:
csharp
object obj = "hello";
if (obj is string) // 只判断类型
{
// ...
}
if (obj is string s) // 模式匹配:判断类型并声明变量 s
{
Console.WriteLine(s.Length); // s 在此作用域内可直接使用
}
2. as - 安全转换
尝试转换,失败返回 null,不抛异常。只能用于引用类型或可空值类型。
csharp
object obj = "hello";
string s = obj as string; // s = "hello"
int? num = obj as int?; // 失败,返回 null(不抛异常)
3. 强制转换((T)x)
直接转换,失败抛出 InvalidCastException。值类型转换也用它。
csharp
object obj = "hello";
string s = (string)obj; // 成功
int i = (int)obj; // 抛出 InvalidCastException(string 不能转 int)
对比总结:
| 方式 | 失败行为 | 适用范围 |
|---|---|---|
is |
返回 false | 任意类型 |
as |
返回 null | 引用类型/可空值类型 |
| 强制转换 | 抛异常 | 任意类型 |
建议:
as后必须判空再使用;不确定类型且可接受 null 时用as,确定能转时用强制转换。
12. 以下代码的打印结果是多少?
csharp
float f = 10.4f;
int i = f / 2;
Console.WriteLine(i);
答案:
编译错误,程序无法运行。
错误信息:
Cannot implicitly convert type 'float' to 'int'
修复方式:
csharp
float f = 10.4f;
int i = (int)(f / 2); // 显式转换,结果为 5
13. C# 中 ulong 类型可以表示最大的整数为多少?C# 中的 BigInteger 类对于我们来说有什么意义?
答案:
ulong 的最大值:
ulong(无符号 64 位整数)的最大值为:
2^64 - 1 = 18,446,744,073,709,551,615
约 1.84 × 10^19,即 1844 亿亿。
BigInteger 类的意义:
System.Numerics.BigInteger 是一个任意大整数类型,没有固定的上限和下限,其大小仅受可用内存限制。
主要用途:
-
处理超大数值
- 超出
long/ulong范围的整数计算
csharpBigInteger huge = BigInteger.Parse("123456789012345678901234567890"); BigInteger result = huge * huge; - 超出
-
加密算法
- RSA、ECC 等公钥加密算法需要处理大素数
-
精确计算
- 需要高精度整数运算的场景
14. int, strArray = new int3, 4 这个语句创建了多少个 int 对象?string, strArray = new string3, 4 这个语句创建了多少个 string 对象?
答案:
| 语句 | 创建的对象数量 | 说明 |
|---|---|---|
int[,] arr = new int[3, 4] |
1 个对象 | 创建一个二维数组对象,包含 12 个 int 值(值类型,内嵌在数组对象中) |
string[,] arr = new string[3, 4] |
1 个对象 | 创建一个二维数组对象,包含 12 个 string 引用(初始为 null),没有创建任何 string 对象 |
总结:
- 数组创建时只分配数组对象本身的内存
- 值类型数组:元素值直接存储在数组中
- 引用类型数组:只存储引用,引用的对象需要单独创建
15. C# 中如何把 int 数组转换为 string 数组?(请至少说出 2 种方式)
答案:
方式一:使用 Array.ConvertAll
最简洁高效的方法:
csharp
int[] intArray = { 1, 2, 3, 4, 5 };
string[] strArray = Array.ConvertAll(intArray, i => i.ToString());
方式二:使用 LINQ 的 Select 和 ToArray
csharp
using System.Linq;
int[] intArray = { 1, 2, 3, 4, 5 };
string[] strArray = intArray.Select(i => i.ToString()).ToArray();
方式三:手动循环
csharp
int[] intArray = { 1, 2, 3, 4, 5 };
string[] strArray = new string[intArray.Length];
for (int i = 0; i < intArray.Length; i++)
{
strArray[i] = intArray[i].ToString();
}
推荐:
- 一般场景:使用
Array.ConvertAll,代码简洁且性能好 - 需要复杂转换逻辑时:使用
for循环
集合与容器
16. 在 C# 当中,我们想要用一个字典容器存储场景中的所有敌对对象,比如 怪物、Boss、可被攻击的场景物件等等,应该如何存储?
答案:
使用多态 和接口实现,将不同类型的敌对对象统一存储。
方案一:使用接口
csharp
// 定义接口
public interface IEnemy
{
int Id { get; }
void TakeDamage(int damage);
bool IsAlive { get; }
}
// 实现接口的不同类型
public class Monster : IEnemy
{
public int Id { get; private set; }
public int Health { get; private set; }
public bool IsAlive => Health > 0;
public void TakeDamage(int damage) => Health -= damage;
}
public class Boss : IEnemy
{
public int Id { get; private set; }
public int Health { get; private set; }
public bool IsAlive => Health > 0;
public void TakeDamage(int damage) => Health -= damage;
public void SpecialAttack() { } // Boss 特有方法
}
public class AttackableProp : IEnemy
{
public int Id { get; private set; }
public int Durability { get; private set; }
public bool IsAlive => Durability > 0;
public void TakeDamage(int damage) => Durability -= damage;
}
// 存储
public class EnemyManager
{
// 按 ID 存储
private Dictionary<int, IEnemy> _enemies = new Dictionary<int, IEnemy>();
// 按类型分组存储(方便按类型查找)
private Dictionary<Type, List<IEnemy>> _enemiesByType = new Dictionary<Type, List<IEnemy>>();
public void AddEnemy(IEnemy enemy)
{
_enemies[enemy.Id] = enemy;
Type type = enemy.GetType();
if (!_enemiesByType.ContainsKey(type))
_enemiesByType[type] = new List<IEnemy>();
_enemiesByType[type].Add(enemy);
}
public void RemoveEnemy(int id)
{
if (_enemies.TryGetValue(id, out var enemy))
{
_enemiesByType[enemy.GetType()].Remove(enemy);
_enemies.Remove(id);
}
}
public IEnemy GetEnemy(int id) => _enemies.GetValueOrDefault(id);
public List<T> GetEnemiesByType<T>() where T : class, IEnemy
{
if (_enemiesByType.TryGetValue(typeof(T), out var list))
return list.Cast<T>().ToList();
return new List<T>();
}
public void DamageAll(int damage)
{
foreach (var enemy in _enemies.Values)
{
enemy.TakeDamage(damage);
}
}
}
选择建议:
- 使用接口,便于多类型统一处理
- 使用
Dictionary<int, IEnemy>按 ID 快速查找 - 如需按类型分组,使用
Dictionary<Type, List<IEnemy>>
17. C# 中,你认为什么时候应该使用数组,什么时候应该使用 List?
答案:
| 场景 | 推荐使用 | 原因 |
|---|---|---|
| 数据大小固定不变 | 数组 T[] |
内存布局紧凑,访问最快 |
| 性能要求极高 | 数组 T[] |
索引访问略快于 List(List 内部也是数组,但有额外的封装开销) |
| 数据大小动态变化 | List<T> |
支持动态添加删除 |
| 需要丰富的方法 | List<T> |
提供 Find、Sort 等便捷方法 |
18. 以下代码执行后,List 中还存在哪些内容?
csharp
List<int> list = new List<int>() { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
for (int i = 0; i < list.Count; i++)
list.RemoveAt(i);
答案:
执行后,列表中剩余 2, 4, 6, 8, 10。
过程分析:
初始列表: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
每次删除索引 i 后,列表元素向前移动,导致跳过了某些元素。
推荐使用反向遍历或 RemoveAll:
csharp
// 反向遍历
for (int i = list.Count - 1; i >= 0; i--)
list.RemoveAt(i);
// 使用 RemoveAll
list.RemoveAll(x => true);
19. C# 中 List 中的 Sort 方法中,传入的比较器委托函数,返回的 -1、0、1 分别代表什么意思?
答案:
| 返回值 | 含义 | 说明 |
|---|---|---|
| 负数(如 -1) | x < y |
x 应该排在 y 前面 |
| 0 | x == y |
两个元素相等,顺序任意 |
| 正数(如 1) | x > y |
x 应该排在 y 后面 |
补充:比较器只需返回"小于 0 / 等于 0 / 大于 0",并不要求严格返回 -1、0、1。
字符串与文本
20. C# 中在字符串前面加一个 @符号或者 $ 符号有什么作用?
答案:
@ 符号 - 逐字字符串标识符
作用:字符串中的转义字符被当作普通字符处理,字符串可以跨多行。
常用场景:
- 文件路径
csharp
// 不用 @:需要双反斜杠
string path = "C:\\Program Files\\Unity\\Editor";
// 使用 @:单反斜杠即可
string path = @"C:\Program Files\Unity\Editor";
- 正则表达式
csharp
// 正则表达式中有大量反斜杠
string pattern = @"\d{3}-\d{4}"; // 清晰易读
string pattern2 = "\\d{3}-\\d{4}"; // 难读
- 多行字符串
csharp
string query = @"
SELECT *
FROM Users
WHERE Name = 'Alice'
";
- JSON 字符串
csharp
string json = @"
{
""name"": ""Alice"",
""age"": 30
}";
// 注意:需要双引号转义为 ""
- 包含特殊字符
csharp
string text = @"He said, ""Hello!"""; // 输出:He said, "Hello!"
string path = @"C:\NewFolder\File.txt"; // 不会被解释为换行符
$ 符号 - 字符串内插标识符
作用:允许在字符串中直接嵌入表达式,用 {} 包裹,比 string.Format 更简洁。
基本用法:
csharp
string name = "Alice";
int age = 30;
// string.Format
string msg1 = string.Format("Name: {0}, Age: {1}", name, age);
// 字符串内插
string msg2 = $"Name: {name}, Age: {age}";
表达式支持:
csharp
// 算术运算
double price = 10.5;
int quantity = 3;
string msg = $"Total: {price * quantity:C}"; // 货币格式,输出取决于当前区域性(en-US 下为 $31.50,zh-CN 下为 ¥31.50)
// 方法调用
DateTime now = DateTime.Now;
string msg = $"Today is {now:yyyy-MM-dd}"; // Today is 2024-01-15
// 三元表达式
string status = $"Status: {isActive ? "Active" : "Inactive"}";
// 复杂表达式
string msg = $"Length: {myArray?.Length ?? 0}";
格式说明符:
csharp
double pi = 3.14159;
string msg = $"π = {pi:F3}"; // π = 3.142
int number = 255;
string msg = $"Hex: {number:X}"; // Hex: FF
逐字内插字符串组合:
csharp
// @$ 或 $@ 都可以,组合使用
string path = @"C:\Data";
string file = "log.txt";
string fullPath = $@"{path}\{file}"; // C:\Data\log.txt
21. string 为什么是不可变的?频繁拼接字符串有什么问题?
答案:
不可变(Immutable)含义: 字符串对象一旦创建,其内容就无法修改。所有看似"修改"的操作(如 +、Replace、Substring)实际都是创建新的字符串对象。
csharp
string s = "Hello";
s += " World"; // 不是修改原对象,而是创建新对象 "Hello World",原对象等待被 GC
为什么设计成不可变:
- 线程安全 - 字符串可被多线程安全共享,无需加锁
- 哈希稳定 - 作为
Dictionary的键,哈希码不变,查找正确 - 字符串驻留 - 相同内容可共享同一对象,节省内存
- 安全性 - 防止别名问题(一个引用修改影响所有引用)
频繁拼接的问题:
csharp
string result = "";
for (int i = 0; i < 10000; i++)
{
result += i.ToString(); // 每次循环都创建一个新字符串,产生大量垃圾
}
每次 += 都会:分配新字符串 → 拷贝旧内容 → 旧对象变成垃圾。复杂度 O(n²),GC 压力大。
解决方案:使用 StringBuilder(见下一题)。
22. StringBuilder 的原理是什么?为什么比 string 拼接高效?
答案:
原理: StringBuilder 内部维护一个可变的 char[] 缓冲区 ,Append 操作直接在数组末尾追加字符,而不是每次创建新对象。
csharp
var sb = new StringBuilder();
for (int i = 0; i < 10000; i++)
{
sb.Append(i.ToString()); // 直接写入内部缓冲区,不产生新字符串
}
string result = sb.ToString(); // 最后一次性生成最终字符串
扩容机制: 当内部缓冲区容量不足时,会按倍增策略 扩容(分配更大的新数组并拷贝旧内容),类似 List<T> 的扩容。因此总体追加的均摊复杂度为 O(n)。
其他特性:
- 可指定初始容量:
new StringBuilder(1000)减少扩容次数 Insert、Replace、Remove等操作也都是就地修改- 线程不安全(不同步)
StringBuilder 和 string 拼接的适用场景:
| 场景 | 推荐 |
|---|---|
固定少量拼接($"a={a}, b={b}") |
string 内插(编译器优化后无额外分配) |
| 循环内大量拼接 | StringBuilder |
| 需要频繁修改字符串内容 | StringBuilder |
23. 字符串比较时 == 和 Equals 的区别?
答案:
结论: 对于 string,== 和 Equals 结果相同,都是按内容比较。区别在于背后的机制和使用灵活性。
csharp
string a = "hello";
string b = "hello";
string c = new string(new char[] { 'h', 'e', 'l', 'l', 'o' });
Console.WriteLine(a == b); // True,按内容比较
Console.WriteLine(a == c); // True,按内容比较(string 重载了 ==)
Console.WriteLine(a.Equals(c)); // True,按内容比较
Console.WriteLine(ReferenceEquals(a, b)); // True(字符串驻留,可能指向同一对象)
Console.WriteLine(ReferenceEquals(a, c)); // False(不同对象,但内容相同)
区别点:
-
==是运算符重载 :string重载了==,使其比较内容;但普通引用类型 的==比较的是引用(除非重载)。 -
Equals是虚方法 :object.Equals默认按引用比较,但string重写了它按内容比较。多态调用时会走重写版本:
csharp
object o1 = a;
object o2 = c;
Console.WriteLine(o1 == o2); // False!编译期类型是 object,调用 object 的 ==(比较引用)
Console.WriteLine(o1.Equals(o2)); // True!Equals 是虚方法,运行时调用 string 的重写
Equals支持重载:可指定比较规则:
csharp
a.Equals(c, StringComparison.OrdinalIgnoreCase); // 忽略大小写
string.Equals(a, c, StringComparison.Ordinal); // 静态重载,可处理 null
建议: 判断字符串内容相等统一用 ==(简洁且不易出错);需要忽略大小写或文化相关比较时用 string.Equals(a, b, StringComparison.xxx);判断引用是否相同用 ReferenceEquals。
24. (代码题)手写字符串反转?
答案:
方法一:使用 char[] 双向交换(推荐,面试手写首选)
csharp
public static string Reverse(string s)
{
if (string.IsNullOrEmpty(s)) return s;
char[] chars = s.ToCharArray();
int left = 0, right = chars.Length - 1;
while (left < right)
{
(chars[left], chars[right]) = (chars[right], chars[left]); // 元组交换
left++;
right--;
}
return new string(chars);
}
方法二:使用 LINQ
csharp
public static string Reverse(string s) => new string(s.Reverse().ToArray());
注意:
s.Reverse()返回IEnumerable<char>,需要用new string(...)构造回字符串;该方法有额外分配,性能不如双向交换。
方法三:StringBuilder(从后往前追加)
csharp
public static string Reverse(string s)
{
var sb = new StringBuilder(s.Length);
for (int i = s.Length - 1; i >= 0; i--)
sb.Append(s[i]);
return sb.ToString();
}
25. (代码题)判断一个字符串是否是回文?
答案:
思路: 双指针从两端向中间比较,任一位置字符不等则不是回文。
csharp
public static bool IsPalindrome(string s)
{
if (string.IsNullOrEmpty(s)) return true;
int left = 0, right = s.Length - 1;
while (left < right)
{
if (char.ToLower(s[left]) != char.ToLower(s[right])) // 忽略大小写
return false;
left++;
right--;
}
return true;
}
进阶版本(忽略非字母数字字符,如 "A man, a plan, a canal: Panama"):
csharp
public static bool IsPalindromeIgnoreNonAlnum(string s)
{
int left = 0, right = s.Length - 1;
while (left < right)
{
while (left < right && !char.IsLetterOrDigit(s[left])) left++;
while (left < right && !char.IsLetterOrDigit(s[right])) right--;
if (char.ToLower(s[left]) != char.ToLower(s[right]))
return false;
left++;
right--;
}
return true;
}
基础算法与代码题
26. 在 C# 中,给你一个数字,表示有 n 秒,请问如何在不改变这个数字的前提下,将它转换成格式为 __小时__分__秒?
答案:
可以使用整除和取余运算将总秒数转换为小时、分钟和秒。
csharp
public static string FormatTime(int totalSeconds)
{
int hours = totalSeconds / 3600;
int minutes = (totalSeconds % 3600) / 60;
int seconds = totalSeconds % 60;
return $"{hours}小时{minutes}分{seconds}秒";
}
// 使用示例
int n = 3665;
string result = FormatTime(n); // 输出: 1小时1分5秒
说明:
totalSeconds / 3600得到小时数totalSeconds % 3600得到除去小时后剩余的秒数(totalSeconds % 3600) / 60得到分钟数totalSeconds % 60得到秒数
另一种实现(TimeSpan):
csharp
public static string FormatTime(int totalSeconds)
{
var ts = TimeSpan.FromSeconds(totalSeconds);
return $"{ts.Hours}小时{ts.Minutes}分{ts.Seconds}秒";
}
27. 这个函数是判断一个整数是不是 2 的 n 次方。请问 if 语句中应该如何书写?
csharp
static bool Is2(uint num)
{
if (/* 此处填写判断条件 */)
return true;
return false;
}
答案:
csharp
static bool Is2(uint num)
{
if ((num & (num - 1)) == 0 && num != 0)
return true;
return false;
}
原理:
2 的 n 次方的二进制表示中只有一位是 1 。num & (num - 1) 可以检查这个特性。num != 0 是为了排除 0(0 & (0 - 1) == 0 恒成立,但 0 不是 2 的 n 次方)。
28. (代码题)斐波那契数列的几种实现?
答案:
斐波那契:F(0)=0, F(1)=1, F(n)=F(n-1)+F(n-2)
方法一:朴素递归(有大量重复计算,指数级复杂度)
csharp
public static long Fib(int n)
{
if (n <= 1) return n;
return Fib(n - 1) + Fib(n - 2); // 重复计算严重,n=50 就会卡死
}
方法二:递归 + 记忆化(缓存已计算结果,O(n))
csharp
static long[] cache = new long[100];
public static long Fib(int n)
{
if (n <= 1) return n;
if (cache[n] != 0) return cache[n];
return cache[n] = Fib(n - 1) + Fib(n - 2);
}
方法三:迭代(面试推荐,O(n) 时间 O(1) 空间)
csharp
public static long Fib(int n)
{
if (n <= 1) return n;
long prev = 0, curr = 1;
for (int i = 2; i <= n; i++)
{
(prev, curr) = (curr, prev + curr);
}
return curr;
}
方法四:动态规划数组
csharp
public static long Fib(int n)
{
if (n <= 1) return n;
long[] dp = new long[n + 1];
dp[0] = 0; dp[1] = 1;
for (int i = 2; i <= n; i++)
dp[i] = dp[i - 1] + dp[i - 2];
return dp[n];
}
面试要点:能说出朴素递归的重复计算问题 、迭代解法最优 (O(n) 时间 O(1) 空间),并指出
long在 n≈93 时溢出。
29. (代码题)手写二分查找?
答案:
要求: 数组已升序排序。查找目标值,存在返回索引,不存在返回 -1。
写法一:闭区间 [left, right](推荐)
csharp
public static int BinarySearch(int[] arr, int target)
{
int left = 0, right = arr.Length - 1;
while (left <= right) // 闭区间,left == right 时仍需比较
{
int mid = left + (right - left) / 2; // 防止 left+right 溢出
if (arr[mid] == target)
return mid;
else if (arr[mid] < target)
left = mid + 1; // 目标在右半区
else
right = mid - 1; // 目标在左半区
}
return -1;
}
写法二:左闭右开 [left, right)
csharp
public static int BinarySearch(int[] arr, int target)
{
int left = 0, right = arr.Length; // 右边界开区间
while (left < right)
{
int mid = left + (right - left) / 2;
if (arr[mid] == target)
return mid;
else if (arr[mid] < target)
left = mid + 1;
else
right = mid;
}
return -1;
}
面试要点:
- 时间复杂度 O(log n),空间复杂度 O(1)
mid的计算用left + (right - left) / 2而非(left + right) / 2,防止整数溢出(经典坑)- 注意边界条件:闭区间用
<=,开区间用<;收缩边界时left = mid + 1/right = mid - 1(或right = mid)
内存与反射入门
30. C# 类中的函数是否占用内存空间?
答案:
函数(方法)本身的代码不占用对象实例的内存空间。
方法代码属于类型 ,所有实例共享相同的方法代码。对象实例只存储实例字段(数据)。
31. C# 中是否可以通过反射获取到类内部的私有成员?如果可以,如何获取?
答案:
可以。通过反射可以获取和修改私有字段、调用私有方法、访问私有属性等。
示例:
csharp
public class MyClass
{
private int _secret = 42;
private void PrivateMethod() => Console.WriteLine("Private method called");
}
MyClass obj = new MyClass();
// 获取私有字段
FieldInfo field = typeof(MyClass).GetField("_secret", BindingFlags.NonPublic | BindingFlags.Instance);
int value = (int)field.GetValue(obj); // 42
// 修改私有字段
field.SetValue(obj, 100);
// 调用私有方法
MethodInfo method = typeof(MyClass).GetMethod("PrivateMethod", BindingFlags.NonPublic | BindingFlags.Instance);
method.Invoke(obj, null); // 输出 "Private method called"
获取私有属性:
csharp
PropertyInfo prop = typeof(MyClass).GetProperty("PrivateProp",
BindingFlags.NonPublic | BindingFlags.Instance);
object propValue = prop.GetValue(obj);
注意事项:
- 需要
using System.Reflection; - 对于基类的私有成员,需要从基类类型获取
第二部分:进阶题(中等)
参数传递与内存布局
32. 以下代码,最终打印结果是多少?为什么?如果解决这种问题?
csharp
public void TestFun(TestClass c)
{
c = new TestClass();
c.a = 20;
}
TestClass c = new TestClass();
c.a = 10;
TestFun(c);
Debug.Log(c.a);
答案:
打印结果: 10
原因分析:
虽然 TestClass 是引用类型,但 C# 的方法参数默认是按值传递的。传递的是引用的拷贝,方法内部重新赋值不影响外部变量。
解决方案:
方式一:使用 ref 关键字
csharp
public void TestFun(ref TestClass c)
{
c = new TestClass();
c.a = 20;
}
TestFun(ref c);
方式二:直接修改传入对象的属性
csharp
public void TestFun(TestClass c)
{
c.a = 20;
}
33. 以下代码最终的打印结果是多少?为什么?
csharp
class TestClass
{
public int i = 0;
}
static void TestFun(TestClass obj)
{
obj = new TestClass();
obj.i = 10;
}
static void Main(string[] args)
{
TestClass t = new TestClass();
TestFun(t);
Console.WriteLine(t.i);
}
答案:
打印结果: 0
原因: 与上题相同,引用类型的按值传递,方法内部重新赋值不影响外部变量。
34. 基于上一题,我们要如何修改,才能让最后的结果为 10?请说出 3 种方式。
答案:
方式一:使用 ref 关键字
csharp
static void TestFun(ref TestClass obj)
{
obj = new TestClass();
obj.i = 10;
}
TestFun(ref t);
方式二:直接修改传入对象的属性
csharp
static void TestFun(TestClass obj)
{
obj.i = 10;
}
方式三:使用返回值
csharp
static TestClass TestFun()
{
TestClass obj = new TestClass();
obj.i = 10;
return obj;
}
t = TestFun();
35. 请问这段代码中,test 对象,test.i 以及 test.str 存储在栈上还是堆上?
csharp
class Test
{
public int i;
public string str;
}
Test test = new Test();
test.i = 42;
test.str = "Hello";
答案:
| 变量/对象 | 存储位置 | 说明 |
|---|---|---|
test 变量(引用) |
栈 | 局部变量,存储对象引用 |
Test 对象实例 |
堆 | new 创建的对象 |
test.i |
堆 | 对象的字段,在堆上 |
test.str 引用 |
堆 | 对象的字段 |
"Hello" 字符串 |
堆 | 字符串对象 |
类型与数值转换
36. 可空类型是什么?它的作用是什么?
答案:
可空类型 允许值类型(如 int、bool)可以被赋值为 null。
语法: T?,例如 int?、bool?
作用:
- 表示缺失值或未知状态
- 与数据库交互(NULL 值映射)
- 简化默认值处理:
int result = value ?? 0;
37. C# 中数值在调用 ToString() 方法时,若传入字符串参数,有哪些格式转换规则?(至少说出 3 点)
答案:
标准数字格式说明符:
| 格式符 | 示例 | 说明 |
|---|---|---|
C |
1234.ToString("C") → $1,234.57(en-US;结果随区域性变化,zh-CN 下为 ¥1,234.57) |
货币 |
D |
1234.ToString("D5") → 01234 |
十进制 |
F |
1234.567.ToString("F2") → 1234.57 |
定点数 |
P |
0.1234.ToString("P2") → 12.34% |
百分比 |
X |
255.ToString("X") → FF |
十六进制 |
38. decimal、float、double 的区别?
答案:
| 对比项 | float |
double |
decimal |
|---|---|---|---|
| 大小 | 32 位(4 字节) | 64 位(8 字节) | 128 位(16 字节) |
| 精度 | 约 7 位有效数字 | 约 15-17 位有效数字 | 28-29 位有效数字 |
| 表示方式 | 二进制浮点 | 二进制浮点 | 十进制浮点(以 10 为底) |
| 取值范围 | ±3.4×10³⁸ | ±1.8×10³⁰⁸ | ±7.9×10²⁸(范围小但精度高) |
| 默认类型 | 需要 f 后缀:1.5f |
1.5 默认是 double |
需要 m 后缀:1.5m |
核心区别:精度 vs 表示误差
float/double 用二进制表示小数,无法精确表示 0.1 这样的十进制小数(0.1 的二进制是无限循环小数),会产生舍入误差:
csharp
double d = 0.1 + 0.2;
Console.WriteLine(d); // 0.30000000000000004(不是 0.3!)
decimal m = 0.1m + 0.2m;
Console.WriteLine(m); // 0.3(decimal 精确表示十进制小数)
适用场景:
| 类型 | 场景 |
|---|---|
float |
游戏开发坐标、图形学、物理计算(精度要求低,内存省一半) |
double |
科学计算、一般数学运算 |
decimal |
金融、货币、计费(必须精确的十进制运算) |
注意:
decimal计算比float/double慢得多(软件十进制运算)- 判断浮点相等不要用
==,用差值比较:Math.Abs(a - b) < 1e-9 - Unity 中
Vector3用float,而金额结算用decimal
39. Convert、Parse、TryParse 的区别?
答案:
三者都用于类型转换,区别在于用途、失败行为和灵活性。
| 方法 | 适用 | 失败行为 | 支持格式 |
|---|---|---|---|
(T)x 强制转换 |
已知类型间的转换 | 抛异常 | 仅类型兼容 |
Convert.ChangeType |
任意类型转任意类型(常用 Convert.ToInt32 等) |
抛异常 | 宽松,支持 null→默认值 |
Type.Parse |
字符串 → 值类型 | 抛 FormatException |
严格 |
Type.TryParse |
字符串 → 值类型 | 返回 false,不抛异常 | 严格,out 返回结果 |
csharp
// Convert:宽松,支持 null、字符串、其他数字类型
int a = Convert.ToInt32("123"); // 123
int b = Convert.ToInt32(null); // 0(null 转默认值)
int c = Convert.ToInt32(3.14); // 3(四舍五入)
// Parse:严格,格式不对就抛异常
int d = int.Parse("123"); // 123
int e = int.Parse("abc"); // 抛 FormatException!
// TryParse:不抛异常,返回是否成功
if (int.TryParse("abc", out int f)) // false,f = 0
{
// 成功才进入
}
使用建议:
- 解析用户输入 / 不确定格式的字符串 →
TryParse(首选,避免异常开销) - 确定格式正确的字符串 →
Parse(代码更简洁) - 数字类型之间转换 (int ↔ double)→ 强制转换或
Convert - 处理
null→Convert(null 转默认值)或可空类型
性能: TryParse 不抛异常,性能远好于"Parse + catch"。高频解析场景务必用 TryParse。
csharp
// 反模式
try { int v = int.Parse(input); }
catch (FormatException) { /* 处理 */ }
// 推荐
if (int.TryParse(input, out int v)) { /* 使用 v */ }
泛型
40. 这句 C# 代码是否有问题,为什么?IList<IList<int>> list = new List<List<int>>();
答案:
有问题,编译错误。
原因: IList<T> 是不变 的,不能将 List<List<int>> 赋值给 IList<IList<int>>。
正确写法:
csharp
IList<IList<int>> list = new List<IList<int>>();
补充:
IEnumerable<out T>是协变 的,所以IEnumerable<List<int>>可以赋给IEnumerable<IEnumerable<int>>;但IList<T>是不变的,不支持这种赋值。
41. C# 泛型有哪些约束?分别代表什么?
答案:
泛型约束通过 where T : ... 限定类型参数必须满足的条件。
csharp
public class Repository<T> where T : class, IEntity, new()
{
// T 必须是引用类型 + 实现 IEntity + 有无参构造函数
}
常用约束一览:
| 约束 | 含义 |
|---|---|
where T : struct |
T 必须是值类型(不能为 null,Nullable<T> 除外) |
where T : class |
T 必须是引用类型(C# 8+ 可写 class? 表示可空引用类型) |
where T : notnull |
T 必须是不可空类型(C# 8+) |
where T : new() |
T 必须有公共无参构造函数(必须放在约束列表最后) |
where T : BaseClass |
T 必须继承自指定基类 |
where T : IInterface |
T 必须实现指定接口 |
where T : U |
T 必须是 U 或 U 的派生类型(U 为另一个类型参数) |
where T : unmanaged |
T 必须是非托管类型(C# 7.3+,如 int、byte*、不含引用字段的 struct) |
where T : Enum |
T 必须是枚举类型(C# 7.3+) |
where T : Delegate |
T 必须是委托类型(C# 7.3+) |
csharp
// 多约束 + 组合
public class Service<TEntity, TKey>
where TEntity : class, IEntity<TKey>, new()
where TKey : struct
{
public TEntity Create() => new TEntity();
}
// unmanaged 约束:配合指针/高性能场景
public static unsafe void CopyTo<T>(T* src, T* dst, int count) where T : unmanaged { }
约束的作用: 让泛型方法内可以安全地使用类型成员:
csharp
public static T Max<T>(T a, T b) where T : IComparable<T>
{
return a.CompareTo(b) >= 0 ? a : b; // 有 IComparable 约束才能调用 CompareTo
}
注意:
new()约束必须写在最后;约束只影响编译期静态检查,不影响运行期类型。
42. 什么是泛型的协变(Covariance)和逆变(Contravariance)?
答案:
背景: 泛型接口/委托默认是**不变(Invariant)**的,即 List<Dog> 不能赋给 List<Animal>。但通过 out(协变)和 in(逆变)可以放宽这个限制。
1. 协变(out): 泛型类型参数只能作为返回值 (输出),允许 子类 → 父类 的赋值。
csharp
IEnumerable<Dog> dogs = new List<Dog>();
IEnumerable<Animal> animals = dogs; // 协变:Dog 的序列可以当作 Animal 的序列
// 典型协变接口:IEnumerable<out T>、IEnumerator<out T>、Func<out TResult>
Func<Dog> getDog = () => new Dog();
Func<Animal> getAnimal = getDog; // 协变:返回 Dog 的函数可当作返回 Animal 的函数
2. 逆变(in): 泛型类型参数只能作为参数 (输入),允许 父类 → 子类 的赋值。
csharp
// 典型逆变接口:IComparer<in T>、Action<in T>、IEqualityComparer<in T>
IComparer<Animal> animalComparer = new AnimalComparer();
IComparer<Dog> dogComparer = animalComparer; // 逆变:能比较 Animal 的也能比较 Dog
Action<Animal> feedAnimal = a => a.Eat();
Action<Dog> feedDog = feedAnimal; // 逆变:接收 Animal 的函数也能接收 Dog
为什么 out 只能返回、in 只能参数? 因为如果用 out T 还允许写入 T,就会破坏类型安全(把 Cat 写进 Animal 序列里)。编译器强制此规则。
自定义协变/逆变接口:
csharp
public interface IProducer<out T> // 协变:只出
{
T Produce();
}
public interface IConsumer<in T> // 逆变:只进
{
void Consume(T item);
}
对比总结:
| 修饰 | 方向 | 类型参数位置 | 赋值方向 |
|---|---|---|---|
| 默认(不变) | - | 可进可出 | 仅同类型 |
out(协变) |
子→父 | 只能作返回值 | List<Dog> → IEnumerable<Animal> |
in(逆变) |
父→子 | 只能作参数 | IComparer<Animal> → IComparer<Dog> |
记忆技巧:out = 输出 = 协变(子转父) ,in = 输入 = 逆变(父转子) 。C# 中数组也有协变(
string[]可赋给object[],但有运行时类型检查风险,不建议依赖)。
43. (代码题)泛型父类中定义了一个静态成员属性,有两个子类都继承该泛型父类,请问打印结果是什么?为什么?
csharp
class Father<T>
{
public static int static_I = 0;
}
class Son1 : Father<Son1> { }
class Son2 : Father<Son2> { }
Son1.static_I = 20;
Son2.static_I = 30;
Console.WriteLine(Father<int>.static_I);
Console.WriteLine(Father<Son1>.static_I);
Console.WriteLine(Son1.static_I);
Console.WriteLine(Son2.static_I);
答案:
0
20
20
30
原因: 泛型类的静态字段是每个封闭类型独立的。
委托与事件
44. Action、Func、Predicate 的区别?
答案:
它们都是 .NET 预定义好的泛型委托,避免了手写委托类型。
| 委托 | 返回值 | 用途 |
|---|---|---|
Action |
无返回值(void) | 执行操作 |
Func<in T, out TResult> |
有返回值 | 计算并返回结果 |
Predicate<T> |
bool |
判断条件(本质等价于 Func<T, bool>) |
Comparison<T> |
int |
比较两个对象(用于排序,等价于 Func<T, T, int>) |
csharp
// Action:无返回值,0~16 个参数
Action sayHello = () => Console.WriteLine("Hello");
Action<int, int> showSum = (a, b) => Console.WriteLine(a + b);
// Func:最后一个泛型参数是返回值,前面是参数
Func<int, int, int> add = (a, b) => a + b;
int result = add(1, 2); // 3
Func<int, string> toString = n => n.ToString();
// Predicate:返回 bool,用于条件判断
Predicate<int> isEven = n => n % 2 == 0;
List<int> nums = new List<int> { 1, 2, 3, 4 };
int firstEven = nums.Find(isEven); // 2
// Comparison:用于排序比较
Comparison<int> desc = (x, y) => y.CompareTo(x);
nums.Sort(desc); // 降序
记忆技巧: Func 的泛型参数最后一个永远是返回值类型 ;Action 全部是参数类型。
45. 什么是多播委托(Multicast Delegate)?
答案:
多播委托 是指一个委托可以同时绑定多个方法 ,调用委托时按绑定顺序依次执行所有方法。所有委托(除单播场景)本质上都是 MulticastDelegate 的子类。
csharp
Action handler = () => Console.WriteLine("方法 1");
handler += () => Console.WriteLine("方法 2");
handler += () => Console.WriteLine("方法 3");
handler();
// 输出:
// 方法 1
// 方法 2
// 方法 3
内部机制: 委托内部维护一个调用列表(链表),+= 将方法追加到链尾,-= 从链中移除。Delegate.Combine / Delegate.Remove 是底层实现。
csharp
handler -= () => Console.WriteLine("方法 2"); // 注意:必须引用同一个方法才能移除
handler(); // 只输出 方法 1、方法 3
移除方法的坑: 用 Lambda 时,-= 必须传同一个 Lambda 实例才能匹配移除;建议先用变量保存再添加/移除:
csharp
Action method2 = () => Console.WriteLine("方法 2");
handler += method2;
handler -= method2; // 才能正确移除
返回值的处理: 多播委托的返回值取最后一个方法 的返回值(前面的被忽略);GetInvocationList() 可获取每个方法的委托,逐个调用以拿到各自的返回值。
csharp
Func<int> f = () => 1;
f += () => 2;
f += () => 3;
Console.WriteLine(f()); // 3,只有最后一个返回值
46. 事件(event)和委托(delegate)的区别?
答案:
事件是委托的封装。 编译器在声明 event 时生成:一个私有的委托字段 + add/remove 两个访问器方法(对应 +=/-=)。
csharp
public class Button
{
public event EventHandler Click; // 事件声明
public void OnClick() => Click?.Invoke(this, EventArgs.Empty);
}
| 对比项 | 委托字段 | 事件 |
|---|---|---|
| 外部操作 | 可以被 = 直接赋值、被外部调用 |
外部只能 +=/-=,不能赋值、不能调用 |
| 封装性 | 差,破坏封装 | 好,发布者独享触发权 |
| 定义位置 | 普通字段,任意位置 | 类内声明,编译器生成访问器 |
| 接口中 | 可以声明为接口成员 | 事件可以声明在接口中 |
| 触发 | 外部可调用 | 只有定义事件的类内部可以触发 |
csharp
// 委托字段:外部可以破坏
public class Bad
{
public Action OnDead; // 外部可直接 OnDead = null 清空所有订阅
}
// 事件:外部只能订阅/退订
public class Good
{
public event Action OnDead; // 外部无法清空或触发
}
为什么事件更安全: 委托字段对外暴露后,外部代码可以 = 覆盖掉已有订阅者(丢失订阅),甚至直接调用。事件机制保证只有定义它的类能触发,+=/-= 之外别无操作。
面试加分点:事件字段会在编译器生成的事件访问器方法中加锁(对静态事件使用锁对象,对实例事件使用
this的锁),保证多线程下+=/-=原子性。
47. Lambda 表达式的本质?闭包(Closure)是什么?
答案:
Lambda 本质是匿名方法(匿名委托), 编译器会将 Lambda 编译成一个私有方法(或实例方法),赋值给委托时实际传递的是方法指针。
csharp
Func<int, int> square = x => x * x;
// 编译后等价于:
private static int LambdaMethod(int x) => x * x;
Func<int, int> square = LambdaMethod;
闭包(Closure): 当 Lambda 捕获了外部作用域的变量 时,就形成了闭包。编译器会把被捕获的变量"提升"到一个编译器生成的额外类中,Lambda 和外部代码访问的是这个类的同一字段。
csharp
public Func<int> CreateCounter()
{
int count = 0; // 被捕获的变量
return () => ++count; // 闭包:count 被提升到隐藏类
}
var counter = CreateCounter();
Console.WriteLine(counter()); // 1
Console.WriteLine(counter()); // 2 // count 在调用间保持状态!
闭包的陷阱(循环捕获):
csharp
var actions = new List<Action>();
for (int i = 0; i < 3; i++)
{
actions.Add(() => Console.WriteLine(i)); // 捕获同一个 i
}
foreach (var a in actions) a();
// 输出:3 3 3(C# 5 之前)
// 输出:0 1 2(C# 5 起 for 循环的 i 每次迭代都是新变量)
补充:C# 5.0 起
for循环变量每次迭代都会创建新的变量实例,所以上面输出 0 1 2;而foreach从 C# 5 起也是同样行为。闭包会延长变量的生命周期(变量本应在栈上,被捕获后提升到堆),这也是闭包可能导致"内存泄漏"的原因(见第125题)。
C# 语言特性
48. 扩展方法(Extension Method)是什么?有什么限制?
答案:
扩展方法 允许在不修改原类型、不继承的情况下,为已有类型追加方法 。本质是静态类的静态方法 ,第一个参数用 this 关键字标记被扩展的类型。
csharp
public static class StringExtensions
{
public static bool IsNullOrEmpty(this string s) // this 标记扩展目标
=> string.IsNullOrEmpty(s);
public static string Repeat(this string s, int count)
=> string.Concat(Enumerable.Repeat(s, count));
}
// 使用
string name = "abc";
bool empty = name.IsNullOrEmpty(); // 像实例方法一样调用
string twice = name.Repeat(2); // "abcabc"
限制(重点):
- 必须定义在静态类 中,方法必须是静态的
- 第一个参数用
this修饰,且只能有一个this参数 - 不能访问被扩展类型的私有/受保护成员(只能使用公共成员)
- 不能重写已有实例方法(实例方法优先级高于扩展方法)
- 扩展方法不能用于属性、事件、运算符
调用优先级: 编译时优先匹配实例方法 ,只有找不到实例方法时才找扩展方法(且扩展方法需在 using 的命名空间内)。
csharp
string s = "hello";
s.Substring(1); // 调用实例方法(即使有同名扩展方法,也优先实例方法)
典型应用:LINQ 就是一组对
IEnumerable<T>的扩展方法;Unity 中也常用扩展方法简化组件获取(如GetComponentInParent包装)。
49. 索引器(Indexer)是什么?如何定义?
答案:
索引器 允许对象像数组一样用 [] 访问内部数据,本质上是特殊的属性 (编译为 get_Item / set_Item 方法)。
csharp
public class StringCollection
{
private List<string> _items = new List<string>();
public string this[int index] // this 关键字 + 参数
{
get => _items[index];
set => _items[index] = value;
}
public string this[string key] // 可以重载,用不同参数类型
{
get => _items.FirstOrDefault(x => x.StartsWith(key)) ?? "";
}
}
var collection = new StringCollection();
collection[0] = "Hello";
Console.WriteLine(collection[0]); // Hello
要点:
- 用
this关键字声明,后面跟参数列表 - 可以有多个参数(如
this[int row, int col],模拟二维访问) - 可以重载(不同参数类型/数量)
- 访问器可以是
get、set,也可以只读 - 不能是静态的;接口中可以声明索引器
csharp
// 二维索引器示例
public class Matrix
{
private int[,] _data;
public int this[int i, int j]
{
get => _data[i, j];
set => _data[i, j] = value;
}
}
50. 运算符重载的规则?哪些运算符不能重载?
答案:
运算符重载 允许自定义类型(struct/class)对运算符(+、-、== 等)的行为进行重新定义。
csharp
public struct Vector2
{
public float X, Y;
public Vector2(float x, float y) { X = x; Y = y; }
public static Vector2 operator +(Vector2 a, Vector2 b)
=> new Vector2(a.X + b.X, a.Y + b.Y);
public static bool operator ==(Vector2 a, Vector2 b)
=> a.X == b.X && a.Y == b.Y;
public static bool operator !=(Vector2 a, Vector2 b)
=> !(a == b);
}
var v1 = new Vector2(1, 2);
var v2 = new Vector2(3, 4);
var sum = v1 + v2; // 自定义加法
规则:
- 必须是公共静态 方法,用
operator关键字 - 至少有一个参数是所属类型(不能重载别人的运算符)
- 某些运算符必须成对重载 :
==和!=、<和>、<=和>= - 重载
==时必须同时重写Equals和GetHashCode(保持一致性约定) - 不能改变运算符的优先级和操作数个数
不能重载的运算符:
&&、||(可通过重载&、|+true/false间接实现)[](用索引器替代)、()(用转换方法替代)=(赋值)、+=等复合赋值(依赖+的重载自动生效)- 成员访问
.、条件访问?.、空合并??、?:、->、new、typeof、is、as等
注意: 重载运算符应保持语义直观,避免滥用导致代码难以理解。
51. 可选参数和命名参数是什么?
答案:
可选参数(Optional Parameters): 声明方法时给参数指定默认值,调用时可以省略该参数。
命名参数(Named Arguments): 调用时用 参数名: 值 的形式传参,可打乱参数顺序。
csharp
public void Log(string message,
string level = "Info", // 可选参数:带默认值
bool writeToFile = false)
{
// ...
}
// 调用方式
Log("启动"); // 省略可选参数
Log("错误", "Error"); // 按位置传参
Log("写入日志", writeToFile: true); // 命名参数:跳过 level
Log(writeToFile: true, message: "调试"); // 命名参数:任意顺序
规则:
- 可选参数必须排在参数列表最后(有默认值的参数之后不能再有无默认值参数)
- 可选参数的默认值必须是编译期常量 (
const值,如数字、字符串、枚举) - 命名参数在 C# 4.0 引入
- 调用 COM 接口或方法重载很多时,命名参数特别有用
- 可选参数和重载同时存在时,编译器按"最具体匹配"选择(一般先尝试完整签名)
注意: 可选参数默认值属于调用方编译时确定,修改默认值后不重新编译调用方不会生效。
LINQ
52. 什么是 LINQ?什么是延迟执行(Deferred Execution)?
答案:
LINQ(Language Integrated Query) 是 .NET 提供的一套查询能力,用类似 SQL 的语法或方法链对数据集合(IEnumerable<T>)进行查询、过滤、投影、聚合等操作。实现方式是一组扩展方法。
csharp
var result = students
.Where(s => s.Age > 18) // 过滤
.OrderByDescending(s => s.Score) // 排序
.Select(s => s.Name) // 投影
.Take(10); // 取前 10 个
延迟执行(Deferred Execution):
大多数 LINQ 方法(Where、Select、OrderBy、GroupBy 等)返回的是惰性查询 ,调用时不立即执行,只有遍历结果时才真正计算。每次迭代都重新执行查询。
csharp
var nums = new List<int> { 1, 2, 3, 4, 5 };
var query = nums.Where(n => n > 2); // 此时还没有执行!
nums.Add(6); // 修改数据源
foreach (var n in query) // 遍历时才执行,输出 3 4 5 6
Console.WriteLine(n);
立即执行的方法: ToList()、ToArray()、Count()、First()、Sum()、Max()、Min()、Any() 等会立刻遍历并执行查询。
csharp
var list = nums.Where(n => n > 2).ToList(); // 立即执行,结果固化
延迟执行的意义: 链式调用不会产生中间集合(性能好);但也意味着查询结果可能受后续数据源修改影响,且每次遍历都重新计算。
53. IEnumerable 和 IQueryable 的区别?
答案:
| 对比项 | IEnumerable<T> |
IQueryable<T> |
|---|---|---|
| 执行位置 | 内存中执行(LINQ to Objects) | 可延迟到数据源执行(如数据库翻译为 SQL) |
| 命名空间 | System.Collections.Generic |
System.Linq |
| 本质 | 内存集合的遍历 | 表达式树(Expression Tree) |
| 适用 | 操作内存集合 | 操作数据库(EF Core)、远程数据源 |
| 扩展方法 | 接收 Func<> |
接收 Expression<Func<>> |
核心区别: IQueryable<T> 把查询表达式保存为表达式树 ,由具体的 Provider(如 EF Core 的 QueryProvider)在真正执行时翻译成 SQL 发送到数据库,只把结果集返回内存。而 IEnumerable<T> 是先把数据全部加载到内存再筛选。
csharp
// EF Core 中:
IQueryable<User> query = db.Users.Where(u => u.Age > 18); // 未执行,构建表达式树
var users = query.ToList(); // 执行时才翻译为: SELECT * FROM Users WHERE Age > 18
// 错误用法:先 ToList 再 Where,会把整张表加载到内存
var all = db.Users.ToList(); // 全表加载
var filtered = all.Where(u => u.Age > 18).ToList(); // 内存过滤(性能差)
关键: 在 EF Core 等场景,不要先 ToList() 再筛选 ,应保持 IQueryable 链直到最终才物化。AsQueryable() 可以把内存集合包装为 IQueryable(此时仍然是内存执行)。
54. First、FirstOrDefault、Single、SingleOrDefault 的区别?
答案:
它们都是 LINQ 的立即执行元素获取方法,区别在于"找不到"和"找到多个"时的行为:
| 方法 | 有匹配元素 | 无匹配元素 | 多个匹配元素 |
|---|---|---|---|
First() |
返回第一个 | 抛 InvalidOperationException |
返回第一个(不报错) |
FirstOrDefault() |
返回第一个 | 返回默认值(引用类型 null / 值类型 0) | 返回第一个 |
Single() |
返回唯一元素 | 抛异常 | 抛 InvalidOperationException |
SingleOrDefault() |
返回唯一元素 | 返回默认值 | 抛异常 |
csharp
var nums = new List<int> { 1, 2, 3, 4 };
nums.First(); // 1
nums.First(n => n > 10); // 抛异常(无匹配)
nums.FirstOrDefault(n => n > 10); // 0
nums.Single(); // 抛异常(有 4 个元素,不止一个)
nums.Single(n => n > 3); // 4
nums.SingleOrDefault(n => n > 10); // 0
使用建议:
- 能确定存在结果、想报错提示 →
First()/Single() - 可能存在空结果、不想抛异常 →
FirstOrDefault()(最常见) - 期望结果唯一(如按 ID 查主键) →
Single()/SingleOrDefault(),多个时抛异常利于发现问题 - 性能 :
Single系列会遍历到第 2 个匹配元素确认唯一性;First找到第一个就停
补充:还有
Last()/LastOrDefault()(取最后一个);ElementAt()/ElementAtOrDefault()(按下标取,ElementAtOrDefault越界返回默认值而非抛异常)。
55. Select 和 SelectMany 的区别?
答案:
Select(投影) :对每个元素一对一映射,返回与源元素数量相同的集合(1 → 1)。SelectMany(扁平化投影) :对每个元素映射出一个子集合,然后**拼接(摊平)**所有子集合(1 → 多 → 摊平)。
csharp
var students = new[]
{
new Student { Name = "A", Courses = new[] { "数学", "语文" } },
new Student { Name = "B", Courses = new[] { "英语", "物理", "化学" } },
};
// Select:结果是"学生 -> 课程数组"的数组(嵌套)
var courseGroups = students.Select(s => s.Courses);
// 类型: IEnumerable<string[]>,长度 2
// SelectMany:把所有课程摊平成一个序列
var allCourses = students.SelectMany(s => s.Courses);
// 类型: IEnumerable<string>,长度 5(数学、语文、英语、物理、化学)
等价写法:
csharp
// SelectMany 等价于 Select + 逐层展开
var allCourses2 = students.Select(s => s.Courses).SelectMany(c => c);
经典场景:
- 一对多关系展平(如上例)
- 多级嵌套结构展开为单一序列
- 收集子集合中符合条件的元素:
csharp
// 收集所有学生的所有课程名(扁平化 + 过滤 + 投影)
var names = students.SelectMany(s => s.Courses).Where(c => c.Length == 2);
56. LINQ 有哪些常见性能陷阱?
答案:
1. 多次枚举导致查询重复执行(延迟执行的代价)
csharp
var query = nums.Where(n => n % 2 == 0);
int count = query.Count(); // 第 1 次遍历
foreach (var n in query) { } // 第 2 次遍历(重新执行整个查询!)
// 修复:先物化一次
var list = query.ToList(); // 只执行一次,之后复用 list
2. 在循环内使用 LINQ 查数据库
csharp
foreach (var userId in userIds)
{
var user = db.Users.FirstOrDefault(u => u.Id == userId); // N+1 查询!
}
// 修复:一次查出全部
var users = db.Users.Where(u => userIds.Contains(u.Id)).ToList();
3. OrderBy 后取单个却写成了 OrderBy(...).ToList()
只取 First()/Max() 时不需要 ToList() 物化整个结果。
4. 用 Count() > 0 判断是否存在
csharp
if (list.Count() > 0) { } // 会遍历全部元素
if (list.Any()) { } // 找到第一个就返回,更高效
5. 不必要的中间集合
csharp
var a = list.Where(...).ToList(); // 物化
var b = a.Select(...).ToList(); // 再次物化
// 若中间结果只被用一次,直接链式调用避免两次分配
6. 滥用 Single()(遍历到第 2 个元素)
确定唯一时用;否则 First() 更省。
7. 闭包/匿名类型装箱
csharp
list.Select(x => new { x.Id, x.Name }) // 匿名类型是引用类型,会分配
// 高频循环内注意 GC 压力
8. 数据库场景先 ToList() 再过滤(见第53题)
把过滤逻辑从数据库搬到内存,全表数据进入内存。
核心心法:延迟执行 (只执行一次)、尽早物化复用 、避免 N+1 、用 Any 不用 Count。
异步编程
57. C# 中异步是什么意思?请简单说说你了解的 C# 中实现异步的方式。
答案:
异步的含义:
异步编程允许程序在等待耗时操作时,不阻塞当前线程,继续执行其他任务。
C# 中实现异步的方式:
-
async/await模式(推荐)csharppublic async Task<string> DownloadDataAsync(string url) { using HttpClient client = new HttpClient(); string result = await client.GetStringAsync(url); return result; } -
Task/Task<T>与Task.Run- 表示异步操作,可以
await;Task.Run用于把 CPU 密集型任务放到线程池执行
- 表示异步操作,可以
-
ValueTask/ValueTask<T>- 用于性能敏感场景,避免异步操作已完成时的额外分配
-
线程与线程池(
Thread/ThreadPool)- 最底层的多线程方式,手动创建和控制线程
-
委托的异步调用(APM 模式)
BeginInvoke/EndInvoke,已过时,不推荐在新代码中使用
-
Parallel/ PLINQ- 用于 CPU 密集型的任务并行和数据并行
58. 游戏开发哪些地方会用到异步编程?(至少说出三种情况)
答案:
-
资源加载
- 加载场景、模型、纹理等,避免卡死主线程
-
网络请求
- 登录验证、排行榜数据获取等
-
文件 I/O
- 读写本地存档文件
-
长时间计算
- AI 寻路、地图生成等
59. Thread、ThreadPool、Task 的区别?(经典必考题)
答案:
| 对比项 | Thread |
ThreadPool |
Task |
|---|---|---|---|
| 抽象层次 | 最底层,直接对应操作系统线程 | 线程池,复用线程 | 基于线程池的异步操作抽象 |
| 创建成本 | 高(每次创建/销毁线程开销大) | 复用池中线程,避免频繁创建 | 复用线程池线程 |
| 控制力度 | 可设置优先级、IsBackground 等 | 无法精细控制单个线程 | 可控制取消、延续、等待、返回值 |
| 返回值 | 无 | 无 | Task<T> 支持返回值 |
| 异常处理 | 线程内自行处理 | 同上 | 可 await 捕获、可检查异常 |
| 适用场景 | 长期运行、需要独占线程 | 短小任务批量执行 | 现代异步编程首选 |
Thread(最底层):
csharp
var t = new Thread(() => Console.WriteLine("线程执行"));
t.IsBackground = true; // 后台线程,主线程退出时自动终止
t.Start();
t.Join(); // 等待线程结束
ThreadPool(线程池): 复用线程避免频繁创建销毁。缺点是无法获取返回值、无法精确控制。
csharp
ThreadPool.QueueUserWorkItem(_ => Console.WriteLine("池中线程执行"));
Task(推荐): 是线程池之上更高级的抽象,支持返回值、延续、取消、异常捕获:
csharp
Task<int> task = Task.Run(() =>
{
// CPU 密集型工作
return 42;
});
int result = await task; // 获取结果(异步等待)
Task.WhenAll(t1, t2); // 并行等待多个任务
Task.WhenAny(t1, t2); // 任一完成即可继续
核心区别总结:
Thread是"线程本身",Task是"一个异步操作",不一定独占线程(await 等待期间线程被释放回池中)Task.Run内部实际上也是把委托丢给ThreadPool执行- 现代 C# 开发几乎不用手动创建
Thread(除长期独占线程场景),统一用Task/async await
60. async/await 的底层原理是什么?(状态机)
答案:
核心:编译器将 async 方法改写为一个状态机类(IAsyncStateMachine)。 这不是运行时魔法,而是编译期语法糖。
csharp
public async Task<int> GetValueAsync()
{
await Task.Delay(100); // 第一次暂停点
return 42;
}
编译器生成的等效逻辑:
1. 创建状态机对象(含状态字段 _state、局部变量提升为字段、awaiter 字段)
2. 方法首次调用:创建状态机并启动,执行到第一个 await
3. 遇到 await:
- 判断任务是否已完成(IsCompleted)
- 未完成:给任务注册 continuation(回调),把 _state 设为下一状态,**立即返回**
→ 调用线程不被阻塞,回到调用方继续执行
- 已完成:继续同步执行(不走回调,避免额外开销)
4. 任务完成时,回调(MoveNext)被线程池/同步上下文调用,继续执行下一段代码
5. 执行完或抛异常:设置结果/异常到 Task,供 await 方获取
关键理解:
- await 不阻塞线程:等待期间,当前线程被释放(回到线程池或消息循环),用于执行其他工作;完成后再回来继续。这正是"异步"和"同步阻塞"的本质区别。
csharp
// 同步阻塞:线程卡住等待
Thread.Sleep(1000);
// 异步:不卡线程,释放控制权
await Task.Delay(1000);
-
await 之后可能不在原线程 :默认会通过
SynchronizationContext回到原上下文(UI 线程),没有同步上下文则回到线程池线程。 -
每个 await 就是一个状态切换点 ,状态机的
MoveNext()方法在每次恢复时从上次中断处继续。
面试加分点:能用"状态机 + 回调续延(continuation)"解释
async/await,并指出编译器生成的AsyncTaskMethodBuilder、MoveNext、_state字段。
61. ConfigureAwait(false) 的作用?什么时候必须用?
答案:
作用: 告诉 await 不捕获和恢复同步上下文(SynchronizationContext),避免异步代码试图回到原线程/上下文。
默认行为: await 默认会捕获当前的 SynchronizationContext(UI 线程、ASP.NET 请求上下文等),任务完成后尝试回到原上下文继续执行。
csharp
// UI 线程示例(WinForms/WPF):
private async void Button_Click(object sender, EventArgs e)
{
var data = await LoadDataAsync(); // 完成后自动回到 UI 线程
textBox.Text = data; // 所以可以直接操作 UI
}
加上 ConfigureAwait(false):
csharp
var data = await LoadDataAsync().ConfigureAwait(false);
// 不回到原上下文,直接在线程池线程继续执行
什么时候必须用/建议用:
- 类库代码(非 UI 代码)中------避免不必要的上下文切换和潜在的上下文捕获开销
- 无 UI 的服务器/后台代码------没必要回到原上下文
- 避免死锁的辅助手段 (配合不要
.Result阻塞)
什么时候不要用:
- UI 代码中 需要操作界面控件时(
ConfigureAwait(false)后无法直接操作 UI,需Invoke) - 依赖
HttpContext.Current、Thread.CurrentPrincipal等线程相关上下文的 ASP.NET 代码(现代 ASP.NET Core 已无此依赖,可用)
面试要点:
ConfigureAwait(false)是性能优化 手段(减少上下文切换、避免捕获开销),也是类库编写规范,不是万能死锁解药------根因还是同步阻塞异步任务。
62. async void 有什么问题?什么时候用?
答案:
问题:
- 异常无法被捕获 :
async void方法抛出的异常不能由调用方 try/catch 捕获 ,会直接抛到SynchronizationContext(或线程池),可能导致程序崩溃。
csharp
try
{
FireAndForget(); // async void 方法
}
catch (Exception ex)
{
// 捕获不到!异常不会到这里
}
async void FireAndForget()
{
await Task.Delay(100);
throw new Exception("boom"); // 异常逃逸到同步上下文,导致崩溃
}
-
无法等待 :调用方无法
await它,也无法感知其完成时机(返回void)。 -
测试困难:无法在单元测试中等待完成。
对比 async Task: 返回 Task 的方法,异常会封装到 Task 中,await 时能正常捕获。
csharp
async Task SafeMethod() { throw new Exception("可被捕获"); }
try { await SafeMethod(); }
catch (Exception ex) { /* 能捕获 */ }
什么时候必须用 async void:
- 事件处理器 (UI 事件、Unity 事件回调)------事件签名要求
void - 异步幂等且允许异常的"发后不理"场景(但仍建议内部 try/catch 兜底)
csharp
private async void Button_Click(object sender, EventArgs e) // 事件处理器,必须 void
{
try
{
await DoWorkAsync();
}
catch (Exception ex)
{
// 事件处理器内必须自己捕获异常!
}
}
结论:除了事件处理器,一律用
async Task/async Task<T>;async void的异常逃逸是 .NET 异步编程最经典的坑之一。
63. 为什么异步方法中不能使用 ref / out 参数?
答案:
原因: async 方法被编译器改写为状态机 ,参数会提升为状态机类的字段,跨 await 存在(生命周期被延长到整个异步操作期间)。
而 ref/out 参数是引用传递 ,需要指向调用方的变量。异步方法在 await 时会立即返回,此时调用方栈帧可能已经销毁,无法安全地保持对调用方变量的引用。
csharp
async Task<int> Bad(ref int x) // 编译错误 CS1988
{
await Task.Delay(100);
return x + 1;
}
编译器报错: CS1988: Cannot use ref, out, or in parameter 'x' inside an async method(异步方法内不能使用 ref/out/in 参数)。
变通方案:
- 通过返回值传递修改后的值:
csharp
async Task<(int result, int updated)> GetResultAsync(int input)
{
await Task.Delay(100);
return (input * 2, input + 1); // 用元组返回多个值
}
- 用类/对象包装要修改的字段,传递对象引用:
csharp
class Box { public int Value; }
async Task ModifyAsync(Box box)
{
await Task.Delay(100);
box.Value = 10; // 修改对象字段,调用方可见
}
补充:
in参数(只读引用)同样不允许。同步方法不受此限制,因为不存在跨 await 的生命周期问题。
64. 调用 .Result / .Wait() 为什么可能导致死锁?
答案:
死锁场景(UI 线程 / 有同步上下文的环境):
csharp
// UI 线程上的代码:
private void Button_Click(object sender, EventArgs e)
{
var result = GetDataAsync().Result; // 同步阻塞等待
}
死锁过程:
1. UI 线程调用 GetDataAsync(),执行到 await(内部)
2. await 默认捕获 UI 线程的 SynchronizationContext,注册"完成后回到 UI 线程"的回调
3. GetDataAsync() 内部任务在后台完成,尝试把 continuation 调度回 UI 线程
4. 但 UI 线程此时被 .Result 阻塞着(卡在等待任务完成)
5. 回调无法执行 → 任务永远标记不完 → .Result 永远等待 → 死锁
本质: 同步阻塞(.Result/.Wait())占用 了异步回调需要返回的线程/上下文,形成循环等待。
规避方案:
- 全程异步 :从入口到出口都
await,不要混合同步阻塞(推荐做法):
csharp
private async void Button_Click(object sender, EventArgs e)
{
var result = await GetDataAsync(); // 不阻塞 UI 线程
}
- 使用
ConfigureAwait(false):让库内部的任务不试图回到 UI 上下文(缓解但不根治):
csharp
// 类库内部:
async Task<string> GetDataAsync()
{
return await httpClient.GetStringAsync(url).ConfigureAwait(false);
}
- 后台线程上阻塞 (迫不得已时):把阻塞操作放到无同步上下文的线程(如
Task.Run内),但这是反模式,不推荐。
注意:控制台应用 默认没有
SynchronizationContext,.Result通常不会死锁,但依然会浪费线程、阻塞性能。核心原则:异步代码不要用同步方式等待。
65. CancellationToken 是什么?如何实现任务取消?
答案:
CancellationToken(取消令牌) 是 .NET 中实现协作式取消的机制:让运行中的任务安全、及时地响应取消请求。
三要素:
CancellationTokenSource:发起取消的"总开关",调用Cancel()触发取消CancellationToken:传递给任务的"令牌",任务通过它感知取消(IsCancellationRequested、ThrowIfCancellationRequested())- 任务方:定期检查令牌,响应取消
csharp
var cts = new CancellationTokenSource();
// 发起方:取消请求
cts.CancelAfter(TimeSpan.FromSeconds(5)); // 5 秒后自动取消
// 或 cts.Cancel();
// 任务方:传入令牌并定期检查
async Task DoWorkAsync(CancellationToken token)
{
for (int i = 0; i < 100; i++)
{
token.ThrowIfCancellationRequested(); // 已取消则抛 OperationCanceledException
await Task.Delay(100, token); // Delay 也支持令牌
Console.WriteLine($"处理第 {i} 项");
}
}
try
{
await DoWorkAsync(cts.Token);
}
catch (OperationCanceledException)
{
Console.WriteLine("任务已取消");
}
要点:
- 协作式 :取消不是强杀线程,而是请求任务在安全点自行退出
ThrowIfCancellationRequested()抛OperationCanceledException,await到取消的任务同样会抛此异常- 取消的任务状态为
TaskStatus.Canceled;通过Task.WhenAll等待多个任务时用catch OperationCanceledException Task.Run传入令牌:可避免任务未启动时排队等待(令牌已取消则直接不执行):
csharp
Task.Run(() => LongCpuWork(), cts.Token);
- 组合令牌:
CancellationTokenSource.CreateLinkedTokenSource(t1, t2)任一取消即取消
应用场景:用户点击"取消下载"、请求超时(配合
cts.CancelAfter)、服务关闭时优雅停止。
多线程与并发
66. 请简单描述死锁是什么意思?
答案:
死锁是指两个或多个线程互相等待对方持有的资源,无法继续执行的状态。
死锁发生的四个必要条件:
- 互斥条件
- 持有并等待
- 非抢占条件
- 循环等待条件
避免死锁的方法:
- 统一锁的获取顺序
- 使用超时机制
- 减少锁的嵌套
67. 死锁如何排查?有哪些实战预防手段?
答案:
排查方法:
- 看转储(Dump) :WinDbg / dotnet-dump 抓取进程转储,用
!clrstack查看各线程调用栈,找出"谁在等谁" - 并行栈(Visual Studio 调试):"调试 → 窗口 → 并行任务/并行堆栈",可视化线程阻塞关系
- 线程窗口 :查看每个线程的状态(
WaitSleepJoin状态说明在等待) dotnet-counters/ 性能分析器 :观察线程池饥饿(ThreadPool Queue Length持续上涨)- 日志埋点:在加锁前/后打日志,定位卡住的锁
手动模拟排查示例:
csharp
// 经典死锁:两个线程以相反顺序获取两把锁
object lockA = new object();
object lockB = new object();
// 线程 1
lock (lockA) { Thread.Sleep(100); lock (lockB) { /* ... */ } }
// 线程 2
lock (lockB) { Thread.Sleep(100); lock (lockA) { /* ... */ } }
// 线程 1 持有 A 等 B,线程 2 持有 B 等 A → 死锁
预防手段(面试重点):
- 统一加锁顺序:所有线程按相同顺序获取锁(先 A 后 B),破坏"循环等待"条件
- 使用超时 :
Monitor.TryEnter(lockObj, TimeSpan.FromSeconds(3))获取失败则放弃,不无限等待 - 减少锁的嵌套/粒度:尽量一次只持有一把锁
- 使用更高级的并发原语 :
SemaphoreSlim、ConcurrentDictionary、Channel等天然避免锁顺序问题 - 避免同步阻塞异步 :
.Result/.Wait()死锁(见第64题) - 锁的作用域最小化 :
lock内不要做耗时操作(I/O、网络) - 代码评审:新增锁代码时检查锁的获取顺序一致性
面试加分点:能背出死锁四必要条件(互斥、持有并等待、不可抢占、循环等待),并指出破坏循环等待 (统一顺序)和破坏持有并等待(超时)是最实用的两条预防路径。
68. lock 关键字的底层原理是什么?
答案:
lock 是 Monitor 类的语法糖。 lock(obj) 语句编译后等价于:
csharp
// lock (obj) { ... } 编译后:
bool lockTaken = false;
try
{
Monitor.Enter(obj, ref lockTaken); // 尝试获取锁(进入临界区)
// ... 临界区代码
}
finally
{
if (lockTaken)
Monitor.Exit(obj); // 释放锁
}
原理:
- 监视器(Monitor) :每个对象在堆上有一个同步块索引(Sync Block Index),用于记录锁状态和持锁线程
- 进入锁(
Monitor.Enter) :如果对象未被锁定则获取;已被其他线程持有则阻塞等待 - 可重入(Reentrant) :同一个线程可以多次
lock同一个对象(嵌套),内部有计数器,每次 Exit 减一,归零才真正释放 - 释放(
Monitor.Exit):退出临界区并唤醒等待队列中的下一个线程(FIFO 大致有序)
csharp
private readonly object _lockObj = new object(); // 推荐:专门的锁对象
public void Add(int value)
{
lock (_lockObj)
{
_count += value; // 临界区:同一时刻只有一个线程能进入
}
}
lock 的注意事项:
- 不要 lock 值类型(会装箱,每次生成新对象,锁失效)
- 不要 lock
this(外部可能也 lock 同一对象导致死锁/阻塞不可控) - 不要 lock 字符串(字符串可能被驻留共享,全局共享同一把锁)
- 用
private readonly object专用锁对象最安全
Monitor 额外能力: Monitor.TryEnter(obj, timeout) 支持带超时尝试获取锁(避免无限期阻塞);Monitor.Pulse / Monitor.Wait 实现条件等待(生产者-消费者的基础)。
69. Monitor、Mutex、Semaphore、SemaphoreSlim 的区别?
答案:
| 同步原语 | 作用 | 跨进程 | 可否限流 | 特点 |
|---|---|---|---|---|
Monitor / lock |
互斥访问临界区 | 否 | 否(独占) | 最常用,可重入,仅线程内 |
Mutex |
互斥访问 | 是 | 否(独占) | 跨进程互斥,可命名;获取成本高 |
Semaphore |
控制多个线程同时访问 | 是 | 是(信号量) | 计数信号量,跨进程版本 |
SemaphoreSlim |
同上 | 否 | 是 | 轻量版,支持 async 等待,推荐 |
1. Monitor(lock):线程内互斥,最常用。
2. Mutex(互斥体): 只允许一个线程/进程持有。
csharp
// 单实例应用:防止程序被启动多次
using (var mutex = new Mutex(true, "Global\\MyAppMutex", out bool createdNew))
{
if (!createdNew)
{
Console.WriteLine("程序已在运行!");
return;
}
// 运行主逻辑...
}
3. Semaphore(信号量): 允许 N 个线程同时进入(如数据库连接池最多 5 个连接)。
csharp
var semaphore = new SemaphoreSlim(5, 5); // 初始 5,最大 5
async Task AccessDbAsync()
{
await semaphore.WaitAsync(); // 异步等待(不阻塞线程)
try
{
// 最多 5 个并发进入
}
finally
{
semaphore.Release();
}
}
选择建议:
| 场景 | 选择 |
|---|---|
| 普通临界区互斥 | lock(Monitor) |
需要 await 等待的互斥/限流 |
SemaphoreSlim(WaitAsync) |
| 跨进程互斥(单实例) | Mutex |
| 跨进程限流 | Semaphore |
| 读多写少的共享数据 | ReaderWriterLockSlim(补充题 139) |
面试加分点:说出
SemaphoreSlim.WaitAsync()是异步友好的(lock/Monitor无法在await期间保持锁------lock不跨 await,这是 .NET 中 lock 和 async 不能混用的原因)。
70. volatile 关键字的作用?为什么不能替代 lock?
答案:
作用: volatile 告诉编译器和 CPU 对该字段的读写不要进行优化重排 ,保证每次读写都直接访问内存中的最新值(而不是寄存器/缓存中的副本)。
csharp
private volatile bool _running = true; // 该字段的读写不被缓存优化
public void Stop() => _running = false; // 其他线程立即可见
public void Run()
{
while (_running) { /* 工作 */ } // 不缓存 _running,能感知到变化
}
volatile 能解决什么:
- 可见性:多线程下字段的修改对其他线程立即可见(防止读取到过期的缓存值)
- 指令重排限制:禁止编译器/CPU 将该字段的读写与相邻指令乱序
为什么不能替代 lock:
- volatile 不保证原子性:
csharp
private volatile int _count;
public void Increment()
{
_count++; // 读-改-写三步,volatile 不管用!两个线程可能同时读到 5,都写回 6
}
++ 是"读取→加 1→写回"三个操作,volatile 只保证每个操作看到最新值,不保证这三个操作整体不被并发打断 。原子性需要 Interlocked 或 lock。
-
volatile 不能实现互斥:多个线程同时写一个 volatile 字段仍是竞态。
-
volatile 无法解决复合操作 :如"检查再修改"(
if (flag) flag = false)、队列操作等。
csharp
// 正确做法:计数用原子操作
private int _count;
public void Increment() => Interlocked.Increment(ref _count);
// 或者用 lock 保护复合操作
lock (_lockObj)
{
if (_flag) { _flag = false; /* 其他操作 */ }
}
适用场景: 简单的标志位(一个线程写、其他线程读),如"停止标志";复杂场景一律用 lock/Interlocked/线程安全集合。
71. Interlocked 是什么?有哪些常用原子操作?
答案:
Interlocked 提供对共享变量的原子操作(读-改-写一气呵成,由 CPU 指令级别保证),无需加锁即可安全并发修改。
常用方法:
| 方法 | 作用 |
|---|---|
Interlocked.Increment(ref x) |
原子加 1 |
Interlocked.Decrement(ref x) |
原子减 1 |
Interlocked.Add(ref x, n) |
原子加 n |
Interlocked.Exchange(ref x, value) |
原子赋值(同时返回旧值) |
Interlocked.CompareExchange(ref x, newVal, compareVal) |
比较后交换:仅当当前值 == compareVal 时赋值为 newVal |
Interlocked.Read(ref x) |
原子读取(64 位下必要) |
csharp
private int _counter;
public void Increment() => Interlocked.Increment(ref _counter);
// 无锁计数器
for (int i = 0; i < 1000; i++)
{
Task.Run(() => Interlocked.Increment(ref _counter));
}
Console.WriteLine(_counter); // 1000,不会丢失更新
经典应用:CAS(Compare-And-Swap)自旋锁:
csharp
private int _lockFlag; // 0 = 空闲,1 = 占用
public void Enter()
{
// 尝试把 0 换成 1;只有当前是 0 时才成功,返回旧值
while (Interlocked.CompareExchange(ref _lockFlag, 1, 0) != 0)
{
Thread.SpinWait(1); // 自旋等待(CPU 忙等,适合短临界区)
}
}
public void Exit() => Volatile.Write(ref _lockFlag, 0);
为什么用 Interlocked 而不是 lock:
| 对比 | Interlocked | lock |
|---|---|---|
| 开销 | 极低(一条 CPU 指令) | 较高(进入/退出监视器) |
| 阻塞 | 不阻塞(自旋或忙等) | 阻塞等待,上下文切换 |
| 适用 | 简单计数、标志、单变量更新 | 复杂临界区、多语句操作 |
面试加分点:说出
Interlocked底层依赖 CPU 的原子指令 (x86 的lock前缀 /cmpxchg),是无锁编程(Lock-Free)的基础原语;CompareExchange是实现自旋锁、无锁栈/队列的基础。
72. 线程安全的集合有哪些?各自特点?
答案:
System.Collections.Concurrent 命名空间提供了线程安全集合,内部采用分段锁或无锁 CAS 实现,多线程并发读写安全。
| 集合 | 对应普通集合 | 特点 |
|---|---|---|
ConcurrentDictionary<TKey, TValue> |
Dictionary |
最常用,多线程读写安全,支持原子 AddOrUpdate/GetOrAdd |
ConcurrentQueue<T> |
Queue |
线程安全队列,FIFO,Enqueue/TryDequeue |
ConcurrentStack<T> |
Stack |
线程安全栈,LIFO,Push/TryPop |
ConcurrentBag<T> |
List |
无序集合,适合生产者-消费者(每线程本地缓存,高吞吐) |
BlockingCollection<T> |
- | 生产者-消费者阻塞集合,内置"满了阻塞写、空了阻塞读" |
ConcurrentBag / Partitioner |
- | 并行数据分块 |
使用示例:
csharp
var dict = new ConcurrentDictionary<string, int>();
// 原子操作:不存在则添加,存在则更新
dict.AddOrUpdate("key", 1, (k, old) => old + 1);
var queue = new ConcurrentQueue<int>();
queue.Enqueue(1);
if (queue.TryDequeue(out int item)) { /* 消费 */ }
BlockingCollection(生产者-消费者经典):
csharp
var bc = new BlockingCollection<int>(boundedCapacity: 10); // 容量 10
// 生产者
Task.Run(() =>
{
for (int i = 0; i < 100; i++)
bc.Add(i); // 满了会阻塞
bc.CompleteAdding(); // 标记生产完成
});
// 消费者
Task.Run(() =>
{
foreach (var item in bc.GetConsumingEnumerable()) // 空了会阻塞,完成时自动结束
{
Process(item);
}
});
重要: 普通 List<T>、Dictionary<TKey,TValue> 不是线程安全的 ,多线程同时读写会引发异常或数据损坏(List 边读边写会抛"集合已修改"异常)。需要并发访问时优先选择上面的并发集合。
面试加分点:
ConcurrentDictionary底层用分段锁 (不同桶区间不同锁)提高并发度;ConcurrentQueue用无锁的链表 + CAS 实现。
73. 什么是生产者-消费者模式?如何实现?
答案:
模式定义: 生产者线程生产数据放入缓冲区,消费者线程从缓冲区取数据消费。缓冲区解耦了生产和消费的速度差异(生产者快、消费者慢时缓冲积压,反之则等待)。
核心需求:
- 缓冲区线程安全(多生产者/多消费者同时访问)
- 缓冲区满时生产者阻塞等待
- 缓冲区空时消费者阻塞等待
- 生产完成时通知消费者结束
实现方式一:BlockingCollection(推荐,最简洁)
csharp
var queue = new BlockingCollection<string>(boundedCapacity: 100);
// 生产者
var producer = Task.Run(() =>
{
foreach (var msg in GenerateMessages())
{
queue.Add(msg); // 满了自动阻塞
}
queue.CompleteAdding(); // 通知消费结束
});
// 消费者
var consumer = Task.Run(() =>
{
foreach (var msg in queue.GetConsumingEnumerable())
{
Process(msg); // 空了自动阻塞等待
}
});
await Task.WhenAll(producer, consumer);
实现方式二:Channel(.NET Core 3.0+,高性能推荐)
csharp
var channel = Channel.CreateUnbounded<string>();
var producer = Task.Run(async () =>
{
for (int i = 0; i < 100; i++)
await channel.Writer.WriteAsync($"消息 {i}");
channel.Writer.Complete();
});
var consumer = Task.Run(async () =>
{
await foreach (var msg in channel.Reader.ReadAllAsync())
{
Process(msg);
}
});
实现方式三:手写(Monitor.Wait/Pulse 条件同步) ------ 面试展示原理:
csharp
public class BoundedBuffer<T>
{
private readonly Queue<T> _queue = new Queue<T>();
private readonly int _capacity;
private readonly object _lock = new object();
public BoundedBuffer(int capacity) => _capacity = capacity;
public void Produce(T item)
{
lock (_lock)
{
while (_queue.Count >= _capacity)
Monitor.Wait(_lock); // 满了:阻塞并释放锁,等待消费者唤醒
_queue.Enqueue(item);
Monitor.PulseAll(_lock); // 唤醒等待的消费者
}
}
public T Consume()
{
lock (_lock)
{
while (_queue.Count == 0)
Monitor.Wait(_lock); // 空了:阻塞等待生产者唤醒
T item = _queue.Dequeue();
Monitor.PulseAll(_lock); // 唤醒等待的生产者
return item;
}
}
}
面试要点:能讲出缓冲区的三个核心条件 (满阻塞/空阻塞/完成通知),并给出
BlockingCollection或Channel的现代实现;手写版用Monitor.Wait/Pulse展示原理是加分项。
74. 什么是竞态条件(Race Condition)?如何避免?
答案:
竞态条件 是指多个线程并发访问共享资源 ,而最终结果依赖于线程执行的时序(谁先谁后),导致结果不可预测、可能是错误的情况。
csharp
private int _balance = 100;
public void Withdraw(int amount) // 两个线程同时调用
{
if (_balance >= amount) // 两个线程都通过了检查
{
// 这里还没执行,另一个线程可能已经扣款了
_balance -= amount; // 余额可能变负数!
}
}
经典问题演示(计数器丢失更新):
csharp
private int _count = 0;
// 两个线程同时执行 _count++(读取→加1→写回)
// 两个线程都读到 5 → 都写回 6 → 少了一次递增(丢失更新)
竞态条件的本质: "检查-再操作"(check-then-act)或"读-改-写"(read-modify-write)这类复合操作被并发打断。
避免竞态的方法:
| 方案 | 适用场景 |
|---|---|
lock / Monitor |
保护复合操作临界区 |
Interlocked |
简单的计数、加减、CAS |
线程安全集合(ConcurrentDictionary 等) |
集合并发访问 |
| 不可变对象 / 局部变量 | 从根本上避免共享 |
| 原子操作 / CAS 无锁算法 | 高性能场景 |
| 减少共享(每线程私有数据) | 设计层面规避 |
csharp
// 修复示例
private readonly object _lockObj = new object();
public void Withdraw(int amount)
{
lock (_lockObj)
{
if (_balance >= amount)
_balance -= amount;
}
}
面试要点:能描述竞态的本质(复合操作被打断)、举出丢失更新的例子,并给出 lock / Interlocked / 线程安全集合等至少两种修复方案。
异常处理
75. throw 和 throw ex 的区别?如何自定义异常?异常过滤器(when)?
答案:
1. throw vs throw ex(重抛异常的堆栈差异)
| 写法 | 效果 |
|---|---|
throw; |
保留原始堆栈信息(异常原始抛出位置) |
throw ex; |
重置堆栈到当前 catch 位置,丢失原始抛出点(反模式) |
csharp
try
{
DoSomething();
}
catch (Exception ex)
{
Log(ex.Message);
throw; // ✅ 保留原始堆栈
// throw ex; // ❌ 堆栈被重置,排查问题困难
}
2. 自定义异常:
继承 Exception,提供标准构造函数(微软规范要求 4 个构造函数):
csharp
[Serializable]
public class InsufficientBalanceException : Exception
{
public InsufficientBalanceException() { }
public InsufficientBalanceException(string message) : base(message) { }
public InsufficientBalanceException(string message, Exception inner) : base(message, inner) { }
}
3. 异常过滤器 when(C# 6): 只有满足条件才进入 catch 块:
csharp
try
{
SaveData();
}
catch (SqlException ex) when (ex.Number == 1205) // 仅处理死锁错误
{
Retry();
}
catch (SqlException ex) when (ex.Number == 2627) // 仅处理主键冲突
{
HandleDuplicate();
}
catch (Exception ex)
{
Log(ex);
}
when 的好处: 条件不满足时继续向上传播,且不会丢失原始堆栈;也支持在 catch 中记录更精确的错误。
4. 异常处理最佳实践:
- 只捕获能处理的异常;不能处理就让它向上抛
- 空 catch(
catch {})是反模式------吞掉异常且不留痕迹 - 使用
finally释放资源(或using) - 避免用异常控制正常流程(性能差且可读性差)
设计模式
76. 单例模式中为什么要加锁?是否是必须的?
答案:
加锁的目的: 防止多线程同时创建多个实例。
是否必须:
- 单线程环境:不需要
- 多线程且需要延迟初始化:需要
- 使用静态初始化:不需要(CLR 保证线程安全)
推荐实现:
csharp
// 静态初始化(无需加锁)
public sealed class Singleton
{
public static readonly Singleton Instance = new Singleton();
private Singleton() { }
}
// Lazy<T>(线程安全)
public sealed class Singleton
{
private static readonly Lazy<Singleton> _lazy = new Lazy<Singleton>(() => new Singleton());
public static Singleton Instance => _lazy.Value;
private Singleton() { }
}
77. 深拷贝和浅拷贝的区别?他们分别有哪些场景使用?
答案:
浅拷贝: 只复制引用,不复制引用对象本身。
深拷贝: 递归复制对象及其所有引用对象。
使用场景:
| 场景 | 推荐方式 |
|---|---|
| 对象状态快照 | 深拷贝 |
| 缓存只读对象 | 浅拷贝 |
| 跨线程传递 | 深拷贝 |
| 游戏存档 | 深拷贝 |
78. 对象池模式(Object Pool)是什么?游戏开发中如何实现?
答案:
对象池 是一种复用对象的模式:对象使用完毕后不销毁,放回池中,下次需要时直接从池中取用,避免频繁创建/销毁的开销。
为什么要用对象池:
- 创建/销毁对象的 CPU 开销(构造函数、初始化)
- GC 压力(反复 new 产生大量垃圾,触发 GC 卡顿)
- 游戏开发中子弹、敌人、特效等高频生成/销毁的对象尤其需要
通用实现:
csharp
public class ObjectPool<T> where T : class, new()
{
private readonly Stack<T> _pool = new Stack<T>();
private readonly object _lock = new object();
public T Get()
{
lock (_lock)
{
return _pool.Count > 0 ? _pool.Pop() : new T();
}
}
public void Return(T item)
{
lock (_lock)
{
_pool.Push(item);
}
}
}
Unity 中的对象池实现(配合 GameObject):
csharp
public class BulletPool : MonoBehaviour
{
public GameObject bulletPrefab;
private Queue<GameObject> _pool = new Queue<GameObject>();
public GameObject Get()
{
if (_pool.Count > 0)
{
var bullet = _pool.Dequeue();
bullet.SetActive(true); // 激活复用
return bullet;
}
return Instantiate(bulletPrefab); // 池空才新建
}
public void Return(GameObject bullet)
{
bullet.SetActive(false); // 隐藏,不销毁
_pool.Enqueue(bullet);
}
}
使用注意:
- 归还对象时要重置状态(位置、速度、血量等)
- 对象池不适合所有场景------初始化成本低的对象直接 new 即可
- 注意池中对象的"假死"问题(引用仍存在,需正确停用)
- .NET 内置的
ArrayPool<T>、ObjectPool<T>(Microsoft.Extensions.ObjectPool)是官方对象池实现
面试加分点: 说出对象池解决了"分配 → GC → 卡顿 "的链路问题;Unity 中结合 Queue + SetActive + 状态重置是标准做法。
79. 工厂模式(Factory Pattern)是什么?简单工厂、工厂方法、抽象工厂的区别?
答案:
核心思想: 把对象的创建逻辑从使用方中抽离,由工厂统一负责,客户端只依赖抽象接口,不关心具体类型。
1. 简单工厂(Simple Factory): 一个静态方法根据参数创建不同对象。
csharp
public interface IEnemy { void Attack(); }
public class Slime : IEnemy { public void Attack() => Console.WriteLine("史莱姆攻击"); }
public class Goblin : IEnemy { public void Attack() => Console.WriteLine("哥布林攻击"); }
public static class EnemyFactory // 简单工厂
{
public static IEnemy Create(string type) => type switch
{
"slime" => new Slime(),
"goblin" => new Goblin(),
_ => throw new ArgumentException($"未知类型: {type}")
};
}
2. 工厂方法(Factory Method): 定义一个创建对象的抽象方法,由子类决定实例化哪个具体类。
csharp
public abstract class EnemySpawner
{
public abstract IEnemy CreateEnemy(); // 工厂方法,子类重写
public void SpawnAndAttack() // 模板方法:使用产品
{
var enemy = CreateEnemy();
enemy.Attack();
}
}
public class SlimeSpawner : EnemySpawner
{
public override IEnemy CreateEnemy() => new Slime();
}
3. 抽象工厂(Abstract Factory): 创建一族相关对象,保证产品间的配套一致。
csharp
public interface IUIFactory // 抽象工厂
{
IButton CreateButton();
IPanel CreatePanel();
}
public class DarkUIFactory : IUIFactory // 深色主题工厂
{
public IButton CreateButton() => new DarkButton();
public IPanel CreatePanel() => new DarkPanel();
}
区别总结:
| 模式 | 创建方式 | 变化点 |
|---|---|---|
| 简单工厂 | 一个静态方法 + switch | 新增类型要改工厂(违反开闭原则) |
| 工厂方法 | 抽象方法 + 子类重写 | 新增类型要新增子类(符合开闭原则) |
| 抽象工厂 | 一组创建方法 | 新增产品族要扩展接口 |
适用场景: 对象创建逻辑复杂(参数多、有默认配置)、需要统一管理创建过程、希望客户端依赖抽象而非具体类。
80. 观察者模式(Observer Pattern)是什么?C# 中如何实现?
答案:
观察者模式 定义了一种一对多 的依赖关系:当被观察对象(Subject)状态变化时,自动通知所有依赖它的观察者(Observer)。C# 中事件(event)和委托就是观察者模式的语言级实现。
csharp
// 被观察者:发布事件
public class HealthSystem
{
public event Action<int> HealthChanged; // 观察者通过 += 订阅
private int _health;
public int Health
{
get => _health;
set
{
_health = value;
HealthChanged?.Invoke(_health); // 状态变化 → 通知所有订阅者
}
}
}
// 观察者:订阅
var health = new HealthSystem();
health.HealthChanged += h => Console.WriteLine($"血条更新: {h}"); // UI 更新
health.HealthChanged += h => Console.WriteLine($"血量变化: {h}"); // 音效/特效
health.Health = 50; // 触发所有订阅者
手动实现(不依赖事件):
csharp
public interface IObserver
{
void OnNotify(string message);
}
public class Subject
{
private List<IObserver> _observers = new List<IObserver>();
public void Attach(IObserver observer) => _observers.Add(observer);
public void Detach(IObserver observer) => _observers.Remove(observer);
public void Notify(string message)
{
foreach (var observer in _observers)
observer.OnNotify(message);
}
}
优缺点:
| 优点 | 缺点 |
|---|---|
| 解耦发布者和订阅者 | 订阅者无法感知彼此 |
| 支持广播通知 | 通知顺序不可控 |
| 易于扩展(新增观察者无需改发布者) | 事件未退订会造成内存泄漏 |
注意(内存泄漏坑): 发布者是长生命周期对象(如单例),订阅者是短生命周期对象(如场景中的 UI),订阅者销毁时必须 -= 退订,否则发布者一直持有订阅者引用,导致其无法被 GC 回收(呼应第125题闭包泄漏)。
csharp
void OnDestroy()
{
health.HealthChanged -= OnHealthChanged; // 必须退订!
}
面试加分点:指出 C# 的
event本身就是观察者模式的实现;Unity 的UnityEvent、UI 按钮的onClick也是观察者模式的应用。
81. 策略模式(Strategy Pattern)是什么?适用场景?
答案:
策略模式 把一组可互换的算法 封装成独立的策略类,运行时可以动态切换算法,避免大量 if/else。
结构: 策略接口 + 多个具体策略 + 上下文(持有策略引用)。
csharp
// 策略接口
public interface IAttackStrategy
{
void Execute(Enemy enemy);
}
// 具体策略
public class MeleeAttack : IAttackStrategy
{
public void Execute(Enemy enemy) => Console.WriteLine("近战攻击");
}
public class RangeAttack : IAttackStrategy
{
public void Execute(Enemy enemy) => Console.WriteLine("远程攻击");
}
// 上下文:持有策略并可以切换
public class Enemy
{
private IAttackStrategy _strategy;
public void SetStrategy(IAttackStrategy strategy) => _strategy = strategy;
public void Attack() => _strategy?.Execute(this);
}
// 使用:运行时切换行为
var enemy = new Enemy();
enemy.SetStrategy(new MeleeAttack());
enemy.Attack(); // 近战攻击
enemy.SetStrategy(new RangeAttack()); // 动态换策略
enemy.Attack(); // 远程攻击
和 if/else 对比:
csharp
// 反模式:硬编码分支,新增行为要改原代码
if (distance < 2) { /* 近战 */ }
else if (distance < 10) { /* 远程 */ }
else { /* 逃跑 */ }
// 策略模式:新增行为 = 新增一个策略类,原代码不动(开闭原则)
适用场景:
- 多个算法可互换(排序方式、寻路算法、伤害计算)
- 需要在运行时动态切换行为
- 消除大量条件分支
优点: 符合开闭原则、消除条件分支、算法独立测试。缺点: 增加类数量、客户端需了解策略差异。
面试加分点:策略模式常与工厂模式 配合(工厂负责创建策略对象),与状态模式的区别在于------策略是"客户主动选择算法",状态是"对象根据内部状态自动切换行为"。
82. 装饰器模式(Decorator Pattern)是什么?
答案:
装饰器模式 在不修改原类的前提下,动态地给对象添加职责(功能)。通过包装(组合)而非继承来扩展功能。
经典例子:给技能/装备加 Buff、给消息加功能。
csharp
// 组件接口
public interface IDamage
{
int CalculateDamage();
}
// 具体组件:基础攻击
public class BaseAttack : IDamage
{
public int CalculateDamage() => 10;
}
// 装饰器基类:持有被包装对象
public abstract class DamageDecorator : IDamage
{
protected IDamage _inner;
protected DamageDecorator(IDamage inner) => _inner = inner;
public abstract int CalculateDamage();
}
// 具体装饰器:暴击加成
public class CritDecorator : DamageDecorator
{
private readonly double _critMultiplier;
public CritDecorator(IDamage inner, double critMultiplier) : base(inner)
=> _critMultiplier = critMultiplier;
public override int CalculateDamage()
=> (int)(_inner.CalculateDamage() * _critMultiplier);
}
// 具体装饰器:火焰附加
public class FireDecorator : DamageDecorator
{
public FireDecorator(IDamage inner) : base(inner) { }
public override int CalculateDamage()
=> _inner.CalculateDamage() + 5;
}
// 使用:层层包装,组合出不同效果
IDamage damage = new BaseAttack();
damage = new CritDecorator(damage, 2.0); // 加暴击
damage = new FireDecorator(damage); // 加火焰
Console.WriteLine(damage.CalculateDamage()); // 10*2 + 5 = 25
装饰器 vs 继承:
| 对比 | 继承扩展 | 装饰器 |
|---|---|---|
| 组合方式 | 静态(编译期确定) | 动态(运行时任意组合) |
| 爆炸问题 | 组合爆炸(2 个特性 × 2 = 4 个子类) | 无(灵活组合) |
| 破坏封装 | 暴露父类细节 | 只通过接口 |
适用场景: 动态组合功能(Buff 系统、日志/缓存包装、流处理------FileStream 外层套 BufferedStream 就是装饰器)。C# 中 LINQ 的链式调用也体现了装饰思想。
面试加分点:
System.IO.Stream家族是装饰器模式的经典应用(BufferedStream包装FileStream);装饰器应保持接口不变,仅增强行为。
83. 命令模式(Command Pattern)是什么?在游戏开发中的应用?
答案:
命令模式 把"请求"封装为独立的对象 (命令对象),从而支持参数化请求、排队请求、撤销/重做。命令对象包含执行所需的所有信息。
csharp
// 命令接口
public interface ICommand
{
void Execute();
void Undo(); // 支持撤销
}
// 具体命令:移动
public class MoveCommand : ICommand
{
private readonly Transform _transform;
private readonly Vector3 _direction;
private Vector3 _previousPosition;
public MoveCommand(Transform transform, Vector3 direction)
{
_transform = transform;
_direction = direction;
}
public void Execute()
{
_previousPosition = _transform.position;
_transform.position += _direction;
}
public void Undo() => _transform.position = _previousPosition;
}
// 调用者:维护命令历史,支持撤销/重做
public class CommandManager
{
private readonly Stack<ICommand> _history = new Stack<ICommand>();
public void Execute(ICommand command)
{
command.Execute();
_history.Push(command);
}
public void Undo()
{
if (_history.Count > 0)
_history.Pop().Undo();
}
}
// 使用:输入系统把按键映射为命令
var manager = new CommandManager();
manager.Execute(new MoveCommand(player, Vector3.right)); // 按 D
manager.Undo(); // 撤销(按 Ctrl+Z)
游戏开发中的应用:
- 输入系统:按键 → 命令对象,实现键位自定义(重新绑定)
- 撤销/重做:编辑器的操作历史(Undo/Redo)
- AI 行为:AI 决策生成命令队列
- 回放系统:记录命令序列实现回放
- 技能系统:技能封装为命令,支持释放、取消
优点: 解耦请求发起者和执行者、支持撤销/重做/排队/日志。缺点: 命令类数量多。
面试加分点:说出命令模式三大扩展能力------撤销、队列、宏命令 (组合多个命令成一个复合命令);Unity 的
Input系统结合命令模式实现键位重绑定是高频考点。
84. 状态模式(State Pattern)是什么?和策略模式的区别?
答案:
状态模式 允许对象在内部状态改变时改变其行为,看起来像"换了类"。将每个状态封装成独立类,状态切换由状态对象自己触发。
csharp
// 状态接口
public interface IPlayerState
{
void HandleInput(Player player, string input);
}
// 具体状态:站立
public class IdleState : IPlayerState
{
public void HandleInput(Player player, string input)
{
if (input == "attack") player.SetState(new AttackState()); // 状态自行切换
else if (input == "jump") player.SetState(new JumpState());
}
}
// 具体状态:攻击
public class AttackState : IPlayerState
{
public void HandleInput(Player player, string input)
{
Console.WriteLine("攻击中,不能转向");
if (input == "release") player.SetState(new IdleState());
}
}
// 上下文:持有当前状态
public class Player
{
private IPlayerState _state = new IdleState();
public void SetState(IPlayerState state)
{
_state = state;
Console.WriteLine($"切换到 {state.GetType().Name}");
}
public void HandleInput(string input) => _state.HandleInput(this, input);
}
// 使用
var player = new Player();
player.HandleInput("attack"); // 切换到 AttackState
player.HandleInput("jump"); // 攻击中无法跳跃
player.HandleInput("release"); // 回到 IdleState
状态模式 vs 策略模式(高频考点):
| 对比项 | 状态模式 | 策略模式 |
|---|---|---|
| 目的 | 管理状态转换(状态机) | 封装可互换算法 |
| 切换发起 | 状态对象自己触发切换(自动) | 客户端主动选择策略(手动) |
| 关注点 | 对象在不同状态下行为不同 | 同一行为的不同实现方式 |
| 状态/策略感知 | 状态彼此感知(知道下一个状态) | 策略互相独立、无感知 |
游戏开发应用: 角色状态机(待机/移动/攻击/受击/死亡)、AI 状态、UI 界面状态(菜单/加载/游戏中)。配合状态转换表或状态机库(如 Animator 状态机)使用。
面试加分点:状态模式的核心是"状态转移的逻辑放在状态类内部",避免用大量 if/else 判断状态;条件复杂时可引入状态转换表或 FSM 框架。
85. 什么是 SOLID 原则?分别代表什么?
答案:
SOLID 是面向对象设计的五大原则缩写,帮助构建易维护、易扩展、低耦合的代码。
S - 单一职责原则(Single Responsibility)
一个类只有一个引起它变化的原因(只做一件事)。
csharp
// ❌ 违反:一个类既管数据又管存取还管展示
public class Player
{
public int Health;
public void SaveToFile() { }
public void DrawUI() { }
}
// ✅ 职责分离
public class Player { public int Health; } // 数据
public class SaveSystem { void Save(Player p) { } } // 存取
public class PlayerUI { void Draw(Player p) { } } // 展示
O - 开闭原则(Open/Closed)
对扩展开放 ,对修改关闭------新增功能应通过扩展实现,而不是修改已有代码。
csharp
// ✅ 用接口+新类扩展,而不是改原方法加 switch
public interface IDamageCalculator { int Calc(int baseDamage); }
public class CritCalculator : IDamageCalculator { ... } // 新增暴击=新增类
L - 里氏替换原则(Liskov Substitution)
子类必须能替换父类而不影响程序正确性(子类不能违背父类契约)。
csharp
// ❌ 违反:子类抛异常/削弱父类能力
public class Bird { public virtual void Fly() { } }
public class Penguin : Bird
{
public override void Fly() => throw new NotSupportedException(); // 企鹅不会飞!
}
// ✅ 应把 Fly 抽象到会飞的接口 IFlyable,而非所有 Bird 都 Fly
I - 接口隔离原则(Interface Segregation)
客户端不应依赖它不用的接口方法------接口要小而专,不要设计臃肿的"万能接口"。
csharp
// ❌ 胖接口:不实现攻击的类也要实现 Attack
public interface IUnit { void Move(); void Attack(); void Heal(); }
// ✅ 拆分为细粒度接口
public interface IMovable { void Move(); }
public interface IAttacker { void Attack(); }
public interface IHealer { void Heal(); }
D - 依赖倒置原则(Dependency Inversion)
高层模块不应依赖低层模块 ,两者都应依赖抽象;抽象不应依赖细节,细节应依赖抽象。
csharp
// ❌ 高层直接依赖具体实现,换实现要改高层代码
public class GameManager
{
private FileLogger _logger = new FileLogger(); // 依赖具体类
}
// ✅ 依赖抽象(接口/抽象类),运行时注入具体实现
public class GameManager
{
private readonly ILogger _logger; // 依赖抽象
public GameManager(ILogger logger) => _logger = logger; // 构造注入
}
实践意义: SOLID 是设计模式的理论基础(开闭→策略/装饰器;依赖倒置→工厂/依赖注入;单一职责→模块化),也是面试中"为什么这么设计"的万能回答框架。
平台与运行时对比
86. C# 和 C++ 在内存管理上有什么区别?(至少说出 2 点区别)
答案:
| 方面 | C# | C++ |
|---|---|---|
| 内存分配 | 托管堆,GC 自动管理 | 手动管理,new/delete |
| 内存释放 | 自动垃圾回收 | 手动释放 |
| 指针操作 | 一般不使用(unsafe除外) | 核心概念 |
| 内存布局 | 有对象头开销 | 更紧凑 |
87. C# 和 Lua 的共同点和区别?
答案:
共同点:
- 高级语言,自动内存管理
- 跨平台
区别:
| 方面 | C# | Lua |
|---|---|---|
| 类型系统 | 静态强类型 | 动态弱类型 |
| 面向对象 | 原生支持(class) | 没有 class 语法,通过 metatable 模拟 |
| 性能 | 高(JIT编译) | 一般(解释执行;LuaJIT 通过 JIT 编译,性能接近 C) |
| 热更新(Unity 语境) | 需要第三方方案(如 HybridCLR、ILRuntime) | 原生支持 |
| 多线程 | 原生支持 | 语言层面无多线程,依赖宿主环境(通常配合协程) |
88. 为什么 C++ 的内存管理方面的性能表现往往被认为优于 C#?(至少说出 2 点原因)
答案:
-
没有垃圾回收开销
- C++ 完全手动管理,无 GC 暂停
- C# GC 可能导致卡顿
-
内存布局更紧凑
- C++ 无对象头开销
- 缓存命中率更高
-
分配速度快
- C++ 栈分配几乎零开销
89. 那为什么 C++ 从执行性能上往往也被认为优于 C# 呢?
答案:
-
编译方式不同
- C++:AOT 编译为机器码,深度优化
- C#:JIT 编译,首次有开销
-
对硬件控制更强
- C++ 可直接使用 SIMD、内联汇编
-
泛型实现差异
- C++ 模板编译时实例化
- C# 泛型:值类型实参在 JIT 时特化生成专用代码,引用类型实参共享同一份代码
90. C# 中的 CLR 是什么?
答案:
CLR(Common Language Runtime) 是 .NET 的核心运行时,负责:
- 代码执行(JIT 编译)
- 内存管理(GC)
- 类型安全验证
- 异常处理
- 跨语言互操作
存档与序列化
91. 使用 C# 制作游戏存档功能,请问有几种做法?(至少说出三种)
答案:
-
JSON 序列化
csharpstring json = JsonConvert.SerializeObject(data); File.WriteAllText(savePath, json); -
二进制序列化
- 使用
BinaryWriter/BinaryReader
- 使用
-
XML 序列化
- 使用
XmlSerializer
- 使用
-
SQLite 数据库
- 适合复杂数据
-
PlayerPrefs(Unity)
- 适合简单数据
92. 在制作游戏存档功能时,C# 中反射主要可以发挥出哪些作用?(至少说出三点)
答案:
-
自动序列化/反序列化
- 遍历所有字段自动保存
-
版本兼容处理
- 处理旧版本存档字段映射
-
编辑器工具支持
- 动态显示和编辑存档数据
-
动态类型处理
- 多态类型反序列化
协程与初始化
93. 协程(Coroutine)的原理是什么?协程和线程的区别?(Unity)
答案:
Unity 协程 是一种协作式伪异步 机制:让代码分段执行,在 yield 处暂停,满足条件后从暂停处继续。它不创建新线程,仍然运行在主线程上。
csharp
IEnumerator Start()
{
Debug.Log("开始");
yield return new WaitForSeconds(1f); // 暂停 1 秒
Debug.Log("1 秒后继续");
yield return null; // 暂停一帧
Debug.Log("下一帧继续");
}
原理: IEnumerator + yield return 被编译器改写为状态机 (和 async/await 类似)。Unity 的协程调度器在每一帧(或延迟时间到)时调用迭代器的 MoveNext(),让代码从上次 yield 处继续执行。
支持的 yield 类型:
| yield 类型 | 暂停条件 |
|---|---|
yield return null |
暂停一帧,下一帧继续 |
yield return new WaitForSeconds(1f) |
暂停指定秒数 |
yield return new WaitForEndOfFrame() |
暂停到本帧渲染结束 |
yield return new WaitUntil(() => cond) |
条件满足时继续 |
yield return new WaitWhile(() => cond) |
条件不满足时继续 |
yield return StartCoroutine(other) |
等待另一个协程结束 |
yield return new WWW(url) / UnityWebRequest |
等待网络请求完成 |
yield break |
直接结束协程 |
协程 vs 线程:
| 对比项 | 协程 | 线程 |
|---|---|---|
| 运行位置 | 主线程上 | 独立线程 |
| 并行 | 不是真正的并行(同一时刻只执行一段) | 真正的并行执行 |
| 开销 | 极低(状态机,无上下文切换) | 高(线程创建/切换) |
| 访问 Unity API | 安全(就在主线程) | 不能直接访问(需主线程调度) |
| 用途 | 延时、时序控制、分段执行 | 真正耗时的并行计算 |
注意:
- 协程不能替代线程做 CPU 密集计算(阻塞在协程里照样卡主线程)
MonoBehaviour被销毁时协程自动停止;StopCoroutine/StopAllCoroutines手动停止yield return null和yield return 0等价
面试加分点:指出协程本质是"状态机 + 主线程分段执行 ",不是多线程;Unity 新版还提供
async/await支持(配合UniTask是高性能替代方案)。
94. (代码题)value1 和 value2 谁先初始化?
csharp
public class Main1
{
static object value1;
static Main1()
{
value1 = new object();
}
static object value2 = new object();
}
答案:
value2 先初始化。
顺序: 静态字段初始化器 → 静态构造函数
第三部分:高级题(困难)
编译与运行时
95. C# 中 JIT 编译器是什么,它如何工作?
答案:
JIT(Just-In-Time)编译器 将 IL 代码在运行时编译为机器码。
工作流程:
源代码 → IL 代码 → JIT 编译 → 机器码(缓存)
详细步骤:
- 方法首次调用时触发 JIT
- 验证 IL 类型安全
- 编译优化(内联、消除边界检查)
- 缓存机器码,后续直接执行
96. C# 中函数是何时被加载到内存中的呢?
答案:
取决于编译方式:
- JIT:首次调用时编译加载
- AOT(IL2CPP):程序启动时全部加载
- 分层编译:首次快速编译,热方法重编译优化
97. C# 函数中一般会声明一些临时变量,当函数被加载内存中后,所占的内存空间是否包含函数中的这些临时变量?
答案:
不包含。
函数代码段只存储指令。临时变量在栈上动态分配,函数返回时释放。
例外:
- 闭包捕获的变量提升到堆
- async/迭代器状态机在堆上
98. 什么是 IL(中间语言)和程序集(Assembly)?
答案:
IL(Intermediate Language,中间语言) 是 C# 编译的中间产物。C# 源码不直接编译成机器码,而是编译成 IL(也常称 CIL/MSIL),由运行时(CLR)在运行时通过 JIT 编译为机器码。
编译流程:
C# 源码 ──编译(CSC)──► IL + 元数据 ──打包──► 程序集(.dll/.exe) ──CLR 加载──► JIT 编译──► 机器码
IL 的特点:
- 平台无关(同一 IL 可在 Windows/Linux/macOS 运行)
- 面向对象的机器码(可直接表达类、方法、异常等概念)
- 人类可读性较好(可用
ildasm/ilspy反编译查看)
il
// 示例:int Add(int a, int b) => a + b; 的 IL
.method public hidebysig instance int32 Add(int32 a, int32 b) cil managed
{
ldarg.1 // 加载参数 a
ldarg.2 // 加载参数 b
add // 相加
ret // 返回
}
程序集(Assembly): .NET 的部署和版本单元,包含 IL 代码 + 元数据(类型定义、引用) + 资源。
csharp
// 反射获取程序集信息
Assembly assembly = typeof(MyClass).Assembly;
Console.WriteLine(assembly.FullName); // 程序集名称、版本、文化
Type[] types = assembly.GetTypes(); // 程序集中的所有类型
元数据(Metadata): 描述类型、方法、字段、属性、特性的数据表,是反射的基础(Assembly.GetTypes()、typeof 都靠它)。
程序集的重要概念:
- 私有/公共程序集:私有(应用目录下)、共享(GAC 全局程序集缓存,.NET Framework 时代)
- 强名称程序集:带公钥签名的程序集,防篡改
.dllvs.exe:本质都是程序集,exe有入口点- 引用程序集:编译期引用,运行时可被替换
反编译工具: ILSpy、dnSpy(调试+反编译)、dotPeek------通过反编译 IL 查看任意 .NET 程序集的实现,是逆向与学习的常用手段。
面试加分点:IL = "C# 的汇编语言";程序集 = "IL + 元数据 + 资源的打包单元";
typeof和反射都依赖元数据。这也是"为什么 .NET 容易被反编译"的原因(IL 比机器码信息丰富得多)。
99. .NET Framework 和 .NET Core / .NET 5+ 的区别?
答案:
| 对比项 | .NET Framework | .NET Core / .NET 5+ |
|---|---|---|
| 跨平台 | 仅 Windows | 跨平台(Windows/macOS/Linux) |
| 开源 | 部分开源(4.x) | 完全开源(MIT) |
| 部署 | 依赖系统安装,全局共享 | 自包含部署(应用带运行时)或框架依赖 |
| 模块化 | 大而全(GAC 全局程序集) | 模块化,按需裁剪 |
| 性能 | 一般 | 持续优化(Span、硬件 intrinsics、分层编译) |
| 新特性 | 停在 4.8 | 持续演进(record、模式匹配、AOT 等) |
| API 兼容 | Windows 专属 API 丰富 | 现代跨平台 API |
| 应用类型 | WinForms/WPF/WebForms | ASP.NET Core、WinForms/WPF(Windows)、MAUI(跨平台 UI)、Unity 脚本后端 |
| 支持状态 | 已停止(4.8 为最终版,仅维护) | 活跃开发 |
为什么从 Framework 迁移到 Core:
- 跨平台部署(服务器可以用 Linux)
- 性能显著提升(ASP.NET Core 在 TechEmpower 基准中常年位居前列)
- 云原生友好(容器、微服务)
- 快速迭代新特性
.NET 5+ 的统一: .NET 5 起不再区分 Framework/Core,统一为 .NET(.NET 5/6/7/8/9),一个平台覆盖所有应用类型。每个版本都有 LTS(长期支持)版本(如 .NET 6、.NET 8)。
与 Unity 的关系: Unity 的 Mono 运行时基于 .NET Framework 的 API 子集;Unity 2021.2+ 默认切换到 .NET Standard 2.1 / .NET Framework 4.x API 级别(取决于版本);HybridCLR 等热更新方案依赖对运行时的深度定制。
面试加分点:说出".NET 5+ = Framework + Core 的统一"、LTS 概念(.NET 6/8 是 LTS)、自包含部署(发布时带运行时免安装)三大要点。
100. 什么是 AOT 编译?NativeAOT 的优缺点?
答案:
AOT(Ahead-Of-Time) 在编译期(部署前)就把 IL 编译成机器码,而不是运行时 JIT。
JIT vs AOT:
| 对比项 | JIT | AOT(NativeAOT) |
|---|---|---|
| 编译时机 | 运行时首次调用 | 发布时一次性编译 |
| 启动速度 | 慢(首次调用要编译) | 快(无需 JIT) |
| 运行时开销 | 需自带 JIT 编译器 | 无 JIT,体积小 |
| 内存占用 | 较高(JIT + 元数据) | 较低(裁剪无用代码) |
| 反射/动态代码 | 完整支持 | 受限(需 RD.xml/源生成器配合) |
| 优化机会 | 可按实际运行 Profile 优化(分层编译) | 编译期静态优化 |
| 适用 | 大多数应用 | 启动敏感、体积敏感、无反射场景 |
NativeAOT(.NET 7+,正式支持):
xml
<PropertyGroup>
<PublishAot>true</PublishAot>
</PropertyGroup>
bash
dotnet publish -c Release -r win-x64
# 产出:单一原生可执行文件(无 .NET 运行时依赖)
NativeAOT 优点:
- 启动极快(毫秒级),无 JIT 预热
- 内存占用低(无运行时 JIT、无元数据冗余)
- 部署简单:单文件,无需安装 .NET 运行时
- 安全(IL 不暴露,更难反编译------相比托管程序集)
NativeAOT 缺点/限制:
- 反射受限 :不能动态加载/生成类型;需要
Rd.xml配置或源生成器(Source Generator)静态生成反射信息 - 动态代码受限 :
Expression.Compile动态 IL 生成不可用 - 互操作需要声明:P/Invoke 需显式指定
- 编译时间较长
Unity 的 IL2CPP 也是 AOT 的一种: 把 IL 转成 C++ 再编译成原生代码,牺牲反射灵活性换性能与平台兼容。
面试加分点:AOT 与 JIT 的核心 trade-off = 启动/体积 vs 反射/动态能力 ;Unity IL2CPP、NativeAOT 都是 AOT 家族;解决反射限制的现代方案是源生成器(编译期生成代码)。
101. P/Invoke 是什么?C# 如何调用 C/C++ 的 DLL?
答案:
P/Invoke(Platform Invoke) 是 .NET 调用非托管代码(C/C++ DLL、Windows API)的机制。
csharp
using System.Runtime.InteropServices;
public static class NativeMethods
{
// 声明 C++ 导出函数
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
public static extern int MessageBox(IntPtr hWnd, string text, string caption, uint type);
[DllImport("mylib.dll", EntryPoint = "add")]
public static extern int Add(int a, int b);
}
工作原理:
[DllImport]声明告诉 CLR:这个 extern 方法对应哪个 DLL 的哪个导出函数- CLR 加载 DLL,查找导出符号(EntryPoint)
- 封送(Marshaling) :CLR 自动在托管类型 ↔ 非托管类型之间转换数据
- 调用完成后释放非托管资源(
Marshal.FreeHGlobal等)
C++ 侧导出:
cpp
// mylib.cpp
extern "C" __declspec(dllexport) int add(int a, int b)
{
return a + b;
}
常用互操作特性:
| 特性 | 用途 |
|---|---|
[DllImport("dll")] |
声明外部函数 |
[StructLayout(LayoutKind.Sequential)] |
控制结构体内存布局(与非托管对应) |
[MarshalAs(...)] |
控制字段/参数的封送方式 |
EntryPoint |
指定 DLL 中的函数名(可重命名) |
CallingConvention |
调用约定(Cdecl/StdCall 等) |
C# 与 C++ 数据传递示例:
csharp
// C++ 结构体
// struct Point { int x; int y; };
[StructLayout(LayoutKind.Sequential)]
public struct Point
{
public int x;
public int y;
}
[DllImport("mylib.dll")]
public static extern Point GetPoint();
// unsafe 指针互操作
[DllImport("mylib.dll")]
public static extern unsafe void FillBuffer(byte* buffer, int length);
注意事项:
- 封送开销:频繁互操作有性能成本,尽量批量调用
- 内存安全 :非托管内存需要手动释放(
Marshal.FreeHGlobal);回调需防止 GC 回收(用GCHandle固定) - 需要
unsafe的互操作要在项目开启 AllowUnsafeBlocks - 现代替代:
[LibraryImport](.NET 7+,编译期生成封送代码,性能更好)
面试加分点:P/Invoke 是 C# 调用 C++/系统 API 的通道(Unity 的底层插件、游戏的 C++ 核心引擎都是这么接的);能说出
DllImport、封送、GCHandle固定三要素。
GC 深入
102. C# 中的垃圾回收机制是使用的引用计数还是其它方式呢?
答案:
不使用引用计数 ,使用标记-清除 和分代收集。
引用计数缺陷:
- 无法处理循环引用
- 每次赋值需更新计数
C# GC 算法:
- 标记阶段 - 从根对象出发遍历,标记所有可达对象
- 清除阶段 - 回收未标记对象(Gen 0/1 会进行压缩以消除碎片,Gen 2 通常只清除不压缩)
- 分代收集 - Gen 0/1/2 提高效率
103. C# 中自动触发 GC 的原因可能有哪些?从分代收集去考虑?
答案:
触发原因:
- Gen 0 已满 - 最常见
- Gen 1 已满 - 回收 Gen 0+1
- Gen 2 已满 - 完全回收
- LOH 分配 - 可能触发 Gen 2
- 操作系统内存压力
- 显式调用
GC.Collect()
104. GC 分代(Generational)收集中,对象的代龄提升机制是什么?
答案:
.NET GC 分 3 代: Gen0(最新分配)、Gen1、Gen2(最老)。分代依据是经验法则:大多数对象生命周期很短(90% 以上对象在 Gen0 就死掉)。
代龄提升机制:
1. 新对象分配到 Gen0
2. 触发 Gen0 回收后仍存活的对象 → 提升到 Gen1
3. Gen1 回收后仍存活的对象 → 提升到 Gen2
4. Gen2 回收后存活的对象 → 留在 Gen2(最老代)
提升判定:
.NET GC 不按"次数"死板计数,而是基于代龄阈值(age threshold):
- 对象在一次回收中幸存 (被标记为存活),其代龄(age)加 1
- 当代龄超过所在代的阈值 (通常 2-3 次回收幸存),对象被提升到下一代
- 提升阈值是动态调整的(GC 会根据存活率调节:存活率高时提高阈值,减少无意义提升)
关键概念:
- 每次回收的覆盖范围 :
- Gen0 满 → 只回收 Gen0(最快)
- Gen1 满 → 回收 Gen0 + Gen1
- Gen2 满 / LOH 分配 / 系统内存压力 → 全回收(Gen0+1+2,最慢)
- Gen0 回收最快:对象少,标记-压缩都在小范围内
- 提升不是"免费"的:大对象频繁幸存会导致 Gen1/Gen2 增长,进而触发更贵的回收
为什么设计分代: 避免每次 GC 都扫描全部对象。大多数对象死在 Gen0,大部分 GC 只回收 Gen0,回收代价小、频率高;只有少数大回收才动 Gen1/Gen2。
csharp
// 观察代龄
GC.GetGeneration(obj); // 查看对象当前所在代
实践意义(面试重点): 减少 Gen0 垃圾(少分配)→ 减少 GC 频率;减少 Gen1/Gen2 幸存(避免对象逃逸到老年代)→ 减少大回收。对象池、避免闭包捕获、避免大数组都是为了这两点。
面试加分点:能说出"幸存一次 → 代龄 +1 → 超过阈值提升 "的机制,以及提升阈值是动态的 ;
GC.GetGeneration可实际验证。
105. GC 的 Workstation 和 Server 模式的区别?
答案:
.NET GC 有两种工作模式,通过运行时配置(appsettings.json / 项目文件 / 环境变量)选择。
| 对比项 | Workstation(工作站) | Server(服务器) |
|---|---|---|
| 默认平台 | 客户端程序默认 | 服务器程序默认 |
| 堆数量 | 每个进程 1 个托管堆 | 每个 CPU 核 1 个托管堆(多堆并行回收) |
| GC 线程 | 触发 GC 的线程自己执行回收 | 每个堆有专用 GC 线程并行回收 |
| 吞吐量 | 较低 | 更高(并行回收快) |
| 内存占用 | 较低 | 较高(每核一堆,有额外开销) |
| 延迟 | 回收时停顿相对短 | 单次停顿可能更长,但频率可能更低 |
| 适用 | 桌面程序、单机应用 | Web 服务、高并发服务器 |
配置方式:
xml
<!-- .csproj 中配置 Server GC -->
<PropertyGroup>
<ServerGarbageCollection>true</ServerGarbageCollection>
</PropertyGroup>
json
// runtimeconfig.json
{
"configProperties": {
"System.GC.Server": true,
"System.GC.Concurrent": true // 后台 GC(并发回收)
}
}
GC 的另外两个重要配置:
- 并发/后台 GC(Concurrent GC) :回收第 2 代时在后台线程 执行,与业务线程并发 ,减少停顿(
System.GC.Concurrent,默认开启) - 延迟模式(Latency Mode) :
GCLatencyMode.Batch:吞吐优先,停顿较长GCLatencyMode.Interactive:默认,平衡GCLatencyMode.LowLatency:尽量不回收(游戏/音频场景,风险是内存增长)GCLatencyMode.SustainedLowLatency:长时间低延迟(服务器)
csharp
// 临时切换到低延迟模式(如游戏加载场景)
var old = GCSettings.LatencyMode;
GCSettings.LatencyMode = GCLatencyMode.LowLatency;
// ...关键路径...
GCSettings.LatencyMode = old; // 用完恢复,避免内存无限增长
面试加分点:Server GC 每核一堆、并行回收;Workstation 单堆。游戏客户端通常用 Workstation + 手动控制分配;ASP.NET Core 默认 Server GC。
106. 为什么不要手动调用 GC.Collect()?
答案:
GC.Collect() 会强制执行全量回收 (或指定代),看似能"清理内存",实际上弊大于利:
1. 性能开销大
csharp
GC.Collect(); // 默认强制回收所有代(Gen0+1+2),全量标记+压缩
全量回收需要:遍历所有根对象 → 标记可达对象 → 压缩移动内存 → 暂停所有线程(STW,Stop-The-World)。在高频调用场景会造成明显的卡顿。
2. 破坏 GC 的自适应调优
.NET GC 会根据内存压力和分配速率 动态调整各代阈值。手动 Collect 会:
- 把本来可以在 Gen0 快速回收的对象提前提升到 Gen1/Gen2 → 老年代膨胀
- 干扰 GC 的"代龄阈值"学习算法 → 后续 GC 表现变差
3. 频繁调用导致"提升过度"
csharp
// ❌ 反模式:每次分配都清理,对象被反复提升,老年代堆积
while (true)
{
// 分配一些对象...
GC.Collect(); // 刚分配的对象被提升到 Gen1/Gen2,永远驻留
}
4. 可能"假释放" :Collect 后对象可能并未真正回收(被终结器/Finalizer 队列延迟、被根引用),开发者误以为内存已释放,产生错误判断。
什么时候可以手动调用(少数合法场景):
- 性能分析 时观察分配行为(
GC.GetTotalMemory(true)) - 应用进入空闲期/后台时(如游戏切场景、长时间闲置)
- 内存压力测试工具
csharp
// 合法的节流示例:空闲时清理 + 次数限制
if (Environment.TickCount - _lastCollect > 60_000)
{
GC.Collect(2, GCCollectionMode.Optimized); // 指定代 + 优化模式
_lastCollect = Environment.TickCount;
}
正确做法: 让 GC 自己调度------少分配、用对象池、避免大对象,而不是事后"手动清扫"。
面试加分点:手动
Collect的三个危害(性能停顿、破坏阈值调优、对象过度提升);若必须用,用GC.Collect(2, GCCollectionMode.Optimized)并节流。
107. C# 中的大对象堆是什么?
答案:
LOH 专门用于分配大于 85,000 字节(约 83 KB)的大对象。
特点:
- 与第 2 代一起回收(通常只在第 2 代回收时收集)
- 默认不进行压缩 ,易产生碎片(.NET 4.5.1+ 可通过
GCSettings.LargeObjectHeapCompactionMode按需压缩) - 回收开销大
优化建议:
- 使用
ArrayPool复用大数组 - 避免频繁分配临时大对象
108. 什么是内存碎片,如何在 C# 中避免内存碎片?
答案:
内存碎片是内存被分割成不连续小块,无法满足大对象分配。
避免方法:
- 使用对象池
- 提前分配并复用
- 将大对象拆分为小对象
- 避免混合大小分配
内存管理深入
109. C# 中 new 一个引用对象做了什么?
答案:
步骤:
- 对象大小在类型加载时已确定,按已知大小在托管堆分配内存(空间不足时会先触发 GC)
- 初始化对象头(同步块索引、类型指针)
- 调用构造函数(实例字段初始化器在构造函数体之前执行)
- 返回对象引用
110. 什么是内存对齐?为什么要进行内存对齐?
答案:
内存对齐是将数据存储在其大小的整数倍地址。
原因:
- 硬件效率 - CPU 访问更快
- 原子操作要求 - CAS 需要对齐
- 缓存一致性 - 提高缓存命中率
111. C# 中怎么计算对象占用多少内存?
答案:
方法:
Marshal.SizeOf<T>(值类型)GC.GetTotalMemory差值法- Memory Profiler
估算公式:
对象大小 = 对象头(64 位下为 16 字节:同步块索引 8 字节 + 类型指针 8 字节;32 位下为 8 字节) + 字段大小 + 对齐填充
112. C# 中堆内存优化的大方向是什么?再说 3 点具体方案?
答案:
大方向: 减少堆分配,降低 GC 频率。
具体方案:
-
使用对象池
csharpbyte[] buffer = ArrayPool<byte>.Shared.Rent(1024); -
使用值类型(struct)
- struct 作为方法内的局部变量时通常分配在栈上(或寄存器中)
- 但作为类的字段、数组元素、集合元素时仍然在堆上
- 相比引用类型,可减少对象头开销、降低 GC 追踪压力
-
避免 LOH 分配
- 拆分大对象
-
减少闭包捕获
- 避免生成额外类
-
使用
Span<T>- 避免不必要的数组分配
113. C# 编程中常见的内存管理问题有哪些?
答案:
| 问题 | 示例 |
|---|---|
| 内存泄漏 | 事件未取消订阅 |
| 频繁 GC | Update 中 new 对象 |
| LOH 碎片 | 频繁分配大数组 |
| 闭包捕获 | Lambda 捕获变量 |
| 装箱拆箱 | ArrayList.Add(1) |
| 非托管资源未释放 | 未调用 Dispose |
| 静态引用持有 | 静态 List 缓存对象 |
114. 什么是内存屏障(Memory Barrier)?和 volatile 的关系?
答案:
内存屏障(Memory Barrier / Fence) 是 CPU/编译器提供的指令,用于限制内存操作的顺序,防止 CPU 乱序执行(Out-of-Order Execution)和编译器优化重排导致的多线程可见性问题。
为什么需要内存屏障:
现代 CPU 和编译器为了性能会重排指令。在单线程下没问题,但多线程下可能产生违反直觉的结果:
csharp
// 经典例子:双线程 flag 依赖
int value = 0;
bool ready = false;
// 线程 1
value = 42;
ready = true; // 可能被重排到 value=42 之前!
// 线程 2
if (ready) // 看到 ready=true,但可能还没看到 value=42
Console.WriteLine(value); // 可能输出 0!
内存屏障的作用:
| 类型 | 作用 |
|---|---|
| 读屏障(Load Barrier) | 屏障后的读操作不提前 |
| 写屏障(Store Barrier) | 屏障前的写操作不滞后 |
| 全屏障(Full Barrier) | 前后读写都不乱序 |
volatile 和内存屏障的关系:
volatile 字段的读写会自动插入内存屏障:
- volatile 字段写 :在写操作前插入写屏障(保证之前的写不重排到它后面)
- volatile 字段读 :在读操作后插入读屏障(保证之后的读不重排到它前面)
csharp
// volatile 修正上面的例子
int value = 0;
volatile bool ready = false;
// 线程 1
value = 42; // 普通写
ready = true; // volatile 写:插入写屏障,确保 value=42 在此之前完成
// 线程 2
if (ready) // volatile 读:插入读屏障
Console.WriteLine(value); // 能读到 42
C# 中的内存屏障相关 API:
csharp
Thread.MemoryBarrier(); // 全内存屏障(强制之前的读写先于之后的)
Volatile.Read(ref x); // volatile 语义的读
Volatile.Write(ref x, 1); // volatile 语义的写
Interlocked.xxx(); // 原子操作内部自带全屏障
注意:
lock、Monitor、Interlocked内部都包含内存屏障(所以它们能同时解决可见性 + 原子性)- 内存屏障只解决顺序/可见性 ,不解决原子性 (
volatile同理,见第70题) - x86 架构内存模型相对宽松(有较强的硬件保证),ARM 架构乱序更激进,屏障更重要
面试加分点:能解释"重排 → 可见性问题 → 屏障"的因果链;volatile = "读写 + 隐式屏障";lock/Interlocked 自带全屏障,所以用锁最省心。
115. C# 中的弱引用是什么?它的作用是什么?
答案:
弱引用允许 GC 回收被引用的对象,不会阻止回收。
作用:
- 缓存系统 - 允许 GC 回收不常用缓存
- 事件订阅/观察者模式 - 防止长生命周期对象(如事件发布者)强持有短生命周期对象(订阅者),导致其无法被回收
注意:.NET 的 GC(标记-清除)本身就能处理循环引用,弱引用并不是用来"打破循环引用"的,而是用于"不阻止对象被回收"的场景。
非托管与高性能内存
116. 托管资源和非托管资源的区别?
答案:
| 对比项 | 托管资源 | 非托管资源 |
|---|---|---|
| 定义 | 托管堆上由 CLR/GC 管理的对象 | GC 无法自动管理的资源 |
| 内存回收 | GC 自动回收 | 必须手动释放 |
| 代表 | 普通 class 对象、string、数组、List | 文件句柄、数据库连接、网络 Socket、窗口句柄(GDI)、Mutex |
| 释放方式 | 无需操心 | IDisposable + using / Dispose |
| 示例 | new List<int>() |
FileStream、SqlConnection、HttpClient(持连接) |
csharp
// 非托管资源的典型使用:必须释放
using (var stream = File.OpenRead("data.bin")) // FileStream 是 IDisposable
{
byte[] buffer = new byte[stream.Length];
stream.Read(buffer, 0, buffer.Length);
} // 自动释放文件句柄
为什么非托管资源要手动释放: 文件句柄、连接数有限(操作系统层面),如果不及时释放会导致资源耗尽(文件被占用、连接池耗尽、句柄泄漏),且这类问题 GC 永远帮不上忙。
常见非托管资源及释放方式:
| 资源 | 释放方式 |
|---|---|
FileStream、StreamReader/Writer |
Dispose / using |
SqlConnection、SqlCommand |
Dispose(或用连接池自动归还) |
Socket、TcpClient |
Dispose / Close |
Bitmap、Graphics(GDI+) |
Dispose |
Mutex、Semaphore |
Dispose / Release |
| 指针/原生内存 | Marshal.FreeHGlobal / free |
关键区分: 一个类持有非托管资源或持有其他 IDisposable 对象 时,它自己必须实现 IDisposable(见上一题)。只含托管资源的类不需要。
面试加分点:说出"GC 管内存、管不了句柄"的本质;
HttpClient是特殊案例------它实现 IDisposable,但官方建议单例复用(频繁创建/销毁会耗尽 Socket),这是一个经典的"反直觉"考点。
117. IDisposable 和 Dispose 模式是什么?为什么要实现它?
答案:
背景: GC 只能管理托管资源 (托管堆上的对象),无法自动释放非托管资源 (文件句柄、数据库连接、网络连接、GDI 句柄、互斥体等)。IDisposable 提供确定性释放非托管资源的机制。
基本用法:using 语句
csharp
using (var file = File.OpenRead("data.txt")) // using 块结束自动调用 Dispose
{
// 使用文件...
} // 此处自动调用 file.Dispose()
// C# 8 的 using 声明:作用域结束时自动释放
using var connection = new SqlConnection(connStr);
// ...
标准 Dispose 模式(微软推荐写法):
csharp
public class ResourceHolder : IDisposable
{
private bool _disposed = false; // 防止重复释放
private FileStream _stream = new FileStream("a.txt", FileMode.Open);
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this); // 告诉 GC 不再调用终结器(对象已手动清理)
}
protected virtual void Dispose(bool disposing)
{
if (_disposed) return;
if (disposing)
{
// 释放托管资源(实现 IDisposable 的托管对象)
_stream?.Dispose();
}
// 释放非托管资源(如果有)
_disposed = true;
}
// 兜底:万一使用者忘了 Dispose,GC 回收时通过终结器释放非托管资源
~ResourceHolder() => Dispose(false);
}
为什么 GC.SuppressFinalize 很重要:
如果不调用 SuppressFinalize,对象会被标记为"需要终结",GC 回收时要经历两次回收 (终结器让对象多存活一代),性能差。手动 Dispose 后告知 GC 无需再走终结器。
IDisposable 使用铁律:
- 谁创建,谁负责释放(using 或 finally 中调用)
- 持有
IDisposable字段的类,自己通常也要实现IDisposable(在Dispose中释放它们) - 重复调用
Dispose必须安全(不抛异常)------靠_disposed标志 - 无法确定资源是否已释放时,终结器作为最后兜底
面试加分点:区分托管资源 (GC 管)和非托管资源 (必须手动释放);
using编译后就是try { } finally { Dispose(); }。
118. C# 中的 ArrayPool 有什么作用?
答案:
ArrayPool<T> 是数组对象池,用于复用大型数组。
作用:
- 减少 GC 压力
- 提高性能
- 减少内存碎片
使用:
csharp
byte[] buffer = ArrayPool<byte>.Shared.Rent(1024);
try { /* 使用 */ }
finally { ArrayPool<byte>.Shared.Return(buffer); }
注意:
Rent返回的数组长度可能大于请求的长度,使用时应以返回值的Length为准。
119. Span 和 Memory 是什么?解决什么问题?
答案:
背景问题: 传统数组操作会产生额外分配(Substring 创建新字符串、Skip/Take 创建新枚举、切片复制数据)。Span<T> 提供无分配的类型安全内存视图。
Span<T>: 表示任意连续内存的引用 (数组、字符串、栈内存、非托管内存的视图),不复制数据,仅记录"起始位置 + 长度"。
csharp
int[] array = new int[10];
Span<int> span = array; // 整个数组的视图
Span<int> slice = span.Slice(2, 5); // 切片:不复制,只是新视图
// 修改 span 会反映到原数组
slice[0] = 100;
Console.WriteLine(array[2]); // 100,原数组被修改
// 字符串切片零分配
string text = "Hello World";
ReadOnlySpan<char> word = text.AsSpan(6, 5); // 不产生新字符串!
Span<T> 的关键限制(ref struct):
- 只能分配在栈上 (
ref struct) - 不能装箱 、不能作为类的字段 、不能用于 async/await 方法中(跨越 await 存活)
- 不能放入
List、Dictionary等集合
Memory<T>: 为了解决 Span 不能跨 async/await 的限制,提供了堆上可存储 的内存视图(有所有权),可被类持有、可放入集合,调用 .Span 获取实际视图。
csharp
// Memory 可以跨越 async 边界
async Task<int> ProcessAsync(Memory<byte> memory)
{
Span<byte> span = memory.Span; // 使用时获取 Span
// ...
await Task.Delay(100); // 可以在 await 前后使用 memory
return span.Length; // 注意:await 后 span 不可用(ref struct),需重新获取
}
解决什么问题(核心价值):
| 问题 | Span 的解决 |
|---|---|
| 数组切片复制数据(内存浪费) | 零拷贝视图 |
| 字符串子串分配新字符串(GC 压力) | AsSpan 零分配 |
| 解析二进制数据(大量临时数组) | 直接操作底层缓冲 |
| 高性能场景(序列化、网络、加密) | 避免所有中间分配 |
注意陷阱: ReadOnlySpan<char> 用于字符串;Span 是"借用"内存,生命周期不能超过被借用内存(编译器通过 ref struct 强制保证)。
面试加分点:
Span<T>是 .NET 高性能编程的基石(与stackalloc、ref struct同族);配合MemoryPool<T>/ArrayPool<T>可构建零 GC 的数据处理管道。
120. stackalloc 是什么?有什么风险和用途?
答案:
stackalloc 在栈上 分配连续内存块,而不是托管堆。栈分配速度极快 (移动栈指针即可)、无 GC 压力(函数返回自动释放)。
csharp
// 在栈上分配 10 个 int
int* values = stackalloc int[10];
values[0] = 42;
// 与 Span 结合(推荐安全用法,无需 unsafe)
Span<int> span = stackalloc int[10]; // C# 7.2+ 无需 unsafe
span[0] = 42;
堆分配 vs 栈分配对比:
| 对比项 | 堆分配(new) | stackalloc |
|---|---|---|
| 位置 | 托管堆 | 栈 |
| 速度 | 较慢(可能触发 GC) | 极快(指针移动) |
| GC 压力 | 产生垃圾 | 零压力 |
| 生命周期 | GC 管理 | 函数返回即释放 |
| 大小限制 | 几乎不限 | 栈大小有限(约 1MB,视平台) |
风险(重点):
- 栈溢出风险 :栈空间有限,分配过大(如 1MB+)会抛
StackOverflowException,该异常无法被 catch,直接导致进程崩溃
csharp
// ❌ 危险:分配过大
Span<byte> big = stackalloc byte[1024 * 1024]; // 可能栈溢出
// ✅ 合理做法:小对象用栈,大对象用堆或池
if (size < 1024)
Span<byte> buffer = stackalloc byte[size]; // 小用栈
else
byte[] arr = ArrayPool<byte>.Shared.Rent(size); // 大用池
- 不能用于引用类型(只能分配非托管类型/值类型)
- 分配的内存未初始化 (除非用
stackalloc的 span 初始化语法)
用途: 高性能临时缓冲区(解析协议头、临时转换缓冲区、避免高频小数组分配)。典型例子:Convert 内部、加密库中的临时缓冲。
面试加分点:说出 stackalloc 是"栈上临时缓冲 ",配合
Span<T>安全使用;大分配选ArrayPool<T>。这是"零 GC 分配"面试题的常见答案之一。
121. fixed 关键字和 GCHandle 的作用?
答案:
两者都用于固定(pin)托管对象 ,使 GC 在压缩内存时不移动 该对象,从而可以安全地获取其内存地址(用于与非托管代码交互)。
为什么需要固定: GC 压缩阶段会移动对象(地址变化),如果非托管代码持有对象地址,对象被移动后指针就失效了。
1. fixed 语句(栈上固定,作用域内有效):
csharp
unsafe
{
int[] array = new int[100];
fixed (int* p = array) // 固定数组,作用域内 GC 不移动它
{
// p 指向数组首元素,可以传给非托管函数
SomeNativeFunction(p, array.Length);
} // 离开作用域自动解除固定
}
fixed 固定字符串:
csharp
unsafe
{
string s = "hello";
fixed (char* p = s) // 固定字符串内部缓冲区
{
// p 指向字符串的 char 缓冲区
}
}
2. GCHandle(堆上固定,可跨作用域,更灵活):
csharp
// 分配固定句柄
GCHandle handle = GCHandle.Alloc(obj, GCHandleType.Pinned);
try
{
IntPtr address = handle.AddrOfPinnedObject(); // 获取固定对象的地址
// 传给非托管代码长期使用
}
finally
{
handle.Free(); // 必须释放句柄!否则对象永远固定,无法被移动和回收
}
GCHandleType 的类型:
| 类型 | 作用 |
|---|---|
Pinned |
固定对象,可获取地址(非托管互操作) |
Normal |
强引用跟踪(用于非托管代码持有托管对象引用) |
Weak |
弱引用跟踪(类似 WeakReference) |
WeakTrackResurrection |
跟踪终结的弱引用 |
fixed vs GCHandle:
| 对比 | fixed |
GCHandle |
|---|---|---|
| 固定时长 | 作用域内(自动解除) | 手动 Free() 前一直固定 |
| 性能 | 栈上,快 | 需要分配句柄,较慢 |
| 适用 | 短时互操作 | 长期固定、跨作用域 |
注意: 过度固定对象会阻碍 GC 压缩 (碎片化),应尽量缩短固定时间;大量固定对象建议使用 GC 不移动的内存(如 NativeMemory.Alloc/Marshal.AllocHGlobal)。
面试加分点:固定 = "告诉 GC 别动我";
fixed是 C# 语法糖(内部也走GCHandle或专用内部机制),GCHandle.Alloc(obj, Pinned)是等价的底层 API。
语言深层原理
122. C# 中委托的本质是什么?它是如何存储函数的?
答案:
委托本质是类 ,继承 MulticastDelegate。
内部结构:
_target:目标对象(实例方法)或 null_methodPtr:方法指针_next:下一个委托(多播链)
123. C# 中事件的本质是什么?
答案:
事件是委托的封装,编译器生成:
- 私有委托字段
add/remove方法(对应+=/-=)
特点:
- 外部只能
+=和-=,不能赋值或调用 - 更好的封装性
124. 为什么我们尽量避免装箱拆箱?避免装箱拆箱的方案有哪些?
答案:
装箱问题:
- 性能开销(慢 10-20 倍)
- GC 压力
- 内存占用增加
避免方案:
-
使用泛型容器
csharpList<int> list = new List<int>(); // 无装箱 -
使用泛型方法重载
-
调用
ToString()避免装箱csharpDebug.Log(health.ToString()); // 无装箱 -
使用
IComparable<T>
125. 为什么说闭包可能会带来内存泄漏?
答案:
机制:
- 闭包编译为额外类
- 捕获的变量成为类字段
- 闭包被长期持有时,外部对象无法回收
避免方法:
- 及时取消订阅事件
- 使用弱引用
- 使用实例方法代替闭包
- 避免在循环中捕获变量
126. 什么是表达式树(Expression Tree)?和委托的区别?
答案:
表达式树 把 Lambda 表达式表示为可检查、可修改、可编译的数据结构(树形结构:节点 = 运算,叶 = 变量/常量),而不是直接编译成 IL 代码。
csharp
using System.Linq.Expressions;
// 普通委托:直接编译为 IL 方法
Func<int, int, int> addFunc = (a, b) => a + b;
// 表达式树:表示为数据结构,可分析
Expression<Func<int, int, int>> addExpr = (a, b) => a + b;
表达式树 vs 委托:
| 对比项 | 委托(Func/Lambda) | 表达式树(Expression) |
|---|---|---|
| 编译时机 | 直接编译为 IL | 先存为数据,可 Compile() 再变委托 |
| 可分析性 | 黑盒,无法查看内部 | 可以遍历、检查、修改(BinaryExpression、ParameterExpression...) |
| 运行时构建 | 不能 | 可以(Expression.Add、Expression.Parameter 动态构造) |
| 用途 | 执行逻辑 | 翻译成其他语言(SQL)、动态生成代码 |
核心应用(为什么面试常考):
1. EF Core / LINQ to SQL:把 C# 表达式翻译成 SQL
csharp
// Where 的参数是 Expression<Func<T, bool>>,EF 把它翻译成 SQL
var users = db.Users.Where(u => u.Age > 18);
// 翻译: SELECT * FROM Users WHERE Age > 18
这就是 IQueryable 的实现基础(第53题):查询保存为表达式树,由 Provider 翻译执行。
2. 动态构建查询 / 动态代码生成(如对象映射器 AutoMapper 的原理):
csharp
// 运行时构建 "x => x + 1" 这样的表达式
var param = Expression.Parameter(typeof(int), "x");
var body = Expression.Add(param, Expression.Constant(1));
var lambda = Expression.Lambda<Func<int, int>>(body, param);
Func<int, int> compiled = lambda.Compile(); // 编译成可执行委托
Console.WriteLine(compiled(5)); // 6
检查表达式树结构:
csharp
Expression<Func<int, int>> expr = x => x * x + 1;
// 根节点: Add
// ├── 左: Multiply (x * x)
// │ ├── ParameterExpression x
// │ └── ParameterExpression x
// └── 右: Constant 1
面试加分点:表达式树 = "代码即数据 ",是 LINQ to SQL / EF Core / AutoMapper 的技术基石;
Compile()生成委托后可获得接近原生的性能(配合缓存可用于高性能反射替代)。
127. C# 的模式匹配(Pattern Matching)有哪些形式?(C# 7-11)
答案:
模式匹配允许用简洁的语法检查对象形状并提取数据,取代冗长的 if/else + 类型判断链。
1. 类型模式(is 表达式,C# 7):
csharp
object obj = "hello";
if (obj is string s) // 类型检查 + 声明变量
{
Console.WriteLine(s.Length);
}
if (obj is int n && n > 0) { } // 组合条件
2. 常量模式:
csharp
if (obj is 42) { } // 判断是否等于常量
if (obj is null) { } // null 判断
3. 属性模式(C# 8):
csharp
if (person is { Age: > 18, Name: not null })
{
// 检查属性并提取
}
if (rect is Rectangle { Width: 10, Height: 10 }) { } // 正方形
4. 位置/元组模式:
csharp
if (point is (0, 0)) { } // 原点
var result = (x, y) switch
{
(0, 0) => "原点",
(_, 0) => "X 轴上",
(0, _) => "Y 轴上",
_ => "其他地方"
};
5. 关系模式(C# 9):
csharp
int score = 85;
string grade = score switch
{
>= 90 => "A",
>= 80 => "B",
>= 70 => "C",
_ => "D"
};
6. 列表模式(C# 11):
csharp
int[] nums = { 1, 2, 3 };
if (nums is [1, 2, 3]) { } // 精确匹配
if (nums is [1, .., 3]) { } // .. 表示任意中间元素
if (nums is [var first, .. var rest]) { } // 提取首元素和剩余部分
7. 逻辑模式(and / or / not,C# 9):
csharp
if (obj is int and > 0 and < 100) { } // 组合
if (obj is not null) { } // 非空判断
经典完整示例(switch 表达式,C# 8):
csharp
public static string Describe(object shape) => shape switch
{
Circle { Radius: > 0 } c => $"圆,面积 {Math.PI * c.Radius * c.Radius:F2}",
Rectangle { Width: var w, Height: var h } => $"矩形 {w}x{h}",
null => "空对象",
_ => "未知形状"
};
面试加分点:说出模式匹配取代了 is+强转+switch 的样板代码;switch 表达式必须有
_(默认)分支且是表达式(有返回值);关系模式/列表模式是 C# 9/11 的亮点。
128. record 类型(C# 9)和 init 访问器是什么?
答案:
record 是一种以值相等性为核心 的引用类型,专为不可变数据设计,编译时自动生成一堆成员。
csharp
// record 声明(位置参数风格)
public record Person(string Name, int Age);
// 使用
var p1 = new Person("Tom", 30);
var p2 = new Person("Tom", 30);
Console.WriteLine(p1 == p2); // True!record 按值比较(字段逐个比较)
Console.WriteLine(p1.Equals(p2)); // True
record 自动生成的能力:
| 成员 | 说明 |
|---|---|
== / != / Equals |
值相等(普通 class 默认引用相等) |
GetHashCode |
基于所有字段 |
ToString |
格式化输出(Person { Name = Tom, Age = 30 }) |
Deconstruct |
解构(var (name, age) = p1;) |
with 表达式 |
非破坏性修改(复制并改部分属性) |
csharp
// with 表达式:基于现有实例创建新实例,只改指定属性
var p3 = p1 with { Age = 31 }; // p1 不变,p3 是新对象
init 访问器(C# 9): 只允许在对象初始化期间赋值,之后只读。
csharp
public class Config
{
public string Name { get; init; } // init:只能在初始化器里赋值
public int Port { get; init; }
}
var config = new Config { Name = "server", Port = 8080 }; // 初始化器赋值 ✅
config.Port = 9090; // ❌ 编译错误:init 之后不可修改
record 的三种形态:
csharp
public record Person(string Name, int Age); // 位置参数 record(引用类型)
public record struct Point(double X, double Y); // record struct(值类型,C# 10)
public readonly record struct Color(byte R, byte G, byte B); // 只读 record struct(C# 10)
record vs class vs struct:
| 对比 | class | record | struct |
|---|---|---|---|
| 类型 | 引用类型 | 引用类型(record struct 是值类型) | 值类型 |
| 相等性 | 引用相等 | 值相等 | 值相等 |
| 可变性 | 可变 | 不可变(配合 init/with) | 可变 |
| 用途 | 一般对象 | DTO、配置、传值数据 | 小值对象 |
面试加分点:record 适合DTO/API 模型/配置 等"数据载体"场景;
with表达式、值相等、ToString自动生成是三大卖点;可用于模式匹配(属性模式提取字段)。
129. 可空引用类型(NRT,C# 8)是什么?为什么引入?
答案:
背景: 值类型有 int?(可空),而引用类型 (class/string)一直默认"可以为 null",编译器不检查,导致大量 NullReferenceException 空引用异常。
可空引用类型(Nullable Reference Types,NRT) 让编译器静态分析 引用类型是否可能为 null,在编译期给出警告,从源头减少空引用异常。
启用方式(必须显式开启):
csharp
#nullable enable // 文件中启用
或在 .csproj 中全局启用:
xml
<PropertyGroup>
<Nullable>enable</Nullable>
</PropertyGroup>
核心语法:? 标注"可能为 null",无标注 = 非空约定
csharp
string name = "Tom"; // 非空:编译器假定不为 null
string? maybeNull = null; // 可空:编译器允许 null 并检查使用
public string GetName() => name; // 返回非空
public string? FindName(int id) { ... } // 可能返回 null,调用方必须处理
// 使用可空值时的检查
string? s = FindName(1);
if (s != null)
{
Console.WriteLine(s.Length); // 检查后编译器不再警告
}
int len = s?.Length ?? 0; // 或使用空合并
编译器给出的警告:
csharp
string? maybe = null;
Console.WriteLine(maybe.Length); // ⚠️ 警告 CS8602:解引用可能为 null 的值
string notNull = null; // ⚠️ 警告 CS8600:将 null 赋给非空引用类型
注意事项:
- 它是编译期注解,不是运行时机制------运行时行为和以前完全一样(null 还是会抛异常),只是编译期帮你提前发现
!(null 忽略运算符):明确告诉编译器"我知道它不为 null":
csharp
string s = maybeNull!; // 我保证不为 null,跳过检查(慎用)
[NotNull]、[MaybeNull]等特性:更细粒度地描述方法返回/参数的空性契约- 对历史代码开启会大量报警告,可逐步迁移(按文件/按项目开启)
面试加分点:NRT 解决的是"编译期空安全 "(警告而非报错),是 .NET 团队向"编译期消除空引用异常"目标迈进的产物;配合
?./??/??=运算符使用。
130. yield return 的原理是什么?(迭代器状态机)
答案:
yield return 用于实现迭代器(Iterator) ,让方法按需、惰性地产生序列元素,而不是一次性返回整个集合。和 async/await 类似,它也是编译器生成状态机的语法糖。
csharp
public static IEnumerable<int> GetNumbers()
{
yield return 1;
yield return 2;
yield return 3;
}
原理(状态机): 编译器把迭代器方法改写为一个状态机类 (实现 IEnumerator<T>),包含:
_state字段:记录当前执行到哪个yield- 局部变量提升为字段(跨 yield 保存)
MoveNext()方法:从上次中断处继续执行到下一个yield return,然后暂停
csharp
// 等价逻辑(简化):
public IEnumerator<int> GetEnumerator()
{
// 状态 0:执行到第一个 yield return 1,暂停,返回 1
// 状态 1:继续执行到 yield return 2,暂停,返回 2
// 状态 2:继续执行到 yield return 3,暂停,返回 3
// 状态 3:结束(MoveNext 返回 false)
}
惰性求值(延迟执行): foreach 遍历时才逐段执行,不是一次性生成所有元素。
csharp
var nums = GetNumbers(); // 此时方法体还没执行!
foreach (var n in nums) // 遍历时才逐段执行
{
Console.WriteLine(n); // 逐个输出 1、2、3
}
yield break: 提前结束迭代。
csharp
public static IEnumerable<int> TakeWhilePositive(int[] arr)
{
foreach (var n in arr)
{
if (n <= 0) yield break; // 遇到非正数立即结束
yield return n;
}
}
迭代器 vs 直接返回 List:
| 对比 | 直接返回 List | yield 迭代器 |
|---|---|---|
| 执行时机 | 方法调用时全部生成 | 遍历时才逐段生成 |
| 内存 | 一次性分配整个集合 | 每次只产生一个元素 |
| 无限序列 | 不能 | 可以(while(true) yield return x; 配合 Take) |
| 开销 | 集合分配 | 状态机对象分配(一次) |
注意事项:
- 迭代器方法不能有
ref/out参数(原因同 async,见第63题) - 方法体在第一次 MoveNext 时才执行 (
yield return之前的第一段代码也是惰性的) - Unity 协程就是基于
IEnumerator+ yield 实现的(见第93题)
面试加分点:
yield状态机与 async 状态机是"同一套编译器魔法";惰性 + 节省内存是核心价值;IEnumerable<T>的 LINQ 链之所以延迟执行,底层就是迭代器。
131. async/await 状态机深入:编译器生成了什么?
答案:
在第60题基础上深入,展示编译器对 async 方法的具体改写产物。
csharp
public async Task<int> ComputeAsync(int x)
{
var a = x * 2;
await Task.Delay(100); // 暂停点 1
var b = a + 1;
await Task.Yield(); // 暂停点 2
return b + x;
}
编译器生成的等价结构(关键元素):
csharp
// 1. 状态机结构体/类,实现 IAsyncStateMachine
struct <ComputeAsync>d__0 : IAsyncStateMachine
{
public int _state; // 状态:-1 初始,0 第一个 await,1 第二个 await
public AsyncTaskMethodBuilder<int> _builder; // 任务构造器(管理 Task 结果/异常)
public int x; // 参数提升为字段
public int a; // 局部变量提升为字段(跨 await 存活)
public int b;
public TaskAwaiter _awaiter1; // 保存等待对象
// 2. MoveNext:每次恢复时从 _state 处继续执行
public void MoveNext()
{
int result;
try
{
switch (_state)
{
case 0: goto awaitDone1; // 从第一个 await 之后恢复
case 1: goto awaitDone2;
}
a = x * 2;
_awaiter1 = Task.Delay(100).GetAwaiter();
if (!_awaiter1.IsCompleted) // 未完成
{
_state = 0;
// 注册回调:完成后线程池调用 MoveNext 继续
_builder.AwaitUnsafeOnCompleted(ref _awaiter1, ref this);
return; // 关键!立即返回,不阻塞
}
awaitDone1:
_awaiter1.GetResult(); // 获取结果(若有异常在此抛出)
b = a + 1;
// ...第二个 await 类似...
}
catch (Exception ex)
{
_state = -1; // 异常:标记结束
_builder.SetException(ex); // 异常存入 Task
return;
}
_state = -2; // 正常结束
_builder.SetResult(result); // 结果存入 Task
}
}
核心机制总结:
_state字段 :记录暂停点,恢复时switch跳转- 局部变量/参数提升为字段:跨 await 存活(这也是 async 方法不能用 ref/out 参数的原因,见第63题)
await未完成时 :注册 continuation 回调并立即 return → 调用线程不被阻塞- 完成后 :回调(
MoveNext)从上次状态继续执行 - 异常处理 :异常被
SetException存入Task,由 await 方接收 AsyncTaskMethodBuilder:负责创建 Task、管理结果/异常、执行 continuation
关于状态机分配: 编译器会尽量把状态机生成为结构体 (stackalloc 优化),异步方法完成时若 Task 已完成则无额外堆分配;热路径可用 ValueTask 进一步减少分配。
面试加分点:能说出
_state、MoveNext、builder、awaiter、回调续延五个要素即达到"懂原理"级别;Unity 协程状态机与 async 状态机同构。
132. ThreadLocal 是什么?和普通静态字段的区别?
答案:
ThreadLocal<T> 为每个线程 维护独立的变量实例------每个线程访问到的值互不影响,线程退出时自动清理。
csharp
private static ThreadLocal<int> _threadId = new ThreadLocal<int>(
() => Thread.CurrentThread.ManagedThreadId); // 每线程独立初始化
public static void Show()
{
Console.WriteLine($"线程 {Thread.CurrentThread.ManagedThreadId} 的值: {_threadId.Value}");
}
// 多线程调用:
// 线程 A → 值 = A 的线程 ID
// 线程 B → 值 = B 的线程 ID(互不干扰)
ThreadLocal vs 普通静态字段:
csharp
private static int _staticValue = 0; // 所有线程共享一份
private static ThreadLocal<int> _threadValue = new ThreadLocal<int>(() => 0); // 每线程一份
| 对比 | 普通静态字段 | ThreadLocal |
|---|---|---|
| 存储 | 所有线程共享一份 | 每线程独立一份 |
| 并发访问 | 需要加锁/原子操作 | 天然无竞争(无需锁) |
| 初始化 | 静态初始化一次 | 每线程首次访问时初始化 |
| 清理 | 进程结束 | 线程结束时自动释放 |
用途场景:
- 消除共享竞争:每线程独立缓存,无需加锁(如每线程一个 StringBuilder 复用)
csharp
private static ThreadLocal<StringBuilder> _sb =
new ThreadLocal<StringBuilder>(() => new StringBuilder(1024));
// 每线程复用同一个 StringBuilder,避免分配 + 无竞争
- 每线程上下文 :如记录当前线程的用户标识、日志上下文(类似
AsyncLocal<T>的同步版) - 随机数生成器 :
Random不是线程安全的,每线程一个实例
csharp
private static ThreadLocal<Random> _random =
new ThreadLocal<Random>(() => new Random(Guid.NewGuid().GetHashCode()));
ThreadLocal vs AsyncLocal:
| 对比 | ThreadLocal | AsyncLocal |
|---|---|---|
| 作用域 | 线程 | 异步流(async/await 上下文) |
| 跨 await | 可能丢失(线程池线程切换) | 自动沿异步流传递 |
| 用途 | 线程级数据 | 请求级上下文(如日志 TraceId) |
面试加分点:ThreadLocal = "每线程私有副本 ",天然免锁;
AsyncLocal是异步版本(日志链路追踪 TraceId 是经典应用);两者都需注意泄漏(线程池线程复用,值会残留)。
133. 字符串驻留(String Interning)是什么?有什么坑?
答案:
字符串驻留(Interning) 是 CLR 的一项优化:内容相同的字符串字面量共享同一个对象,由**驻留池(Intern Pool)**统一管理。
csharp
string a = "hello";
string b = "hello"; // 字面量相同 → 指向驻留池中的同一个对象
Console.WriteLine(ReferenceEquals(a, b)); // True!共享同一对象
驻留机制:
- 编译期 :相同的字符串字面量在元数据中只存一份
- 运行期:CLR 把字符串字面量放入驻留池(一个哈希表),内容相同则复用
- 运行时创建的字符串不会自动驻留:
csharp
string c = new string(new char[] { 'h', 'e', 'l', 'l', 'o' }); // 运行时构造
Console.WriteLine(ReferenceEquals(a, c)); // False!c 是新对象,不在池中
// 但用 == 比较仍是 True(内容相等)
手动驻留:string.Intern
csharp
string d = string.Intern(c); // 把 c 加入驻留池(若已存在则返回池中对象)
Console.WriteLine(ReferenceEquals(a, d)); // True
优点:
- 相同字面量只占一份内存(节省内存)
- 字符串比较可快速失败(引用相等 → 内容必然相等)
坑与注意事项:
string.Intern会导致驻留池内存只增不减 ------动态构造大量不同字符串并 Intern,池会无限增长(生产环境慎用 Intern)
csharp
// ❌ 危险:循环生成不同字符串并 Intern,驻留池膨胀
for (int i = 0; i < 1_000_000; i++)
{
string.Intern(i.ToString()); // 池中对象永不被回收!
}
- 不要依赖引用相等做业务判断 :应始终用
==(内容比较),驻留是内部优化,不要利用它 - 拼接结果不驻留:
csharp
string e = "he" + "llo"; // 编译期常量折叠 → 仍是 "hello",驻留
string f = "he" + "llo"; // 同上,编译期确定
string g = a.Substring(0); // 运行时结果,不驻留(特殊情况可能返回原引用)
- 锁字符串是反模式:驻留意味着"相同内容的字符串是同一把锁",可能被无关代码意外阻塞(呼应第68题"不要 lock 字符串")
面试加分点:驻留 = "字面量字符串共享对象 ";运行时构造的字符串不自动驻留;
Intern的内存泄漏风险;以及"不要用字符串当锁"正是因为驻留导致的全局共享。
反射与特性
134. 为什么反射慢?如何优化反射性能?
答案:
为什么反射慢:
- 类型解析开销 :
typeof、GetType()需要解析元数据表 - 动态调用开销 :
MethodInfo.Invoke走的是晚期绑定(参数装箱、安全检查、异常包装),无法像直接调用那样内联/优化 - 装箱:调用值类型参数/返回值时发生装箱
- 安全检查:每次调用做访问检查、类型检查
csharp
// 慢:每次调用都动态解析
MethodInfo method = typeof(Foo).GetMethod("Bar");
method.Invoke(foo, new object[] { 1, 2 }); // 参数数组 + 装箱
// 快:直接调用
foo.Bar(1, 2);
优化方案:
1. 缓存反射结果(最简单有效):
csharp
// 把获取的 MemberInfo/ConstructorInfo 缓存,避免重复 GetMethod
private static readonly MethodInfo _cachedMethod =
typeof(Foo).GetMethod("Bar", BindingFlags.Public | BindingFlags.Instance);
2. 委托化(Delegate.CreateDelegate):把反射调用变成强类型委托
csharp
MethodInfo method = typeof(Foo).GetMethod("Bar");
var bar = (Func<Foo, int, string>)Delegate.CreateDelegate(
typeof(Func<Foo, int, string>), method);
// 调用时几乎和直接调用一样快
string result = bar(foo, 42);
3. 表达式树编译(Expression.Compile):
csharp
// 动态构建调用表达式并编译为委托,性能接近原生
var instance = Expression.Parameter(typeof(Foo), "i");
var arg = Expression.Parameter(typeof(int), "a");
var call = Expression.Call(instance, method, arg);
var compiled = Expression.Lambda<Func<Foo, int, string>>(call, instance, arg).Compile();
string result = compiled(foo, 42); // 近乎直接调用
4. 源生成器(Source Generator,现代最佳实践):
csharp
// 编译期生成强类型代码,完全消除运行时反射
[JsonSerializable(typeof(MyModel))]
public partial class MyJsonContext : JsonSerializerContext { }
// System.Text.Json 的源生成器:编译期生成序列化代码,零反射
5. 减少反射使用范围: 高频路径避免反射;反射放在初始化阶段(缓存、构建委托),运行期用缓存结果。
性能对比:
| 方式 | 相对性能 |
|---|---|
| 直接调用 | 最快(基准) |
| 表达式树 Compile + 缓存 | 接近直接调用 |
| Delegate.CreateDelegate + 缓存 | 接近直接调用 |
| MethodInfo.Invoke(每次) | 慢 10-100 倍 |
面试加分点:优化三板斧------缓存 + 委托化/表达式编译 + 源生成器;Source Generator 是 .NET 官方的"消灭运行时反射"方案(JSON 序列化、依赖注入等都用它)。
135. Attribute(特性)是什么?如何自定义特性?
答案:
Attribute 是附加在代码元素(类、方法、属性、字段、程序集等)上的声明性元数据 ,通过反射读取。它不影响代码执行,但可以被工具和框架读取并驱动行为。
csharp
// 内置特性
[Obsolete("请使用 NewMethod")]
public void OldMethod() { } // 编译警告
[Serializable]
public class SaveData { } // 标记可序列化
[Conditional("DEBUG")]
public void DebugLog() { } // 仅在 DEBUG 编译时生效
[DebuggerDisplay("Name = {Name}")]
public class Person { public string Name { get; set; } } // 调试器显示
[CallerMemberName]
public void Log(string caller = "") { } // 编译器注入调用者名字
自定义特性:
csharp
// 1. 继承 Attribute
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, // 可应用的目标
AllowMultiple = false, // 是否允许重复标注
Inherited = true)] // 是否被继承
public class PlayerSettingAttribute : Attribute
{
public string DisplayName { get; }
public int Priority { get; }
public PlayerSettingAttribute(string displayName, int priority = 0) // 构造参数 = 位置参数
{
DisplayName = displayName;
Priority = priority;
}
}
// 2. 使用
[PlayerSetting("玩家配置", 1)]
public class PlayerConfig
{
[PlayerSetting("速度")]
public float Speed;
}
// 3. 反射读取
var type = typeof(PlayerConfig);
var attr = type.GetCustomAttribute<PlayerSettingAttribute>();
Console.WriteLine(attr?.DisplayName); // 玩家配置
Attribute 的参数:
- 位置参数:构造函数参数(必须有对应构造函数)
- 命名参数 :公共字段/属性(
[PlayerSetting("x", Priority = 2)])
应用场景:
| 场景 | 例子 |
|---|---|
| 标记/标注 | [Serializable]、[Obsolete] |
| 序列化控制 | [JsonIgnore]、[JsonPropertyName] |
| 数据验证 | [Required]、[Range](ASP.NET Core 模型验证) |
| 映射 | [Column]、[Key](EF Core 实体映射) |
| 编辑器工具 | Unity 的 [SerializeField]、[Header]、[SerializeField] |
| 路由/API | [HttpGet]、[Route](ASP.NET Core 控制器) |
| 框架行为 | [Test](NUnit/xUnit 测试标记) |
面试加分点:Attribute 是"代码的元数据 + 反射驱动 ";自定义流程三要素:继承 Attribute →
AttributeUsage限制目标 → 反射GetCustomAttribute读取。Unity 的[SerializeField]、测试框架的[Test]都是它的实际应用。
安全与工程实践
136. C# 中提供了序列化和反序列化 2 进制数据的公共类 BinaryFormatter,为什么我们一般还要自己去实现序列化和反序列化 2 进制数据的方法?
答案:
BinaryFormatter 问题:
- 安全漏洞 - 可能执行恶意代码
- 版本兼容性差 - 类结构变化导致失败
- 效率低 - 数据体积大
替代方案:
- JSON(可读性好)
- MessagePack/Protobuf(高效)
- 自定义 BinaryWriter(完全控制)
补充:BinaryFormatter 自 .NET 5 起被标记为 obsolete(编译警告 SYSLIB0011),在 .NET 8+ 中默认直接抛出异常,无法使用。
137. 异或加密的原理是什么?请简要说明。
答案:
原理: 利用异或运算的可逆性。
加密:密文 = 明文 XOR 密钥
解密:明文 = 密文 XOR 密钥
特性:
A XOR A = 0A XOR 0 = A- 对称加密,加解密同一函数
注意: 异或加密安全性低,不用于敏感数据。
138. 代码混淆是什么意思?有什么作用?
答案:
代码混淆将代码转换为功能相同但难以理解的形式。
混淆技术:
- 名称混淆(重命名为无意义字符)
- 控制流混淆(打乱执行顺序)
- 字符串加密
- 代码虚拟化
作用:
- 防止逆向工程、增加破解成本
- 保护敏感信息(如授权校验逻辑)
139. 在游戏开发中的日志系统一般会配合 C# 中的什么一起使用?用于记录报错信息。
答案:
配合特性:
-
条件编译(
#if)csharp#if UNITY_EDITOR || DEVELOPMENT_BUILD Debug.Log(message); #endif -
堆栈跟踪(
StackTrace)- 获取调用栈信息
-
CallerMemberName等特性- 编译时获取调用者信息,无运行时开销
-
日志分级
- Debug/Info/Warning/Error/Fatal
140. 在 Unity 中使用 C# 的 foreach 是否会带来额外内存和性能开销?
答案:
取决于运行时(Mono / IL2CPP)对泛型共享的优化程度:
- 老版本 Unity(约 2019 年之前) :受限于 Mono 的泛型共享实现,
List<T>.GetEnumerator()返回的结构体枚举器可能被装箱,导致foreach产生 GC 分配 - 较新版本 Unity :随着 Mono / IL2CPP 的更新,
List<T>的foreach已不再产生 GC 分配(具体版本边界与所用运行时相关,说法不一)
仍可能产生 GC:
- 遍历
ArrayList(非泛型) - 使用 LINQ 方法
建议: 高频遍历使用 for 循环更安全。
设计模式对比
141. 为什么我们使用单例模式更多,而几乎不使用静态类?
答案:
单例优势:
- 可以继承、实现接口
- 支持多态
- 可以延迟初始化
- 可以销毁重建(配合重新赋值/初始化方法)
- 可通过初始化方法传参(构造函数是私有的,无法直接传参)
- 相比静态类更便于测试(可替换实例;配合依赖注入效果更佳,但全局静态访问点本身并不容易 Mock)
静态类适用:
- 纯工具方法(无状态)
- 扩展方法容器
- 常量定义
🎬 博客主页:https://xiaoy.blog.csdn.net
🎥 本文由 呆呆敲代码的小Y 原创 🙉
🎄 学习专栏推荐:Unity系统学习专栏
🌲 游戏制作专栏推荐:游戏制作
🌲Unity实战100例专栏推荐:Unity 实战100例 教程
🏅 欢迎点赞 👍 收藏 ⭐留言 📝 如有错误敬请指正!
📆 未来很长,值得我们全力奔赴更美好的生活✨
------------------❤️分割线❤️-------------------------




资料白嫖,技术互助
| 学习路线指引(点击解锁) | 知识定位 | 人群定位 |
|---|---|---|
| 🧡 Unity系统学习专栏 | 入门级 | 本专栏从Unity入门开始学习,快速达到Unity的入门水平 |
| 💛 Unity实战类项目 | 进阶级 | 计划制作Unity的 100个实战案例!助你进入Unity世界,争取做最全的Unity原创博客大全。 |
| ❤️ 游戏制作专栏 | 难度偏高 | 分享学习一些Unity成品的游戏Demo和其他语言的小游戏! |
| 💚 游戏爱好者万人社区 | 互助/吹水 | 数万人游戏爱好者社区,聊天互助,白嫖奖品 |
| 💙 Unity100个实用技能 | Unity查漏补缺 | 针对一些Unity中经常用到的一些小知识和技能进行学习介绍,核心目的就是让我们能够快速学习Unity的知识以达到查漏补缺 |
