【实战精选面试题】C# 面试题详细解答,按难易程度排序

  • [【实战精选面试题】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. 静态构造函数(静态构造器)有什么特点?

答案:

静态构造函数用于初始化类型的静态成员,具有以下特点:

  1. 无参数、无访问修饰符、不能重载
  2. 只执行一次,在整个应用生命周期内最多运行一次
  3. 触发时机:类型第一次被使用前(首次访问静态成员或创建第一个实例时)由 CLR 自动调用
  4. 线程安全:CLR 保证静态构造函数不会并发执行(多线程同时触发时只有一个线程执行,其他线程等待)
  5. 不能显式调用,也不能被继承/重写
  6. 初始化顺序:静态字段初始化器先于静态构造函数体执行(补充题 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 的条件(全部满足):

  1. 类型逻辑上表示单个值(如坐标、颜色、区间)
  2. 实例大小较小(一般建议 ≤ 16 字节)
  3. 不可变(字段不可修改)
  4. 不会被频繁装箱
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 是一个任意大整数类型,没有固定的上限和下限,其大小仅受可用内存限制。

主要用途:

  1. 处理超大数值

    • 超出 long/ulong 范围的整数计算
    csharp 复制代码
    BigInteger huge = BigInteger.Parse("123456789012345678901234567890");
    BigInteger result = huge * huge;
  2. 加密算法

    • RSA、ECC 等公钥加密算法需要处理大素数
  3. 精确计算

    • 需要高精度整数运算的场景

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# 中在字符串前面加一个 @符号或者 $ 符号有什么作用?

答案:

@ 符号 - 逐字字符串标识符

作用:字符串中的转义字符被当作普通字符处理,字符串可以跨多行。

常用场景:

  1. 文件路径
csharp 复制代码
// 不用 @:需要双反斜杠
string path = "C:\\Program Files\\Unity\\Editor";

// 使用 @:单反斜杠即可
string path = @"C:\Program Files\Unity\Editor";
  1. 正则表达式
csharp 复制代码
// 正则表达式中有大量反斜杠
string pattern = @"\d{3}-\d{4}";  // 清晰易读
string pattern2 = "\\d{3}-\\d{4}"; // 难读
  1. 多行字符串
csharp 复制代码
string query = @"
SELECT *
FROM Users
WHERE Name = 'Alice'
";
  1. JSON 字符串
csharp 复制代码
string json = @"
{
    ""name"": ""Alice"",
    ""age"": 30
}";
// 注意:需要双引号转义为 ""
  1. 包含特殊字符
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

为什么设计成不可变:

  1. 线程安全 - 字符串可被多线程安全共享,无需加锁
  2. 哈希稳定 - 作为 Dictionary 的键,哈希码不变,查找正确
  3. 字符串驻留 - 相同内容可共享同一对象,节省内存
  4. 安全性 - 防止别名问题(一个引用修改影响所有引用)

频繁拼接的问题:

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(不同对象,但内容相同)

区别点:

  1. == 是运算符重载 :string 重载了 ==,使其比较内容;但普通引用类型 的 == 比较的是引用(除非重载)。

  2. 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 的重写
  1. 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?

作用:

  1. 表示缺失值或未知状态
  2. 与数据库交互(NULL 值映射)
  3. 简化默认值处理: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
{
    // 成功才进入
}

使用建议:

  1. 解析用户输入 / 不确定格式的字符串 → TryParse(首选,避免异常开销)
  2. 确定格式正确的字符串 → Parse(代码更简洁)
  3. 数字类型之间转换 (int ↔ double)→ 强制转换或 Convert
  4. 处理 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"

限制(重点):

  1. 必须定义在静态类 中,方法必须是静态的
  2. 第一个参数用 this 修饰,且只能有一个 this 参数
  3. 不能访问被扩展类型的私有/受保护成员(只能使用公共成员)
  4. 不能重写已有实例方法(实例方法优先级高于扩展方法)
  5. 扩展方法不能用于属性、事件、运算符

调用优先级: 编译时优先匹配实例方法 ,只有找不到实例方法时才找扩展方法(且扩展方法需在 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

要点:

  1. 用 this 关键字声明,后面跟参数列表
  2. 可以有多个参数(如 this[int row, int col],模拟二维访问)
  3. 可以重载(不同参数类型/数量)
  4. 访问器可以是 get、set,也可以只读
  5. 不能是静态的;接口中可以声明索引器
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;   // 自定义加法

规则:

  1. 必须是公共静态 方法,用 operator 关键字
  2. 至少有一个参数是所属类型(不能重载别人的运算符)
  3. 某些运算符必须成对重载 :== 和 !=、< 和 >、<= 和 >=
  4. 重载 == 时必须同时重写 Equals 和 GetHashCode(保持一致性约定)
  5. 不能改变运算符的优先级和操作数个数

不能重载的运算符:

  • &&、||(可通过重载 &、| + 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: "调试");        // 命名参数:任意顺序

规则:

  1. 可选参数必须排在参数列表最后(有默认值的参数之后不能再有无默认值参数)
  2. 可选参数的默认值必须是编译期常量 (const 值,如数字、字符串、枚举)
  3. 命名参数在 C# 4.0 引入
  4. 调用 COM 接口或方法重载很多时,命名参数特别有用
  5. 可选参数和重载同时存在时,编译器按"最具体匹配"选择(一般先尝试完整签名)

注意: 可选参数默认值属于调用方编译时确定,修改默认值后不重新编译调用方不会生效。


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# 中实现异步的方式:

  1. async/await 模式(推荐)

    csharp 复制代码
    public async Task<string> DownloadDataAsync(string url)
    {
        using HttpClient client = new HttpClient();
        string result = await client.GetStringAsync(url);
        return result;
    }
  2. Task / Task<T> 与 Task.Run

    • 表示异步操作,可以 await;Task.Run 用于把 CPU 密集型任务放到线程池执行
  3. ValueTask / ValueTask<T>

    • 用于性能敏感场景,避免异步操作已完成时的额外分配
  4. 线程与线程池(Thread / ThreadPool)

    • 最底层的多线程方式,手动创建和控制线程
  5. 委托的异步调用(APM 模式)

    • BeginInvoke / EndInvoke,已过时,不推荐在新代码中使用
  6. Parallel / PLINQ

    • 用于 CPU 密集型的任务并行和数据并行

58. 游戏开发哪些地方会用到异步编程?(至少说出三种情况)

答案:

  1. 资源加载

    • 加载场景、模型、纹理等,避免卡死主线程
  2. 网络请求

    • 登录验证、排行榜数据获取等
  3. 文件 I/O

    • 读写本地存档文件
  4. 长时间计算

    • 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 方获取

关键理解:

  1. await 不阻塞线程:等待期间,当前线程被释放(回到线程池或消息循环),用于执行其他工作;完成后再回来继续。这正是"异步"和"同步阻塞"的本质区别。
csharp 复制代码
// 同步阻塞:线程卡住等待
Thread.Sleep(1000);

// 异步:不卡线程,释放控制权
await Task.Delay(1000);
  1. await 之后可能不在原线程 :默认会通过 SynchronizationContext 回到原上下文(UI 线程),没有同步上下文则回到线程池线程。

  2. 每个 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);
// 不回到原上下文,直接在线程池线程继续执行

什么时候必须用/建议用:

  1. 类库代码(非 UI 代码)中------避免不必要的上下文切换和潜在的上下文捕获开销
  2. 无 UI 的服务器/后台代码------没必要回到原上下文
  3. 避免死锁的辅助手段 (配合不要 .Result 阻塞)

什么时候不要用:

  1. UI 代码中 需要操作界面控件时(ConfigureAwait(false) 后无法直接操作 UI,需 Invoke)
  2. 依赖 HttpContext.Current、Thread.CurrentPrincipal 等线程相关上下文的 ASP.NET 代码(现代 ASP.NET Core 已无此依赖,可用)

面试要点:ConfigureAwait(false) 是性能优化 手段(减少上下文切换、避免捕获开销),也是类库编写规范,不是万能死锁解药------根因还是同步阻塞异步任务。


62. async void 有什么问题?什么时候用?

答案:

问题:

  1. 异常无法被捕获 :async void 方法抛出的异常不能由调用方 try/catch 捕获 ,会直接抛到 SynchronizationContext(或线程池),可能导致程序崩溃。
csharp 复制代码
try
{
    FireAndForget();   // async void 方法
}
catch (Exception ex)
{
    // 捕获不到!异常不会到这里
}

async void FireAndForget()
{
    await Task.Delay(100);
    throw new Exception("boom");   // 异常逃逸到同步上下文,导致崩溃
}
  1. 无法等待 :调用方无法 await 它,也无法感知其完成时机(返回 void)。

  2. 测试困难:无法在单元测试中等待完成。

对比 async Task: 返回 Task 的方法,异常会封装到 Task 中,await 时能正常捕获。

csharp 复制代码
async Task SafeMethod() { throw new Exception("可被捕获"); }

try { await SafeMethod(); }
catch (Exception ex) { /* 能捕获 */ }

什么时候必须用 async void:

  1. 事件处理器 (UI 事件、Unity 事件回调)------事件签名要求 void
  2. 异步幂等且允许异常的"发后不理"场景(但仍建议内部 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 参数)。

变通方案:

  1. 通过返回值传递修改后的值:
csharp 复制代码
async Task<(int result, int updated)> GetResultAsync(int input)
{
    await Task.Delay(100);
    return (input * 2, input + 1);   // 用元组返回多个值
}
  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())占用 了异步回调需要返回的线程/上下文,形成循环等待。

规避方案:

  1. 全程异步 :从入口到出口都 await,不要混合同步阻塞(推荐做法):
csharp 复制代码
private async void Button_Click(object sender, EventArgs e)
{
    var result = await GetDataAsync();   // 不阻塞 UI 线程
}
  1. 使用 ConfigureAwait(false):让库内部的任务不试图回到 UI 上下文(缓解但不根治):
csharp 复制代码
// 类库内部:
async Task<string> GetDataAsync()
{
    return await httpClient.GetStringAsync(url).ConfigureAwait(false);
}
  1. 后台线程上阻塞 (迫不得已时):把阻塞操作放到无同步上下文的线程(如 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("任务已取消");
}

要点:

  1. 协作式 :取消不是强杀线程,而是请求任务在安全点自行退出
  2. ThrowIfCancellationRequested() 抛 OperationCanceledException,await 到取消的任务同样会抛此异常
  3. 取消的任务状态为 TaskStatus.Canceled;通过 Task.WhenAll 等待多个任务时用 catch OperationCanceledException
  4. Task.Run 传入令牌:可避免任务未启动时排队等待(令牌已取消则直接不执行):
csharp 复制代码
Task.Run(() => LongCpuWork(), cts.Token);
  1. 组合令牌:CancellationTokenSource.CreateLinkedTokenSource(t1, t2) 任一取消即取消

应用场景:用户点击"取消下载"、请求超时(配合 cts.CancelAfter)、服务关闭时优雅停止。


多线程与并发

66. 请简单描述死锁是什么意思?

答案:

死锁是指两个或多个线程互相等待对方持有的资源,无法继续执行的状态。

死锁发生的四个必要条件:

  1. 互斥条件
  2. 持有并等待
  3. 非抢占条件
  4. 循环等待条件

避免死锁的方法:

  • 统一锁的获取顺序
  • 使用超时机制
  • 减少锁的嵌套

67. 死锁如何排查?有哪些实战预防手段?

答案:

排查方法:

  1. 看转储(Dump) :WinDbg / dotnet-dump 抓取进程转储,用 !clrstack 查看各线程调用栈,找出"谁在等谁"
  2. 并行栈(Visual Studio 调试):"调试 → 窗口 → 并行任务/并行堆栈",可视化线程阻塞关系
  3. 线程窗口 :查看每个线程的状态(WaitSleepJoin 状态说明在等待)
  4. dotnet-counters / 性能分析器 :观察线程池饥饿(ThreadPool Queue Length 持续上涨)
  5. 日志埋点:在加锁前/后打日志,定位卡住的锁

手动模拟排查示例:

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 → 死锁

预防手段(面试重点):

  1. 统一加锁顺序:所有线程按相同顺序获取锁(先 A 后 B),破坏"循环等待"条件
  2. 使用超时 :Monitor.TryEnter(lockObj, TimeSpan.FromSeconds(3)) 获取失败则放弃,不无限等待
  3. 减少锁的嵌套/粒度:尽量一次只持有一把锁
  4. 使用更高级的并发原语 :SemaphoreSlim、ConcurrentDictionary、Channel 等天然避免锁顺序问题
  5. 避免同步阻塞异步 :.Result/.Wait() 死锁(见第64题)
  6. 锁的作用域最小化 :lock 内不要做耗时操作(I/O、网络)
  7. 代码评审:新增锁代码时检查锁的获取顺序一致性

面试加分点:能背出死锁四必要条件(互斥、持有并等待、不可抢占、循环等待),并指出破坏循环等待 (统一顺序)和破坏持有并等待(超时)是最实用的两条预防路径。


68. lock 关键字的底层原理是什么?

答案:

lock 是 Monitor 类的语法糖。 lock(obj) 语句编译后等价于:

csharp 复制代码
// lock (obj) { ... } 编译后:
bool lockTaken = false;
try
{
    Monitor.Enter(obj, ref lockTaken);   // 尝试获取锁(进入临界区)
    // ... 临界区代码
}
finally
{
    if (lockTaken)
        Monitor.Exit(obj);               // 释放锁
}

原理:

  1. 监视器(Monitor) :每个对象在堆上有一个同步块索引(Sync Block Index),用于记录锁状态和持锁线程
  2. 进入锁(Monitor.Enter) :如果对象未被锁定则获取;已被其他线程持有则阻塞等待
  3. 可重入(Reentrant) :同一个线程可以多次 lock 同一个对象(嵌套),内部有计数器,每次 Exit 减一,归零才真正释放
  4. 释放(Monitor.Exit):退出临界区并唤醒等待队列中的下一个线程(FIFO 大致有序)
csharp 复制代码
private readonly object _lockObj = new object();   // 推荐:专门的锁对象

public void Add(int value)
{
    lock (_lockObj)
    {
        _count += value;     // 临界区:同一时刻只有一个线程能进入
    }
}

lock 的注意事项:

  1. 不要 lock 值类型(会装箱,每次生成新对象,锁失效)
  2. 不要 lock this(外部可能也 lock 同一对象导致死锁/阻塞不可控)
  3. 不要 lock 字符串(字符串可能被驻留共享,全局共享同一把锁)
  4. 用 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:

  1. volatile 不保证原子性:
csharp 复制代码
private volatile int _count;

public void Increment()
{
    _count++;   // 读-改-写三步,volatile 不管用!两个线程可能同时读到 5,都写回 6
}

++ 是"读取→加 1→写回"三个操作,volatile 只保证每个操作看到最新值,不保证这三个操作整体不被并发打断 。原子性需要 Interlocked 或 lock。

  1. volatile 不能实现互斥:多个线程同时写一个 volatile 字段仍是竞态。

  2. 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. 什么是生产者-消费者模式?如何实现?

答案:

模式定义: 生产者线程生产数据放入缓冲区,消费者线程从缓冲区取数据消费。缓冲区解耦了生产和消费的速度差异(生产者快、消费者慢时缓冲积压,反之则等待)。

核心需求:

  1. 缓冲区线程安全(多生产者/多消费者同时访问)
  2. 缓冲区满时生产者阻塞等待
  3. 缓冲区空时消费者阻塞等待
  4. 生产完成时通知消费者结束

实现方式一: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);
    }
}

