最近在写【重拾前端】系列,下面有几个快速通道,大家自取
前言
用于记录本人的学习过程,希望可以帮助到大家~🤠
JavaScript有自动垃圾回收机制,可能因为这个自动让我们前端开发人员忽略了对他的认识(包括我自己),但是阿里的面试官可不会和你嘻嘻哈哈。
“垃圾回收的机制是什么?什么时候会去触发”
我一脸懵逼….啥…这个不是他自己运行的吗…🤕
还是老规矩,黄金圈法则:
为什么有垃圾回收♻️,或者说垃圾回收的作用是什么?
计算机在启动一个程序的时候,会为他分配一块内存,用来存放代码、运行中的数据和一个执行任务的主线程。我们知道电脑的内存其实是有限的,比如常见的8G、16G等等,甚至还有一些什么虚拟内存就讨论了。我们关注点在于内存是一个种稀缺资源,对于不用的东西我们不要#占着茅坑不拉屎#。
所以垃圾回收的作用就是释放一下不再使用的内存空间。
内存的结构
JavaScript中,分为两种内存空间
- 堆内存->存储引用类型
- 栈内存->存储基本类型
基本类型又叫原始类型;因为本人已经说习惯了基本类型,所以以下都叫做基本类型哈。
引用类型、基本类型与堆栈的爱恨纠葛
变量存放
基本类型
截止北京时间2020年8月25日;
在MDN中显示的基本类型(原始类型)有以下7种
基本类型是存储在栈内存里面的。栈内存里面还存着指向堆内存的内存地址。
PS:闭包中,基本类型也是存放在堆内存中的哦,要注意一下。
引用类型
除了基本类型的数据结构就是引用类型,包括什么数组(Array)、函数(Function)、对象(Object)之类的都是引用类型。
引用类型和闭包的变量都是存储在堆内存中的。而指针或者说内存地址是存在栈内存的。
当查询引用类型的变量时, 先从栈中读取内存地址, 然后再通过地址找到堆中的值。对于这种,我们把它叫做按引用访问。
干说有点难理解。身为马良的我给大家画一幅画,帮助理解。
1 | var a = 123; |
堆内存和栈内存的总结
栈内存 | 堆内存 |
---|---|
存储基础数据类型以及内存地址 | 存储引用数据类型以及闭包变量 |
按值访问 | 按引用(内存地址)访问 |
存储的值大小固定 | 存储的值大小不定,可动态调整 |
由系统自动分配内存空间 | 由代码进行指定分配 |
空间小,运行效率高 | 空间大,运行效率相对较低 |
深拷贝、浅拷贝、赋值铁人三项的不解渊源
赋值
赋值分两种情况:
创建新值
1
2var a = 123;
var b = [1, 2, 3];这种就是赋新值的情况。
赋值已有的变量的值
在上面的例子基础上修改一下。
1
2
3
4var a = 123;
var b = [1, 2, 3];
var A = a;
var B = b;这种情况就是赋值已有变量。
👌,接下来来看一个例子,还是把👆的🌰修改一下
1 | var a = 123; |
带🔥自己打开控制台,参与进来!看完之后然后再看看和自己的理解是不是一样,如果是一样的话,说明你很👍,不是的话就一起来看看👇的解释吧。
我们观察之后发现,我们只修改了B
但是,我们英俊的b
也被修改了。
???(脑补黑人问号脸)
其实我们在开发过程中肯定是不希望这种情况的发生的,我们修改的只是B
,不希望修改b
的,不然我们肯定会写b.push(4)
。
这时就需要用到铁人三项的另外两个项目:浅拷贝和深拷贝。
浅拷贝和深拷贝
刚刚说到引用类型其实是放在堆内存中的,而栈内存里面只存指向堆内存的地址。(有点拗口….🧘♂️)
浅拷贝浅拷贝,顾名思义。就是浅一点的拷贝,有多浅呢?
一层那么浅。
也就是说除了第一层的东西,第一层之外更深的层级就不是拷贝了,叫拿别人的地址。
口说无凭,我弄个demo给大家🔥see see。
可以很清晰,清晰的不能再清晰的看到,浅拷贝出来的对象里面的属性b
其实还是指向之前的对象,之前说过,再深的他就不拷贝了,验证的话就交给大家了,毕竟我们是一个动手节目组!
那么浅拷贝有哪些方法呢?
- Object.assign()
- es6的扩展运算符
{...xxx}
- Array.prototype.slice()
深拷贝
看看我那个完美的图就已经知道了,深拷贝就是完美的弄一个一模一样的出来。
帅哥帅哥,深拷贝有啥办法捏?
- JSON.parse(JSON.stringify(object))
- 会忽略
undefined
- 会忽略
symbol
- 不能序列化函数
- 不能解决循环引用的对象
- 不能正确处理`new Date()
- 不能处理正则
- 会忽略
- 自己写一个递归然后疯狂浅拷贝….
- 终极方案:lodash的
cloneDeep
总结
和原数据是否指向同一对象 | 第一层数据为基本数据类型 | 原数据中包含子对象 | |
---|---|---|---|
赋值 | 是 | 改变会使原数据一起改变 | 改变会使原数据一起改变 |
浅拷贝 | 否 | 改变不会使原数据一起改变 | 改变会使原数据一起改变 |
深拷贝 | 否 | 改变不会使原数据一起改变 | 改变不会使原数据一起改变 |
垃圾回收机制♻️
JavaScript有自动垃圾收集机制,垃圾收集器会每隔一段时间或者达到某个阈值就执行一次释放操作,找出那些不再继续使用的值,然后释放其占用的内存。
判断方式
JavaScript是怎么判断某个变量是否需要被回收的?
有以下几个方法
标记清除
比如一个函数内部声明的一个变量,一旦我们的主线程离开了这个函数,那么这个函数声明的一些变量外部就无法用到,那么就会被标记为”离开环境”。这样在下一个垃圾回收周期来领的时候,就自动释放了这些变量占用的内存。
引用计数
另一种不太常见的垃圾收集策略叫做引用计数(reference counting)。引用计数的含义是跟踪记录每 个值被引用的次数。当声明了一个变量并将一个引用类型值赋给该变量时,则这个值的引用次数就是 1。 如果同一个值又被赋给另一个变量,则该值的引用次数加 1。相反,如果包含对这个值引用的变量又取 得了另外一个值,则这个值的引用次数减 1。当这个值的引用次数变成 0 时,则说明没有办法再访问这 个值了,因而就可以将其占用的内存空间回收回来。这样,当垃圾收集器下次再运行时,它就会释放那 些引用次数为零的值所占用的内存。
引用计数有一个非常严重的BUG就是,如果存在循环引用的话,系统就无法判断你这个内存到底要不要回收了。而且引用的数字永远都不会是0。举个例子
1 | var element = document.getElementById("some_element"); |
这样的话element
和myObject
这对情侣就永远如胶似漆,系统无法将他们送上天堂。
那咋办?要拆散他们有一个主意,就是在不用的时候给他们置为null
1 | myObject.element = null; |
将变量设置为 null 意味着切断变量与它此前引用的值之间的连接。当垃圾收集器下次运行时,就 会删除这些值并回收它们占用的内存。
插播一个小故事
IE 的垃圾收集器是根据内存分配量运行的,具体 一点说就是 256 个变量、4096 个对象(或数组)字面量和数组元素(slot)或者 64KB 的字符串。达到 上述任何一个临界值,垃圾收集器就会运行。这种实现方式的问题在于,如果一个脚本中包含那么多变 量,那么该脚本很可能会在其生命周期中一直保有那么多的变量。而这样一来,垃圾收集器就不得不频 繁地运行。结果,由此引发的严重性能问题促使 IE7 重写了其垃圾收集例程。
随着 IE7 的发布,其 JavaScript 引擎的垃圾收集例程改变了工作方式:触发垃圾收集的变量分配、 字面量和(或)数组元素的临界值被调整为动态修正。IE7 中的各项临界值在初始时与 IE6 相等。如果 垃圾收集例程回收的内存分配量低于 15%,则变量、字面量和(或)数组元素的临界值就会加倍。如果 例程回收了 85%的内存分配量,则将各种临界值重置回默认值。这一看似简单的调整,极大地提升了 IE 在运行包含大量 JavaScript 的页面时的性能。
—-摘自JavaScript高级程序设计
V8垃圾回收策略
什么是V8?
简单介绍一下什么是V8?
V8是一个由Google开发的开源JavaScript引擎,用于Google Chrome及Chromium中[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是怎么分割内存的。
内存的到来先进入From,然后等到From满了之后,新生代的GC就会启动。
首先释放掉一些不再使用的内存,然后将From和To两个功能进行更换,也就是说之前的From -> To 之前的 To -> From。这样的话新生代的一轮GC就此结束了。
这种算法有点类似空间换时间,而新生代的对象生命一般都是比较短的,所以所以非常适合新生代。
晋升
对象的晋升其实主要有两个要求,就好像在来P6升P7有一堆的要求一样。
- 新生代算法刚刚我们已经学习过了,就是From和To的互换功能。如果一个对象他经历了两次的新生代的更替,还没有被回收内存,说明他很强。就需要晋升为老生代,让老生代的算法来对付他。就好像在阿里的一个P6两轮361都没有被淘汰说明他很有实力,所以直接升级他为P7。
- 如果一个对象在To的内存占比超过了25%,在第一次更替的时候就直接将他晋升为老生代了。可以理解为一个技术大牛,像马云,一去阿里就被星探发现,直接就是P7评级,不需要经过什么复杂的面试或者什么。
老生代算法
根据刚刚对《晋升》的了解,我们知道了在老生代里面的对象都是一些难缠的选手,比如本来就占内存比例很大,或者经历过2次的新生代GC,依旧坚挺的选手。
所以,V8在老生代中主要采用了Mark-Sweep和Mark-Compact相结合的方式进行垃圾回收。
在介绍老生代算法之前,先科普一下Mark-Sweep和Mark-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
3function foo(arg) {
bar = "this is a hidden global variable"; // 意外挂在在 window 全局变量,导致内存泄漏
}这里是想说一下高程(JavaScript高级程序设计第三版)里面的一个非常经典的demo。
闭包的滥用
WeakMap与WeakSet
其实这两个在这篇文章里面的意义相同,只需要介绍一个就行。
我们知道Map是简单的key-value。而且key可以为任意类型,比如引用类型,如果我们的key是引用类型那么他所占据的内存就无法被释放了。
又有同学问题了,我有毛病啊,我非要引用类型吗?我string和number他不香吗?
基本上,如果你要往对象上添加数据,又不想干扰垃圾回收机制,就可以使用 WeakMap。一个典型应用场景是,在网页的 DOM 元素上添加数据,就可以使用WeakMap
结构。当该 DOM 元素被清除,其所对应的WeakMap
记录就会自动被移除。
1 | const wm = new WeakMap(); |
上面代码中,先新建一个 Weakmap 实例。然后,将一个 DOM 节点作为键名存入该实例,并将一些附加信息作为键值,一起存放在 WeakMap 里面。这时,WeakMap 里面对element
的引用就是弱引用,不会被计入垃圾回收机制。
也就是说,上面的 DOM 节点对象的引用计数是1
,而不是2
。这时,一旦消除对该节点的引用,它占用的内存就会被垃圾回收机制释放。Weakmap 保存的这个键值对,也会自动消失。
总之,WeakMap
的专用场合就是,它的键所对应的对象,可能会在将来消失。WeakMap
结构有助于防止内存泄漏。
更多的知识就不讨论了,这里是内存世界!不是什么WeakMap专场哦。
怎么验证?emmmmm….这里我copy一下阮一峰老师的内容。
阮老师的demo
首先,打开 Node 命令行。
1 | $ node --expose-gc |
上面代码中,--expose-gc
参数表示允许手动执行垃圾回收机制。
然后,执行下面的代码。
1 | // 手动执行一次垃圾回收,保证获取的内存使用状态准确 |
上面代码中,只要外部的引用消失,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高级程序设计(第三版)