00:00
在我的上一个视频中,我们学习了站,这是一个我们可以保持数据有序且紧凑的内存区域。将数据保存在站上可以提高我们程序的性能,但它也有限制。因为站上的数据是紧凑的,我们不能容纳动态大小的数据。例如,如果我们想向数组添加元素,我们可能不得不覆盖占中存储的其他值。此外,站的大小是有限的,这意味着如果我们试图将非常大的数据放在上面,可能会发生战役。处在这个视频中,我们将探索这些问题的解决方案。嗨,朋友们,我叫乔治,这里是call dump.今天,我们将学习堆一个非成怕的内存区域,但它存在是因为它有必要。对于更有经验的人,请考虑这个视频中的信息是为了易于理解而过度简化的。如果你想知道为什么,我需要澄清这一点。嗯,我刚刚收到了一个人的评论,说我在展示一个事例时很愚蠢,其中我使用了一个八位无符号整数来表示年龄。根据这个人的说法,我的视力程序是垃圾,因为将来的某一天,人们将活超过255年,我的程序将会崩溃。
01:12
然后一些黑客新闻文章将会谈论我的错误,最终prime agent的增增增增增增将会对我的失败做出反应。如果你同意这个人,我很抱歉。作为一个AI模型,我需要用这些信息重新训练,以理解你们这些家伙所属的任何奇怪的编码文化。无论如何,让我们继续视频,像往常一样,让我们来看一下一些概念,第一个是系统调用。出于历史原因,主要是为了安全,我们的程序不应该直接访问计算机硬件。这就是操作系统存在的原因。他们充当中介。当我们的程序需要硬件资源时,操作系统有责任分配这些资源。考虑一个hello world程序。尽管你可能认为这是你可以编写的最简单的程序之一,但你会惊讶于在幕后发生的一切。你在屏幕上显示这两个词。
02:06
例如C中的print f只是另一个函数。他根据提供的格式说明符格式化字符串。顺便说一下,函数名末尾的if就是这个意思。但,然后它将格式化后的字符串传递给其他函数。现在,根据实现,事情可能从这里开始变化。但如果你跟随格式化字符串的流程,你会发现在某个时候,它被传递给了right函数。这是一个系统调用的包装器。在这种情况下,系统调用只是操作系统将字符输出到标准输出流,通常是终端。系统调用是由操作系统提供的API,我们使用它们来请求只有操作系统才能执行的服务。所有这些让我们感兴趣的是,系统调用在性能方面可能很昂贵。当一个程序被执行时,他获得了一个新名字,一个进程。
03:01
我们的进程总是有一个状态,在非常低的层次上,CPU通过它的寄存器、程序、计数器等保持那个状态。如果你不熟悉寄存器,可以把它们想象成CPU内部的内置变量,它们本质上是硬件在电路中表示变量的方式。一些寄存器是多功能的,可以用于各种任务,而另一些则是为特定目的设计的。因为他们保存的值随着我们的进程运行而变化,我们可以说作为一个集合寄存器携带了我们进程的状态。当我们的进程进行系统调用时,会向操作系统发出请求,因为操作系统也是软件,它需要使用CPU来满足那个请求。然而,操作系统不能简单的开始使用CPU,因为这意味着操纵我们的进程,正在使用的寄存器改变它的状态。
04:00
由于我们不想失去我们的程序在进行系统调用之前的状态,它需要被捕货,有点像拍照。这个被捕获的信息被称为进程的执行上下文,它需要存储在内存中,以便稍后检索。一旦操作系统完成服务系统调用。保存进程的当前状态,然后加在不同进程的状态,以便它可以继续执行的过程被称为上下文切换。上下文切换会产生开销,因为保存和恢复执行上下文需要时间和资源。注意,除了上下文切换的成本外,操作系统处理、系统调用本身也需要时间。这意味着还有更多的开销。好的,但我们为什么关心这个呢?嗯,正如你可能会猜到的,每当一个进程需要向操作系统请求内存时,就需要一个系统调用。在我的上一个视频中,我提到了占之所以快速的原因之一,是因为它的所有内存在程序执行开始时就已经预先分配了。
05:06
所以没有必要在运行时从操作系统请求内存。为了简单起见,在那个视频中我不能解释为什么请求内存这么昂贵。但现在你知道了,它需要一个系统调用及其相关的开销,让我们明确区分我们的进程所使用的内存区域。编译后,我们的代码被翻译成二进制机器代码,这是一系列需要在启动程序时加载到内存中的指令。因此,将这个内存区域识别为仅包含指令是有益的。通常,这个区域被称为文本段。此外,我们指定一个特定区域来存储已初始化和未初始化的全局静态数据,然后是占,我们已经熟悉这个概念了。注意,这些区域有一个共同点。它们的大小是固定的。不要被弄糊涂了,顺便说一下,它的大小永远不会改变,变化的是存储在它上面的数据量。最后我们定义了一个可以根据需要进行扩展的特殊内存区域。
06:10
这个区域被称为堆。与站不同,堆的大小可以通过系统调用动态调整。这就是我们所知道的进程的内存布局。一个典型的C进程的内存布局,看起来像这样,我认为这种布局有点难以解释。所以让我们保持简单。另外,由于屏幕尺寸和使动画易于理解,这就是我将在本视频的其余部分中说明栈和堆的方式。需要非常提到的一点是,当我们通过系统调用请求内存时,操作系统不会给出我们恰好需要的内存量。相反,它通过给我们一个内存块来响应这种行为有影响。我们将很快讨论能够请求尽可能多的内存块,使我们可以在堆上分配的内存量实际上是无限的。
07:01
这使它成为解决占限制的完美选择。我们将要解决的第一个限制是占无法存储大量数据。我们可以利用这样一个事实,每一字节的内存都可以通过地址访问。由于地址本质上是数值,我们可以将它们表示为整数,这些整数在编译时已知的固定大小。我们选择不在站上直接推送数据,而是将其存储在堆上。随后在站上,我们只存储数据在堆中的位置的内存地址。表示内存地址的数值,通常在低级语言中被称为指针。这个术语相当自解释。另外重要的是要理解指针只代表一个内存地址,并不包含它指向的数据的长度或大小的信息。因此,我们有责任管理和跟踪堆中的内存子区域,区分哪些被占用,哪些没有。
08:02
在当前事例中,在堆上存储数据后,我们有1个已使用的子区域和2个可用的子区域。值得注意的是,将数据存储在堆上并不总是需要请求额外的内存,这就是操作系统返回内存块的影响所在。如果有足够的空间从之前的分配中留下,我们可以简单的在那里写入我们的值,在这种情况下,不需要额外的系统调用。现在我们有2个已使用的子区域和3个可用的子区域。如果你看了我之前的视频,你现在可能会担心碎片化。如果你不熟悉这个概念,让我为你说明。假设我们现在想存储一个包含10个无符号8位整数的数组,这是10个字节长。这里的问题是,尽管总共有15个空闲字节在堆上,但它们不是连续的,所以数组不适合。这个问题被称为外部碎片化。
09:00
在这种情况下,唯一适应数组的方法是请求另一个块。从操作系统。但要做到这一点,我们的程序必须付出进行系统调用的代价。碎片化可以通过尽可能紧凑的保持数据来避免。在这个例子中,如果我们从一开始就组织我们的值,就不需要系统调用。现在乍一看,似乎我们要做的所有事情就是避免堆中的碎片化,就是尽可能的存储我们的值。不幸的是,堆的情况并非如此。注意这部分我即将要说的,关键是理解战和对之间的根本区别。在战中只能移除最后推入的值。由于这种可预测的行为,保证了数据总是被紧凑站中,不可能发生碎片化。堆的问题在于其不可预测性。
10:01
即使努力将值存储在一起,与占不同,没有关于直将被移除的顺序的确定性。为了理解这一点,让我们考虑一个服务器应用程序场景。这个服务器的主要功能是接收来自客户端的图像,然后返回给客户端应用了黑白滤镜的图像。这个服务器设计为同时处理多个客户端。最初,在开发过程中,开发人员面临不确定性,关于客户端将发送的图像的大小。为了防止潜在的溢出,由于接收到可能不适合占的过大图像,决定将每个接收到的图像存储在堆上。当服务器部署时,6个客户端同时向服务器发送请求。服务器生成6个工作线程来处理每个客户端的请求,再从客户端接收到图像后,每个线程以紧凑的方式再堆上存储图像。但是由于不同客户端发送的图像大小的差异,处理时间各不相同。
11:04
较小的图像处理的更快。一旦这些较小的图像被处理,服务器响应,相应的客户端为这些请求分配的内存就不再需要允许这些段再次变为空闲。正如你所看到的,存储在堆中的任何值都可以随时被移除,使得像我们对战那样以可预测的方式组织数据是不可能的。堆上的碎片化本质上是不可避免的,但现在既然我们理解了堆将包含这些到处的洞,那么逻辑上要尽可能的利用它们。每当我们需要在堆上为一个值分配空间时,我们应该检查是否有任何这些洞足够大,因为如果是,我们可以绕过调用系统调用的开销。现在我们需要确定这些洞间隙或可用区域中的哪一个我们将要使用。有3种常见的选择策略。选择第一个足够大的洞来容纳我们的值。
12:00
选择最小的洞仍然足够大,以容纳我们的值,或者选择最大的可用洞,我们的值可以适应。请注意,这些策略都不能避免碎片化,他们之间的选择取决于几个因素。首次适应通常更快,但不是减少碎片化的最佳选择。最佳适应和最差适应可以在某些情况下减少碎片化,但他们可能没有那么快。无论我们选择哪种策略,它都涉及在各种因素之间的权衡。为了完成本节视频,让我们组织一下到目前为止我们学到的内容。我们将定义两个函数llock和free。Allo函数接受一个参数N,表示我们想要在堆上存储的值的大小,以字节为单位。在内部,阿lock将使用一些讨论过的策略来定位堆中的一个可用的区域,确保它至少有N个字节大小。如果没有找到合适的子区域,Aloc将调用系统调用从操作系统请求更多内存。
13:02
一旦确定了所需的空间,Aloook将更新管理堆状态的数据结构,并返回指向分配区域的指针。Free函数在不再需要分配的内存时使用。他接收区域的指针,并在表中搜索它,再次将其标记为可用。此外,如果释放的子区域与另一个空闲子区域相邻,Free负责合并这两个子区域。现在,如果你感觉这是太多信息,好消息是你通常不需要自己做所有这些。例如在C中,所有这些都已经实现在标准库中。当你需要使用堆时,你所要做的就是包含库并开始使用它函数。就这样,我们已经解决了占的一个限制。如果值太大而不适合放在站上,我们将其存储在堆上,而在站上我们只维护对值的引用。我们还有一个限制需要解决。动态大小的值。
14:02
正如视频开头提到的,因为站上的所有内容都是紧凑的值不能在不覆盖其他值的情况下增长或缩小。在评论中,有人问我,为什么我们不能仅仅提取要的数据,调整我们感兴趣的值的大小,然后将数据推回。这确实是可能的,但是如果你考虑一下这样做,将需要分配额外的内存来临时存储该数据,然后将其推回站。这个过程可能还需要一个系统调用来请求额外的内存。即使不需要系统调用,复制内存中的数据也需要额外的时间。在这一点上,战不再那么快了。这里的有趣之处在于,堆本身并没有解决这个问题。在堆上存储数组,可能仍然涉及在添加元素时覆盖其他值,类似于站上发生的情况。在这种情况下,我们的解决方案是数据结构。
15:02
最简单的选择是练表。由于指针在编译时已知的固定大小,它们可以作为自定义类型的成员。在站上,我们保持对第一个节点的指针。每个节点存储在堆上,包含一个元素和一个指向下一个节点的指针。当我们需要添加一个元素时,我们在堆上为一个新节点分配内存,并确保链表的最后一个元素指向新节点,它成为新的最后一个节点。就这样,问题解决了。有了链表,我们不需要大量的连续内存,事实上,他们利用了在堆上出现的洞。链表确实有缺点,其中之一是节点遍布整个堆,导致缓存命中率降低。如果我们的目标是保持列表的紧凑性,我们需要一个数组列表,通常在低级编程语言中被称为向量。数组列表的实现值得拥有自己的视频,所以我们不会在这里详细介绍。视频已经很长了,让我们进入最后一部分。为什么堆于如此可怕?
16:08
嗯,管理对比管理站要复杂得多。在站上存储值相对简单。因为占指针。相比之下,堆的运作方式大不相同,在堆中搜索可用子区域是一个耗时的过程。此外,在最坏的情况下,可能没有足够的可用子区域迫使我们通过系统调用请求内存,这会带来显著的性能惩罚。本质上,堆上的过度分配可以大幅减慢我们的程序。但那还不是全部。由于其性质,手动操作堆非常容易出错。在像C这样的语言中,有一大堆与手动内存管理相关的常见错误。一个世界对于高级开发者来说是隐藏的,因为垃圾收集器让他们保持安全。我们将在即将到来的一集中更详细的讨论这些错误,还有动画等等。
17:04
最后我只想澄清,如果负责任的使用堆可以成为你最好的盟友。当你听到有人说堆很慢时,这是非常误导的。所以是的,我点击了你。分配堆内存的整个过程是慢的。但是一旦你获得了那部分内存,如果你足够聪明,并记住像缓存这样的概念,这样你可以选择一个好的数据结构,访问堆上的内存可以和站一样快。视频就到这里了,如果你喜欢这个视频,点击喜欢按钮并考虑关注。
我来说两句