Skip to content

Latest commit

 

History

History
277 lines (150 loc) · 13.9 KB

JavaScript进阶-内存机制.md

File metadata and controls

277 lines (150 loc) · 13.9 KB

前言

距离上一次更《JavaScript 进阶系列》的文章已经一个月了, 抱歉请原谅我最近工作是有那么点小忙😅, 而且主要是去写 《全网最详bpmn.js教材》 系列了.

并不是我三心二意哈😳, 而是想着自己在捣鼓bpmn.js这东西的时候累死累活的, 所以捣鼓完之后就急切的想出一系列教材来降低大家的脱发率...

这周开始, 更新继续啦😄.

那么还是说说本章的内容吧. 其实看了标题大家也都知道我今天要说的是关于JavaScript中的内存机制了.

在写之前我也看了很多关于内存机制比较好的文章, 发现其实了解内存机制主要是为了帮我们更好的了解JavaScript中的垃圾回收机制以及避免内存泄露等问题.

内存回收

我们已经知道在JS中不管是全局变量还是局部变量它们都存储内存之中, 那么这些变量在使用完之后, 是停留在它原来的地方还是去了什么别的地方呢🤔️?

如果说这些变量我们已经不再需要它了, 那我们是不是可以称它们为“垃圾”🤔️?

既然都已经是“垃圾”了, 那么是不是应该被清理掉🤔️?

清理它们的又是谁呢🤔️?

是用什么方式清理呢🤔️?

抱歉...一不小心来了几个灵魂拷问...

下面👇就为大家一一解答哈😄.

其实在JS中是有一个自动垃圾收集机制的, 垃圾收集器(就像是我们的保洁阿姨)会每隔一段时间就执行一次释放操作, 去清理掉那些不再使用的值, 来释放它们占用的内存.

销毁局部变量和全局变量

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

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的一种权衡.

这个限制是不可修改的吗🤔️?

并不是的, 你可以通过执行以下命令来修改它:

// 这是调整老生代这部分的内存,单位是MB。后面会详细介绍新生代和老生代内存
node --max-old-space-size=2048 xxx.js 

// 这是调整新生代这部分的内存,单位是 KB。
node --max-new-space-size=2048 xxx.js

之前我在用Angular打包项目的时候就遇到过频繁报内存溢出:FATAL ERROR: CALL_AND_RETRY_LAST Allocation failed - process out of memory。并且打包速度相当慢,估计项目过大了.

解决办法就是:

定义在项目根目录node_modeles文件夹下的.bin目录里面,.bin目录下我们能找到一个叫ng的文件,在该文件的首行写上#!/usr/bin/env node --max_old_space_size=4096,这样也就可以解除v8node的内存使用限制了。

当然如果你是用vuereact开发的话就没这么麻烦了, 具体可以看这篇文章:

《nodejs 前端项目编译时内存溢出问题的原因及解决方案》


堆内存的分代管理

V8引擎对堆内存中的JS对象进行了分代管理, 也就是分为 新生代老生代.

首先让我们来了解以下几个知识点:

  • 新生代 就是临时分配的内存,存活时间短, 如临时变量、字符串等;
  • 老生代 是常驻内存,存活的时间长, 如主控制器、服务器对象等;
  • V8的堆内存, 就是两个内存之和.

就像下面的这张图一样:

新生代内存的回收

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

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

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

新生代内存结构

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

memory2.png

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

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

Scavenge算法

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

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

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

一张图方便你理解🤔:

memory3.png

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

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

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

memory4.png

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

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

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

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

(原文链接:https://blog.csdn.net/jin13277480598/article/details/54409543)

而这种零散的空间也有一个名字, 叫做 内存碎片.

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

memory5.png

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

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

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

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

老生代内存的回收

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

产生晋升的情况:

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

通过上面👆的介绍我们已经知道, 老生代内存的空间会比新生代的大了很多, 而且老生代累计的变量空间一般都是很大的.

因此老生代的垃圾回收就不能用Scavenge算法了, 一是会浪费一半的空间, 二对庞大的内存空间进行复制本身就是个“很重的体力活”.

标记清除

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

标记清除主要是经过以下几个过程:

  1. 遍历堆中的所有对象, 给它们做上标记;
  2. 之后对于代码环境中使用的变量被强引用的变量取消标记(被标记的都是垃圾);
  3. 依然被标记的变量当成垃圾给清除掉, 进行空间的回收;

当然, 和新生代一样, 在清理了之后, 还要整理内存碎片, 当然它的整理办法就是在清理阶段结束后把存活对象全部往一端靠拢.

memory6.png

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

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

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

并发标记

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

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

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

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

memory7.png

其实它内部并没有上面👆说的这么简单, 还是有很多实现机制的, 具体的可以看这里:

《引擎V8推出“并发标记”,可节省60%-70%的GC时间》

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

后语

这一章节就主要介绍到这里, 关于更多内存泄露以及避免内存泄露的内存我想还是放到下一章来讲吧.

这次也是我第一次在文章中使用表情包, 之前看到很多优秀的作者喜欢这样用, 然后今天尝试了一下...

发现停不下来了, 越用越兴奋😂.

确实有些表情能很好的表达出作者想要表达的意思, 还能搏看官们一笑😄, 何乐而不为呢, 哈哈.

参考文章: