「阅读本文,可以了解到JS中是如何进行垃圾回收的,为什么需要垃圾回收,在这个基础上,作为开发者我们可以如何优化我们的应用」
一、垃圾回收
首先我们需要了解一下栈内存和堆内存的区别,栈内存一般是由操作系统去自动管理的,而下面我们要讨论的内存管理指的是堆内存,可以被人工管理,比如c/c++,但是人工的风险总是很大的,所以很多语言引入了自动堆内存管理GC机制,比如JVM、JavaScript、C#、Golang、OCaml和ruby。但是交给这个语言机制就真的万无一失了么,你错了,因为GC也是程序员创作的,那就会有BUG~
垃圾回收(GC: Garbage Collecation)
:垃圾回收器
在特定的时候
以某种策略
去找到程序中不再使用
的变量,然后释放它所占用的内存
想必我们也会产生下面的疑问:
-
垃圾回收器
是什么? -
特定的时候
指的是什么时候? -
某种策略
又有哪些? - 如何判断
不再使用
?
接下来我们带着这些疑问接着往下看。
二、垃圾回收策略
为什么还需要讲究策略,我们知道JavaScript是单线程语言,所有变量的状态都受该线程管理,而垃圾回收器
做的事情是改变这些变量的状态,也就是说当其工作的时候,为了拿到变量的最新状态,JavaScript的主线程是处于等待状态的,意味着回收动作会阻塞代码的执行,那么回收策略就显得至关重要了。
1. 标记清除/整理(紧缩)法
结合我们日常编码,我们每次声明变量的时候一般会给变量赋值,当执行到这里的时候,系统会给上面的值类型的值以及引用类型的引用和值进行内存的分配,而此时如果内存不足以写入新的内容,那么就会执行垃圾回收,具体的分为标记
和清除
/整理
两个阶段:
-
标记
:从根(这个是可达性算法的关键,到底什么是根,并不是我们平时理解的window/document等全局变量,可以理解成垃圾回收器的某个实例)
使用深度优先搜索,将可达的对象打上活跃的标记。 -
清除
:将标记阶段不活跃的变量占用内存收回 -
整理(紧缩)
::将标记为活跃的变量向内存的一端移动,另一端不活跃的则进行释放清除
与整理
的区别在于前者会产生不连续的内存碎片,影响下次新的写入,后者的话可以理解通过移动重新分配新的内存区域给活跃的变量,这样整个内存区域则都是连续的,方便下次写入,两者都会将不活跃的变量占用内存进行释放回收。
上面提到,回收会阻塞主线程代码的执行,如果说为了处理很多活跃对象的回收时,可能会引发长时间的停顿,为了解决这个问题,谷歌在原来的基础上引入了增量标记
和惰性清理
的方法,大大减少了停顿时间。
2. 引用计数法
区别于上文给活跃的变量打标记的做法,引用计数法将是否活跃的判定更加的简单化,也就是去检查改变量被引用的次数,一旦不被任何对象引用,则可进行释放回收。引用计数的话有一个明显的问题:循环引用造成的内存泄漏,比较常见于两个互相引用的对象,其中一个执行JSON.stringify的时候,会触发这个隐患,目前比较简单的解决方案是将引用的对象暂存在其他变量上,然后通过赋值断开对象间的互相引用。 上面的可达性算法之所以不会有这个问题,就在于根的定义,即使出现循环引用,但是对象与根的连接已经断开,于是被判定为可回收。
3. 分代算法
脚本中,绝大多数对象的生存期很短,只有某些对象的生存期较长。为利用这一特点,V8将堆内存分了新生代和老生代区域,当然还有其他区域,这里不详细说:
-
新生代
:大多数对象被分配在这里。新生区是一个很小的区域,垃圾回收在这个区域非常频繁,与其他区域相独立,因为回收比较活跃,所以存储的是存活较短的对象 -
老生代
:扛过新生期没被回收的大佬,就会转移到这个区域 在分代的基础上,新生代
内存区域采用的回收方法主要是Scavenge-Cheney
算法,将该区域的变量分为From
和To
两个子区域,每个变量写入时都会首先在From
区域分配内存,当新生代区域内存满了的时候,会检查From
中不活跃的对象,将其释放,然后将剩下的活跃对象复制到To
区域,再进行交换,也就是原来的活跃对象又换到From
区域,不断进行周期性上述动作,最后将度过超过2个周期的对象移动到老生代
区域。 而老生代
区域采取的是上面介绍的标记-清除/整理
方法去进行内存回收,这里就不多做介绍了。
三、内存管理中产生的问题
通过上面我们知道了再JavaScript中,内存的管理是托管给垃圾回收程序通过不同的方法和策略去执行的,同样的并没有完美无缺的方法,是存在漏洞的,比如上面提到的循环引用所导致的内存问题,那么接下来我们可以了解下关于内存管理中常见的有哪些问题,以及他们的表现是什么
1. 内存泄漏
在我们访问页面的时候,如果随着时间的推移,页面的性能会逐渐变差,给人越来越卡的感觉,那么这就很有可能是由于页面中异常使用越来越多的内存导致的内存泄漏,代码中比较常见造成这个现象的有下面几种情况:
过度使用缓存
开发的过程中,使用大量的对象去缓存数据,并且没在不被需要的时候及时清理掉,这就导致无法被垃圾回收器回收掉,造成内存浪费
不合理的使用闭包
众所周知,闭包的隐患之一就是内部函数外部引用的变量无法随着内部函数作用域的消失而释放,这也会导致内存泄漏的问题
无效的DOM引用
我们经常会将DOM对象保存下来,但是却总是忘记在销毁DOM后或者不需要该引用的时候,去释放对应的DOM引用变量
未清除的定时器及未解绑的全局监听事件
如果没有及时的将不再使用的定时器或者监听事件程序消除,那么它们将会一直存在在内存中,造成泄漏
2. 内存膨胀
内存膨胀的表现是用户进入应用,给人的表现一直很差,不是很流畅,也就是页面使用的内存超过了该终端的页面最佳速度所需的内存。 当然,这个也是根据不同设备有不同的结果,那么又是如何去判定呢?这边有一个RAIL模型可以用来测试你的页面体验情况,从而判断是否存在内存膨胀的问题
3. 垃圾回收频繁触发
通过上面的内容我们知道,当我们每次往内存写入新的变量的时候,就有可能触发垃圾回收,而垃圾回收的话可能造成停顿,所以说频繁过多的执行对一些实时性比较高的场景(比如游戏、动画)是不太友好的。那么在这种情况下,理论上我们减少新变量的写入,复用之前的旧变量
就有一定的效果了,下面我们详细看看
对象Object的优化
在日常的开发当中,我们经常这么做:
function a(){
//do something
return {
name:'leon'
}
}
//模拟一个多次执行的情况
document.body.onclick = function(){
this.res = a()
}
复制代码
通过调用一个返回新对象结果的方法,获取最新的某个值,最后更新上去,上面的click每执行一次,方法a便需要去内存里面申请一个空间给返回值使用,等到下次回收的时候再去释放这个空间,那么这个时候,如果我们优化一下,就能避免了
function a(){
//do something
this.res.name = 'leon'
}
//模拟一个多次执行的情况
document.body.onclick = function(){
a()
}
复制代码
这样的话就能避免新的对象写入,实时性要求很高的代码中,将会有效的减少垃圾堆积,并且最终避免垃圾回收停顿,具有一定收益。
数组Array的优化
大家应该对下面的代码很熟悉,我们会声明一个容器去根据不同的条件塞进去不同值,然后最后收集每次值
var a = []
var b = []
for(var i = 0; i < 10; i++){
b = []
//do something
for(var j = 0; j < 5; j++>){
b.push(j)
}
a.push(b)
}
复制代码
显而易见,每次外循环我们都需要去重置原先的容器,好push新的值进去,这个时候我们就会往内存中写入一个新的Array类型的内容,等到下次循环的时候,失去引用之后,便被回收。那我们有没有一种既可以清空容器又不会新写一个内容的操作呢,请往下看
var a = []
var b = []
for(var i = 0; i < 10; i++){
b.length = 0
//do something
for(var j = 0; j < 5; j++>){
b.push(j)
}
a.push(b)
}
复制代码
实际上,将数组长度赋值为0,同样可以清空数组,并且同时能实现数组重用,减少内存垃圾的产生。
总的来说,就是尽量避免使用比如pop、slice等产生多余变量的方法,除非本身是需要产生的结果的,然后复用现有的一些对象。
总结
现在再回到之前遗留的三个问题,相信大家心里都有答案了~目前虽然说大多场景存在性能过剩的情况,即使不关注这些也仅仅是几十毫秒甚至微秒级别的差异,但是在一些特殊的场景下,要求还是比较高的,另外,作为前端开发工程师,即使工作中大多业务场景不需要我们关注这些,但是我觉得对其有一定的了解还是很有必要的。
好了,今天的分享就到这里,如果你是正在学习前端或准备学习前端,可以去我的前端学习交流裙(109029339)免费下载一些前端学习视频,而且不定时还有大咖直播分享,希望能帮助大家共同成长。
有疑问加站长微信联系(非本文作者)