avatar

【重识前端】深入内存世界

最近在写【重拾前端】系列,下面有几个快速通道,大家自取

【重识前端】原型/原型链和继承

【重识前端】闭包与模块

【重识前端】全面攻破this

【重识前端】一次搞定JavaScript的执行机制

【重识前端】什么是BFC、IFC、GFC 和 FFC

【重识前端】深入内存世界

前言

用于记录本人的学习过程,希望可以帮助到大家~🤠

JavaScript有自动垃圾回收机制,可能因为这个自动让我们前端开发人员忽略了对他的认识(包括我自己),但是阿里的面试官可不会和你嘻嘻哈哈。

“垃圾回收的机制是什么?什么时候会去触发”

我一脸懵逼….啥…这个不是他自己运行的吗…🤕

还是老规矩,黄金圈法则:

为什么有垃圾回收♻️,或者说垃圾回收的作用是什么?

计算机在启动一个程序的时候,会为他分配一块内存,用来存放代码、运行中的数据和一个执行任务的主线程。我们知道电脑的内存其实是有限的,比如常见的8G、16G等等,甚至还有一些什么虚拟内存就讨论了。我们关注点在于内存是一个种稀缺资源,对于不用的东西我们不要#占着茅坑不拉屎#。

所以垃圾回收的作用就是释放一下不再使用的内存空间。

内存的结构

JavaScript中,分为两种内存空间

  • 堆内存->存储引用类型
  • 栈内存->存储基本类型

基本类型又叫原始类型;因为本人已经说习惯了基本类型,所以以下都叫做基本类型哈。

引用类型、基本类型与堆栈的爱恨纠葛

变量存放

基本类型

截止北京时间2020年8月25日;

MDN中显示的基本类型(原始类型)有以下7种

基本类型是存储在栈内存里面的。栈内存里面还存着指向堆内存的内存地址。

PS:闭包中,基本类型也是存放在堆内存中的哦,要注意一下。

引用类型

除了基本类型的数据结构就是引用类型,包括什么数组(Array)、函数(Function)、对象(Object)之类的都是引用类型。

引用类型和闭包的变量都是存储在堆内存中的。而指针或者说内存地址是存在栈内存的。

当查询引用类型的变量时, 先从栈中读取内存地址, 然后再通过地址找到堆中的值。对于这种,我们把它叫做按引用访问。

干说有点难理解。身为马良的我给大家画一幅画,帮助理解。

1
2
3
4
5
6
7
8
9
var a = 123;
var b = true;
var c = null;
var d = '123';
var e = undefined;
var f = Symbol();
var g = 123n;
var h = new Date();
// 字太丑了。。。代码可以看这里

1

堆内存和栈内存的总结

栈内存 堆内存
存储基础数据类型以及内存地址 存储引用数据类型以及闭包变量
按值访问 按引用(内存地址)访问
存储的值大小固定 存储的值大小不定,可动态调整
由系统自动分配内存空间 由代码进行指定分配
空间小,运行效率高 空间大,运行效率相对较低

深拷贝、浅拷贝、赋值铁人三项的不解渊源

赋值

赋值分两种情况:

  1. 创建新值

    1
    2
    var a = 123;
    var b = [1, 2, 3];

    这种就是赋新值的情况。

  2. 赋值已有的变量的值

    在上面的例子基础上修改一下。

    1
    2
    3
    4
    var a = 123;
    var b = [1, 2, 3];
    var A = a;
    var B = b;

    这种情况就是赋值已有变量。

👌,接下来来看一个例子,还是把👆的🌰修改一下

1
2
3
4
5
6
7
8
9
10
11
var a = 123;
var b = [1, 2, 3];
var A = a;
var B = b;

A = 1234;
console.log(a);
console.log(A);
B.push(4);
console.log(b);
console.log(B);

带🔥自己打开控制台,参与进来!看完之后然后再看看和自己的理解是不是一样,如果是一样的话,说明你很👍,不是的话就一起来看看👇的解释吧。

