文章目录
JavaScript
内嵌了垃圾回收器
1. 内存周期
- 分配你所需要的内存
- 使用分配到的内存(读、写)
- 不需要时将其释放、归还
2. 内存分配
在 JavaScript
中,内存一般分为栈内存
和堆内存
。基本类型
储存在栈内存
,栈内存
由操作系统管理;引用类型
储存在堆内存
,堆内存
由引擎管理。
3. 内存的回收和释放
常说的内存管理是在堆内存
上的。内存的回收,也就是垃圾回收机制
是有算法(策略)的。最常见的有两种,一种是引用计数
,还有一个是标记-清除
。
3.1 引用计数
最早期的垃圾回收策略是引用计数
。
思路:对每个值都记录它的引用次数。声明变量并引用时,这个值得引用数就会加1,类似地,如果对值引用的变量被其他值覆盖了,那么引用数就减1。垃圾回收程序下次运行就会释放引用数为0的内存。
引用计数有一个严重的问题:循环引用
,就是对象A有一个指针指向对象B,而对象B也引用了对象A。它们永远不会被回收。这就会造成内存泄漏
早期的 IE 浏览器
使用的就是引用计数
优点:
立即回收
。当一个内存的引用为0的时候,这部分的内存会被立即回收。减少程序的暂停
。当前执行平台的内存肯定是有上限的,所以内存肯定有占满的时候。由于引用计数算法是时刻监控着内存引用值为0的对象,保证了当前内存是不会有占满的时候。
缺点:
循环引用对象无法被回收
。按照引用计数的回收标准,函数内部的循环引用的对象是没法被回收的,因为他们的引用数永远不会是0。计数比较耗时
。需要频繁的去查看哪些对象引用数为0了,当维护的对象数大的时候,查找的过程就比较耗时了。引用计数的GC会占用主线程,会阻塞其他任务的运行。
3.2 标记-清除
这个策略分为标记
和清除
两个阶段。标记阶段即为所有活动对象做上标记,清除阶段则把没有标记(也就是非活动对象)销毁。
可达性
:就是可以到达,因为堆内存
中往往都存在根对象
(不是传统意义上的对象)。我们标记也是从根对象上去开始递归遍历
的。当一个访问一个对象的路径内切断了,他就是不可达的
。那么就需要被清理
。
所以,当函数
执行完毕的时候,当前函数就和全局断开了连接。这就是我们一般是没法访问函数内部定义的变量的原因(特殊的闭包
除外)。这时候这些变量就会因为没法到达而无法标记
,所以就会在下次的 GC
的时候被回收。
标记清除
和引用计数
的最大区别就是,回收的标准
的不同。零引用的内存一定不可到达,但是非零引用的内存不一定可达到。
标记清除算法大致流程:
- 垃圾收集器给内存中的所有变量都加上一个标记,假设内存中所有对象都是垃圾,全标记为0
- 然后从各个根对象开始遍历,把不是垃圾的节点改成1
- 清理所有标记为0的垃圾,销毁并回收它们所占用的内存空间
- 最后,把所有内存中对象标记修改为0,等待下一轮垃圾回收
优点:
实现简单
。打标记也无非打与不打两种情况,这使得一位二进制位(0和1)就可以为其标记。
缺点:
内存碎片化
。在回收内存后,原来对象占用的位置被空下来,这就造成了内存的不完整性
。
分配速度慢
。将这些碎片化的内存重新分配是需要时间的,一般采用以下方案:First-fit
,就是找到大于或者等于需要分配内存大小的内存后,就立刻返回。(常用)Best-fit
,查找整个内存,直到找到符合待分配大小的最小的内存块。Worst-fit
,查找整个内存,找到最大的内存块,先切除需要分配大小的内存部分,然后返回剩下的。
标记整理
:用来解决回收内存后,内存的不完整性
问题。原理其实就是在标记结束后多干一件事情,将活着的对象向内存的一端移动,最后清理掉边界的内存。
4. V8 引擎的垃圾回收机制
V8 是一个由 Google 开源的高性能 JavaScript
引擎,其源代码使用 C++ 编写。V8 被用于 Google 的开源浏览器 Chrome
中,同时也被用于 Node.js
,以及其他一些软件中。
V8 的垃圾回收也是基于标记清除算法
4.1 分代式垃圾回收
将堆内存分为新生代
和老生代
两个区域,然后两个区域采用不同的垃圾回收策略。
新生代
:生命周期短,占用内存小
老生
代:生命周期长或者占用大小比较大。
新生代
新生代的对象一般是存活时间比较短
且比较小
的对象。这部分通常只有很小的内存分配,一般是 1~8M 的容量。
Cheney
算法将新生代中的内存一分为二,一个是出于使用状态的区域,我们称之为 使用区
,一个是出于闲置状态的 称之为空闲区
。
流程:首先先进入标记流程
,新生代垃圾回收器找出使用区内的活动的对象进行标记
,接着就是将被标记的对象复制
到空闲区
并排序。随后,进入垃圾清洁阶段,将使用区
中的未被标记的空间清理掉
,最后将两个区域角色互换
一下。
晋升规则:
- 在一个变量的移动阶段,如果这个变量所占用的空间的大小
超过
了空闲区的25%
,那么这个变量会直接晋升为老生代
。 - 当一个变量已经进过了
多次交换后
还存活
,那么这个变量也会晋升为老生代
。
老生代
主要使用了标记-清除
算法。在此基础上,使用了许多优化技术。
全量清除:JavaScript
是一门单线程的语言,他运行在主线程上,垃圾回收
的时候势必会 阻塞
JavaScript 脚本的执行,等垃圾回收完毕后再恢复脚本的执行,我们把这种行为叫做全停顿
,这种清除方式叫做全量清除
(Stop-the-world sweeping)
它利用最新的和最好的垃圾回收技术来降低主线程挂起的时间
, 比如:并行
(parallel)清除,增量
(incremental)清除和并发
(concurrent)清除。
并行清除
并行垃圾回收
是主线程
和协助线程
同时执行同样的
工作,但是这仍然是一种全量清除
的垃圾回收方式。很重要的特征就是虽然是多个线程通知回收,但是保证不会操作同一个对象。减少了停顿的时间。
增量清除
增量清除
其实是在主线程
上交替
进行脚本执行
和垃圾回收
,和之前不同的地方其实就是,Oilpan 将大块的垃圾回收拆分成很多小的垃圾回收任务。
这种标记法并没有减少总的垃圾回收时间,甚至于会增加一点。但是这个避免了垃圾回收影响用户的操作等。
并发清除
随着应用程序及其生成的对象图越来越大,增量清除开始影响应用程序的性能。为了改善增量清除
,我们开始利用并发清除
来同时回收内存
并发清除
是主线程
和垃圾回收
线程同时
运行。主线程执行 JavaScript ,垃圾回收线程专注于垃圾回收。
Oilpan
强制终结器
(finalizers)在主线程
上运行,以帮助开发人员并排除应用程序代码内部的数据争用。为了解决此问题,Oilpan
将对象终结处理
推迟到主线程
。更具体地讲,每当并发清除
程序遇到具有终结器
(析构函数)的对象时,它将其推入终结队列
(finalization queue),该队列将在单独的终结阶段中进行处理,该队列始终在运行应用程序的主线程上执行。并发清除的总体工作流程如下所示:
5. 内存泄漏
5. 内存泄漏和内存溢出的区别
内存泄漏
:程序运行过程中,分配给内存的临时变量
,用完之后却没有被回收
。
内存溢出
:简单的说就是程序运行过程中申请
的内存大于
系统能提供
的内存,导致无法申请
到足够内存。
他们之间的关系应该是:过多
的内存泄漏
最终会造成内存溢出
。
5.2 常见的内存泄漏
特殊的闭包
只有应用了函数的内部变量
的闭包才算是引发内存泄漏
的闭包
function fn(){
let test = new Array(1000)
return function(){
console.log(test)
return test
}
}
let fnChild = fn()
fnChild()
test 变量的使用被外部调用了。所以他不能被回收。
优化
:在用完闭包的时候,将其置为null
隐式全局变量
function fn(){
// 没有声明从而制造了隐式全局变量test1
test1 = new Array(1000).fill('isboyjc1') // 函数内部this指向window,制造了隐式全局变量test2
this.test2 = new Array(1000).fill('isboyjc2')
}
fn()
这里面使用test1就会被隐式的声明为全局变量
。对于全局变量来说,垃圾回收很难判断什么时候不会被需要的。所以全局变量统称不会被回收
。
优化
:在不用变量的时候将其置空;使用let
、const
等去声明变量。
被遗忘的 DOM 引用
如果一个很大的DOM对象被引用而被忘记清除也会造成内存泄漏。
优化
:在不用的时候将其置空。
被遗忘的定时器
像setTimeout
和 setInerval
这种的定时器在不被清除的时候,是不会消失的。
优化
:在不用的时候将其置空。
被遗忘的事件监听器
事件监听器和上面的定时器是一个原理,都需要手动去解除监听。
未被清理的 console 输出
浏览器
保存了我们输出对象的信息数据引用
,也正是因此未清理的 console
如果输出了对象也会造成内存泄漏
优化
:及时清除代码中的console.log
Map和Set
当使用 Map
或 Set
存储对象时,同 Object
一致都是强引用
,如果不将其主动清除引用,其同样会造成内存不自动进行回收。
优化:使用WeakMap
以及 WeakSet