3.内存分配和数据结构

我们将继续探索构成高效和稳健软件系统基石的基本方面。内存管理是每一行代码背后的无声架构师,影响着性能、可扩展性和响应能力。了解如何分配和管理内存对于构建高性能应用程序至关重要。

本章将指导您了解内存分配的复杂性以及可用数据结构的战略性使用。无论您是经验丰富的开发人员还是相对较新的开发人员,掌握这些基础知识对于优化代码都至关重要。

本章探讨以下内容:

  • 内存分配机制
  • 选择正确的数据结构
  • 处理大型对象和数组

总体而言,这种理解有助于编写更高效、更稳健的代码,并使开发人员能够设计和实现可维护、可扩展且在速度和资源使用方面都最佳的系统。有效处理内存和数据结构的应用程序更具可扩展性。了解如何处理大型对象和数组有助于更有效地管理内存,尤其是在高负载和大规模应用程序中。这些知识有助于防止内存碎片化,并做出有关数据结构设计的明智决策,从而避免性能随时间推移而下降。

选择最合适的数据结构来开发高效、可扩展且性能良好的应用程序的重要性怎么强调也不为过。数据结构是软件应用程序的构建块,提供组织和访问数据的有效方法。在 .NET 开发中,选择适当的数据结构会显著影响应用程序的性能和可扩展性。在数组、列表、字典或更专业的结构(如树和图)之间进行选择,并了解它们的特性和对特定任务的适用性,对于制定高性能解决方案至关重要。

本章旨在揭开内存管理和数据结构的秘密,使您能够创建高效运行且可优雅扩展的软件。让我们首先深入研究 .NET 运行时内置的内存分配机制。

内存分配机制

每个应用程序性能的核心都在于内存分配的复杂性。如上一章所述,.NET 运行时使用内置堆栈和堆来存储和跟踪在应用程序运行时创建的变量和对象。了解这些机制将使您具备做出明智决策和编写优化内存资源的代码的知识。.NET 通过旨在优化内存使用并缓解常见内存相关问题(如内存泄漏和缓冲区溢出)的机制采用托管内存模型。

在探索更多 .NET 分配机制之前,让我们先研究一下其他流行语言,例如 C/C++ 和 Java,看看这些技术与 .NET 中的技术有何不同。

C/C++ 中的分配机制

每种语言都有自己的分配处理方法。C 被认为是一种基础语言,因为许多其他语言(如 C++、Java 和 C#)都从它的语法和执行方式中汲取了灵感。然而,每种衍生语言都试图改善开发人员的体验,并在一定程度上提高效率。

由于以下原因,C 可以被视为基础语言:

  • 接近硬件:C 是一种低级语言,可直接访问硬件资源,因此适用于系统编程、嵌入式系统和设备驱动程序。其语法和功能与底层硬件架构紧密结合,允许开发人员编写高效、可移植的代码。
  • 可移植性:C 程序只需进行少量修改即可轻松移植到不同的平台和架构上。C 编译器可用于各种系统,这促进了这种可移植性,使其成为开发跨平台软件的热门选择。
  • 效率:C 以其运行时性能和内存使用效率而闻名。它允许开发人员直接控制内存分配和管理,从而实现针对特定硬件约束和性能要求的优化。
  • 灵活性:C 平衡了高级和低级编程范式,允许开发人员在各种抽象级别上工作。其丰富的功能集(包括指针、结构和内存管理原语)为实现复杂算法和数据结构提供了灵活性。

关于内存管理,我们已经确定 C 要求开发人员在整个应用程序代码中手动控制分配和释放。这就是为什么前面几点成为可能的基础。C 允许开发人员直接控制内存分配和释放,使他们能够根据特定的应用程序要求管理内存资源。

这种控制可以对内存使用情况进行微调,以实现最佳性能和资源利用率。开发人员可以仅在需要时分配内存,并在不再需要时立即释放内存,从而最大限度地减少内存开销和碎片。这使得内存使用模式更加透明和易于理解。这种可预测性简化了内存优化和调试过程。

了解 C 中的手动内存管理为理解其他语言和环境中的内存管理奠定了坚实的基础。无需深入了解 C 或 C++ 中的代码细节,我们可以从高层次探索这些概念:

  • 分配所需的函数位于 <stdio.h> 头文件中。
  • malloc() 可以动态地为数据结构分配内存。
  • 在尝试访问指向新分配内存的变量之前,我们需要通过检查指针是否为 NULL 来检查分配是否成功。此步骤至关重要,因为分配可能会因内存不足而失败。如果没有足够的连续可用内存来满足分配请求,就会出现这种风险。如果内存以 非统一模式 分配和释放,则可能会发生这种情况,从而导致内存段碎片化。在这种情况下,malloc() 将返回 NULL

具有内存限制或配额的系统将限制可用于分配的内存量。这可能发生在资源受限的环境中或运行内存访问受限的应用程序时。硬件故障、操作系统错误或其他软件组件的干扰也可能导致分配操作意外失败。

一旦我们确认分配成功,我们就可以根据操作的要求操作数组。在此代码片段中,我们添加并打印一些值。最后,在程序关闭之前必须释放在程序执行期间分配的内存。我们使用 free() 函数释放分配的内存以防止泄漏。

C++ 是作为 C 的扩展而开发的,其中 C++ 保留了大多数控制结构、函数、标准库和内存管理方法。C++ 引入的一些重要改进如下:

  • 面向对象编程 (OOP):C++ 支持 OOP 范式,包括类、继承、多态性和封装。这些功能允许创建模块化、可重用和可维护的代码,使 C++ 适合大规模软件开发。
  • 标准模板库 (STL):它提供了一组通用数据结构(例如向量、列表和映射)和算法(例如排序和搜索),这些结构和算法经过高度优化且易于使用。它还显著减少了日常任务所需的手动编码量,提高了工作效率和代码质量。
  • 模板:允许开发人员编写适用于任何数据类型的通用代码。它们可以创建灵活高效的算法和数据结构,这些算法和数据结构可以适应不同的数据类型而不会牺牲性能。
  • 异常处理:这提供了一种处理运行时错误和异常情况的结构化机制。异常允许优雅的错误恢复和传播,从而提高 C++ 程序的稳健性和可靠性。
  • 标准化:C++ 由 ISO C++ 标准标准化,该标准确保了不同编译器和平台之间的语言一致性和兼容性。标准化过程有助于维护 C++ 的稳定性并促进不同 C++ 代码库之间的互操作性。

C 和 C++ 提供手动内存管理,这意味着开发人员负责明确分配和释放内存。由于 C++ 引入了 OOP 特性,C++ 中的内存管理通常涉及管理使用这些 OOP 特性创建的对象,而 C 中不存在这种特性。在 C++ 中,可以使用 new 运算符将对象分配给堆,该运算符创建具有可变大小或生命周期的对象。还必须使用 delete 运算符明确释放在堆上手动分配的内存,以防止内存泄漏。指针同样用于存储内存地址。

C++ 提供了构造函数和析构函数,它们是在创建和销毁对象时自动调用的特殊成员函数。构造函数初始化对象状态,包括内存分配,而析构函数清理资源,包括释放内存。C 没有这样的语言特性。

C++ 还在其标准库中引入了智能指针(std::unique_ptrstd::shared_ptrstd::weak_ptr)。智能指针自动管理动态分配对象的内存,确保在超出范围时正确释放内存。与 C 中使用的原始指针相比,它们提供更安全、更强大的内存管理。

其他升级的内存管理功能包括资源获取初始化RAII)等工具,它利用对象的确定性销毁行为来自动管理资源。 RAII 背后的核心思想是将资源(例如动态分配的内存、文件句柄、互斥锁或网络连接)的生命周期与表示该资源的对象的生命周期联系起来。