使用注意:

  1. 归还对象时要重置状态(位置、速度、血量等)
  2. 对象池不适合所有场景------初始化成本低的对象直接 new 即可
  3. 注意池中对象的"假死"问题(引用仍存在,需正确停用)
  4. .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 点原因)

答案:

  1. 没有垃圾回收开销

    • C++ 完全手动管理,无 GC 暂停
    • C# GC 可能导致卡顿
  2. 内存布局更紧凑

    • C++ 无对象头开销
    • 缓存命中率更高
  3. 分配速度快

    • C++ 栈分配几乎零开销

89. 那为什么 C++ 从执行性能上往往也被认为优于 C# 呢?

答案:

  1. 编译方式不同

    • C++:AOT 编译为机器码,深度优化
    • C#:JIT 编译,首次有开销
  2. 对硬件控制更强

    • C++ 可直接使用 SIMD、内联汇编
  3. 泛型实现差异

    • C++ 模板编译时实例化
    • C# 泛型:值类型实参在 JIT 时特化生成专用代码,引用类型实参共享同一份代码

90. C# 中的 CLR 是什么?

答案:

CLR(Common Language Runtime) 是 .NET 的核心运行时,负责:

  • 代码执行(JIT 编译)
  • 内存管理(GC)
  • 类型安全验证
  • 异常处理
  • 跨语言互操作

