JavaScript进阶-内存机制

104 阅读11分钟

销毁局部变量和全局变量

首先局部变量和全局变量的清除有什么不同呢?

1.局部变量的销毁

对于局部变量,由于它们是存在于函数中,那么当这个函数执行完了之后,它里面的变量会被 GC(垃圾收集)掉吗? 很多教材中说的是:

垃圾收集器很容易做出判断并回收.

确实, 这里还真不是全部被清理掉, 还是得看情况.

比如闭包中的变量并不会随着函数的执行完毕而被清除掉,反而会一直保留着,除非这个闭包被清除-也就是闭包中涉及的变量再也没有被别的函数引用到. 2. 全局变量的销毁

全局变量所存在的作用域太过广泛了, 什么时候需要自动释放内存空间就很难判断. 具体要不要回收还是得看后面的垃圾回收机制, 所以才说要避免使用全局变量.

V8引擎的内存机制

我们常说的引擎,它在使用的时候其实是会使用系统的内存的。 对于象 Java/Go这样的后端语言,在使用内存的时候没有什么限制的。 但是对于我们 V8引擎来说(应该都知道V8引擎是一种JS引擎的实现), 它只能使用系统的一部分内存.

查看了一下资料:

  • 64位系统下能使用约1.4GB;
  • 32位系统下能使用约0.7GB.
  • 在我们前端看来好像已经很多了, 够用了, 但是别忘了node.js这位“后端大哥”.想想要是它遇到了一个很大的文件, 比如2G的文件, 那么它就无法将其全部读入内存且进行其他的操作.

再来想想我们JS中的存储, 分为栈存储和堆存储. 1.对于栈内存,当 ESP 指针(你只需知道它是栈指针)下移,也就是上下文切换之后,栈顶的空间会自动被回收。 2.而对象的存储是通过堆来进行分配的,当在构建一个对象且进行赋值操作的时候,JS会将相应的内存分配到堆上,所以每创建一个对象之后。堆就会大一点。 那么前面我们也说了, V8引擎只能使用系统的一部分内存, 你的堆可能会不停的增大, 直到大小达到了V8引擎的内存上限为止.

可是V8引擎为什么要给它设置一个内存的上限呢? 如果没有上限或者上限很大, 那么不是能够干更多的事啦🤔️? 其实这个还真不怪V8, 主要原因是两个大家都经常听到的词:

  • JS的单线程执行机制
  • JS垃圾回收机制的限制

为什么说这两个是限制内存上限的原因呢🤔️?

在JS中, 由于它是单线程运行的, 也就是一次只能做一件事, 那么意味着一旦进入了垃圾回收阶段, 其它的运行逻辑都得暂停了, 得等它过了这个阶段才继续执行.

但是好巧不巧的是, 垃圾回收是一件非常耗时的事情, 以 1.5GB 的垃圾回收堆内存为例,V8 做一次小的垃圾回收需要50ms 以上,做一次非增量式的垃圾回收甚至要 1s 以上.

所以若是垃圾回收时常过久的话, JS代码会一直没有响应, 造成了应用卡顿, 其中的坏处我就不用多说了吧.

就这样, V8干脆给它限制了堆内存大小, 这样就算你到顶了也不会说太卡, 而且其实大部分情况也不会说有操作几个G的情况, 因此这也是V8的一种权衡.

堆内存的分代管理

V8引擎对堆内存中的 JS对象进行了分代管理,也就是新生代老生代。 首先让我们来了解以下几个知识点: 新生代 就是临时分配的内存,存活时间短,如临时变量、字符串; 老生代 就是常驻内存,存活的时间长,如主控制器、服务器对象等; v8的堆内存,就是两个内存只和 就像下面的这张图一样

image.png

新生代内存的回收

其实也像图里画的一样, 新生代的默认内存限制很小:

  • 64位系统下为32MB;
  • 32位系统下为16MB.

确实是够小的啦, 主要原因是新生代中的变量存活时间短,来了马上就走,不容易产生太大的内存负担,因此可以将它设的足够小

新生代内存结构

新生代内存会被分为两个部分

image.png 一块叫做From, 另一块叫做To. (别的教材中是这么命名的, 后来我去找寻原因, 发现大概是因为在V8的源码-内存管理中有from_space_和to_space_这两个东西吧)

  • From表示正在使用的内存;
  • To表示目前闲置的内存.

Scavenge算法

上面已经介绍了新生代内存的结构, 下面来说说它具体是如何进行垃圾回收的.

当进行垃圾回收的时候, 会经过以下几个步骤:

  1. V8将From部分的对象全部检查一遍;
  2. 检查出若是 存活对象 则复制到To内存中, 若不是则直接回收;
  3. 复制到To内存中是按照顺序从头放置的;
  4. 当From中所有的存活对象全部复制完毕之后, From和To就会 对调 , 也就是From被闲置, To在使用;
  5. 如此循环.

一张图方便你理解 🤔 :

image.png 不就是个清理垃圾的动作吗? 为什么V8要整的这么复杂啊, 又是遍历又是复制的.

而且为什么还要在To内存中按照顺序从头放置呢🤔️?

其实, 它这样做是有一定好处的, 首先让我们来看看下面这张图:

image.png 在上图中, 黄色的部分是待分配的内存, 而蓝色的小方块就是存活对象.

看起来存活对象非常的散乱, 使得空间变得零零散散, 并且堆内存又是连续分配的, 若是碰到稍微大点的对象的话都没有办法进行空间分配了.

