阅读 556

「JavaScript进阶」一文吃透内存机制

前言

为什么要关注内存呢?

如果有内存溢出,程序占用的内存会越来越大,最终引起程序卡顿,甚至无响应等性能体验问题。这篇文章从内存的数据结构、内存空间管理、内存回收算法、内存泄露场景对内存机制进行一个全方面的介绍。

数据结构

理解数据结构是理解后面内存空间管理内存机制的基础,我们接下来介绍下 JavaScript 中的数据结构的特点和使用场景。

JavaScript 中的数据结构主要有 栈、堆、队列 三种数据结构。

1.栈

是一个后进先出(LIFO)的数据结构

可以把想象成是一个杯子一样的容器,我们往杯子里放石头时,每次放的石头都在杯子的顶部;把石头取出来时,杯子顶部的石头(即后放进去的石头)会先被取出来,这就是 后进先出

注:基本数据类型引用数据类型地址 都存储在

2.堆

可以被看作是一棵有一定规则的完全二叉树数组对象

堆有两个特点:

  • 堆的某个节点总是不大于或不小于其父节点
  • 堆总是一颗完全二叉树

比如把数组对象 [ 10, 7, 2, 5, 1 ] 用二叉树(从上往下,从左往右)画出来:

image.png

是一个 完全二叉树 结构,且每个子节点都不大于父节点的值,是一个堆结构。根结点最大的堆叫做大根堆

我们再把上面的数组反过来 [ 1, 5, 2, 7, 10 ] ,用二叉树画出来,也是一个完全二叉树,且每个子节点都不大于父节点,是一个堆结构。根结点最小的堆叫做小根堆

image.png

注:引用数据类型存放在 里。

3.队列

队列 是一个先进先出(FIFO)的数据结构

队列很好理解,可以想像成我们排队买肯德基,每次新来的人只能排在队列的最后面,先排队的人先点餐离开,这就是 先进先出

注:队列是事件循环(Event Loop)的基础数据结构,后面再单独写一篇文章详细分析事件循环。

内存空间管理

JavaScript 的内存生命周期可以分为分配内存、使用内存、释放内存三个阶段。

1.分配内存

当我们声明变量、函数、对象时,系统会自动为他们分配内存。

我们已经知道,JavaScript 有栈内存堆内存两种内存,那 JavaScript 是如何分配内存的呢?

变量存储类型 中,我们详细介绍了变量有 基本数据类型引用数据类型两种类型,系统会根据不同的数据额类型分配不同类型的内存(栈内存堆内存

  • 1)基本数据类型的大小固定,系统在编译阶段就为其分配了栈内存
let age = 10; // 分配到栈内存
let name = '谷底飞龙'; // 分配到栈内存
复制代码
  • 2)引用数据类型的值比较复杂(比如对象里可以包含多个基本类型等),值的大小不固定,因此,值是存储在堆内存中的,栈内存中存储的是其引用地址。
// 分配对象 info 的引用地址到栈内存,分配对象 info 及其包含的内容到堆内存
let info = {
  name: '谷底飞龙',
  age: 10
}

// 分配函数 f() 的引用地址到栈内存,将 f() 的内容以字符串的形式存储到堆内存中
function f(){
   console.log('我是函数')
}
复制代码

2.使用内存

当我们读写变量和对象、赋值、调用函数的时候,就是在使用内存

基本数据类型:访问的时候,直接从栈内存中访问,速度比较快

let age = 10; // 分配到栈内存
let name = '谷底飞龙'; // 分配到栈内存

// 访问变量,使用内存
console.log(`my name is ${name}, my age is ${age}`)
复制代码

引用数据类型:访问的时候,先从栈内存中访问引用地址,再通过地址去堆内存中找到对应的值,速度会慢一点

// 分配对象 info 的引用地址到栈内存,分配对象 info 及其包含的内容到堆内存
let info = {
  name: '谷底飞龙',
  age: 10
}
// 访问对象,使用内存
console.log(info);

// 分配函数 f() 的引用地址到栈内存,将 f() 的内容以字符串的形式存储到堆内存中
function f(){
   console.log('我是函数')
}

// 调用函数,使用内存
f();
复制代码

3.释放内存

JavaScript 有自动垃圾收集机制,垃圾收集器会每隔一段时间就执行一次释放操作,找出那些不再继续使用的值,然后释放其占用的内存

局部变量:函数内部定义的局部变量,在函数执行完成后,就会自动释放

function f() {
  // 在函数内部定义变量 name 和对象 info
  var name = '谷底飞龙'
  var info = { name }
  
  /* 函数执行结束,name、info占用的内存就会自动释放 */
}
// 执行函数
f();
复制代码

全局变量:全局变量什么时候需要自动释放内存空间则很难判断,所以在开发中尽量避免使用全局变量。

内存回收算法

早期浏览器的内存回收算法是引用计数算法,2012年之后,所有的浏览器都改成标记清除算法

1.引用计数算法(旧算法)

记录每个值被引用的次数,当某个值被引用一次(赋值、被别的对象持用等),就将计数器+1,如果引用被释放,则计数器-1,当引用数为0时,表示这个值不再使用了,判定可以进行释放

引用计数算法有一个弊端:循环引用

var obj1 = {name: '谷底飞龙'};
var obj2 = {age: 28};

obj1.a = obj2;  // obj1 持有 obj2
obj2.b = obj1; // obj2 又持有 obj1

// 验证是否存在循环引用
console.log(JSON.stringify(obj1));
复制代码