存档与序列化

91. 使用 C# 制作游戏存档功能,请问有几种做法?(至少说出三种)

答案:

  1. JSON 序列化

    csharp 复制代码
    string json = JsonConvert.SerializeObject(data);
    File.WriteAllText(savePath, json);
  2. 二进制序列化

    • 使用 BinaryWriter/BinaryReader
  3. XML 序列化

    • 使用 XmlSerializer
  4. SQLite 数据库

    • 适合复杂数据
  5. PlayerPrefs(Unity)

    • 适合简单数据

92. 在制作游戏存档功能时,C# 中反射主要可以发挥出哪些作用?(至少说出三点)

答案:

  1. 自动序列化/反序列化

    • 遍历所有字段自动保存
  2. 版本兼容处理

    • 处理旧版本存档字段映射
  3. 编辑器工具支持

    • 动态显示和编辑存档数据
  4. 动态类型处理

    • 多态类型反序列化

协程与初始化

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 编译 → 机器码(缓存)

详细步骤:

  1. 方法首次调用时触发 JIT
  2. 验证 IL 类型安全
  3. 编译优化(内联、消除边界检查)
  4. 缓存机器码,后续直接执行

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 时代)
  • 强名称程序集:带公钥签名的程序集,防篡改
  • .dll vs .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:

  1. 跨平台部署(服务器可以用 Linux)
  2. 性能显著提升(ASP.NET Core 在 TechEmpower 基准中常年位居前列)
  3. 云原生友好(容器、微服务)
  4. 快速迭代新特性

