likes
comments
collection

JavaScript的垃圾回收机制

作者站长头像
站长
· 阅读数 15

简介

垃圾回收是一种自动的内存管理机制。当计算机中的动态内存不再需要时,就会予以释放。

因为计算机中的内存是有限的,如果变量、函数等动态内存只有产生而没有消亡的过程,那么内存被占满也只是时间问题罢了。

注:JavaScript中的垃圾回收是自动进行的,但有些情况会导致一些没用的变量占据内存,无法自动被消除,后续会讲到。

方式一:引用计数

在内存管理环境中,对象A如果有访问对象B的权限,那么就叫做对象A引用对象B。

引用计数的策略是将 “对象是否不再需要” 简化成 “对象有没有其他对象引用到它”,如果没有其他对象引用这个对象,那么这个对象将会被回收。

例如:

let obj1 = { a: 1 }; // A对象({a:1})被创建,赋值给 obj1,A的引用个数为1
let obj2 = obj1; // A对象的引用个数变为2

obj1 = null; // A的引用个数变为1
obj2 = null; // A的引用个数变为0,此时A对象就可以被垃圾回收了

缺点:引用计数有个最大的问题:循环引用

function fn() {
  let obj1 = {};
  let obj2 = {}
  obj1.a = obj2; // obj1 引用了 obj2
  obj2.a = obj1; // obj2 引用了 obj1
}

fn 函数执行后,返回值是 undefined,所以整个函数以及内部的变量都应该被回收,但根据引用计数的方式,obj1obj2 的引用此时都不为 0,所以它们并不会被回收。

想要解决上述问题,需要在不使用它们的时候,手动将其设置为空:

function fn() {
  let obj1 = {};
  let obj2 = {}
  obj1.a = obj2; // obj1 引用了 obj2
  obj2.a = obj1; // obj2 引用了 obj1
  
  obj1 = null;
  obj2 = null;
}

方式二:标记-清除(可达性)

这是 JavaScript 中最常见的垃圾回收方式了。从 2012年起,所有现代浏览器都使用了 标记-清除的垃圾回收方式。

可达性就是指那些可访问或可用的值,它们被保证存储在内存中。

1、什么是标记-清除?

JavaScript 中有个全局对象,浏览器中是 window

标记阶段,垃圾回收期将从这个全局对象开始,寻找所有从这个全局对象开始引用的其他对象,再寻找其他对象引用的对象...,对这些能从找到的对象进行标记。

清除阶段,就会清理那些没有被标记的对象。

比如上述的代码示例中,fn 函数的返回值是 undefined 的,如果外界没有参数对其进行接收的话,整个 fn 函数(以及内部的变量)都会被清理掉。

2、标记-清除的缺点

**(1)效率低:**在垃圾回收阶段需要去遍历检查每一个引用,如果代码中对象过多,会导致非常严重的效率问题

**(2)同步JS代码执行:**浏览器在标记的过程中 js 是不能执行的,垃圾回收会拖慢我们 js 的运行时间

3、优化点

**(1)分代垃圾回收:**可以将对象分为新生代和老生代,对于二者采用不同的回收策略。例如 window 对象(老生代),我们对于老生代的回收可以不用那么勤快;对于新生代,需要马上标记回收。

**(2)增量执行:**不需要一次性遍历完成全部的引用,每次只遍历一部分,提高性能。

**(3)空余时间执行:**可以在 js 执行完成之后再执行,这样就不会影响到 js 的执行效率了。

  • 解决方案:标记-整理

标记-整理在标记阶段没有什么不同,但是在标记结束后,它会将活着的(未被清理的)对象往内存的一边移动,最后清理掉边界的内存。

不过这种方法因为多做了一步处理,效率肯定是没有标记-清除高的---计算机中很多做法都是相互妥协的结果。

内存泄露

程序的运行需要内存,当程序提出要求,操作系统就会供给内存。对于不再用到的内存,没有及时释放,就叫做内存泄露

对于持续运行的应用程序,必须及时释放内存,否则随着内存占用越来越高,轻则影响系统性能,重则导致应用崩溃。

1、循环引用

一旦数据不再使用,最好手动将其的值设置为 null

那么标记-清除法是如何解决循环引用问题的呢?

假如对象 A 和 对象 B 是相互引用的,但是它们俩和其他对象没有任何引用关系了,那么在 标记-清除 过程中(从 root开始寻找)是无法到达 A、B两个对象的,所以这俩就变成了 “可回收对象”

2、全局变量

  • 正常的函数如下:
function fn() {
  const name = "hayes";
}
fn()

fn 函数执行后,变量 name 就会被标记为可回收。

因为当函数执行时,内部创造了一个作用域来让函数内部的变量在其中声明。进入这个作用域后,浏览器就会为变量 name 创建一个内存空间,当 fn 执行完成后,其创建的作用域中的变量也都会被标记为可回收(垃圾),在下一个垃圾回收周期时就会被清理掉了。

  • 不正常的函数:
function fn() {
  name = "hayes"
}
fn()

上述代码可能是无意中声明了一个全局变量,导致 name 的作用域变成了 window,所以 fn 函数执行完成后,name 也不会被垃圾回收机制清理掉。

  • 另外一种不正常的函数:
function fn(name) {
  this.name = name
}
fn("hayes")

fn 执行期间,内部的 this 实际上是 window(因为fn的调用者是 window),这里犯的错误和上面一致(无意中声明了全局变量)

3、定时器

let name = "hayes"
setInterval(() => {
  const node = document.querySelector("#name");
  if(node) {
    node.innerText = name
  }
}, 1000)

上述代码中,每隔一秒就将 name 更新到 DOM 中。在 setInterval 没有结束前,回调函数中的变量已经回调函数本身都无法被回收。

只有调用了 clearInterval ,这个定时器才是结束了。

如果回调函数内没做什么事情,并且整个程序也没有调用 clearInterval的话,就会造成内存泄露(这个定时器是无法被清理的)。

4、DOM引用

  • 示例1:

当我们需要多次访问同一个 DOM 元素时,一般会将其存储在一个变量中,因为访问 DOM 的效率比较低,应该避免频繁地访问 DOM 元素

const button = document.querySelector("#button")

当我们不需要这个按钮时(需要删除按钮)

document.body.removeChild(document.querySelector("#button"))

看上去是删除了这个 DOM 元素,但这个 DOM 元素仍然被 button 变量引用,所以在内存层面,这个 DOM 元素是无法被回收的。

正确的做法是在使用结束后,将 button 设置为 null

  • 示例2:
const li = document.querySelector(".li-active")
// 操作..
document.body.removeChild(document.querySelector("ul"))

我们在代码中保存了 ul 列表中的某一项 li 的引用。在使用完成后,将整个 ul 列表移出 DOM,我们会认为内存仅仅会保存特点的这个 li,而将其他列表项都清理掉。但事实并非如此,因为这个特点的 liul 的子元素,子元素与父元素之间是引用关系,所以如果内存中保留有 li 的引用,那么整个 ul 也将会继续呆在内存中。

总结

  • 垃圾回收的方式
    • 引用计数
    • 标记-清除(多数浏览器的实现)
  • JavaScript 中可能存在的内存泄露
    • 全局变量
    • 定时器
    • DOM引用