与 C 程序一样,我们分别包含输入/输出和内存操作所需的头文件 。在 main 函数中,我们使用 new 动态分配大小为 10 的数组的内存。这会创建一个原始指针,我们必须通过检查 nullptr 值来检查该指针以确保分配成功。在操作中使用数组后,我们使用 delete[] 语法释放内存。如果内存是动态分配的,我们必须使用 delete 关键字释放任何分配的内存。

接下来,我们使用 std::unique_ptr,这是 C++ 标准库提供的智能指针。我们动态分配单个整数的内存并将其用于某些操作。与原始指针相比,智能指针会自动管理内存释放,并在函数范围完成后自动释放分配。

虽然 C# 并非直接基于 C/C++,但它受语法的影响并包含一些基础功能。我们可以看到 C# 在内存管理技术和功能方面的优势,因为它基于 .NET 运行时,可自动处理内存分配和释放。我们在上一章中讨论了这一点,并回顾了代码片段,了解了定义和使用对象有多容易,而无需考虑事后清理它们。

Java 和 C# 更相似,因为它们都基于 C/C++ 并具有自己的运行时,这有助于自动管理对象。接下来我们将比较它们的方法。

Java 中的分配机制

Java 由 Sun Microsystems, Inc. 创建,并于 1995 年首次发布。Java 与其他编程语言的工作方式之间的差异是革命性的。不同的语言使用编译器将原始语法转换为特定类型计算机的指令。相比之下,Java 编译器将代码转换为所谓的 字节码,然后由 Java 运行时环境 (JRE) 或 Java 虚拟机 (JVM) 解释。JRE 解释字节码并将其转换为主机或设备。这使得 Java 可用于许多平台,几乎可以在任何地方和任何设备上运行。这种可移植性使其在各种软件开发环境中广受欢迎,尤其是在当时的互联网上。

Java 的设计原则强调可靠性和错误处理机制。可靠的类型检查、异常处理和自动内存管理(垃圾收集)等功能有助于提高 Java 程序的稳定性和可预测性,从而降低运行时错误和崩溃的可能性。它也是一种纯 OOP 语言,其中所有内容都被视为对象。它还包括一个名为 Java Standard Edition - Java SE 的强大标准库,它为日常输入/输出操作、网络、并发和数据操作任务提供了一系列类和 API。

在比较 Java 和 .NET 时,会出现几个关于在应用程序运行时如何分配和管理内存的反复出现的主题。Java 和 .NET 都通过垃圾收集提供自动内存管理。

Java 对象存储在称为 的区域中,该区域在 JVM 启动时初始化,并可在运行时动态调整其大小。当堆达到容量时,会发生垃圾收集,其中不再使用的对象被识别和删除,从而为新对象腾出空间。JVM 还使用堆以外的内存。组件(例如 Java 方法、线程堆栈和本机句柄)具有自己分配的内存空间,与堆不同。

JVM 堆通常分为两个部分:幼稚(或年轻空间)和 空间。托儿所是专门用于分配新对象的区域。一旦它填满,就会发生年轻代收集过程,将托儿所中存在了特定时间的对象提升到老生代空间,并清除剩余的对象。当老生代空间达到容量时,就会发生垃圾收集过程,称为老生代收集。使用托儿所的根源在于许多对象的寿命都很短。因此,年轻代收集过程经过优化,可以快速识别和重新定位托儿所中新分配的对象。通常,此过程比单代堆设置中老生代或垃圾收集更有效地释放内存,因为单代堆设置没有托儿所。