.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 优点:

  1. 启动极快(毫秒级),无 JIT 预热
  2. 内存占用低(无运行时 JIT、无元数据冗余)
  3. 部署简单:单文件,无需安装 .NET 运行时
  4. 安全(IL 不暴露,更难反编译------相比托管程序集)

NativeAOT 缺点/限制:

  1. 反射受限 :不能动态加载/生成类型;需要 Rd.xml 配置或源生成器(Source Generator)静态生成反射信息
  2. 动态代码受限 :Expression.Compile 动态 IL 生成不可用
  3. 互操作需要声明:P/Invoke 需显式指定
  4. 编译时间较长

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);
}

工作原理:

  1. [DllImport] 声明告诉 CLR:这个 extern 方法对应哪个 DLL 的哪个导出函数
  2. CLR 加载 DLL,查找导出符号(EntryPoint)
  3. 封送(Marshaling) :CLR 自动在托管类型 ↔ 非托管类型之间转换数据
  4. 调用完成后释放非托管资源(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);

注意事项:

  1. 封送开销:频繁互操作有性能成本,尽量批量调用
  2. 内存安全 :非托管内存需要手动释放(Marshal.FreeHGlobal);回调需防止 GC 回收(用 GCHandle 固定)
  3. 需要 unsafe 的互操作要在项目开启 AllowUnsafeBlocks
  4. 现代替代:[LibraryImport](.NET 7+,编译期生成封送代码,性能更好)

