我最近有机会与大量的C#新手聊天。 他们中的许多人都对语言和平台感兴趣,这很酷。 在绿色初中,人们对明显的事物(只是读了一本关于记忆的事物)的含糊其辞很普遍。 这也促使我创建本文。 本文主要针对初学者,但我认为许多事实对实践工程师很有用。 好吧,最明显和最无趣的错误当然会被省略。 这是最有趣和最有意义的,尤其是从通过面试的角度来看。
#1 在任何情况下都赞美3代
这比错误更不准确。 对于开发人员而言,有关“ C#中的垃圾收集器”的问题已成为经典,很少有人会开始对世代的概念做出明智的回答。 但是,由于某些原因,很少有人会注意到强大而可怕的垃圾收集器是运行时的一部分这一事实。 因此,我会明确指出这不是手指,而是会问涉及哪种运行时环境。 对于查询“ C#中的垃圾收集器”,可以在Internet上找到许多类似信息。 但是,很少有人提到此信息是指
CLR / CoreCLR (通常)。 但是不要忘了Mono,它是一种轻量级,灵活且嵌入式的运行时,已在移动开发(Unity,Xamarin)中占据了利基市场,并在Blazor中使用。 对于各个开发人员,我建议您查询Mono中组装设备的详细信息。 例如,在“单代垃圾收集器世代”的请求下,您可以看到只有两个世代-
托儿所和
老一代 (在新的和时髦的垃圾收集器
SGen中 )。
#2 在任何情况下都对大约两个阶段的垃圾收集进行口头禅
不久前,垃圾收集器的源对所有人都隐藏了。 但是,人们一直对平台的内部结构感兴趣。 因此,以不同的方式提取信息。 收集器逆向工程中的一些错误导致了一个神话,即收集器在两个阶段工作:标记和清洁。 甚至更糟的是3个阶段-标记,清洁,压缩。
但是,随着
CoreCLR的出现和收集器的源代码
激起 了人们的
怒火 ,一切都变了。 CoreCLR的编译器代码完全来自CLR版本。 没人分别从头开始编写它,几乎可以从CoreCLR源代码中学到的所有内容对于CLR都是正确的。 现在,要了解某些东西是如何工作的,只需转到github并在源代码中找到它或阅读
readme 。 在那里,您可以看到分为5个阶段:标记,规划,更新链接,压缩(带有重定位的删除)和不重定位的删除(这很难翻译)。 但从形式上讲,它可以分为三个阶段-标记,计划,清洁。
在
标记阶段,发现收集器不应该收集哪些对象。
在
计划阶段,计算内存当前状态的各种指标,并收集清洁阶段所需的数据。 根据在此阶段收到的信息,可以决定是否需要压缩(碎片整理),还可以计算移动物体等所需的空间。
并且在
清洁阶段 ,根据压缩的需要,可以更新链接并压缩或删除链接而无需移动。
#3 在堆上分配内存与在堆栈上分配内存一样快
同样,不准确而不是绝对的不真实。 当然,在一般情况下,内存分配速度的差异很小。 确实,在最佳情况下,使用
凹凸指针分配 ,内存分配只是指针移位,就像在堆栈上一样。 但是,诸如将新对象分配给旧字段(这将影响
写障碍 ,更新
卡表 ,这种机制可让您跟踪从较早一代到较年轻的链接的机制),终结器(必须将类型添加到适当的队列中)的因素会影响堆上内存的分配。还有可能将对象记录在堆中的一个空闲孔中(组装后不进行碎片整理)。 而且,找到这样的空洞虽然快,但显然比简单的指针移动要慢。 好吧,当然,每个创建的对象都会使下一个垃圾收集更加紧密。 在下一个分配内存的过程中,它可能会发生。 当然,这将需要一些时间。
#4 通过堆栈和堆的概念定义引用,有意义的类型和包装
正确的经典,幸运的是,它不是那么普遍。
引用类型位于堆上。 在堆栈上很重要。 当然,许多人经常听到这些定义。 但是,这不仅是部分事实,因此通过泄漏的抽象来定义概念不是一个好主意。 对于所有定义,建议您参考CLI标准
ECMA 335 。 首先,有必要弄清楚类型描述值。 因此,引用类型的定义如下-引用类型(链接)描述的值指示另一个值的
位置 。 对于重要类型,其描述的值是独立的(自包含)。 关于这些或这些类型的单词的位置。 您仍然应该知道这是一个泄漏的抽象。
重要类型可能位于:
- 在动态内存(堆)中,如果它是位于堆上的对象的一部分,或者在打包的情况下;
- 在堆栈上,如果它是方法的局部变量/参数/返回值;
- 在寄存器中,如果允许有效类型的大小和其他条件。
引用类型,即链接指向的值,当前位于堆上。
链接本身可以与重要类型位于同一位置。
包装也不是通过存储位置来确定的。 考虑一个简单的例子。
C#代码public struct MyStruct { public int justField; } public class MyClass { public MyStruct justStruct; } public static void Main() { MyClass instance = new MyClass(); object boxed = instance.justStruct; }
以及对应于Main方法的IL代码
IL代码 1: nop 2: newobj instance void C/MyClass::.ctor() 3: stloc.0 4: ldloc.0 5: ldfld valuetype C/MyStruct C/MyClass::justStruct 6: box C/MyStruct 7: stloc.1 8: ret
由于有效类型是引用的一部分,因此很明显它将位于堆上。 第六行清楚地表明我们正在处理包装。 因此,“从堆栈复制到堆”的典型定义失败了。
对于初学者来说,要确定一个包是什么,值得一提的是,对于每种重要类型,CTS(通用类型系统)都定义了一个引用类型,称为打包类型。 因此,
打包是对重要类型的操作,它创建包含原始值的按位副本的相应打包类型的值。
#4 事件-单独的机制
事件是从语言的第一个版本开始存在的,关于事件的问题比事件本身更为常见。 但是,值得理解和了解它的含义,因为这种机制允许您编写非常松散耦合的代码,这有时很有用。
不幸的是,事件通常被理解为单独的工具,类型,机制。 BCL
EventHandler中的类型特别方便了该操作,其名称表明它是分开的。
定义事件应首先定义属性。 我很早就为自己画了一个类比,最近看到它是在CLI规范中绘制的。
该属性定义命名的值和访问它的方法。 听起来很明显。 我们传递给事件。 CTS支持事件以及属性,访问的BUT方法不同,并且包括用于订阅和取消订阅事件的方法。 根据C#语言规范,该类定义一个事件...,它使人联想到字段声明,并添加了event关键字。 此声明的类型必须是委托的类型。 感谢CLI标准的定义。
因此,这意味着该事件不过是一个仅公开代表功能的一部分的代表-将另一个代表添加到列表中以执行,并将其从该列表中删除。 在类内部,事件与简单的委托类型字段没有什么不同。
#5 托管和非托管资源。 终结器和IDisposable
处理这些资源时绝对会感到困惑。 Internet在很大程度上促进了这一点,其中有数千篇文章介绍了正确实现Dispose模式。 实际上,在这种模式下没有任何犯罪行为-针对特定案例的修改后的模板方法。 但是问题是,是否根本需要它。 出于某种原因,有些人对每次打喷嚏都需要实施终结器。 最有可能的原因不是对“非托管资源”的完全理解。 关于这样的事实,即在终结器中,通常由于这种不完全的理解而释放了不受管理的资源,这些资源过去了,并没有保留在头上。
非托管资源是非
托管资源 (但是可能很奇怪)。 反过来,
托管资源是CLI通过称为垃圾回收的过程自动分配和释放的
资源 。 我从CLI标准中大胆地删除了此定义。 但是,如果您尝试更简单地进行解释,则非托管资源是那些垃圾收集器不知道的资源。 (严格来说,我们可以使用GC.AddMemoryPressure和GC.RemoveMemoryPressure向收集器提供有关此类资源的一些信息,这可能会影响收集器的内部调整)。 因此,他将无法照顾自己的释放,因此我们必须为他这样做。 对此可以有很多方法。 为了使代码不会因开发人员的想象力的多样性而令人眼花,乱,因此使用了2种普遍接受的方法。
- IDisposable接口(及其IAsyncDisposable的异步版本)。 它受所有代码分析器的监视,因此很难忘记它的调用。 提供单一方法-处置。 编译器支持是using语句。 Dispose方法的主体的最佳选择是调用类中某个字段的相似方法或释放非托管资源。 由类用户显式调用。 类中此接口的存在意味着完成实例的工作后,您需要调用此方法。
- 终结器 保险是其核心。 在垃圾回收期间在未定义的时间隐式调用。 减慢内存分配(垃圾回收器的工作)至少可以延长对象的生存期,直到下一个程序集,甚至更长的时间为止,但是即使没有人调用它,它也会被自身调用。 由于其不确定性,因此只能在其中释放未管理的资源。 您还可以找到一些示例,在这些示例中,使用终结器来复活对象并以此方式组织对象池。 但是,这样实现对象池绝对不是一个好主意。 就像尝试登录,引发异常,访问数据库以及数千个类似操作一样。
您可以轻松地想象在编写对性能至关重要的库时的情况,该库在内部使用不受管理的资源,可以通过对该资源的有效处理来简单地处理它,并手动释放内存。 在编写此类高性能库时,OOP,支持以及其他类似的库会被遗弃。
与断言“ Dispose”违反了CLR会为我们做所有事情,强迫我们自己做某事,记住某件事等的说法相反,我将说以下内容。 在使用非托管资源时,必须准备好不要由您以外的任何人来管理它们。 通常,几乎不会遇到将这些资源用于企业价格的情况。 在大多数情况下,您可以使用出色的包装器类,例如SafeHandle,该类提供了关键的资源终结方法,可防止资源过早组装。
如果由于某种原因,您的应用程序中有大量资源需要释放其他步骤,那么您应该看看JetBrains的出色模式Lifetime。 但是,当您看到第一个IDisposable对象时,不应使用它。
#6 流堆栈,调用堆栈,计算堆栈和 堆栈<T>
最后一段出于这个目的而增加了笑声;我认为没有人将后者归因于前两个。 但是,关于什么是流堆栈,调用堆栈和计算堆栈有很多困惑。
调用堆栈是一个数据结构,即一个堆栈,用于存储返回地址,以便从函数中返回。 调用堆栈是一个更合理的概念。 它没有规定应在何处以及如何存储信息以返回。 事实证明,调用堆栈是最常见和本机的堆栈,即 堆叠(笑话)。 当调用CALL指令和中断时,将本地变量存储在其中,传递参数,并将返回地址存储在其中,随后RET指令将其用于从函数/中断返回。 来吧 流的主要笑话之一是指向该指令的指针,该指令将进一步执行。 线程依次执行组合成功能的指令。 因此,每个线程都有一个调用堆栈。 因此,事实证明流堆栈是调用堆栈。 也就是说,此流的调用堆栈。 通常,它也以其他名称称呼:软件堆栈,机器堆栈。
在
上一篇文章中已对其进行了详细
讨论 。
同样,调用堆栈定义用于指示以特定语言进行的特定方法的调用链。
计算堆栈(评估堆栈) 。 如您所知,C#代码被编译为IL代码,这是生成的DLL的一部分(在最一般的情况下)。 堆栈机器是运行时的核心,它吸收我们的DLL并执行IL代码。 几乎所有的IL指令都使用特定的堆栈进行操作。 例如,
ldloc将特定索引下的局部变量加载到堆栈上。 在此,堆栈是指某个虚拟堆栈,因为最后该变量很有可能位于寄存器中。 算术,逻辑和其他IL指令对堆栈中的变量进行运算,并将结果放在此处。 即,通过该堆栈进行计算。 因此,事实证明,计算堆栈是运行时的抽象。 顺便说一下,许多虚拟机都是基于堆栈的。
#7 更多线程-更快的代码
从直觉上看,并行处理数据比交替处理要快。 因此,掌握了有关使用线程的知识之后,许多人尝试并行化任何周期和计算。 几乎每个人都已经知道开销,这会导致线程的创建,因此他们使用
ThreadPool和
Task中的线程。 但是创建流的开销远没有结束。 在这里,我们要处理另一个泄漏的抽象,即处理器用来提高性能的机制-缓存。 而且经常发生的是,缓存是双刃刀片。 一方面,通过顺序访问一个流中的数据,它显着加快了工作速度。 但是,另一方面,当几个线程工作时,即使不需要同步它们,缓存不仅无济于事,而且会减慢工作速度。 在缓存失效上花费了额外的时间,即 维护相关数据。 而且不要小看这个问题,起初看起来像是一件小事。 高速缓存有效的算法将比多线程算法更快地执行一个线程,而在多线程算法中,低效使用高速缓存。
尝试使用来自多个线程的驱动器也会导致自杀。 在许多使用它的程序中,磁盘已经成为一个制约因素。 如果您尝试从多个线程使用它,则需要忘记速度。
对于所有定义,我建议在这里联系:
C#语言规范
-ECMA-334很好的资料来源:
Konrad Kokosa-Pro .NET内存管理
CLI规范
-ECMA-335CoreCLR开发人员有关运行时的知识-运行时的
书来自Stanislav Sidristy的有关定型和更多内容的信息-.NET
Platform Architecture