[科普]JavaScript GC

为什么需要垃圾回收

  • 在C / C++中,跟踪内存的使用和管理内存对开发者来说是很大的负担
    • JavaScript是使用垃圾回收机制的语言,也就是说执行环境负责在代码执行时管理内存,帮开发者卸下了这个负担
    • 通过自动内存管理实现内存的分配和资源的回收
    • 基本思路很简单,确定哪个变量不会再被使用了,把它的内存空间释放
    • 这个过程是周期性的,意思是这个垃圾回收程序每隔一段时间就会运行一次
  • 像JS中的对象、字符串、对象的内存是不固定的,只有真正用到的时候才会动态分配内存
    • 这些内存需在不使用后进行释放以便再次使用,否则在计算机可用内存耗尽后造成崩溃
  • 浏览器发展史上的垃圾回收法主要有
    • 引用计数法
    • 标记清除法

引用计数法

思路

  • 变量只是对值进行引用
  • 当变量引用该值时,引用次数+1
  • 当该变量的引用被覆盖或者清除时,引用次数-1
  • 当引用次数为0时,就可以安全地释放这块内存。
let arr = [1, 0, 1]   // [1, 0, 1]这块内存被arr引用  引用次数为1
arr = [0, 1, 0] // [1, 0, 1]的内存引用次数为0被释放
// [0, 1, 0]的内存被arr引用 引用次数为1
const tmp = arr // [0, 1, 0]的内存被tmp引用 引用次数为2

循环引用问题

Netscape Navigator 3.0 采用

  • 在这个例子中,ObjectA和ObjectB的属性分别相互引用
  • 造成这个函数执行后,Object被引用的次数不会变成0,影响了正常的GC。
  • 如果执行多次,将造成严重的内存泄漏。
  • 而标记清除法则不会出现这个问题。
function Example(){

let ObjectA = new Object();
let ObjectB = new Object();

ObjectA.p = ObjectB;
ObjectB.p = ObjectA;

}

Example();
  • 解决方法:在函数结束时将其指向null
ObjectA = null;
ObjectB = null;

标记清除法

为了解决循环引用造成的内存泄漏问题,Netscape Navigator 4.0 开始采用标记清除法

到了 2008 年,IE、Firefox、Opera、Chrome 和 Safari 都在自己的 JavaScript 实现中采用标记清理(或 其变体),只是在运行垃圾回收的频率上有所差异。

思路

  • 在变量进入执行上下文时打上“进入”标记
  • 同时在变量离开执行上下文时也打上“离开”标记
    • 从此以后,无法访问这个变量
    • 在下一次垃圾回收时进行内存的释放
function Example(n){
const a = 1, b = 2, c = 3;
return n * a * b * c;
}
// 标记Example进入执行上下文

const n = 1; // 标记n进入执行上下文
Example(n); // 标记a,b,c进入执行上下文
console.log(n); // 标记a, b, c离开执行上下文,等待垃圾回收

const和let声明提升性能

  • const和let不仅有助于改善代码风格,同时有利于垃圾回收性能的提升
  • const和let使JS有了块级作用域,当块级作用域比函数作用域更早结束时,垃圾回收程序更早介入
  • 尽早回收该回收的内存,提升了垃圾回收的性能

V8引擎的垃圾回收

V8引擎的垃圾回收采用标记清除法与分代回收法

分为新生代和老生代

新生代

新生代垃圾回收采用Scavenge 算法

分配给常用内存和新分配的小量内存

  • 内存大小

    • 32位系统16M内存
    • 64位系统32M内存
  • 分区

    • 新生代内存分为以下两区,内存各占一半
    • From space
    • To space
  • 运行

    • 实际运行的只有From space
    • To space处于空闲状态
  • Scavenge算法

    • 当From space内存使用将要达到上限时开始垃圾回收,将From space中的不可达对象都打上标记
    • 将From space的未标记对象复制到To space。
      • 解决了内存散落分块的问题(不连续的内存空间)
      • 相当于用空间换时间。
    • 然后清空From space、将其闲置,也就是转变为To space,俗称反转。
  • 新生代 -> 老生代

    • 新生代存放的是新分配的小量内存,如果达到以下条件中的一个,将被分配至老生代
      • 内存大小达到From space的25%
      • 经历了From space <-> To space的一个轮回