面试加分点:P/Invoke 是 C# 调用 C++/系统 API 的通道(Unity 的底层插件、游戏的 C++ 核心引擎都是这么接的);能说出 DllImport、封送、GCHandle 固定三要素。


GC 深入

102. C# 中的垃圾回收机制是使用的引用计数还是其它方式呢?

答案:

不使用引用计数 ,使用标记-清除 和分代收集。

引用计数缺陷:

  • 无法处理循环引用
  • 每次赋值需更新计数

C# GC 算法:

  1. 标记阶段 - 从根对象出发遍历,标记所有可达对象
  2. 清除阶段 - 回收未标记对象(Gen 0/1 会进行压缩以消除碎片,Gen 2 通常只清除不压缩)
  3. 分代收集 - Gen 0/1/2 提高效率

103. C# 中自动触发 GC 的原因可能有哪些?从分代收集去考虑?

答案:

触发原因:

  1. Gen 0 已满 - 最常见
  2. Gen 1 已满 - 回收 Gen 0+1
  3. Gen 2 已满 - 完全回收
  4. LOH 分配 - 可能触发 Gen 2
  5. 操作系统内存压力
  6. 显式调用 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 会根据存活率调节:存活率高时提高阈值,减少无意义提升)

关键概念:

  1. 每次回收的覆盖范围 :
    • Gen0 满 → 只回收 Gen0(最快)
    • Gen1 满 → 回收 Gen0 + Gen1
    • Gen2 满 / LOH 分配 / 系统内存压力 → 全回收(Gen0+1+2,最慢)
  2. Gen0 回收最快:对象少,标记-压缩都在小范围内
  3. 提升不是"免费"的:大对象频繁幸存会导致 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 的另外两个重要配置:

  1. 并发/后台 GC(Concurrent GC) :回收第 2 代时在后台线程 执行,与业务线程并发 ,减少停顿(System.GC.Concurrent,默认开启)
  2. 延迟模式(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# 中避免内存碎片?

答案:

内存碎片是内存被分割成不连续小块,无法满足大对象分配。

避免方法:

  1. 使用对象池
  2. 提前分配并复用
  3. 将大对象拆分为小对象
  4. 避免混合大小分配

内存管理深入

109. C# 中 new 一个引用对象做了什么?

答案:

步骤:

  1. 对象大小在类型加载时已确定,按已知大小在托管堆分配内存(空间不足时会先触发 GC)
  2. 初始化对象头(同步块索引、类型指针)
  3. 调用构造函数(实例字段初始化器在构造函数体之前执行)
  4. 返回对象引用

110. 什么是内存对齐?为什么要进行内存对齐?

答案:

内存对齐是将数据存储在其大小的整数倍地址。

原因:

  1. 硬件效率 - CPU 访问更快
  2. 原子操作要求 - CAS 需要对齐
  3. 缓存一致性 - 提高缓存命中率

111. C# 中怎么计算对象占用多少内存?

答案:

方法:

  1. Marshal.SizeOf<T>(值类型)
  2. GC.GetTotalMemory 差值法
  3. Memory Profiler

估算公式:

复制代码
对象大小 = 对象头(64 位下为 16 字节:同步块索引 8 字节 + 类型指针 8 字节;32 位下为 8 字节) + 字段大小 + 对齐填充

112. C# 中堆内存优化的大方向是什么?再说 3 点具体方案?

答案:

大方向: 减少堆分配,降低 GC 频率。

具体方案:

  1. 使用对象池

    csharp 复制代码
    byte[] buffer = ArrayPool<byte>.Shared.Rent(1024);
  2. 使用值类型(struct)

    • struct 作为方法内的局部变量时通常分配在栈上(或寄存器中)
    • 但作为类的字段、数组元素、集合元素时仍然在堆上
    • 相比引用类型,可减少对象头开销、降低 GC 追踪压力
  3. 避免 LOH 分配

    • 拆分大对象
  4. 减少闭包捕获

    • 避免生成额外类
  5. 使用 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();               // 原子操作内部自带全屏障

注意:

  1. lock、Monitor、Interlocked 内部都包含内存屏障(所以它们能同时解决可见性 + 原子性)
  2. 内存屏障只解决顺序/可见性 ,不解决原子性 (volatile 同理,见第70题)
  3. x86 架构内存模型相对宽松(有较强的硬件保证),ARM 架构乱序更激进,屏障更重要

面试加分点:能解释"重排 → 可见性问题 → 屏障"的因果链;volatile = "读写 + 隐式屏障";lock/Interlocked 自带全屏障,所以用锁最省心。


115. C# 中的弱引用是什么?它的作用是什么?

答案:

弱引用允许 GC 回收被引用的对象,不会阻止回收。

作用:

  1. 缓存系统 - 允许 GC 回收不常用缓存
  2. 事件订阅/观察者模式 - 防止长生命周期对象(如事件发布者)强持有短生命周期对象(订阅者),导致其无法被回收

注意:.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 使用铁律:

  1. 谁创建,谁负责释放(using 或 finally 中调用)
  2. 持有 IDisposable 字段的类,自己通常也要实现 IDisposable(在 Dispose 中释放它们)
  3. 重复调用 Dispose 必须安全(不抛异常)------靠 _disposed 标志
  4. 无法确定资源是否已释放时,终结器作为最后兜底

面试加分点:区分托管资源 (GC 管)和非托管资源 (必须手动释放);using 编译后就是 try { } finally { Dispose(); }。


118. C# 中的 ArrayPool 有什么作用?

答案:

ArrayPool<T> 是数组对象池,用于复用大型数组。

作用:

  1. 减少 GC 压力
  2. 提高性能
  3. 减少内存碎片

使用:

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,视平台)

