深入解析javascript垃圾回收机制

2,547

前言

相信各位大佬都遇到过这样一种场景:我们给页面做一个精美的点击效果,例如点击页面任意地方产生一个渐渐扩散的涟漪,扩散到一定大小后消失。在js中我们可以给window添加一个点击事件,点击后创建一个行内元素(假设是span元素)挂载到body上,再利用setTimeoutelement.remove延时清除这个创建的span对象,最后给相应的元素添加一点样式和动画就完成效果了。但是这个span标签对象真的消除了吗?

动画实现

<style>
  body {
    margin: 0;
    padding: 0;
    background-color: rgb(255, 122, 228);
  }

  span {
    position: fixed;
    border-radius: 50%;
    width: 40px;
    height: 40px;
    transform: translate(-50%, -50%);
    background-color: rgb(0, 162, 255);
    animation: animation 2s linear infinite;
  }

  @keyframes animation {
    0% {
      width: 0px;
      height: 0px;
      opacity: 0.5;
    }

    100% {
      width: 16vw;
      height: 16vw;
      opacity: 0;
    }
  }
</style>

<body id="body">
  <script>
     let body = document.getElementById('body')
    window.addEventListener('click', e => {
      let x = e.clientX
      let y = e.clientY

      let eleSpan = document.createElement('span')
      eleSpan.style.left = `${x}px`
      eleSpan.style.top = `${y}px`

      body.appendChild(eleSpan)
      
      setTimeout(() => {
        eleSpan.remove()
        console.log(eleSpan);
      }, 2000);
    })
  </script>
</body>

我在eleSpan.remove之后打印了eleSpan,结果到底如何呢,会是undefined吗?这就来试试:

huishou1.gif 可以清晰地看到,我在页面上点击了两次,并且在每次点击延时两秒之后,控制台都打印了span标签对象,这说明eleSpan.remove只是将span标签从DOM节点中移除了,并没有在内存中将其删除。

危害和解决方法

上述例子中,我只是在页面中点击了两次,假如各位大佬将来的项目上线,每天都要受到成千上万次的点击,内存中保存了成千上万个这样的span标签对象,那岂不是瞬间卡死。这时必定有大佬就要说了,在这bb这么久,直接把eleSpan赋值为null不就行了。我只能说,大佬就是大佬,果然我还是班门弄斧了。确实是这样解决的。

  setTimeout(() => {
    span.outerHTML = null
    span = null
    console.log(span);
  }, 2000);

eleSpan设为null之后,就不仅仅是移除了该节点,而是手动实现了一次垃圾回收。 这里稍微有一点需要注意的地方,在将eleSpan赋值为null之前,需要先将eleSpan.outerHTML赋值null,不然会出现eleSpan设为null后,DOM节点上的span元素依然还在。具体原因我也不清楚,欢迎各位大佬评论区留言指教!

当span元素被另一个对象引用

在将创建出来的span元素被回收之前,我创建一个新的对象storage,把span标签对象设为其属性dom的值,并将storage.dom作为节点挂载到body上,代码如下:

  let storage = {
     dom: eleSpan
  }
  
  body.appendChild(storage.dom)

  let str = '打印storage.dom:'
  setTimeout(() => {
    eleSpan.outerHTML = null
    eleSpan = null
    console.log(`打印eleSpan:${eleSpan}`);
    console.log(str, storage.dom);
  }, 2000);

再来看看效果吧

huishou2.gif 哦吼,只有eleSpan被回收了,storage.dom仍然存在于内存之中,也就是说,在这种情况下,手动回收内存失败了。这到底是为什么呢?原因如下:

原理

对象的引用是计数引用,当一个引用类型被创建时,它在内存空间中被引用次数的计数就会加一,上述案例中span标签对象被创建时计数就已经加了一,后来被storage.dom引用时又加了一,总计数为2,把eleSpan赋值为null之后,计数减一,还剩一。

如何解决

解决这个问题也很简单,只要将storage.dom赋值为null,让span的引用计数再减一即可。

js的存储和垃圾回收机制

存储机制

在JavaScript中有两种内存空间,栈内存和堆内存。栈内存比较小,一般用于存储简单数据类型,可以快速地完成执行栈的切换;堆内存很大,复杂数据类型都存储在堆内存中,当我们把一个复杂数据类型赋值给一个变量时,例如var obj = {},JavaScript做了如下几件事情:

  • 在栈内存中声明一个名为obj的变量
  • 在堆内存中开辟一块新的内存空间存储{}
  • obj的指针指向堆内存中{}存放的空间 这样obj就对{}的内存空间产生了一个强引用关系。如果将obj赋值为null,就解除了这个强引用关系。 由此可知,在上述案例中,storagr.dom = eleSpan执行后,storagr.dom就指向了span标签对象在堆内存的存储空间,span标签对象同时eleSpanstoragr.dom被两个变量保持引用关系,当span = null执行后,只解除了变量eleSpanspan标签对象在堆内存存储空间的引用,而storagr.dom上的引用关系并不会受到影响。

垃圾回收机制