堆包含一个链表来维护已用和空闲的内存块。在堆上新分配(用 new 或者 malloc)内存是从空闲的内存块中找到一些满足要求的合适块。所以可能让人觉得只要有很多不连续的零散的小区域,只要总数达到申请的内存块,就可以分配。

但事实上是不行的,这又让人觉得是不是零散的内存块不能连接成一个大的空间,而必须要一整块连续的内存空间才能申请成功. 而这种零散的空间也有一个名字, 叫做 内存碎片.

因此将其按照顺序从头放置也是为了解决 内存碎片 的问题, 在一顿复制之后, To内存会被排列的整整齐齐的:

image.png 整顿之后就大大方便了后续连续空间的分配.

上面👆说的这种新生代垃圾回收算法也被叫做 Scavenge 算法 (scavenge的本意就是回收).

所以这个Scavenge算法不仅仅是将非存活对象给回收了, 还需要对内存空间做整顿.

就像是我们平常打扫房间, 不仅仅是将不要的垃圾清理掉, 还顺便把房间内的东西给放整齐了😊.

老生代内存的回收

如果新生代中的变量经过多次回收之后依然存在的话,它就会发生“晋升”,被放入老生代内存在 产生晋升的情况:

  • 已经经历过一次Scavenge回收;
  • To(闲置内存)空间的内存不足75%.

通过上面👆的介绍我们已经知道, 老生代内存的空间会比新生代的大了很多, 而且老生代累计的变量空间一般都是很大的. 因此老生代的垃圾回收就不能用Scavenge算法了, 一是会浪费一半的空间, 而对庞大的内存空间进行复制本身就是个“很重的体力活”.

标记清除

所以对于老生代的垃圾回收干脆粗暴点吧, 采用标记清除的方式进行回收.

  1. 遍历堆中的所有对象, 给它们做上标记;
  2. 之后对于代码环境中使用的变量和被强引用的变量取消标记(被标记的都是垃圾);
  3. 把依然被标记的变量当成垃圾给清除掉, 进行空间的回收;
  4. 当然, 和新生代一样, 在清理了之后, 还要整理内存碎片, 当然它的整理办法就是在清理阶段结束后把存活对象全部往一端靠拢.

image.png 所以总的来说, 对于老生代内存的回收主要就是经过:

  • 标记清除阶段, 留下存活对象;
  • 整理阶段, 把存活对象往一边靠拢.

因此, 对于现在的主流浏览器来说, 只要切断对象与根部的关系, 就可以将对象进行回收.

并发标记

在上面我们已经介绍过了V8在进行垃圾回收的时候, 不可避免地会阻塞业务逻辑的执行, 特别如果是老生代垃圾回收的任务比较繁重的时候, 会很耗时严重影响应用的性能.

为优化解决此问题, V8官方在2018年推出了名为增量标记的技术.

总的来说该技术的作用就是将原本一口气完成的标记任务分为了很多小的部分去完成, 每完成一个小任务就停一会, 让js逻辑执行一会, 然后再继续执行下面的部分.

在 GC 扫描和标记活动对象时,它允许 JavaScript 应用程序继续运行

image.png 在通过增量标记后, 垃圾回收过程对JS应用的阻塞时间减少到原来了1 / 6, 可以说这优化相当大了啊.

引用计数

引用计算的核心思想是对每个值都记录它被引用的次数。声明变量并给他赋一个引用值时,这个值的应用数为1。

如果同一个值又被赋给另一个变 量,那么引用数加 1。类似地,如果保存对该值引用的变量被其他值给覆盖了,那么引用数减 1。当一 个值的引用数为 0 时,就说明没办法再访问到这个值了,因此可以安全地收回其内存了。垃圾回收程序 下次运行的时候就会释放引用数为 0 的值的内存。

引用计数有一个严重的问题,就是循环引用,所谓的循环引用,就是对象 A 有一个指针指向对象 B,而对象 B 也引用了对象 A,比如:

function foo() { const A = {}; const B = {}; A.foo = B; B.foo = A; }

在这个例子中,AB 通过各自的属性相互引用,意味着它们的引用数都是2。在 标记清理策略下,这不是问题,因为在函数结束后,这两个对象都不在作用域中。而在引用计数策略下,AB 在函数结束后还会存在,因为它们的引用数永远不会变成0。如果函数被多次调用,则会导致大量内存永远不会被释放

引用计数的优势:

  1. 可即刻回收垃圾,当被引用数值为0时,对象在变成垃圾的时候就立刻被回收。
  2. 因为是即时回收,那么‘程序’不会暂停去单独使用很长一段时间的GC,那么最大暂停时间很短。

引用计数的缺点:

  1. 时间开销大,因为引用计数算法需要维护引用数,一旦发现引用数发生改变需要立即对引用数进行修改;
  2. 最大的缺点还是无法解决循环引用的问题;

看一个例子,很鲜明的表示了引用计数存在的缺点了:

function foo() { const A = {}; const B = {}; A.foo = B; B.foo = A; return "hi"; } foo();

foo在执行完成之后理应回收foo作用域里面的内存空间,但是因为 A 里面有一个属性引用 B,导致B的引用次数始终为1,B也是如此,而又非专门当做闭包来使用,所以这里就应该使AB被销毁。因为算法是将引用次数为0的对象销毁,此处都不为0,导致GC不会回收他们,那么这就是内存泄漏问题。

一直手动解决的办法就是把变量设置为 null 实际上会切断变量与其之前引用值之间的关系。当下次垃圾回收程序运行时, 这些值就会被删除,内存也会被回收。