风险(重点):

  1. 栈溢出风险 :栈空间有限,分配过大(如 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);  // 大用池
  1. 不能用于引用类型(只能分配非托管类型/值类型)
  2. 分配的内存未初始化 (除非用 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 压力
  • 内存占用增加

避免方案:

  1. 使用泛型容器

    csharp 复制代码
    List<int> list = new List<int>();  // 无装箱
  2. 使用泛型方法重载

  3. 调用 ToString() 避免装箱

    csharp 复制代码
    Debug.Log(health.ToString());  // 无装箱
  4. 使用 IComparable<T>


125. 为什么说闭包可能会带来内存泄漏?

答案:

机制:

  • 闭包编译为额外类
  • 捕获的变量成为类字段
  • 闭包被长期持有时,外部对象无法回收

避免方法:

  1. 及时取消订阅事件
  2. 使用弱引用
  3. 使用实例方法代替闭包
  4. 避免在循环中捕获变量

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 赋给非空引用类型

注意事项:

  1. 它是编译期注解,不是运行时机制------运行时行为和以前完全一样(null 还是会抛异常),只是编译期帮你提前发现
  2. !(null 忽略运算符):明确告诉编译器"我知道它不为 null":
csharp 复制代码
string s = maybeNull!;   // 我保证不为 null,跳过检查(慎用)
  1. [NotNull]、[MaybeNull] 等特性:更细粒度地描述方法返回/参数的空性契约
  2. 对历史代码开启会大量报警告,可逐步迁移(按文件/按项目开启)

面试加分点: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)
开销 集合分配 状态机对象分配(一次)