img

老生代

老生代采用mark-sweep标记清除和mark-compact标记整理

通常存放较大的内存块和从新生代分配过来的内存块

  • 内存大小
    • 32位系统700M左右
    • 64位系统1.4G左右
  • 分区
    • Old Object Space
      • 字面的老生代,存放的是新生代分配过来的内存。
    • Large Object Space
      • 存放其他区域放不下的较大的内存,基本都超过1M
    • Map Space
      • 存放存储对象的映射关系
    • Code Space
      • 存储编译后的代码
  • 回收流程
    • 标记分类(三色标记)
      • 未被扫描,可回收,下面简称1类
      • 扫描中,不可回收,下面简称2类
      • 扫描完成,不可回收,下面简称3类
    • 遍历
      • 采用深度优先遍历,遍历每个对象。
      • 首先将非根部对象全部标记为1类,然后进行深度优先遍历。
      • 遍历过程中将对象压入栈,这个过程中对象被标记为2类
      • 遍历完成对象出栈,这个对象被标记为3类
      • 整个过程直至栈空
    • Mark-sweep
      • 标记完成之后,将标记为1类的对象进行内存释放
        img
    • Mark-compact
      • 垃圾回收完成之后,内存空间是不连续的。
      • 这样容易造成无法分配较大的内存空间的问题,从而触发垃圾回收。
      • 所以,会有Mark-compact步骤将未被回收的内存块整理为连续地内存空间。
      • 频繁触发垃圾回收会影响引擎的性能,内存空间不足时也会优先触发Mark-compact

img

垃圾回收优化

  • 增量标记
    • 如果用集中的一段时间进行垃圾回收,新生代倒还好,老生代如果遍历较大的对象,可能会造成卡顿。
    • 增量标记:使垃圾回收程序和应用逻辑程序交替运行,思想类似Time Slicing
  • 并行回收
    • 在垃圾回收的过程中,开启若干辅助线程,提高垃圾回收效率。
  • 并发回收
    • 在逻辑程序执行的过程中,开启若干辅助线程进行垃圾回收,清理和主线程没有任何逻辑关系的内存。

内存泄露场景

全局变量

// exm1
function Example(){
exm = 'LeBron'
}

// exm2
function Example(){
this.exm = 'LeBron'
}
Example()

未清除的定时器

const timer = setInterval(() => {
//...
}, 1000)

// clearInterval(timer)

闭包

function debounce(fn, time) {
let timeout = null;
return function () {
if (timeout) {
clearTimeout(timeout);
}

timeout = setTimeout(() => {
fn.apply(this, arguments);
}, time);
};
}

const fn = debounce(handler, 1000); // fn引用了timeout

未清除的DOM元素引用

const element = {
// 此处引用了DOM元素
button:document.getElementById('LeBron'),
select:document.getElementById('select')
}

document.body.removeChild(document.getElementById('LeBron'))

如何检测内存泄漏

这个其实不难,浏览器原带的开发者工具Performance就可以

  • 步骤
    • F12打开开发者工具
    • 选择Performance工具栏
    • 勾选屏幕截图和Memory
    • 点击开始录制
    • 一段时间之后结束录制
  • 结果
    • 堆内存会周期性地分配和释放
    • 如果堆内存的min值在逐渐上升则存在内存泄漏

img

优化内存使用

  1. 尽量不在for循环中定义函数
// exm
const fn = (idx) => {
return idx * 2;
}

function Example(){
for(let i=0;i<1000;i++){
//const fn = (idx) => {
// return idx * 2;
// }
const res = fn(i);
}
}
  1. 尽量不在for循环中定义对象
function Example() {
const obj = {};
let res = "";
for (let i = 0; i < 1000; i++) {
// const obj = {
// a: i,
// b: i * 2,
// c: i * 3,
// };
obj.a = i;
obj.b = i * 2;
obj.c = i * 3;
res += JSON.stringify(obj);
}
return res
}
  1. 清空数组
arr = [0, 1, 2]
arr.length = 0; // 清空了数组,数组类型不变
// arr = [] // 重新申请了一块空数组对象内存

掘金:前端LeBron

知乎:前端LeBron

持续分享技术博文,关注微信公众号👇🏻

img