我们观察之后发现,我们只修改了B但是,我们英俊的b也被修改了。

???(脑补黑人问号脸)

其实我们在开发过程中肯定是不希望这种情况的发生的,我们修改的只是B,不希望修改b的,不然我们肯定会写b.push(4)

这时就需要用到铁人三项的另外两个项目:浅拷贝和深拷贝。

浅拷贝和深拷贝

刚刚说到引用类型其实是放在堆内存中的,而栈内存里面只存指向堆内存的地址。(有点拗口….🧘‍♂️)

浅拷贝浅拷贝,顾名思义。就是浅一点的拷贝,有多浅呢?

一层那么浅。

也就是说除了第一层的东西,第一层之外更深的层级就不是拷贝了,叫拿别人的地址。

口说无凭,我弄个demo给大家🔥see see。

2

可以很清晰,清晰的不能再清晰的看到,浅拷贝出来的对象里面的属性b其实还是指向之前的对象,之前说过,再深的他就不拷贝了,验证的话就交给大家了,毕竟我们是一个动手节目组!

那么浅拷贝有哪些方法呢?

  1. Object.assign()
  2. es6的扩展运算符{...xxx}
  3. Array.prototype.slice()

深拷贝

看看我那个完美的图就已经知道了,深拷贝就是完美的弄一个一模一样的出来。