注意事项:

  1. 迭代器方法不能有 ref/out 参数(原因同 async,见第63题)
  2. 方法体在第一次 MoveNext 时才执行 (yield return 之前的第一段代码也是惰性的)
  3. 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
    }
}

核心机制总结:

  1. _state 字段 :记录暂停点,恢复时 switch 跳转
  2. 局部变量/参数提升为字段:跨 await 存活(这也是 async 方法不能用 ref/out 参数的原因,见第63题)
  3. await 未完成时 :注册 continuation 回调并立即 return → 调用线程不被阻塞
  4. 完成后 :回调(MoveNext)从上次状态继续执行
  5. 异常处理 :异常被 SetException 存入 Task,由 await 方接收
  6. 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
存储 所有线程共享一份 每线程独立一份
并发访问 需要加锁/原子操作 天然无竞争(无需锁)
初始化 静态初始化一次 每线程首次访问时初始化
清理 进程结束 线程结束时自动释放

用途场景:

  1. 消除共享竞争:每线程独立缓存,无需加锁(如每线程一个 StringBuilder 复用)
csharp 复制代码
private static ThreadLocal<StringBuilder> _sb =
    new ThreadLocal<StringBuilder>(() => new StringBuilder(1024));

// 每线程复用同一个 StringBuilder,避免分配 + 无竞争
  1. 每线程上下文 :如记录当前线程的用户标识、日志上下文(类似 AsyncLocal<T> 的同步版)
  2. 随机数生成器 :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!共享同一对象

