阅读 706

JS内存管理生命周期和垃圾回收机制

最近在看大神写的专栏,很精辟,笔者想通过总结的方式加深理解,不一定准确,只是笔者自己的想法,欢迎指正。

TL;DR

  • JS 的内存管理生命周期:分配、读写、释放
  • 垃圾回收机制的算法:引用计数法、标记清除法
  • 内存泄漏成因:闭包引起的共享父作用域、变量没声明、未清除定时器、删除不要的dom

JS 内存生命周期

内存管理又是是每一种编程语言都会具备的一种基本能力。

但有些语言暴露内存管理的方法,如 c,有些语言不暴露,如JS

所以,不暴露,就不容易看见和知道了。。。

JS 内存生命周期,三个:

  • “挖坑”—— 在内存空间的沃土里,划出自己的地,此举称为 “分配内存”。
  • “用坑”—— 往地里 “种菜” :填入你需要存储的信息。此后你可以读取它,也可以更改它,此举称为 “内存的读与写” 操作。
  • “还坑”—— 用坑一时爽,但作为好公民,咱用完这个地就得及时上交给村里。这个 “还回去” 的动作,就叫做内存的释放。

但挖坑的时候,得看你种啥,从而选择不同的土壤。

1.分配内存

内存分为两种:

  • 栈内存:线性表结构。适合存 基本类型:Sting、Number、Boolean、null、undefined、Symbol
  • 堆内存:树结构。适合存 引用类型:Object Array 等...

2.读写内存

栈内存和堆内存的结构不一样,所以读写的方式也不一样。

先看看以下是放在哪种内存里。

let a = 0;
let b = "Hello World";
let c = null;
let d = { name: "修言" };
let e = ["修言", "小明", "bear"];
复制代码

memory1

在访问 a、b、c 三个变量时,过程非常简单:从栈中直接获取该变量的值。

而在访问 d 和 e 时,则需要分两步走:

  1. 从栈中获取变量对应对象的引用(即它在堆内存中的地址)
  2. 拿着 1 中获取到的地址,再去堆内存空间查询,才能拿到我们想要的数据

3.释放内存

JS 没有管理内存的方法,所以 JS 隔一段时间就巡查一次。
判断一个变量不再被需要之后,就会把个变量所占用的内存空间给释放掉。

垃圾回收: 巡查 => 判断 => 释放 的过程
垃圾回收的算法:怎么判断变量不再被需要

两种判断的法子:

  • 引用计数法(已被淘汰)
  • 标记清除法(正使用的)

引用计数法

“引用” 这个概念,其实可以认为它描述的是变量所处那块内存的内存地址。

用一个变量指向了一个值,那么就创建了一个针对这个值的 “引用”:

// 内存中数组这个值有一个引用,就是student这个变量
let students = ["修言", "小明", "bear"];
复制代码

在引用计数法的机制下,内存中的每一个值都会对应一个引用计数。当垃圾收集器感知到某个值的引用计数 为 0 时,就判断它 “没用” 了,随即这块内存就会被释放。

// 如果将students改成null,则上面数组的引用数就变成0了,就会被回收
students = null;
复制代码

引用计数法的缺陷

看个例子:

function badCycle() {
  var cycleObj1 = {};
  var cycleObj2 = {};
  cycleObj1.target = cycleObj2;
  cycleObj2.target = cycleObj1;
}
badCycle();
复制代码

一般函数执行完,内部变量自动会被清除。 但是用引用计数法的话,cycleObj1 和 cycleObj2循环引用,其引用计数一直是 1,就不能被回收。

所以,引用计数法的缺陷是:循环引用的变量,很容易不能被回收。

标记清除法

标记清除法分为两个阶段:

  • 标记阶段:垃圾收集器会先找到根对象,在浏览器里,根对象是 Window;在 Node 里,根对象是 Global。从 根对象出发,垃圾收集器会扫描所有可以通过根对象触及的变量,这些对象会被标记为 “可抵达”。
  • 清除阶段: 没有被标记为 “可抵达” 的变量,就会被认为是不需要的变量,这波变量会被清除

“可抵达”:意味着可以被使用

重新看循环引用的代码:

function badCycle() {
  var cycleObj1 = {};
  var cycleObj2 = {};
  cycleObj1.target = cycleObj2;
  cycleObj2.target = cycleObj1;
}
badCycle();
复制代码

badCycle 执行完毕后,从根对象 Window 出发,cycleObj1 和 cycleObj2 都会被识别为不可达的对象(不可被使用的对象),它们会按照 预期被清除掉。这样一来,循环引用的问题,就被标记清除干脆地解决掉了。

闭包和内存泄露

啥是内存泄露?

该释放的变量(内存垃圾)没有被释放,仍然霸占着原有的内存不松手,导致内存占用不断攀高,带来性能恶化、 系统崩溃等一系列问题,这种现象就叫内存泄漏。

先说一句,单纯由闭包导致的内存泄漏,极少极少(除非你的代码写得有问题)。

先看一段经典的闭包造成内存泄露的代码(写的有问题哈,改进就不会了)。

var theThing = null;
var replaceThing = function() {
  var originalThing = theThing;
  var unused = function() {
    if (originalThing)
      // 'originalThing'的引用
      console.log("嘿嘿嘿");
  };
  theThing = {
    longStr: new Array(1000000).join("*"),
    someMethod: function() {
      console.log("哈哈哈");
    }
  };
};
setInterval(replaceThing, 1000);
复制代码

在 V8 中,一旦不同的作用域位于同一个 父级作用域下,那么它们会共享这个父级作用域。

在这段代码里, unused 是一个不会被使用的闭包,但和它共享同一个父级作用域的 someMethod,则是一个 “可抵达”的闭包。
unused 引用了 originalThing,这导致和它共享作用域的 someMethod 也 间接地引用了 originalThing。
结果就是 someMethod “被迫” 产生了对 originalThing 的持续引用,originalThing 虽然 没有任何意义和作用,却永远不会被回收。
不仅如此,originalThing 每次 setInterval 都会改变一次指向(指向最近 一次的 theThing 赋值结果),这导致无法被回收的无用 originalThing 越堆积越多,最终导致严重的内存泄漏。

内存泄露的原因

  • 被迫产生对父作用域变量的引用。上面的例子。避免方案:将某个闭包多裹一层,从而不共享父作用域

  • 在函数里,没有声明而直接赋值的变量。避免方案:变量必须声明

本身想在函数内部用,用完就回收,但是因为变成了全局变量,则不会被回收

function test() {
  me = "yan";
}
复制代码
  • 忘记清除的 setInterval 和 setTimeout。避免方案:必须清定时器

在 轮询调用setInterval 和链式调用的 setTimeout 这两种场景下,定时器的工作可以说都是无穷无尽的。 当定时器囊括的函数逻辑不再被需要、而我们又忘记手动清除定时器时,它们就会永远保持对内存的占用。

  • 清除不当的 DOM。避免方案:手动置为null。

虽然删除了节点,但myDiv 这个变量对 这个 DOM 的引用仍然存在,它仍然是一块 “可抵达” 的内存。

const myDiv = document.getElementById('myDiv')
function handleMyDiv() {
// 一些与myDiv相关的逻辑
}
// 使用myDiv
handleMyDiv()
// 尝试”删除“ myDiv
document.body.removeChild(document.getElementById('myDiv'));
复制代码
文章分类
前端
文章标签