Java 和 .NET 在垃圾收集和内存管理方面有几处相似之处,这源于它们共同的目标和安全高效地管理内存的基本原则,尤其是在托管运行时环境中。Java 和 .NET 都旨在简化开发并提高应用程序的性能和可靠性。

以下是它们相似之处的一些原因:

  • 自动内存管理:Java 和 .NET 都旨在从开发人员那里抽象出手动内存管理的复杂性。这两个框架都有助于防止内存泄漏和悬空指针错误,这些错误在需要手动内存管理的语言(如 C 和 C++)中很常见。
  • 托管运行时环境:Java 在 JVM 上运行,而 .NET 应用程序在 通用语言运行时CLR)上运行。这些环境在操作系统上提供了一个抽象层,使应用程序在某种程度上独立于平台。JVM 和 CLR 在管理程序执行时处理内存分配和垃圾收集,这导致内存管理方式相似。
  • 分代垃圾收集:Java 和 .NET 使用分代垃圾收集算法,该算法基于大多数对象在年轻时死亡的观察结果。内存被分为几代,在垃圾收集周期中幸存下来的对象被移至较老的几代。这种方法通过关注最有可能发现垃圾的内存区域来优化垃圾收集,从而降低对应用程序性能的总体影响。
  • 标记和清除算法:“标记和清除”算法涉及标记仍在使用的对象,然后清除未使用的对象,释放内存以用于新对象。Java 和 .NET 在决定应收集哪些对象以及哪些对象幸存时使用此方法。
  • 即时编译:Java 和 .NET 都将代码编译成中间语言(Java 为 Java 字节码,.NET 为通用中间语言 (CIL)),然后在运行时由即时 (JIT) 编译器将其编译成本机代码。虽然 JIT 编译主要是为了优化性能,但它也会通过优化代码在运行时的内存使用情况来影响内存管理。

总之,Java 和 .NET 具有相似的垃圾收集和内存管理目标,这简化了开发、确保了应用程序的安全性和可靠性,并提高了托管运行时环境中的性能。这些相似之处反映了软件开发朝着抽象、自动化和平台独立性发展的更广泛趋势。

现在我们已经了解了其他流行语言如何分配和处理内存,让我们将 .NET 的分配性能与它们的分配性能进行比较。

.NET 分配性能

.NET 中的内存分配通常被认为非常高效,尤其是在托管语言和环境中。多年来,.NET 运行时和垃圾收集器GC)已针对各种应用程序进行了优化,性能良好。此外,.NET Core 的持续改进提高了垃圾收集和内存分配的效率,反映了 Microsoft 致力于提高 .NET 性能的承诺。

.NET Core 相对于 .NET Framework 的内存分配改进源于多项架构更改和优化。由于 .NET Core 从一开始就设计为比传统 .NET Framework 更模块化和轻量级,因此 .NET Core 中的内存分配通常比其前身更快、更高效,因为它拥有更快的内存分配,这是其现代架构、优化的 JIT 编译、模块化设计和创新功能(如 SpanMemory)的结果。这些进步使 .NET Core 非常适合构建可在多个平台上高效运行的高性能应用程序。

.NET、Java、C 和 C++ 之间的性能比较在很大程度上取决于上下文,包括应用程序的类型、运行环境、代码的编写方式以及正在执行的特定工作负载或任务。最终,.NET 应用程序与用 Java、C 或 C++ 编写的应用程序相比的性能取决于应用程序的设计和实现情况。

经过良好优化的 C 或 C++ 程序在计算密集型任务方面可能优于 .NET 或 Java。对于优先考虑生产力、安全性和快速开发,并且运行时检查和垃圾收集的性能开销可以接受或可以通过优化最小化的应用程序,.NET 或 Java 可能是更好的选择。性能测试和分析对于决定哪种平台或语言适合特定项目至关重要。

无论应用程序类型或语言如何,最好始终有意识地编写代码并注意不同数据类型的优缺点。我们将讨论开发应用程序时的一些主要考虑因素,以及为什么一种数据类型可能更适合特定场景。

选择最佳数据结构

在 .NET 和 C# 开发中选择适当的数据结构对于应用程序的性能、可扩展性、可维护性和复杂性至关重要。您选择的数据结构会影响数据在内存中的组织方式、访问和操作数据的效率以及应用程序在数据量或用户需求增加时的扩展能力。

使用适当的数据结构可以使代码更易于理解和维护。它可以使代码背后的意图对其他人(或未来的您自己)更直接,降低出错风险,并使代码库更易于扩展和重构。正确的数据结构可以帮助确保您的应用程序随着数据或用户数量的增长而有效扩展。提供高效操作的结构有助于防止性能瓶颈。

在本节中,我们还将编写示例代码并实施基准测试以报告显示性能差异的统计数据。不过,在开始编程冒险之前,我们应该探索和理解开发中一个常被忽视的概念,即 Big O 符号,以及它如何帮助我们决定哪种编程结构最适合某种场景。让我们讨论这个概念,以了解它如何帮助我们选择最适合我们操作的数据结构。

大 O 符号