驻留机制:

  1. 编译期 :相同的字符串字面量在元数据中只存一份
  2. 运行期:CLR 把字符串字面量放入驻留池(一个哈希表),内容相同则复用
  3. 运行时创建的字符串不会自动驻留:
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

优点:

  • 相同字面量只占一份内存(节省内存)
  • 字符串比较可快速失败(引用相等 → 内容必然相等)

坑与注意事项:

  1. string.Intern 会导致驻留池内存只增不减 ------动态构造大量不同字符串并 Intern,池会无限增长(生产环境慎用 Intern)
csharp 复制代码
// ❌ 危险:循环生成不同字符串并 Intern,驻留池膨胀
for (int i = 0; i < 1_000_000; i++)
{
    string.Intern(i.ToString());   // 池中对象永不被回收!
}
  1. 不要依赖引用相等做业务判断 :应始终用 ==(内容比较),驻留是内部优化,不要利用它
  2. 拼接结果不驻留:
csharp 复制代码
string e = "he" + "llo";        // 编译期常量折叠 → 仍是 "hello",驻留
string f = "he" + "llo";        // 同上,编译期确定
string g = a.Substring(0);      // 运行时结果,不驻留(特殊情况可能返回原引用)
  1. 锁字符串是反模式:驻留意味着"相同内容的字符串是同一把锁",可能被无关代码意外阻塞(呼应第68题"不要 lock 字符串")

面试加分点:驻留 = "字面量字符串共享对象 ";运行时构造的字符串不自动驻留;Intern 的内存泄漏风险;以及"不要用字符串当锁"正是因为驻留导致的全局共享。


反射与特性

134. 为什么反射慢?如何优化反射性能?

答案:

为什么反射慢:

  1. 类型解析开销 :typeof、GetType() 需要解析元数据表
  2. 动态调用开销 :MethodInfo.Invoke 走的是晚期绑定(参数装箱、安全检查、异常包装),无法像直接调用那样内联/优化
  3. 装箱:调用值类型参数/返回值时发生装箱
  4. 安全检查:每次调用做访问检查、类型检查
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 = 0
  • A XOR 0 = A
  • 对称加密,加解密同一函数

注意: 异或加密安全性低,不用于敏感数据。


138. 代码混淆是什么意思?有什么作用?

答案:

代码混淆将代码转换为功能相同但难以理解的形式。

混淆技术:

  • 名称混淆(重命名为无意义字符)
  • 控制流混淆(打乱执行顺序)
  • 字符串加密
  • 代码虚拟化

作用:

  1. 防止逆向工程、增加破解成本
  2. 保护敏感信息(如授权校验逻辑)

139. 在游戏开发中的日志系统一般会配合 C# 中的什么一起使用?用于记录报错信息。

答案:

配合特性:

  1. 条件编译(#if)

    csharp 复制代码
    #if UNITY_EDITOR || DEVELOPMENT_BUILD
        Debug.Log(message);
    #endif
  2. 堆栈跟踪(StackTrace)

    • 获取调用栈信息
  3. CallerMemberName 等特性

    • 编译时获取调用者信息,无运行时开销
  4. 日志分级

    • 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的知识以达到查漏补缺
相关推荐
水无痕simon1 小时前
3、工程重构
java·spring cloud·重构
阿里云基础软件1 小时前
Agent 任务提速 10.71%,内存占用降低 21.60%|Alibaba Cloud Linux 4 重大发布
java·linux·人工智能·操作系统·agent
淡海水2 小时前
09-01-内存-Unity资源生命周期全景
unity·c#·游戏引擎·.netcore·gc
Wang's Blog2 小时前
Java 项目实战: 交友项目 - 项目:需求分析与滑动数据设计
java·开发语言·交友
前端 贾公子2 小时前
第 5 章:Codex 从可运行程序到可交付工具
java·前端·数据库
CoderYanger3 小时前
Java EE 进阶:5. Spring IoC&DI(第三弹):Spring DI
java·开发语言·spring boot·spring·面试·java-ee·mvc
思想觉悟3 小时前
SEAndroid实战
android·linux·c++
一条泥憨鱼3 小时前
【Kafka】零基础学习教程
java·分布式·学习·kafka
思想觉悟3 小时前
Aosp开发磨刀石之ADB速查
android·c++