其实JavaScript是有自动内存回收机制的,但由于引用数据类型的特殊性质和闭包的存在,这种自动回收机制就显得不可靠:一般而言,简单数据类型一但结束调用,它在栈内存空间中就会被销毁,在闭包中它的生命周期会得到延长;对于引用数据而言,只要当它在内存中的引用计数为0时才会被回收掉,或者说当所有对它的引用关系都解除之后才会被回收。

垃圾回收很重要

垃圾回收对于性能优化非常重要,因为对于任何设备来说,空间都是有限的、宝贵的,所有关于内存的优化都应该做垃圾回收,做到及时释放内存。

手动调试内存

node.js环境下,我们可以通过一些命令来手动调试内存,观察内存的开销。在任意文件下打开终端,执行以下命令:

1. node --expose-gc 进入交互模式

Quicker_20211125_031951.png

2. process.memoryUsage() 查看内存开销情况

Quicker_20211125_032234.png

其中heapUsed表示堆空间被使用的情况。

3. global.gc() 手动执行一次垃圾回收,其中globalnode.js中的全局对象。

由此我们不妨在交互模式下做一点小测试:

process.memoryUsage()
/*输出:
{
  rss: 26640384,
  heapTotal: 4792320,
  heapUsed: 4125200,
  external: 1722368,
  arrayBuffers: 59073
}
*/

let arr = new Array(5 * 1024 * 1024) 
process.memoryUsage() 
/*输出:
{
  rss: 68145152,
  heapTotal: 47525888,
  heapUsed: 45540456,
  external: 1722410,
  arrayBuffers: 75459
}
*/

可以看到初始状态heapUsed的值为4,125,200,当我声明了一个大小为5MB数组之后,heapUsed的值变成了45,540,456,比初始状态的十倍还多。继续测试:

arr = null
global.gc()
process.memoryUsage()
{
  rss: 26234880,
  heapTotal: 5054464,
  heapUsed: 3291664,
  external: 1721811,
  arrayBuffers: 132797
}
*/

可以看到将arr设为null之后,执行global.gc()进行一次垃圾回收,内存就被释放了,heapUsed的大小又回到了初始水平。 掌握了这个技巧后大佬们就能进行一些更复杂的测试啦,大佬们不妨试试对上述arr保持多个引用时,要怎样才能彻底释放内存。

WeakMap

通过前面的赘述,我们不难知道对一个保持多个引用关系的引用数据类型进行垃圾回收是一件复杂又困难的事情,这让我们对内存的性能优化十分不友好。好在巨人的眼镜也是雪亮的,JavaScript的开发者们在ed6中提供了一些新的数据结构,其中有一个名为WeakMap的就是用来解决垃圾回收问题的。 WeakMap是一种特殊的Map结构,相比于Map,它有如下特性:

  • WeakMap 的键是不可枚举的
  • WeakMap 的键必须是复杂数据类型
  • WeakMap 的键所指的对象如果没有被引用会被自动垃圾回收掉 原因: WeakMap 的键对其他对象是的引用弱引用关系,当键所指对象在其他地方被回收后,WeakMap会自动解除和原对象的引用关系。我们不妨在交互模式下(手动调试内存小结中有进入交互模式的详情)做个测试:
let key = new Array(5 * 1024 * 1024)
let map = new WeakMap()
map.set(key, 1)

process.memoryUsage()
/*输出:
{
  rss: 68268032,
  heapTotal: 47001600, 
  heapUsed: 45424824, // 8位数
  external: 1721770,
  arrayBuffers: 157372
}
*/

key=null
global.gc()
process.memoryUsage()

/*输出:
{
  rss: 26259456,
  heapTotal: 5054464,
  heapUsed: 3308112, // 7位数
  external: 1721820,
  arrayBuffers: 280262
}
*/

从上述代码输出的内存开销情况来看,执行key = null之后数组数组在堆内存中的存储空间就被回收了,并没有因为map对其保持引用导致内存无法回收。这就是WeakMap键保持弱引用关系的作用:相对于强引用关系,弱引用关系不会让被引用对象在堆内存中的引用计数加一。

WeakMap扩展

WeakMap实现私有变量

WeakMap还能作为实现私有变量的一种方式,代码如下:

const privateData = new WeakMap()

class Person {
  constructor(name, age) {
    privateData.set(this, { name: name, age: age })
  }
  getName() {
    return privateData.get(this).name
  }
  getAge() {
    return privateData.get(this).age
  }
}


const kaSha = new Person('卡莎', 18) 
console.log(kaSha.name);  // undefined
console.log(kaSha.getName()); // 卡莎
console.log(kaSha.getAge()); // 18

当然这里使用Map也是能实现效果的,但Map的键是保持强引用关系,使用Map可能会造成内存没有自动回收的问题,进而造成信息泄露,因此不建议使用。

结语

JavaScript由于引用数据类型的特殊性质,常常导致无法自动回收内存,这就使得我们常常要手动回收引用数据的内存,这项工作有可能会很困难,因此在es6规范中,js提供了WeakMap来做引用数据的内存自动回收,当然WeakMap作用不仅仅是这样,它还具备其他功能,比如实现私有变量

*写文章不易,各位大佬点个赞再走吧🥳😄👍