Skip to main content

JS 垃圾回收和内存泄漏

一、垃圾回收机制

在 C/C++ 等语言中,可以直接控制内存的申请和回收。但在 Java、C#、JavaScript 中,变量的内存空间的申请和释放都由程序自己处理,开发人员不需要关心。也就是说 Javascript 具有自动垃圾回收机制(Garbage Collecation)。

JavaScript 垃圾回收的机制:

找出不再使用的变量,然后释放掉其占用的内存,但是这个过程不是实时的,因为其开销比较大,所以垃圾回收器会按照固定的时间间隔周期性的执行。

var city1 = "Guangzhou"
var city2 = "Shenzhen"

var city1 = city2 // 重写 city1

这段代码运行之后,“Guangzhou” 这个字符串失去了引用(之前被 city1 引用),系统检测到之后,就会释放该字符串的存储空间以便这些空间可以被再利用。

那么,垃圾回收机制怎么知道哪些内存不再需要呢?

垃圾回收有两种方法:

  • 标记清除
  • 引用计数

1、标记清除

变量进入执行环境时会被标记,执行后无法访问到的变量将保留标记,离开时被标记的变量将被销毁。

1-1、V8 的垃圾回收机制(GC)

  1. 通过 GC Root 标记空间中活动对象和非活动对象。目前 V8 采用的是可访问性算法, 从 GC Root 出发遍历所有的对象, 通过 GC Root 可以遍历到的标记为可访问的, 称为活动对象,必须保留在内存中, GC Root 无法遍历到的标记为不可访问的, 称为非活动对象, 这些不可访问的对象将会被 GC 清理掉。
  2. 回收非活动对象所占据的内存,其实就是在所有的标记完成之后,统一清理内存中所有被标记为可回收的对象。
  3. 做内存整理。一般来说,频繁回收对象后,内存中就会存在大量不连续空间,我们把这些不连续的内存空间称为内存碎片。

1-2、V8 引擎采用的两个垃圾回收器

  • 主垃圾回收器 -Major GC:负责老生代的垃圾回收。
  • 副垃圾回收器 -Minor GC (Scavenger):负责新生代的垃圾回收。

1-3、堆内存分为两块区域

  • 新生代:内存区域较小, 垃圾回收频繁。
  • 老生代:内存区对象占用空间相对较大, 存活时间较长, 垃圾回收频率低。

2、引用计数

所谓"引用计数"是指语言引擎有一张"引用表",保存了内存里面所有值的引用次数。如果一个值的引用次数是 0,就表示这个值不再用到,可以将这块内存释放。

上图中,左下角的两个值,没有任何引用,所以可以释放。

如果一个值不再需要,引用数却不为 0,则垃圾回收机制无法释放这块内存,从而导致内存泄漏。

2-1、引用计数示例

let obj1 = { a: 1 }; // { a: 1 } 的引用个数为 1 
let obj2 = obj1; // { a: 1 } 的引用个数变为 2

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

上面代码中,对象 { a: 1 } 被创建,赋值给 obj1{ a: 1 } 的引用个数为 1,obj1 赋值给 obj2 后,{ a: 1 } 的引用个数为 2.

2-2、引用计数的循环引用问题

function func() {
let obj1 = {}
let obj2 = {}

obj1.a = obj2 // obj1 引用 obj2
obj2.a = obj1 // obj2 引用 obj1
}

当函数 func 执行结束后,返回值为 undefined,所以整个函数以及内部的变量都应该被回收,但根据引用计数方法,obj1obj2 的引用次数都不为 0,所以他们不会被回收。

要解决循环引用的问题,最好是在不使用它们的时候手动将它们设为空:

obj1 = null
obj2 = null

二、什么是内存泄漏

不再用到的内存,没有及时释放,就叫做内存泄漏(memory leak)。

三、引起内存泄漏的情况

虽然 JS 会自动垃圾收集,但如果代码写法不当,会使变量一直处于“进入环境”的状态,无法被回收。

内存泄漏常见的几种情况:

1、意外的全局变量

function foo(arg) {
bar = "this is a hidden global variable";
}

这里 bar 没被声明,会变成一个全局变量,在页面关闭前不会被释放。

另一种意外的全局变量可能由 this 创建:

function foo() {
this.variable = "potential accidental global";
}
foo(); // foo 调用自己,this 指向了全局对象(window)

在 JavaScript 文件头部加上 use strict,可以避免此类错误发生。

启用严格模式解析 JavaScript ,避免意外的全局变量。

2、被遗忘的定时器

设置了定时器在不要的时候记得使用 clearIntervalclearTImeout 清除定时器。