这种数学符号描述了算法复杂度的上限,表示时间或空间(内存)的最坏情况,它是输入大小(n)的函数。它提供了对算法性能如何随着数据增加而扩展的高级理解,而不会陷入硬件细节或所用编程语言的泥潭。大 O 符号侧重于导致算法复杂度的两个主要因素:

  • 时间复杂度:算法的执行时间如何随着输入数据的大小而增加。了解数据结构中操作的时间复杂度(例如,访问数组中的元素是O(1),而搜索链接列表中的组件是O(n))可让开发人员选择满足其应用程序性能要求的适当数据结构。
  • 空间复杂度:算法所需的内存量如何随着输入数据大小而增加。空间复杂度分析有助于了解算法的内存使用情况。在资源有限的环境或处理大型数据集的应用程序中,选择具有高效内存使用率的数据结构至关重要。它可以防止内存耗尽并确保应用程序的可扩展性。

大 O 符号提供了一个分析和比较算法效率的公式。这在操作和与集合类型交互时尤其重要。此分析有助于为给定上下文选择最有效的方法,从而显著影响性能和可扩展性。您将看到的一些标准符号包括:

  • O(1) – 恒定时间:执行时间或空间是固定的,不会随着输入数据大小而变化。
  • O(log n) – 对数:执行时间或空间随着输入数据的增加而呈对数增长。
  • O(n) – 线性:执行时间或空间随着输入数据大小线性增加。
  • O(n log n) – 线性:执行时间或空间比线性增加得更快,但不如多项式快。
  • O(n2)、O(n3) – 多项式:执行时间或空间会随着输入数据大小的增加而急剧增加。这意味着随着数据的增长,性能会显著下降。
  • O(2^n)、O(n!) – 指数和阶乘:效率极低,随着输入大小的增长,执行时间或空间会以爆炸式的速度增加。

在软件开发和计算机科学中,问题通常可以通过多种方式解决。了解大 O 符号有助于评估不同的方法并优化现有解决方案,以获得更好的性能和更低的资源消耗。选择合适的结构可以显著降低计算成本并提高性能。

让我们看一个例子,说明大 O 符号如何帮助确定用户管理系统中此场景的最佳数据结构,其中我们必须频繁检查系统中是否存在用户、添加新用户和删除用户。系统应尽可能高效地执行这些操作:

  • 选项 1 – 列表:要在 列表 中查找用户,您可能必须扫描每个元素,这使得搜索操作复杂度为 O(n)。在列表末尾添加新用户复杂度为 O(1),但在特定位置插入用户复杂度为 O(n),因为它可能需要移动元素。与插入一样,删除操作复杂度为 O(n),因为它可能涉及在删除项目后移动元素。
  • 选项 2 – 哈希集HashSet 建立在哈希表上,允许搜索的平均常数时间复杂度为 O(1)。由于哈希提供的直接访问模式,插入新用户通常平均复杂度为 O(1)。删除用户平均复杂度也为 O(1)
  • 选项 3 – 已排序的数据结构SortedSet 在底层使用二叉搜索树,提供 O(log n) 搜索时间。插入新用户是 O(log n),因为它需要保持顺序。出于与插入相同的原因,删除用户也是 O(log n)

如果主要操作是频繁搜索,则 HashSet 具有优势,因为它在搜索、插入和删除操作中的平均时间复杂度为常数。如果元素的顺序很重要,或者需要已排序的数据,SortedSet 可能是更好的选择,尽管操作成本为对数级,因为它可以保持顺序并提供高效的查找。如果简单性和对元素的有序迭代是效率较低的插入和删除的可接受权衡,List 可能仍然合适。

现在我们了解了 Big O 符号,并可以用它来帮助我们决定哪种数据类型最适合不同的场景,让我们来看看数据类型选择的第一个建议:如果可能,我们应该选择结构而不是类。现在让我们回顾一下设置代码项目的一些步骤,这将成为我们基准测试活动的基础。

尽可能用结构体替换类

在 .NET Core 开发中用结构体替换类的建议并不普遍适用。不过,在特定情况下,它确实具有某些好处,这主要是由于 .NET 运行时处理值类型(例如结构体)与引用类型(例如类)的方式不同。

类比结构体消耗更多的内存资源。与类不同,结构体没有方法表对象头。在用只有少量数据成员的类替换类时,请考虑使用结构体。方法表、虚拟方法表vtable是 OOP 语言中用于支持动态调度、多态性、继承和方法的后期绑定的机制。它本质上是一个与每个类关联的查找表,其中包含指向其方法的指针。当在对象上调用方法时,运行时使用方法表来查找并调用正确的方法实现,从而允许诸如在派生类中重写方法之类的行为。

方法表会给类带来开销,因为每个类都有一个方法表,这会消耗内存。在具有许多类或创建多个类实例的系统中,这种开销更为明显。关于整体性能,动态调度需要额外的运行时检查和间接查找方法表中的正确方法。与静态调度相比,这会导致方法调用速度稍慢,因为在静态调度中,要调用的方法在编译时已知。

结构体是 C# 和 .NET 中的值类型,通常不支持继承或方法覆盖,就像类一样。由于结构体设计为轻量级和高效,因此它们不使用方法表进行动态调度。结构体上的方法调用是在编译时(静态调度)而不是运行时解析的,从而消除了对方法表的需求并避免了其相关开销。

对象头是许多托管运行时(如 .NET CLR 和 JVM)中对象内部表示的一部分。它包含有关对象的元数据,包括运行时内存管理和同步机制所需的信息。这些元数据可以包括但不限于指向对象方法表的指针、垃圾收集器的信息以及同步信息(用于在多线程场景中锁定)。

