当前位置: 首页 > 科技观察

深度探索Node-(4)《内存控制》有十五道题

时间:2023-03-19 14:39:09 科技观察

本文转载自微信公众号《前端阳光》,作者张拉拉,事业有成。转载本文请联系前方阳光公众号。1、V8用什么为对象分配内存?2、为什么V8要限制堆的大小?3、那么,你知道垃圾回收机制的策略是什么吗?4、为什么要分代?5.哦,那说说世代是怎么划分的?6、新生代如何回收?7.很好奇新生代是怎么晋升到老年代的。8、为什么要设置这么低的25%?9.newgeneration中的对象被提升后就变成了oldgeneration,为什么oldgeneration不能被Scavenge回收?10.老年代的对象怎么办?11、那为什么还需要标记和清理?12.嘿!由于mark和clear是由mark和clear演变而来,即包含mark和clear。标记和清除怎么样?13、原来是这样的。如果垃圾回收算法时间长了,岂不是卡死了?14.你知道Buffer对象吗?Buffer对象是通过V8分配内存的吗?15.可以使用Arefs.readFile()和fs.writeFile()方法读写大文件吗?1、V8用什么为对象分配内存?在V8中,所有JavaScript对象都是通过堆分配的。Node提供了一种在V8中查看内存使用情况的方法。执行如下代码获取输出的内存信息:$node>process.memoryUsage();{rss:14958592,heapTotal:7195904,heapUsed:2821496}在上述代码中,memoryUsage()方法返回的三个属性中,heapTotal和heapUsed是V8的堆内存使用量,前者是已经申请的堆内存,后者是当前使用的量。至于为什么是rss,我们会在后续的内容中介绍。V8的堆图:当我们在代码中声明变量和赋值时,使用的对象的内存是在堆中分配的。如果分配的堆空闲内存不足以分配新的对象,它会继续申请堆内存,直到堆的大小超过V8的限制。2、为什么V8要限制堆的大小?表面上的原因是,V8本来就是为浏览器设计的,不太可能遇到内存占用大的场景。对于网页来说,V8的限制已经绰绰有余了。根本原因是V8的垃圾回收机制的限制。按照官方的说法,以1.5GB的垃圾回收堆内存为例,V8做一次小型垃圾回收需要50多毫秒,做一次非增量垃圾回收甚至需要1秒以上。这是垃圾回收期间导致JavaScript线程暂停执行的时间,在这样的时间花费下,应用程序的性能和响应能力将直线下降。这样的情况不仅后端服务不能接受,前端浏览器也不能接受。所以,在当时的考虑下,直接限制堆内存是一个不错的选择。3、那么,你知道垃圾回收策略是什么吗?V8的垃圾回收策略主要基于分代垃圾回收机制。4.为什么要分代?因为在实际应用中,对象的生命周期各不相同,不同的算法只能针对特定的情况有最好的结果。为此,在现代垃圾回收算法中,将内存的垃圾回收根据对象的存活时间划分为不同的代,然后对不同代的内存应用更高效的算法。5.哦,那一代怎么说呢?在V8中,内存主要分为两代:新生代和老年代。新生代中的对象是存活时间短的对象,老年代中的对象是存活时间长或常驻内存的对象。图为V8代的示意图。6、新生代如何回收?在分代的基础上,新生代中的对象主要通过Scavenge算法进行垃圾回收。它是一种通过复制实现的垃圾回收算法。它将堆内存分成两部分,每一部分的空间称为半空间。两个半空间中,只有一个在使用中,另一个闲置。使用中的半空间称为From空间,空闲状态的空间称为To空间。当我们分配一个对象时,我们首先在From空间分配它。当垃圾回收开始时,会检查From空间中的存活对象,并将这些存活对象复制到To空间,并释放非存活对象占用的空间。复制完成后,From空间和To空间的角色互换。简而言之,在垃圾回收的过程中,就是通过在两个半空间之间复制幸存的对象。Scavenge的缺点是只能使用一半的堆内存,这是分区空间和拷贝机制决定的。但是Scavenge在时间效率上有出色的表现,因为它只复制存活对象,而且只有一小部分存活对象的生命周期很短。由于Scavenge是一种典型的牺牲空间换取时间的算法,不能大规模应用于所有的垃圾回收。但是可以发现Scavenge非常适合应用在新生代中,因为新生代中对象的生命周期很短,正好适合这种算法。因此,V8的堆内存图应该如图所示。当一个对象在多次复制中幸存下来时,它被认为是一个长期存在的对象。生命周期较长的对象将被移至老年代并使用新算法进行管理。将对象从年轻代移动到老年代的过程称为提升。7.很好奇新生代是怎么晋升到老一代的。对象提升的条件主要有两个,一是对象是否经历过Scavenge回收,二是To空间的内存使用率超过限制。默认情况下,V8的对象分配主要集中在From空间。当一个对象从From空间复制到To空间时,会检查它的内存地址,以确定该对象是否经历过Scavenge恢复。如果经历过,则将对象从From空间复制到老年代空间,如果没有,则复制到To空间。提升过程如图所示。另一个判断条件是To空间的内存使用率。从From空间复制一个对象到To空间时,如果To空间已经使用超过25%,该对象将直接提升到老年代空间。本次推广的判断图如图所示。8、为什么要设置这么低的25%?之所以设置25%的限制值是因为当Scavenge回收完成后,To空间会变成From空间,下一次内存分配会在这个空间进行。如果比例过高,会影响后续的内存分配。9.新生代中的对象提升后,成为老年代。为什么不能用Scavenge回收老年代?对于老年代中的对象,由于存活对象所占比例较大,使用Scavenge方法存在两个问题:一是存活对象很多,复制存活对象的效率会很低;另一个问题仍然是浪费一半空间的问题。这两个问题导致Scavenge在处理长生命周期的对象时会捉襟见肘。10.如何处理老年代的对象?V8主要是在老年代使用Mark-Sweep和Mark-Compact的组合来进行垃圾回收。Mark-Sweep是标记去除的意思,分为标记和清除两个阶段。与Scavenge相比,Mark-Sweep并没有将内存空间一分为二,因此不会出现浪费一半空间的行为。不同于Scavenge复制活对象,Mark-Sweep在标记阶段遍历堆中的所有对象,对活对象进行标记。在后续的清理阶段,只清理没有被标记的对象。可以看出Scavenge只复制活对象,而Mark-Sweep只清理死对象。活对象只占新生代的一小部分,死对象只占老年代的一小部分,这也是为什么这两种回收方式都能高效处理的原因。图为老年代空间Mark-Sweep标记示意图,黑色部分标记为死对象。11、那为什么要标记整理?Mark-Sweep最大的问题是,一次mark-sweep恢复后,内存空间会出现不连续。这种内存碎片会给后续的内存分配带来问题,因为很有可能需要分配一个大对象。此时所有的碎片空间都无法完成本次分配,会提前触发垃圾回收,本次回收是不必要的。为了解决Mark-Sweep的内存碎片问题,提出了Mark-Compact。Mark-Compact是标记和整理的意思,是在Mark-Sweep的基础上演变而来的。它们的区别在于对象被标记为死亡后,在排序过程中将存活的对象移到一端。移动完成后,直接清理边界外的内存。图为Mark-Compact标记和移动幸存物体的示意图。白色格子是幸存物,深色格子是死物,浅色格子是幸存物移动后留下的洞。移动完成后,可以直接清空最右边存活对象后面的内存区,完成回收。12.嘿!既然mark-clearing是在mark-clearing的基础上进化而来的,也就是包含了mark-clearing,那么好,那就用mark-clearing,为什么说是和mark-clearing结合使用呢?这里将Mark-Sweep和Mark-Compact结合起来,不仅是因为这两种策略是递进的,而且在V8回收策略中也是结合使用的。该表是对目前介绍的三种主要垃圾回收算法的简单比较。从表中可以看出,在Mark-Sweep和Mark-Compact之间,由于Mark-Compact需要移动物体,其执行速度不可能很快,所以在权衡方面,V8主要使用Mark-Sweep,而在空间方面,当不足以分配从年轻代提升的对象时使用Mark-Compact。13.所以结果发现如果垃圾回收算法时间长了,岂不是卡死了?垃圾回收的三种基本算法都需要暂停应用逻辑,在垃圾回收完成后恢复应用逻辑。这种行为称为“停止世界”。在V8的分代垃圾回收中,小型垃圾回收只收集新生代。由于新生代默认配置很小,里面存活的对象通常比较少,即使是句号,影响也不大。但是老一代的V8通常配置更大,存活对象更多。完全垃圾回收(fullgarbagecollection)的标记、清理、整理动作造成的停顿会很糟糕,我们需要想办法改善它们。为了减少全堆垃圾回收带来的停顿时间,V8从标记阶段入手,把本该一口气完成的动作改为增量标记(incrementalmarking),即拆分成许多小的“steps”,每完成一个“step”,JavaScript应用逻辑就会执行一小会儿,垃圾回收和应用逻辑交替执行,直到标记阶段完成。图为增量打标示意图。V8通过增量标记改进后,垃圾回收的最大停顿时间可以减少到原来的1/6左右。后来V8还引入了lazysweeping和incrementalcompaction,使得cleanup和compaction动作是增量的。同时,还计划引入并行标记和并行清理,进一步利用多核性能减少每次停顿的时间。14.你知道Buffer对象吗?Buffer对象是否通过V8分配内存?是的。他不是。为什么不通过V8分配Buffer对象?这是因为Node不同于浏览器应用场景。在浏览器中,JavaScript可以直接处理字符串来满足大部分业务需求,而Node需要处理网络流和文件I/O流,操作字符串远远不能满足传输的性能需求。关于Buffer的细节,后面会详细解释。因此,从这里我们可以知道,Node的内存主要由V8分配的部分和Node自己分配的部分组成。V8的堆内存主要受限于V8的垃圾回收。15.fs.readFile()和fs.writeFile()可以读写大文件吗?由于V8的内存限制,我们不能直接通过fs.readFile()和fs.writeFile()来操作大文件。而是使用fs.createReadStream()和fs.createWriteStream()方法通过流实现对大文件的操作。下面的代码展示了如何读取一个文件,然后将数据写入另一个文件:由于读写模型是固定的,所以上面的方法有更简洁的方式,如下图:图像可读流提供了一个管道方法pipe(),它封装了数据事件和写操作。通过流式处理,上述代码不会受到V8内存限制的影响,有效提高了程序的健壮性。如果不需要在字符串级别进行操作,则不需要使用V8来处理它们。可以尝试进行纯Buffer操作,不会受到V8堆内存的限制。但是,在这么大的区域使用内存时还是要小心。即使V8不限制堆内存的大小,物理内存仍然是有限的。14.你知道Buffer对象吗?Buffer对象是否通过V8分配内存?是的。他不是。为什么不通过V8分配Buffer对象?这是因为Node不同于浏览器应用场景。在浏览器中,JavaScript可以直接处理字符串来满足大部分业务需求,而Node需要处理网络流和文件I/O流,操作字符串远远不能满足传输的性能需求。关于Buffer的细节,后面会详细解释。因此,从这里我们可以知道,Node的内存主要由V8分配的部分和Node自己分配的部分组成。V8的堆内存主要受限于V8的垃圾回收。15.fs.readFile()和fs.writeFile()可以读写大文件吗?由于V8的内存限制,我们不能直接通过fs.readFile()和fs.writeFile()来操作大文件。而是使用fs.createReadStream()和fs.createWriteStream()方法通过流实现对大文件的操作。下面的代码展示了如何读取一个文件,然后将数据写入另一个文件:由于固定的读写模型,上面的方法有更简洁的方式,如下:可读流提供管道方法pipe(),封装数据事件和写操作。通过流式处理,上述代码不会受到V8内存限制的影响,有效提高了程序的健壮性。如果不需要在字符串级别进行操作,则不需要使用V8来处理它们。可以尝试进行纯Buffer操作,不会受到V8堆内存的限制。但是,在这么大的区域使用内存时还是要小心。即使V8不限制堆内存的大小,物理内存仍然是有限的。