阅读 139

JavaScript垃圾回收机制与内存泄漏

下文只是学习过程中参考书籍和博客总结的一些笔记和个人理解。

一、垃圾回收机制

1、JavaScript有全局执行环境和函数执行环境,函数执行时函数执行环境入栈,执行完毕出栈销毁。全局执行环境在关闭页面或浏览器时才销毁。

2、执行环境会负责管理代码执行过程中使用的内存,基本类型的值存储保存在栈内存中,而对于引用类型在栈内存中存储其引用,值存储在堆内存中。垃圾回收针对的是引用类型在堆内存的值。

3、垃圾回收机制:找出那些不再继续使用的变量,然后释放其占用的内存。

“跟踪哪个变量使用哪个变量不使用”

(一)引用计数法

跟踪思想:变量初始化赋值为引用类型的值,引用计数为1。每被引用多一次,引用计数就加1,每被引用少一次则减1,当引用计数变为0时,就可以释放这个引用类型的值占用的内存了。辟如下面这个函数运行,{a:1}最终引用计数为0,{b:2}和{c:3}引用计数都是1,函数运行完毕后,obj和obj1的引用会被销毁,所以其值的引用计数就变为0,可以回收了。

function f(){
	var obj={a:1};  //count({a:1})=1
	var obj1=obj;   //count({a:1})=2
	obj={b:2}   //count({a:1})=1
	obj1={c:3}  //count({a:1})=0,{a:1}占用的内存可以被回收了 
}
复制代码

存在问题:循环引用的变量其引用计数不会为0,引用类型的值无法被释放。像下面这个函数,运行结束后,obj和obj1的引用纵然被销毁,但是在堆内存中,两个引用类型的值会互相引用者,引用次数为1,不能被销毁。

function f(){
    var obj={a:1};    //count({a:1})=1
    var obj1={b:2};   //count({b:2})=1
    obj.c=obj1;       //count({a:1})=2
    obj1.c=obj        //count({b:2})=2
}
复制代码

IE9以前的旧版IE浏览器中,基于引用计数法的垃圾回收机制,循环引用会引起内存泄漏。

(二)标记清除法

工作原理:

1、垃圾收集器在运行时给存储在内存中的所有变量都打上标记(栈内存、堆内存)。

2、然后,去掉环境中的变量以及被环境中的变量引用的变量的标记。

3、那么,剩余的被打上标记的变量将被视为准备删除的变量。

根:”环境中的变量“,即执行栈中的全局执行环境的变量对象中的变量,以及函数执行环境的作用域链上的变量对象的变量。(本地函数的局部变量和参数,当前嵌套调用链上的其他函数的变量和参数)

可达性:通过”环境中的变量“可以访问得到的变量。

跟踪思想:获取”根“,并标记他们,如果”根“是引用类型,就标记这个引用所指向的堆内存中对象,如果这个对象也有引用类型的属性,则其指向的对象也被标记,如此直到某个对象没有引用类型的属性。从标记过程可以看出,被标记出来的都是可达的(可访问的),其他都是不可达的,也就是可删除的。那么除了被标记出来的可达节点,其他都被删除。

参考链接:segmentfault.com/a/119000001…

且看针对循环引用的问题,标记清除法是如何规避的。

function f(){
    var obj={a:1};    //count({a:1})=1
    var obj1={b:2};   //count({b:2})=1
    obj.c=obj1;       //count({a:1})=2
    obj1.c=obj        //count({b:2})=2
}
复制代码

这个函数运行时,被标记的可达链如下。函数运行完毕后,执行环境出栈,obj和obj1也就不是根节点了,所以所指向的对象也就不可达最后被回收。

二、内存泄漏

参考链接:

juejin.cn/post/684490…

(一)什么原因造成内存泄漏?

造成内存泄漏的主要因素是”不期望(需要)的引用“。