帅哥帅哥,深拷贝有啥办法捏?

  1. JSON.parse(JSON.stringify(object))
    1. 会忽略 undefined
    2. 会忽略 symbol
    3. 不能序列化函数
    4. 不能解决循环引用的对象
    5. 不能正确处理`new Date()
    6. 不能处理正则
  2. 自己写一个递归然后疯狂浅拷贝….
  3. 终极方案:lodash的cloneDeep

总结

和原数据是否指向同一对象 第一层数据为基本数据类型 原数据中包含子对象
赋值 改变会使原数据一起改变 改变会使原数据一起改变
浅拷贝 改变不会使原数据一起改变 改变会使原数据一起改变
深拷贝 改变不会使原数据一起改变 改变不会使原数据一起改变

垃圾回收机制♻️

JavaScript有自动垃圾收集机制,垃圾收集器会每隔一段时间或者达到某个阈值就执行一次释放操作,找出那些不再继续使用的值,然后释放其占用的内存。

判断方式

JavaScript是怎么判断某个变量是否需要被回收的?

有以下几个方法

标记清除

比如一个函数内部声明的一个变量,一旦我们的主线程离开了这个函数,那么这个函数声明的一些变量外部就无法用到,那么就会被标记为”离开环境”。这样在下一个垃圾回收周期来领的时候,就自动释放了这些变量占用的内存。

引用计数

另一种不太常见的垃圾收集策略叫做引用计数(reference counting)。引用计数的含义是跟踪记录每 个值被引用的次数。当声明了一个变量并将一个引用类型值赋给该变量时,则这个值的引用次数就是 1。 如果同一个值又被赋给另一个变量,则该值的引用次数加 1。相反,如果包含对这个值引用的变量又取 得了另外一个值,则这个值的引用次数减 1。当这个值的引用次数变成 0 时,则说明没有办法再访问这 个值了,因而就可以将其占用的内存空间回收回来。这样,当垃圾收集器下次再运行时,它就会释放那 些引用次数为零的值所占用的内存。

引用计数有一个非常严重的BUG就是,如果存在循环引用的话,系统就无法判断你这个内存到底要不要回收了。而且引用的数字永远都不会是0。举个例子

1
2
3
4
var element = document.getElementById("some_element"); 
var myObject = new Object();
myObject.element = element;
element.someObject = myObject;

这样的话elementmyObject这对情侣就永远如胶似漆,系统无法将他们送上天堂。

那咋办?要拆散他们有一个主意,就是在不用的时候给他们置为null

1
2
myObject.element = null; 
element.someObject = null;

将变量设置为 null 意味着切断变量与它此前引用的值之间的连接。当垃圾收集器下次运行时,就 会删除这些值并回收它们占用的内存。

插播一个小故事

IE 的垃圾收集器是根据内存分配量运行的,具体 一点说就是 256 个变量、4096 个对象(或数组)字面量和数组元素(slot)或者 64KB 的字符串。达到 上述任何一个临界值,垃圾收集器就会运行。这种实现方式的问题在于,如果一个脚本中包含那么多变 量,那么该脚本很可能会在其生命周期中一直保有那么多的变量。而这样一来,垃圾收集器就不得不频 繁地运行。结果,由此引发的严重性能问题促使 IE7 重写了其垃圾收集例程。

随着 IE7 的发布,其 JavaScript 引擎的垃圾收集例程改变了工作方式:触发垃圾收集的变量分配、 字面量和(或)数组元素的临界值被调整为动态修正。IE7 中的各项临界值在初始时与 IE6 相等。如果 垃圾收集例程回收的内存分配量低于 15%,则变量、字面量和(或)数组元素的临界值就会加倍。如果 例程回收了 85%的内存分配量,则将各种临界值重置回默认值。这一看似简单的调整,极大地提升了 IE 在运行包含大量 JavaScript 的页面时的性能。

—-摘自JavaScript高级程序设计

V8垃圾回收策略

什么是V8?

简单介绍一下什么是V8?

V8是一个由Google开发的开源JavaScript引擎,用于Google ChromeChromium[2]

—摘自维基百科

既然V8是Chrome的引擎,那么别的浏览器呢?

世界上主要的浏览器有以下三种。

V8——开源,由 Google 开发,使用 C++ 编写

SpiderMonkey——第一个 JavaScript 引擎,该引擎过去驱动 Netscape Navigator,如今驱动 Firefox 浏览器。

JavaScriptCore——开源,苹果公司为 Safair 浏览器开发的

okokok,收!回归初心。

V8的垃圾回收策略

我们的伟大的Chrome,根据不同的情况有不同的对策。他把内存分成两块

  • 新生代
  • 老生代

分代内存

默认情况下,32位系统新生代内存大小为16MB,老生代内存大小为700MB,64位系统下,新生代内存大小为32MB,老生代内存大小为1.4GB。

新生代平均分成两块相等的内存空间,叫做semispace,每块内存大小8MB(32位)或16MB(64位)。

新生代算法

首先,我们科普一下什么是新生代。

新生代的内存呢,就是存活时间比较短的对象。如果存活的时间比较长的话就会《晋升》为老生代。等一下会稍微详细的说一下《晋升》这个概念。

新生代采用Scavenge GC垃圾回收算法,在算法实现时主要采用Cheney算法。

Cheney算法呢是将内存一分为二,一块暂时把他叫做《From》,另一块暂时叫做《To》。看一下下图,就可以很清晰的知道V8是怎么分割内存的。

3

内存的到来先进入From,然后等到From满了之后,新生代的GC就会启动。

首先释放掉一些不再使用的内存,然后将From和To两个功能进行更换,也就是说之前的From -> To 之前的 To -> From。这样的话新生代的一轮GC就此结束了。

这种算法有点类似空间换时间,而新生代的对象生命一般都是比较短的,所以所以非常适合新生代。

晋升

对象的晋升其实主要有两个要求,就好像在来P6升P7有一堆的要求一样。

  1. 新生代算法刚刚我们已经学习过了,就是From和To的互换功能。如果一个对象他经历了两次的新生代的更替,还没有被回收内存,说明他很强。就需要晋升为老生代,让老生代的算法来对付他。就好像在阿里的一个P6两轮361都没有被淘汰说明他很有实力,所以直接升级他为P7。
  2. 如果一个对象在To的内存占比超过了25%,在第一次更替的时候就直接将他晋升为老生代了。可以理解为一个技术大牛,像马云,一去阿里就被星探发现,直接就是P7评级,不需要经过什么复杂的面试或者什么。

老生代算法

根据刚刚对《晋升》的了解,我们知道了在老生代里面的对象都是一些难缠的选手,比如本来就占内存比例很大,或者经历过2次的新生代GC,依旧坚挺的选手。

所以,V8在老生代中主要采用了Mark-SweepMark-Compact相结合的方式进行垃圾回收。

在介绍老生代算法之前,先科普一下Mark-SweepMark-Compact

Mark-Sweep

Mark-Sweep是标记清除的意思,它分为标记和清除两个阶段。

与Scavenge不同,Mark-Sweep并不会将内存分为两份,所以不存在浪费一半空间的行为。Mark-Sweep在标记阶段遍历堆内存中的所有对象,并标记活着的对象,在随后的清除阶段,只清除没有被标记的对象。

也就是说,Scavenge只复制活着的对象,而Mark-Sweep只清除死了的对象。活对象在新生代中只占较少部分,死对象在老生代中只占较少部分,这就是两种回收方式都能高效处理的原因。

在标记大型对内存时,可能需要几百毫秒才能完成一次标记。这就会导致一些性能上的问题。为了解决这个问题,2011 年,V8 从 stop-the-world 标记切换到增量标志。在增量标记期间,GC 将标记工作分解为更小的模块,可以让 JS 应用逻辑在模块间隙执行一会,从而不至于让应用出现停顿情况。但在 2018 年,GC 技术又有了一个重大突破,这项技术名为并发标记。该技术可以让 GC 扫描和标记对象时,同时允许 JS 运行,你可以点击 该博客 详细阅读。

Mark-Compact

在清除完对象后,这些对象在内存内部就不是连续的。这样内存地址就无法很好地利用起来,因为他们需要连续的内存,有的大的对象就无法塞入刚刚释放的小内存当中。

而Mark-Compact就是用于解决内存碎片的问题,他主要干的活就是移动这些对象,让他们变成紧凑起来。

清除对象后会造成堆内存出现碎片的情况,当碎片超过一定限制后会启动压缩算法。在压缩过程中,将活的对象像一端移动,直到所有对象都移动完成然后清理掉不需要的内存。

总结

在V8的回收策略中,Mark-Sweep和Mark-Conpact两者是结合使用的。

由于Mark-Conpact需要移动对象,所以它的执行速度不可能很快,在取舍上,V8主要使用Mark-Sweep,在空间不足以对从新生代中晋升过来的对象进行分配时,才使用Mark-Compact。

JavaScript内存泄漏

什么是内存泄漏

程序的运行需要内存。只要程序提出要求,操作系统或者运行时(runtime)就必须供给内存。

对于持续运行的服务进程(daemon),必须及时释放不再用到的内存。否则,内存占用越来越高,轻则影响系统性能,重则导致进程崩溃。

不再用到的内存,没有及时释放,就叫做内存泄漏(memory leak)。

常见的内存泄漏

  1. 定时器未及时清除

  2. 意外的全局变量

    1
    2
    3
    function foo(arg) {
    bar = "this is a hidden global variable"; // 意外挂在在 window 全局变量,导致内存泄漏
    }

    这里是想说一下高程(JavaScript高级程序设计第三版)里面的一个非常经典的demo。

  3. 闭包的滥用

WeakMap与WeakSet

其实这两个在这篇文章里面的意义相同,只需要介绍一个就行。

我们知道Map是简单的key-value。而且key可以为任意类型,比如引用类型,如果我们的key是引用类型那么他所占据的内存就无法被释放了。

又有同学问题了,我有毛病啊,我非要引用类型吗?我string和number他不香吗?

基本上,如果你要往对象上添加数据,又不想干扰垃圾回收机制,就可以使用 WeakMap。一个典型应用场景是,在网页的 DOM 元素上添加数据,就可以使用WeakMap结构。当该 DOM 元素被清除,其所对应的WeakMap记录就会自动被移除。

1
2
3
4
5
6
const wm = new WeakMap();

const element = document.getElementById('example');

wm.set(element, 'some information');
wm.get(element) // "some information"

上面代码中,先新建一个 Weakmap 实例。然后,将一个 DOM 节点作为键名存入该实例,并将一些附加信息作为键值,一起存放在 WeakMap 里面。这时,WeakMap 里面对element的引用就是弱引用,不会被计入垃圾回收机制。

也就是说,上面的 DOM 节点对象的引用计数是1,而不是2。这时,一旦消除对该节点的引用,它占用的内存就会被垃圾回收机制释放。Weakmap 保存的这个键值对,也会自动消失。

总之,WeakMap的专用场合就是,它的键所对应的对象,可能会在将来消失。WeakMap结构有助于防止内存泄漏。

更多的知识就不讨论了,这里是内存世界!不是什么WeakMap专场哦。

怎么验证?emmmmm….这里我copy一下阮一峰老师的内容。

阮老师的demo

首先,打开 Node 命令行。

1
$ node --expose-gc

上面代码中,--expose-gc参数表示允许手动执行垃圾回收机制。

然后,执行下面的代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
// 手动执行一次垃圾回收,保证获取的内存使用状态准确
> global.gc();
undefined

// 查看内存占用的初始状态,heapUsed 为 4M 左右
> process.memoryUsage();
{ rss: 21106688,
heapTotal: 7376896,
heapUsed: 4153936,
external: 9059 }

> let wm = new WeakMap();
undefined

// 新建一个变量 key,指向一个 5*1024*1024 的数组
> let key = new Array(5 * 1024 * 1024);
undefined

// 设置 WeakMap 实例的键名,也指向 key 数组
// 这时,key 数组实际被引用了两次,
// 变量 key 引用一次,WeakMap 的键名引用了第二次
// 但是,WeakMap 是弱引用,对于引擎来说,引用计数还是1
> wm.set(key, 1);
WeakMap {}

> global.gc();
undefined

// 这时内存占用 heapUsed 增加到 45M 了
> process.memoryUsage();
{ rss: 67538944,
heapTotal: 7376896,
heapUsed: 45782816,
external: 8945 }

// 清除变量 key 对数组的引用,
// 但没有手动清除 WeakMap 实例的键名对数组的引用
> key = null;
null

// 再次执行垃圾回收
> global.gc();
undefined

// 内存占用 heapUsed 变回 4M 左右,
// 可以看到 WeakMap 的键名引用没有阻止 gc 对内存的回收
> process.memoryUsage();
{ rss: 20639744,
heapTotal: 8425472,
heapUsed: 3979792,
external: 8956 }

上面代码中,只要外部的引用消失,WeakMap 内部的引用,就会自动被垃圾回收清除。由此可见,有了 WeakMap 的帮助,解决内存泄漏就会简单很多。

看完几件事

如果你觉得对你有帮助,就帮我点个赞吧,也算是对我的肯定。谢谢,如果有错误的地方欢迎大家指出来。一起讨论学习。我是Derrick,一名正在去阿里路上的前端开发工程师。

reference

https://muyiy.cn/blog/1/1.3.html#%E6%A0%88%E6%95%B0%E6%8D%AE%E7%BB%93%E6%9E%84

http://www.ruanyifeng.com/blog/2017/04/memory-leak.html

https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Map

https://juejin.im/post/6844903615300108302

https://juejin.im/post/6844903591510016007

JavaScript高级程序设计(第三版)

文章作者: Derrick
文章链接: http://derricktel.github.io/2020/08/25/%E3%80%90%E9%87%8D%E8%AF%86%E5%89%8D%E7%AB%AF%E3%80%91%E6%B7%B1%E5%85%A5%E5%86%85%E5%AD%98%E4%B8%96%E7%95%8C/
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 Derrick
打赏
  • 微信
    微信
  • 支付寶
    支付寶