堆栈分配的结构的生命周期由声明它们的范围管理,从而消除了垃圾收集的需要,进而消除了对元数据(例如对象头中的元数据)的需求。它们也不能从其他结构或类继承(尽管它们可以实现接口),因此不需要对象头提供的运行时类型信息来支持动态调度或多态性。虽然结构可以实现接口,但这通常与类继承的处理方式不同,并且不需要每个结构实例中都有方法表指针。

由于结构和类在内存分配方面存在差异,因此结构没有类的内存分配开销:

  • 结构是值类型,通常分配在堆栈上,这比类(引用类型)的堆分配更有效。堆栈分配可以减少 GC 的压力,并可能提高性能。由于结构不在堆上分配(除非装箱或属于类的一部分),因此它们不会产生垃圾收集开销。这可以带来更可预测的性能,特别是在高吞吐量或低延迟应用程序中。
  • 结构具有值语义,这意味着它们在分配时被复制并通过值传递给方法。此行为不同于通过引用传递的引用类型。值语义有利于确保不变性和避免意外的副作用。
  • 结构是小型、不可变数据结构的理想选择。堆栈分配的效率和结构的值语义使它们适合表示简单值或需要不变性的小型数据聚合。
  • 结构可以在应用程序的部分中提供性能关键优势,其中最小化 GC 开销至关重要。它们还可以作为小型数据结构(例如数学向量或坐标)上的高频操作来实现。
  • 结构有助于避免堆分配开销。对于最小化堆分配和 GC 压力至关重要的场景,使用结构可以帮助减少开销。这在紧密循环或实时系统中尤其重要,因为性能一致性至关重要。

虽然使用结构而不是类可以获得明显的性能优势,但也有一些注意事项和限制:

  • 虽然结构避免了 GC 开销,但它们在分配和传递给方法时会被完全复制。与引用类型相比,这可能会导致大型结构的性能下降,因为引用类型只复制引用。
  • 当分配给对象或接口类型时,结构可能会被“装箱”到引用类型中,从而导致堆分配和潜在的 GC 压力。必须小心使用以避免无意的装箱。

我们将回顾一些代码示例和基准测试以回顾不同的场景。由于我们将编写一些代码,因此让我们为该项目创建一个新文件夹并在系统终端中导航到它。使用以下 dotnet CLI 命令创建一个新的控制台应用程序:

dotnet new console

设置完成后,执行以下命令提示NuGet包管理器安装BenchmarkDotNet

dotnet add package BenchmarkDotNet

从现在开始,我们将以此为基础开展一些即将开展的编码活动。确保在发布模式下运行测试控制台应用程序。让我们首先讨论一些内存分配概念。

应用程序的要求和性能特征应指导您在 .NET Core 开发中选择结构还是类。虽然结构可以在降低 GC 压力和高效内存分配方面提供性能优势,但它们的适用性取决于所表示数据的大小以及它在应用程序中的使用方式。以下代码片段比较了结构和类之间的基准:

public class StructVsClassesBenchmarks
{
    const int items = 100000;
    [GlobalSetup]
    public void GlobalSetup()
    {
        //Write your initialization code here
    }
    [Benchmark]
    public void CreatingInstancesUsingClass()
    {
        MyClass[] myClassArray = new MyClass[items];
        for (int i = 0; i < items; i++)
        {
            myClassArray[i] = new MyClass();
        }
    }
    [Benchmark]
    public void CreatingInstancesUsingStruct()
    {
        MyStruct[] myStructArray = new MyStruct[items];
        for (int i = 0; i < items; i++)
        {
            myStructArray[i] = new MyStruct();
        }
    }
}
class MyClass
{
    public double Num1 { get; set; }
    public double Num2 { get; set; }
    public double Num3 { get; set; }
}
struct MyStruct
{
    public double X { get; set; }
    public double Y { get; set; }
    public double Z { get; set; }
}

此代码片段使用两种方法创建两个大数组,一个用于类,另一个用于结构。然后使用 for 循环填充它们。图 3*.1* 显示了此基准测试的结果。在 Program.cs 文件中,写入以下内容以执行基准测试:

var summary = BenchmarkRunner.Run<StructVsClassesBenchmarks>();

我们需要在发布模式下执行此程序,因为发布配置会构建可部署的应用程序版本。因此,您可以在终端中使用以下命令:

dotnet run -c release

一旦运行此命令,控制台窗口将启动、运行测试并输出类似于图 3.1 中显示的信息:

在这里插入图片描述

图 3.1 – 类和结构之间的基准比较结果

结果表明,结构数组分配所用时间只是类数组执行相同操作所需时间的一小部分。在实践中,对于值语义有益的小型不可变数据结构,我们应该优先使用结构,而对于更大、更复杂的对象或需要继承时,则使用类。与性能优化一样,分析和测试对于做出明智的决策至关重要。

现在,让我们研究如何减少分配开销并使用优化的集合类型。

使用优化的集合类型

根据您的需求选择正确的集合类型会显著影响性能,尤其是在内存使用和执行速度方面。.NET 在 System.CollectionsSystem.Collections.GenericSystem.Collections.ConcurrentSystem.Collections.Immutable 命名空间等中提供了各种专门的集合类型。有效使用这些优化的集合类型需要了解它们的特性和最佳用例。

以下是选择和使用它们的方法:

  • 泛型集合 (System.Collections.Generic):与非泛型集合相比,首选泛型集合,例如 List、Dictionary<TKey, TValue>HashSet,因为它们提供更好的类型安全性,有助于消除运行时错误以及在运行时进行昂贵的类型检查或强制转换的需要,从而提高性能。它们的性能也更高,因为它们的使用避免了装箱和拆箱。非泛型集合将元素存储为 对象,要求在将值类型添加到集合时对其进行装箱,并在检索时对其进行拆箱。装箱和拆箱是计算成本高昂的操作,涉及在堆上为值类型创建包装器对象,然后从包装器中提取值类型。泛型集合通过直接存储值类型(无需装箱)来消除这种开销,从而提高内存使用率和访问速度。
  • 并发集合 (System.Collections.Concurrent):在多线程场景中使用并发集合(例如 ConcurrentDictionary<TKey, TValue>、ConcurrentQueueConcurrentBag)可确保线程安全,而无需手动处理同步。许多并发集合使用复杂的算法来最大限度地减少锁定。例如,ConcurrentDictionary 使用细粒度锁定或无锁技术进行读取操作,允许多个线程同时读取和写入,而争用较少。由于并发集合处理其同步,因此开发人员可以编写更直接、更简洁的代码。避免手动同步可减少认知负担和出错的可能性,使开发人员能够专注于业务逻辑,并可能产生更高效、更易于维护的代码。虽然并发集合提供了许多好处,但它们并不是解决所有并发问题的灵丹妙药。
  • 不可变集合 (System.Collections.Immutable)ImmutableListImmutableDictionary<TKey, TValue>ImmutableHashSet 等不可变集合在设计上是线程安全的,并且在集合预计不会更改或您想要避免突变的副作用的情况下工作良好。它们还消除了从多个线程访问这些集合时对同步原语(如锁)的需求,从而减少了开销并避免了潜在的锁争用问题。当由于操作(例如添加或删除元素)而创建新集合时,它会重用大部分现有结构,而不是复制整个集合。这可以显著减少内存使用量和创建新集合的开销,尤其是对于大型数据集。

以下是这些集合类型之间的基准比较,其中我们添加元素然后对它们进行迭代:

public class CollectionBenchmark
{
    private const int N = 1000;
    [Benchmark]
    public void AddToList()
    {
        List<int> list = new List<int>();
        for (int i = 0; i < N; i++)
        {
            list.Add(i);
        }
    }
    [Benchmark]
    public void AddToArray()
    {
        int[] array = new int[N];
        for (int i = 0; i < N; i++)
        {
            array[i] = i;
        }
    }
    [Benchmark]
    public void AddToConcurrentBag()
    {
        ConcurrentBag<int> bag = new ConcurrentBag<int>();
        for (int i = 0; i < N; i++)
        {
            bag.Add(i);
        }
    }
    [Benchmark]
    public void AddToImmutableList()
    {
        ImmutableList<int> immutableList = ImmutableList<int>.Empty;
        for (int i = 0; i < N; i++)
        {
            immutableList = immutableList.Add(i);
        }
    }
    [Benchmark]
    public void IterateList()
    {
        List<int> list = new List<int>(Enumerable.Range(0, N));
        foreach (var item in list) { }
    }
    [Benchmark]
    public void IterateArray()
    {
        int[] array = Enumerable.Range(0, N).ToArray();
        foreach (var item in array) { }
    }
    [Benchmark]
    public void IterateConcurrentBag()
    {
        ConcurrentBag<int> bag = new ConcurrentBag<int>
        (Enumerable.Range(0, N));
        foreach (var item in bag) { }
    }
    [Benchmark]
    public void IterateImmutableList()
    {
        ImmutableList<int> immutableList = 
        ImmutableList.CreateRange(Enumerable.Range(0, N));
        foreach (var item in immutableList) { }
    }
}

让我们使用以下命令执行代码:

dotnet run -c release

图 3.2 显示了结果,表明单线程应用程序中的性能有利于标准 ListArray 数据类型:

在这里插入图片描述

图 3.2 – 不同集合类型的基准测试结果

由于每次都需要创建新集合,不可变集合和并发集合在添加操作中通常表现出较慢的性能和较高的内存使用率。相比之下,List 和数组可能由于直接插入元素而表现更好,因为数组通常由于其连续的内存布局而提供最佳的迭代性能,这是缓存友好的,而 List 紧随其后。

通常,我们更喜欢数组或 List,以便在单线程应用程序中降低内存开销,或者当集合变异局限于单个线程时。在多线程场景中使用并发集合,其中对集合的操作频繁且并发,接受更高的内存使用率以获得开箱即用的线程安全性。我们稍后将探讨这些多线程场景。

现在,让我们回顾一下预先调整数据结构大小的概念。

预先确定数据结构的大小

.NET 中预先确定数据结构大小的概念是指在创建集合时根据集合所包含的元素数量指定集合的初始大小或容量。这种做法可以显著提高性能,尤其是当您向集合中添加许多项目(例如列表、字典或哈希集)时。

当您向动态大小的集合中添加元素而不指定初始容量时,集合可能需要多次增加其大小以容纳新元素。每次增加长度时,集合通常都需要分配一个比前一个数组更大的新数组,然后将元素从旧数组复制到新数组。此过程称为调整大小,由于以下原因,它在计算上可能很昂贵:

  • 每次都需要为新数组分配内存
  • 必须将旧数组中的每个元素复制到新数组
  • 旧数组成为垃圾,GC 最终必须回收

通过预先调整大小,我们事先为预期的元素数量分配足够的空间,从而最大限度地减少或消除调整大小的需要。以下是预先调整 List 大小的示例,即使这本来是一个动态集合类型:

int expectedItems = 1000;
List<int> numbers = new List<int>(expectedItems); // Pre-sized list
for (int i = 0; i < expectedItems; i++)
{
    numbers.Add(i);
}