不再需要的引用指的是:引用一块内存并向其中存储数据,当开发者不再需要这个数据时(这时内存被应该被回收),由于某些原因导致这些占用内存的数据依然被标记为活跃且不可回收的状态而仍然存在于根列表树内。在JavaScript的上下文中,不再需要的引用首先是一个变量,这个变量存在与代码中的某处,这个变量指向某一块内存的引用,当这个变量在未来不需要再被使用时本应该被回收而没有被回收。由于开发者的失误导致了内存的泄漏。

来源:juejin.cn/post/684490…

(二)造成泄漏的场景

下面先说旧版IE浏览器,基于引用计数机制引起的内存泄漏

1、循环引用:旧版浏览器使用引用计数法作为垃圾回收机制,不能回收会循环引用的变量引起的内存泄漏。

2、事件处理程序(事件监听器):下面的例子,myBtn元素和事件处理程序都会保留在内存中造成内存泄漏,以为这个元素在dom数上已经被覆盖了,不再需要,但是其引用计数并不为0。

<div id="myDiv">        
	<input type="button" value="Click me" id="myBtn"/>    
</div>    
<script>        
	var btn=document.getElementById("myBtn");    //myBtn元素引用次数是2(变量btn+dom树)        
        btn.onclick=function(){            
		console.log("clicked")            
		document.getElementById("myDiv").innerHTML="Processing..." ; //myBtn元素引用次数是1(变量btn)       
	}   //匿名函数引用次数是1   
</script>
复制代码

下面介绍”标记-清除“的垃圾回收机制引起的内存泄漏。

依据”标记-清除“的垃圾回收机制,应分析下面的场景中,内存泄漏对象时可达的,但是代码中不再用到。

3、全局变量:声明变量时忘记使用var声明的变量,或者是特意声明的全局变量而最后没有赋值为null。

function foo(){
  msg= "this is a hidden global variable";
  console.log(msg);
}
复制代码

在这个场景下,全局作用域中的全局作用域时”根“,必定是可达的。函数运行时输出这个变量值,运行完毕后就不需要了,所以应该被回收。但是因为开发者的疏忽把它定义为全局变量,回收不了,故造成内存泄漏。

4、定时器回调函数中的引用或事件监听器回调函数中的引用

(1)定时器回调函数内的引用被泄漏

在下面的例子中,循环定时器回调函数运行时会进入环境栈,其中的局部变量(node)是可达的。同时它还引用着外层作用域的变量someResource,所以foo()运行完毕其的变量对象(活动对象)是不能被销毁的,通过循环定时器回调函数执行环境的作用域链可以访问到,故someResource也是可达的。

当node节点在页面中移除时,回调函数进不了if语句用不到someResource,期望被回收。但由于someResource变量所在的变量对象在定时器回调函数的作用链中,不能被回收。那么someResource所指向的数据就是内存泄露了——本质也是闭包引起的内存泄漏。最佳实践:不需要 interval 或者 timeout 时,最好调用 clearInterval 或者 clearTimeout清除定时器。

function foo(){
	var someResource = getData();
	setInterval(function(){
	  var node = document.getElementById('Node');
	  if(node){
		// 执行一些对node和someResource的操作
		node.innerHTML = JSON.stringify(someResource);
	  }
	}, 1000);
}
foo()
//来源链接:https://juejin.cn/post/6844903553207631879
复制代码

(2)事件监听器回调函数的引用被泄漏。一旦不再需要用到某个事件监听器,就必须明确的将他们移除掉(或者在相关联的对象将要被回收之前完成)

5、闭包

闭包函数创建时初始化作用域链,就已经包含外部函数的变量对象了,因此会比其他函数占用更多内存。所以闭包不合理就很容易引起内存泄漏。泄漏的对象就是外部函数的变量对象。

function fn2(){
  let test = new Array(1000).fill('isboyjc')
  return function(){
    console.log(test)
  }
}
let fn2Child = fn2()
fn2Child()
来源:https://juejin.cn/post/6984188410659340324,并闭包删除retun语句
复制代码

闭包函数即使运行完毕,闭包函数的作用域链依然引用了fn2的变量对象(知识点:函数创建时就有作用域链,而运行时函数执行环境的作用域链是在创建时的作用域链前端添加上函数自己的变量对象而已),所以fn2()的变量对象不会被回收。如果把例子改造成下面这样:

