JavaScript 中的垃圾回收
MelindaPier
9年前
<p style="text-align:center"><img src="https://simg.open-open.com/show/f848e7e27202bdd70e647d9d1c7f17a7.png"></p> <p>根据 Wiki 的定义, 垃圾回收 是一种自动的内存管理机制。当计算机上的动态内存不再需要时,就应该予以释放,以让出内存。直白点讲,就是程序是运行在内存里的,当声明一个变量、定义一个函数时都会占用内存。内存的容量是有限的,如果变量、函数等只有产生没有消亡的过程,那迟早内存有被完全占用的时候。这个时候,不仅自己的程序无法正常运行,连其他程序也会受到影响。好比生物只有出生没有死亡,地球总有被撑爆的一天。所以,在计算机中,我们需要垃圾回收。需要注意的是,定义中的“自动”的意思是语言可以帮助我们回收内存垃圾,但并不代表我们不用关心内存管理,如果操作失当,JavaScript 中依旧会出现内存溢出的情况。</p> <p>垃圾回收基于两个原理:</p> <ul> <li> <p>考虑某个变量或对象在未来的程序运行中将不会被访问</p> </li> <li> <p>向这些对象要求归还内存</p> </li> </ul> <p>而这两个原理中,最主要的也是最艰难的部分就是找到“所分配的内存确实已经不再需要了”。</p> <h2><strong>垃圾回收方法</strong></h2> <p>下面我们看看在 JavaScript 中是如何找到不再使用的内存的。主要有两种方式:引用计数和标记清除。</p> <p><strong>引用计数(reference counting)</strong></p> <p>在内存管理环境中,对象 A 如果有访问对象 B 的权限,叫做对象 A 引用对象 B。引用计数的策略是将“对象是否不再需要”简化成“对象有没有其他对象引用到它”,如果没有对象引用这个对象,那么这个对象将会被回收。上例子:</p> <pre> <code class="language-javascript">let obj1 = { a: 1 }; // 一个对象(称之为 A)被创建,赋值给 obj1,A 的引用个数为 1 let obj2 = obj1; // A 的引用个数变为 2 obj1 = 0; // A 的引用个数变为 1 obj2 = 0; // A 的引用个数变为 0,此时对象 A 就可以被垃圾回收了</code></pre> <p>但是引用计数有个最大的问题: 循环引用。</p> <pre> <code class="language-javascript">function func() { let obj1 = {}; let obj2 = {}; obj1.a = obj2; // obj1 引用 obj2 obj2.a = obj1; // obj2 引用 obj1 }</code></pre> <p>当函数 func 执行结束后,返回值为 undefined,所以整个函数以及内部的变量都应该被回收,但根据引用计数方法,obj1 和 obj2 的引用次数都不为 0,所以他们不会被回收。</p> <p>要解决循环引用的问题,最好是在不使用它们的时候手工将它们设为空。上面的例子可以这么做:</p> <pre> <code class="language-javascript">obj1 = null; obj2 = null;</code></pre> <p><strong>标记-清除(mark and sweep)</strong></p> <p>这是 JavaScript 中最常见的垃圾回收方式。为什么说这是种最常见的方法,因为从 2012 年起,所有现代浏览器都使用了标记-清除的垃圾回收方法,除了低版本 IE...它们采用的是引用计数方法。</p> <p>那什么叫标记清除呢?JavaScript 中有个全局对象,浏览器中是 window。定期的,垃圾回收期将从这个全局对象开始,找所有从这个全局对象开始引用的对象,再找这些对象引用的对象...对这些活着的对象进行标记,这是标记阶段。清除阶段就是清除那些没有被标记的对象。</p> <p>标记-清除法的一个问题就是不那么有效率,因为在标记-清除阶段,整个程序将会等待,所以如果程序出现卡顿的情况,那有可能是收集垃圾的过程。</p> <p>2012 年起,所有现代浏览器都使用了这个方法,所有的改进也都是基于这个方法,比如标记-整理方法。</p> <p>标记清除有一个问题,就是在清除之后,内存空间是不连续的,即出现了内存碎片。如果后面需要一个比较大的连续的内存空间时,那将不能满足要求。而标记-整理方法可以有效地解决这个问题。标记阶段没有什么不同,只是标记结束后,标记-整理方法会将活着的对象向内存的一边移动,最后清理掉边界的内存。不过可以想象,这种做法的效率没有标记-清除高。计算机中的很多做法都是互相妥协的结果,哪有什么十全十美的事儿呢。</p> <h2><strong>内存泄漏</strong></h2> <p>在谈什么是良好实践(这里指有益于内存管理)之前,我想先谈谈内存泄漏,也就是差的实践。内存泄漏是指计算机可用的内存越来越少,主要是因为程序不能释放那些不再使用的内存。</p> <p><strong>循环引用</strong></p> <p>这个没什么好说的,上面已经介绍了。</p> <p>需要强调的一点就是,一旦数据不再使用,最好通过将其值设为 null 来释放其引用,这个方法被称为“解除引用”。</p> <p><strong>无意的全局变量</strong></p> <pre> <code class="language-javascript">function foo(arg) { const bar = ""; } foo();</code></pre> <p>当 foo 函数执行后,变量 bar 就会被标记为可回收。因为当函数执行时,函数创造了一个作用域来让函数里的变量在里面声明。进入这个作用域后,浏览器就会为变量 bar 创建一个内存空间。当这个函数结束后,其所创建的作用域里的变量也会被标记为垃圾,在下一个垃圾回收周期到来时,这些变量将会被回收。</p> <p>但事情并不会那么顺利。</p> <pre> <code class="language-javascript">function foo(arg) { bar = ""; } foo();</code></pre> <p>上面的代码就无意中声明了一个全局变量,会得到 window 的引用,bar 实际上是 window.bar,它的作用域在 window 上,所以 foo 函数执行结束后,bar 也不会被内存收回。</p> <p>另外一种无意的全局变量的情况是:</p> <pre> <code class="language-javascript">function foo() { this.bar = ""; }</code></pre> <p>在 foo 函数中,this 指的是 window,犯的错误跟上面类似。</p> <p><strong>被遗忘的计时器和回调函数</strong></p> <pre> <code class="language-javascript">let someResource = getData(); setInterval(() => { const node = document.getElementById('Node'); if(node) { node.innerHTML = JSON.stringify(someResource)); } }, 1000);</code></pre> <p>上面的例子中,我们每隔一秒就将得到的数据放入到文档节点中去。但在 setInterval 没有结束前,回调函数里的变量以及回调函数本身都无法被回收。那什么才叫结束呢?就是调用了 clearInterval。如果回调函数内没有做什么事情,并且也没有被 clear 掉的话,就会造成内存泄漏。不仅如此,如果回调函数没有被回收,那么回调函数内依赖的变量也没法被回收。上面的例子中,someResource 就没法被回收。同样的,setTiemout 也会有同样的问题。所以,当不需要 interval 或者 timeout 时,最好调用 clearInterval 或者 clearTimeout。</p> <p><strong>DOM</strong></p> <p>在 IE8 以下的版本里,DOM 对象经常会跟 JavaScript 之间产生循环引用。看一个例子:</p> <pre> <code class="language-javascript">function setHandler() { const ele = document.getElementById('id'); ele.onclick = function() {}; }</code></pre> <p>在这个例子中,DOM 对象通过 onclick 引用了一个函数,然而这个函数通过外部的词法环境引用了这个 DOM 对象,形成了循环引用。不过现在不必担心,因为所有现代浏览器都采用了标记-整理方法,避免了循环引用的问题。</p> <p>除了这种情况,我们现在还会在其他时候在使用 DOM 时出现内存泄漏的问题。当我们需要多次访问同一个 DOM 元素时,一个好的做法是将 DOM 元素用一个变量存储在内存中,因为访问 DOM 的效率一般比较低,应该避免频繁地反问 DOM 元素。所以我们会这样写:</p> <pre> <code class="language-javascript">const button = document.getElementById('button');</code></pre> <p>当删除这个按钮时:</p> <pre> <code class="language-javascript">document.body.removeChild(document.getElementById('button'));</code></pre> <p>虽然这样看起来删除了这个 DOM 元素,但这个 DOM 元素仍然被 button 这个变量引用,所以在内存上,这个 DOM 元素是没法被回收的。所以在使用结束后,还需要将 button 设成 null。</p> <p>另外一个值得注意的是,代码中保存了一个列表 ul 的某一项 li 的引用,将来决定删除整个列表时,我们自觉上会认为内存仅仅会保留那个特定的 li,而将其他列表项都删除。但事实并非如此,因为 li 是 ul 的子元素,子元素与父元素是引用关系,所以如果代码保存 li 的引用,那么整个 ul 将会继续呆在内存里。</p> <h2><strong>良好实践</strong></h2> <p>1、优化内存的一个最好的衡量方式就是只保留程序运行时需要的数据,对于已经使用的或者不需要的数据,应该将其值设为 null,这上面说过,叫“解除引用”。需要注意的是,解除一个值的引用不代表垃圾回收器会立即将这段内存回收,这样做的目的是让垃圾回收器在下一个回收周期到来时知道这段内存需要回收。</p> <p>在内存泄漏部分,我们讨论了无意的全局变量会带来无法回收的内存垃圾。但有些时候,我们会有意识地声明一些全局变量,这个时候需要注意,如果声明的变量占用大量的内存,那么在使用完后将变量声明为 null。</p> <p>2、减少内存垃圾的另一个方法就是避免创建对象。new Object() 是一个比较明显的创建对象的方式,另外 const arr = [];、const obj = {};也会创建新的对象。另外下面这种写法在每次调用函数时都会创建一个新的对象:</p> <pre> <code class="language-javascript">function func() { return function() {}; }</code></pre> <p>另外,当清空一个数组时,我们通常的做法是 array = [],但这种做法的背后是新建了一个新的数组然后将原来的数组当作内存垃圾。建议的做法是 array.length = 0,这样做不仅可以重用原来的变量,而且还避免创建了新的数组。</p> <p></p> <p>因为时间关系,关于垃圾回收的内容将在接下来1-2周内更新完毕,内容涉及更加详细的内存管理、V8 引擎中的垃圾回收等。另外对本文其他内容还有建议的也欢迎留言,我也会一并更新。</p> <p><strong>参考:</strong></p> <ol> <li> <p><a href="/misc/goto?guid=4959726795403116873" rel="nofollow,noindex">内存管理 </a></p> </li> <li> <p><a href="/misc/goto?guid=4959726795491339178" rel="nofollow,noindex">A tour of V8: Garbage Collection </a></p> </li> <li> <p><a href="/misc/goto?guid=4959726795578185346" rel="nofollow,noindex">Memory leaks </a></p> </li> <li> <p><a href="/misc/goto?guid=4959726795661024104" rel="nofollow,noindex">4 Types of Memory Leaks in JavaScript and How to Get Rid Of Them </a></p> </li> <li> <p><a href="/misc/goto?guid=4959726795743145781" rel="nofollow,noindex">High-Performance, Garbage-Collector-Friendly Code </a></p> </li> </ol> <p> </p> <p>来自:https://zhuanlan.zhihu.com/p/23992332</p> <p> </p>