3、滥用闭包

闭包可以使函数内局部变量始终保存在内存中。

function bindEvent() {
var obj = document.createElement('xxx')
obj.onclick = function () {
// Even if it is a empty function
}
}

上面定义事件回调时,由于是函数内定义函数,并且内部函数的事件回调引用外部函数,形成了闭包。

解决方法:

  • 将事件处理函数定义在外部,解除闭包。
  • 或者在定义事件处理函数的外部函数中,删除对 dom 的引用。
// 方法一:将事件处理函数定义在外面
function bindEvent() {
var obj = document.createElement('xxx')
obj.onclick = onclickHandler
}

// 方法二:在定义事件处理函数的外部函数中,删除对 dom 的引用
function bindEvent() {
var obj = document.createElement('xxx')
obj.onclick = function () {
// Even if it is a empty function
}
obj = null
}

4、没有清理的 DOM 元素引用

有时,保存 DOM 节点内部数据结构很有用。例如快速更新表格的几行内容,把每一行 DOM 存成字典(JSON 键值对)或数组。此时,同样的 DOM 元素存在两个引用:一个在 DOM 树中,另一个在字典中。将来删除这些行时,需要把两个引用都清除。

var elements = {
button: document.getElementById('button'),
image: document.getElementById('image'),
text: document.getElementById('text')
}

function doStuff() {
image.src = 'http://xx.png'
button.click()
console.log(text.innerHTML)
}

function removeButton() {
document.body.removeChild(document.getElementById('button'))
// 此时,仍旧存在一个全局的 #button 的引用
// elements 字典。button 元素仍旧在内存中,不能被 GC 回收。
}

虽然用 removeChild 移除了 button,但还在 elements 对象里保存着 #button 的引用,也就是说 DOM 元素还在内存中。

四、内存泄漏的识别方法

新版本的 Chrome 在控制台的 performance 中查看:

步骤:

  • 打开开发者工具 Performance
  • 勾选 Screenshots 和 memory
  • 左上角小圆点开始录制(record)
  • 停止录制

录制结果图中的 Heap 可以看到内存在周期性的回落,也可以看到垃圾回收的周期。

如果垃圾回收之后的最低值(min)在不断上涨,则有较为严重的内存泄漏问题。

五、如何避免内存泄漏

避免内存泄漏的一些方式:

  • 减少不必要的全局变量,或者生命周期较长的对象,及时对无用的数据进行垃圾回收;
  • 手动清除定时器;
  • 少用闭包;
  • 注意程序逻辑,避免“死循环”之类的;
  • 避免创建过多的对象。

总而言之需要遵循一条原则:不用了的东西要及时归还

但最好能有一种方法,在新建引用的时候就声明,哪些引用必须手动清除,哪些引用可以忽略不计,当其他引用消失以后,垃圾回收机制就可以释放内存。这样就能大大减轻负担,只要清除主要引用就可以了。

ES6 考虑到了这一点,推出了两种新的数据结构:WeakSetWeakMap。它们对于值的引用都是不计入垃圾回收机制的,所以名字里面才会有一个 "Weak",表示这是弱引用。

六、垃圾回收的使用场景优化

1、数组 array 优化

[] 赋值给一个数组对象(arr = []),可以快速清空数组,但这种方式又创建了一个新的空对象,且原来的数组对象变成了一小片内存垃圾。

实际上,将数组长度赋值为 0(arr.length = 0)也能清空数组,同时能实现数组重用,减少内存垃圾的产生。

// 优化前
const arr = [1, 2, 3]
arr = [] // arr 变成空数组,但在堆上重新申请了一个空数组对象

// 优化后
const arr = [1, 2, 3]
arr.length = 0 // 可以直接清空 arr,而且数组类型不变

2、对象尽量复用

对象尽量复用,尤其是在循环等地方出现创建新对象,能复用就复用。

不用的对象,尽可能设置为 null,尽快被垃圾回收掉。

// 优化前
for (var i = 0; i < 10; i++) {
var t = {} // 每次循环都会创建一个新对象。
t.index = i
}

// 优化后
var t = {}
for (var i = 0; i < 10; i++) {
t.index = i
}
t = null // 对象如果已经不用了,设置为 null 等待垃圾回收。

3、在循环中的函数表达式,能复用最好放到循环外面

// 优化前
for (var k = 0; k < 10; k++) {
var t = function (a) {
// 这里在循环中使用函数表达式,创建了 10 次函数对象。
console.log(a)
}
t(k)
}

// 优化后
function t(a) {
console.log(a)
}
for (var k = 0; k < 10; k++) {
t(k)
}
t = null