var flag=false;  //或者被改写成其他布尔值为false的值
function fn2(){
  let test = new Array(1000).fill('isboyjc')
  return function(){
	if(flag){
		console.log(test)
	}
    
  }
}
let fn2Child = fn2()
fn2Child()
复制代码

就和定时器回调函数内的引用被泄漏的场景一样,闭包函数用不到fn2()的变量对象,但是却一直引用着它,造成内存泄漏。

所以,闭包函数不用时,应赋值为null,解除对匿名函数的引用,释放内存。

6、无效DOM引用

除了DOM树对节点的引用,自己为了方便操作DOM还创建了变量指向DOM节点的时候,当使用DOM API移除DOM节点时,实际节点对象并未被回收,因为还被其他变量引用着,所以无法被回收,造成内存泄漏。

下面时直接拷贝的例子:

<div id="root">
  <ul id="ul">
    <li></li>
    <li></li>
    <li id="li3"></li>
    <li></li>
  </ul>
</div>
<script>
  let root = document.querySelector('#root')
  let ul = document.querySelector('#ul')
  let li3 = document.querySelector('#li3')
  
  // 由于ul变量存在,整个ul及其子元素都不能GC
  root.removeChild(ul)
  
  // 虽置空了ul变量,但由于li3变量引用ul的子节点,所以ul元素依然不能被GC
  ul = null
  
  // 已无变量引用,此时可以GC
  li3 = null
</script>链接:https://juejin.cn/post/6984188410659340324
复制代码

三、V8引擎的垃圾回收机制

《JavaScript高级程序设计》提到V8引擎尝试回收闭包占用的内存,所以有必要了解下V8引擎。

由于闭包会携带包含它的函数的作用域,因此会比其他函数占用更多的内存。过度使用闭包可能会导致内存占用过多,建议读者只在绝对必要时再考虑使用闭包。虽然像V8等优化后的JavaScript引擎会尝试回收被闭包占用的内存,但请大家还是要慎重使用闭包。

下面引用的来源都是这篇文章:juejin.cn/post/698158…

V8引擎的垃圾回收机制基于分代式垃圾回收机制。

新生代是存活时间较短的对象,老生代是存活时间较长的对象。新生代和老生代使用不同的

的垃圾回收器(策略)进行处理。

新生代:Cheney(复制)算法

1、将新生代堆内存一分为二,一个是处于使用状态的空间我们暂且称之为 使用区,一个是处于闲置状态的空间我们称之为 空闲区。
2、使用区快满了就开始垃圾回收,对使用区中的活动对象做标记,标记完成之后将使用区的活动对象复制进空闲区并进行排序,随后进入垃圾清理阶段,即将非活动对象占用的空间清理掉。最后进行角色互换,把原来的使用区变成空闲区,把原来的空闲区变成使用区。

垃圾回收也是JS主线程(JS引擎线程)干的活,为避免垃圾回收时,JS主线程全停顿太久,垃圾回收器在主线程上执行的过程中,开启多个辅助线程,同时执行同样的清理工作,加快回收工作完成。

老生代:标记-清除策略。

1、标记方式采用增量标记方式代替全停顿标记方式。

增量就是将一次 GC 标记的过程,分成了很多小步,每执行完一小步就让应用逻辑执行一会儿,这样交替多次后完成一轮 GC 标记。

2、标记完成使用惰性清理的方式清理。增量标记完成后,如果内存还充足,就延迟清理。清理时也无须一次性清理完成。

即便如此,增量标记和惰性清理结合使用,JS主线程每次停顿时间短了,但是停顿的总时间并没有少。

3、并发回收就是让垃圾回收另开线程执行,使得垃圾回收时不影响JS主线程运行。

老生代融合使用增量标记、惰性清理、并发回收等策略。JS主线程执行时,辅助线程可以标记不阻塞主线程(并发标记);JS主线程清理时,辅助线程辅助清理,清理时使用增量的方式分批清理(并行回收)。

另外的参考文章:www.jianshu.com/p/b8ed21e8a…

文章分类
前端
文章标签