obj1obj2相互持有,就会存在循环引用的问题,使用引用计数算法,计数器永远不可能为0,也就是存在循环引用的对象的内存不会被回收。

注:可以使用JSON.stringify()来判断对象是否存在循环引用,如果存在,则会报错

image.png

2.标记清除算法(新算法)

标记清除算法也可以理解为基于执行环境的清除算法,当进入执行环境时,标记为进入环境,离开执行环境时,标记为离开环境

每个执行环境关联一个保存该环境所有变量和函数的变量对象,当离开该执行环境时,即该环境内所有的代码执行完成时,该变量对象就会被销毁,执行环境的变量和函数占用的内存被回收

全局执行环境:JavaScript 中的全局执行环境是最外围的一层环境,在浏览器中,全局环境是window,全局变量和全局函数都是挂在window下的,只有当程序退出或者关闭浏览器时,全局执行环境才会被销毁。

局部执行环境:一般是指函数执行环境,每个函数在被调用的时候,都会产生一个局部执行环境,当函数执行完成的时候,函数执行环境就会被销毁。

function f(){
   var name = '谷底飞龙'; // 标记为 “进入环境”
   console.log(name);
}
f(); // 函数执行完成,name 被标记为“离开环境”,回收内存 
复制代码

对执行环境理解不是很清楚的,建议看下我的这篇文章 一文吃透执行上下文和执行栈

使用标记清除算法进行内存回收,可以解决循环引用的问题。

内存泄漏场景

简单的理解,内存泄露就是本应被销毁的变量,由于代码编写不当导致没有被回收内存

1.意外的全局变量

  • 函数内部局部变量未定义,导致意外的变成全局变量
"use strict"
function f() {
   // name 未定义,默认是全局变量,被挂在浏览器的全局对象 window 上
   name = '谷底飞龙';
}
// 会打印出 ‘谷底飞龙’,说明 name 未定义时,被变成全局变量了
console.log(window.name);
复制代码

这里的 name未定义,实际上会被挂在全局对象上(浏览器上的全局对象是window),无法随着函数执行环境销毁,导致内存泄露。

  • this导致的意外的全局变量
function f() {
   // 这里 this 指向的是全局对象 window
   this.name = '谷底飞龙';
}
f();
// 会打印出 ‘谷底飞龙’
console.log(window.name);
复制代码

这里调用函数 f()时,等价于 window.f(),因此,函数内部的this指向的是全局对象window,变量 name 变成全局变量。

解决方法:在代码文件头部使用use strict,此时函数内部的 this 指向的是undefined,执行时就会给出报错提示

"use strict"
function f() {
   // 这里 this 指向的是 undefined
   this.name = '谷底飞龙';
}
f();
复制代码

image.png

2.被遗忘的定时器或观察者

  • 当不需要setInterval或者setTimeout时,定时器没有被clear,定时器的回调函数以及内部依赖的变量都不能被回收,导致内存泄露。
var someResource = getData();
setInterval(function() {
    var node = document.getElementById('Node');
    if(node) {
        // 处理 node 和 someResource
        node.innerHTML = JSON.stringify(someResource));
    }
}, 1000);
复制代码

当计时器不再需要时,node 对象可以删除,整个回调函数也不需要了。可是,计时器回调函数仍然没被回收(计时器停止才会被回收)。同时,someResource 如果存储了大量的数据,也是无法被回收的。

解决方法:定时器不再需要时(如页面将被销毁等),记得调用clearTimeout()clearInterval()停止计时器。

  • 注册了事件监听,在不需要监听时(如页面销毁),没有移除观察者,在老版本的浏览器(使用引用计数算法回收内存)会导致内存泄露。
var element = document.getElementById('button');
function onClick(event) {
    element.innerHTML = 'text';
}
element.addEventListener('click', onClick);

复制代码

对于观察者和循环引用,在新版本的浏览器(使用标记清除算法回收内存),已经可以正确检测和处理循环引用了,回收节点内存时,不必非要调用 removeEventListener 了。

3.DOM 的引用

  • DOM 元素的生命周期取决于是否挂在 DOM 树上,当从DOM树移除了,就可以被销毁回收了。
  • 但如果 DOM 还被其它 js 对象引用,那DOM元素的生命周期就取决于DOM 树和持有它的 js 对象,需要把相关引用都释放掉,不然,就会出现内存泄露
var elements = {
    button: document.getElementById('button')
};

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

如果把DOM 存成字典(JSON 键值对)或者数组,此时,同样的 DOM 元素存在两个引用:一个在 DOM 树中,另一个在字典中。那么将来需要把两个引用都清除。

4.闭包

闭包的关键是匿名函数可以访问父级作用域的变量。闭包可以维持函数内局部变量,使其得不到释放。

// 函数内部嵌套函数,形成闭包
function bindEvent() {
  var obj = document.createElement('xxx')
  obj.onclick = function () {
     // Even if it is a empty function
  }
}
复制代码

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

解决方法

  • 将事件处理函数定义在外面
// 将事件处理函数定义在外面
function bindEvent() {
  var obj = document.createElement('xxx')
  obj.onclick = onclickHandler
}
复制代码
  • 或者在定义事件处理函数的外部函数中,删除对dom的引用
// 在定义事件处理函数的外部函数中,删除对dom的引用
function bindEvent() {
  var obj = document.createElement('xxx')
  obj.onclick = function() {
    // Even if it is a empty function
  }
  obj = null
}
复制代码

参考

文章分类
前端
文章标签