以下代码片段显示了一个基准测试,其中我们比较了动态大小列表与固定大小列表的分配速度。它对将元素添加到动态列表与将元素添加到预定大小列表进行了基准比较:

public class ListBenchmark
{
    private const int NumberOfElements = 10000;
    [Benchmark]
    public void AddToDynamicList()
    {
        List<int> dynamicList = new List<int>(); // Dynamic list
        for (int i = 0; i < NumberOfElements; i++)
        {
            dynamicList.Add(i);
        }
    }
    [Benchmark]
    public void AddToPreSizedList()
    {
        List<int> preSizedList = new List<int>(NumberOfElements); 
        // Pre-sized list
        for (int i = 0; i < NumberOfElements; i++)
        {
            preSizedList.Add(i);
        }
    }
}

为了执行,我们重用以下命令。

dotnet run -c release

输出如下所示:

在这里插入图片描述

图 3.3 – 将项目添加到固定和动态大小列表的基准测试结果。

下一个技巧是让我们尽可能多地使用连续内存。

访问连续内存

连续内存分配可确保数据结构的元素在内存中彼此相邻存储。这可以显著提高缓存局部性并减少读取和写入数据所需的时间。正如我们已经探讨过的,数组是最简单的连续内存分配形式。它们将相同类型的元素存储在连续的内存块中,从而允许通过索引快速访问:

int[] numbers = new int[5] { 1, 2, 3, 4, 5 };
// Accessing an element
int thirdNumber = numbers[2]; // Access is O(1)

.NET Core 2.1 和 C# 7 中引入了 SpanMemory,以提供一种类型安全的方式来表示连续的内存区域。使用时,它们不拥有内存,而是提供一个内存窗口。这可以是堆栈分配的内存、托管数组或本机内存。Span 是一种仅适用于堆栈的类型,适用于短暂的场景,而 Memory 可以存储在堆上,使其成为 async 方法的理想选择。以下是使用 Slice() 方法将数组中的某些元素作为 Span 查看的示例:

int[] array = new int[] { 0, 1, 2, 3, 4, 5 };
Span<int> span = new Span<int>(array);
// Creating a slice of the array from index 1 to 3
Span<int> slice = span.Slice(1, 3);
foreach (var item in slice)
{
    Console.WriteLine(item); // Outputs 1, 2, 3
}

ArraySegment 是另一种查看数组段的方法,无需为该段分配额外的内存。与 SpanMemory 一样,它允许高效地访问连续内存,但它是一种引用类型,可用于 Span 无法使用的地方:

int[] numbers = { 1, 2, 3, 4, 5 };
ArraySegment<int> segment = new ArraySegment<int>(numbers, 1, 3);
foreach (int i in segment)
{
    Console.WriteLine(i); // Outputs 2, 3, 4
}

通过利用使用连续内存的数据结构和类型,开发人员可以通过改进缓存局部性和减少内存访问时间来实现显着的性能改进。

现在,我们已经探索了有助于我们编写内存高效应用程序的场景。我们还确定并非所有闪亮的东西都是金子,每种工具都有一种更好的使用场景和一种不是最佳选择的场景。我们必须认真了解我们正在实施的算法以及可能影响其性能的因素。有了这种洞察力,我们可以更敏锐地决定使用哪些工具。

接下来,我们将探索处理大对象和数组的实际方法。

处理大对象和数组

由于大对象的集合可能会对应用程序性能和内存使用产生影响,因此需要仔细考虑此主题。大对象通常定义为 85,000 字节或更大。为了提高垃圾收集的效率,这些对象被分配在堆的特定区域中,称为大对象 LOH)。

LOH 上的对象在第二代收集期间被收集。由于第二代收集频率较低,大对象可以在内存中保留更长时间,从而增加内存使用量。由于 LOH 未压缩,因此堆可能会随着时间的推移而变得碎片化,从而可能导致内存不足异常或由于无法有效利用可用空间而导致内存使用量增加。

垃圾收集器不会压缩 LOH,因为在 LOH 周围移动大对象的成本很高。相反,它会在大对象不再使用时删除它们占用的内存。因此,随着时间的推移,LOH 中会出现内存空洞,或者内存会变得碎片化。内存碎片会对应用程序的性能和可扩展性产生不利影响。

尽管垃圾收集器不会压缩 LOH,但您仍然可以使用以下代码明确压缩 LOH:

GCSettings.LargeObjectHeapCompactionMode = GCLargeObjectHeapCompactionMode.CompactOnce;
GC.Collect();

虽然您可以通过编程方式压缩 LOH,但建议您尽可能避免使用 LOH。虽然完全避免使用 LOH 可能并不适用于所有应用程序,但以下策略可以帮助减少对它的依赖,减轻内存碎片,并提高整体应用程序性能。

每个应用程序都是独一无二的,因此分析和测试更改以了解它们对性能和内存使用的影响非常重要。让我们讨论在可能的情况下使用较小的对象。

使用较小的对象

如果可行,将大型数据结构拆分为较小的块。例如,考虑使用较小数组的列表而不是单个大型数组。这可以帮助将各个数组保持在 LOH 阈值以下。这种方法通常用于您可能想要使用非常大的数组的场景,例如缓冲大量数据以进行处理。

假设您正在实现处理大型数据集的功能。您可以使用数组列表而不是单个大型数组,每个数组的大小都易于管理:

