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"];
在访问 a、b、c 三个变量时,过程非常简单:从栈中直接获取该变量的值。
而在访问 d 和 e 时,则需要分两步走:
- 从栈中获取变量对应对象的引用(即它在堆内存中的地址)
- 拿着 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'));