using System;
using System.Collections.Generic;
public class SmallObjectManager
{
    // Each array size is set to a value that won't be allocated 
    // on the LOH.
    private const int MaxArraySize = 20_000; 
    // size * sizeof(int) < 85,000 bytes for int
    private List<int[]> arrays = new List<int[]>();
    public void AddData(IEnumerable<int> data)
    {
        int[] currentArray = null;
        int currentIndex = 0;
        foreach (var item in data)
        {
            // Allocate a new array when needed.
            if (currentArray == null || currentIndex >= MaxArraySize)
            {
                currentArray = new int[MaxArraySize];
                arrays.Add(currentArray);
                currentIndex = 0;
            }
            currentArray[currentIndex++] = item;
        }
    }
    public IEnumerable<int> GetData()
    {
        foreach (var array in arrays)
        {
            foreach (var item in array)
            {
                yield return item;
            }
        }
    }
}
public class Program
{
    public static void Main()
    {
        SmallObjectManager manager = new SmallObjectManager();
        // Example: Adding a large amount of data in chunks
        manager.AddData(GenerateLargeDataSet());
        // Retrieve and process the data
        foreach (var item in manager.GetData())
        {
            // Process each item
            Console.WriteLine(item);
        }
    }
    // Simulate generating a large set of data
    private static IEnumerable<int> GenerateLargeDataSet()
    {
        const int largeSize = 100_000;
        for (int i = 0; i < largeSize; i++)
        {
            yield return i;
        }
    }
}

SmallObjectManager 类在内部管理一个 int 数组列表 (List<int[]>),确保每个数组都低于 LOH 阈值。添加数据时,它会检查是否需要分配新数组。GetData 方法将此分段存储作为单个集合进行迭代,从而抽象出底层的复杂性。通过将各个数组保持在 LOH 阈值以下,此策略可降低内存碎片化的风险以及相关的性能问题。

我们已经讨论了为什么字符串可能是危险的内存构造。回顾它们在 LOH 分配方面的潜力,以及我们如何实现高效的字符串操作。

优化字符串使用

经验不足的开发人员通常没有意识到 C# 中的字符串值是一个不可变的字符集合。这意味着每次我们似乎修改字符串时,我们都会强制在内存中创建一个新字符串(或字符数组),并将以前的版本留给垃圾回收。如果字符串值足够大,它可能会进入 LOH,现在我们可以理解这可能会如何影响应用程序的长期性能。

正如我们对一般集合类型应用特殊考虑一样,我们在应用程序中使用字符串时也必须小心谨慎。

第一个技巧是避免连接。 每次连接都会创建一个新的字符串对象,如果生成的字符串超过 LOH 阈值,这可能会很快导致大对象分配。

以下代码片段显示了一个标准连接,但是一个非常有害的操作:

string result = "";
for (int i = 0; i < 10000; i++)
{
    result += "some text ";
}

连接字符串(尤其是在循环中)可能会导致内存使用量过大和性能低下。StringBuilder 类旨在高效处理字符串连接场景:

StringBuilder builder = new StringBuilder();
for (int i = 0; i < 10000; i++)
{
    builder.Append("some text ");
}
string result = builder.ToString();

String interning 是另一种方法,它可以减少用于字符串存储的内存,并且在使用许多相同字符串时可以通过仅存储每个不同字符串值的一个副本来节省内存。当您知道要处理许多重复的字符串时,驻留很有用。不过,应该谨慎使用它,因为垃圾收集器直到卸载应用程序域时才会收集驻留的字符串:

string a = string.Intern("Hello, world!");
string b = string.Intern("Hello, world!");
bool areSameReference = object.ReferenceEquals(a, b); // True

对于涉及大量文本的操作(例如文件处理或网络通信),请考虑使用character arrays或**Span**类型,而不是字符串对象进行可变操作。这样可以避免创建可在 LOH 上分配的中间字符串对象:

char[] largeTextValue = new char[8192]; // 16KB buffer for characters
// Fill or modify the buffer
// use the Span<char>
char[] source = new char[8192]; // Large char array
Span<char> span = new Span<char>(source);
// Use span.Slice, span.CopyTo, etc., to work with portions of the 
// buffer

在 .NET 中优化字符串的使用需要注意如何以及何时创建、操作和存储字符串。字符串生成器、字符串驻留和字符数组等技术可以减少对内存使用和性能的影响,尤其是对 LOH 的影响。请记住,每种优化技术都应根据应用程序的特定需求和上下文应用,并且应通过分析和测试来衡量其好处。现在,让我们总结一下本章。

总结

本章探讨了内存分配和优化概念的几个维度。反复出现的主题是不同的构造为我们的算法和应用程序带来不同的好处。我们首先讨论了 Java、C 和 C++ 中内存分配的基础知识,因为它们是基础语言,是 C# 的有力竞争对手。我们看到了 C 和 C++ 将处理内存分配和释放的责任放在开发人员手中。相比之下,Java 和 .NET 采用自动清理过程并遵循类似的理念。

我们还深入研究了各种 .NET 数据结构,例如数组、列表、字典和更专业的集合,例如 SpanMemory 和并发集合。我们探讨了为任务选择适当的数据结构如何显著影响内存使用和应用程序性能。

内存分配和数据结构是 .NET 编程的基础,影响应用程序的性能和可靠性。通过应用本章概述的原则和实践,开发人员可以编写更高效、可扩展和可维护的 .NET 代码。了解 .NET 内存管理模型的细微差别,开发人员可以优化他们的应用程序以获得最佳性能和内存占用,确保流畅且响应迅速的用户体验。在下一章中,我们将仔细研究如何管理资源并避免内存泄漏。

  • 5
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

0neKing2017

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值