JavaScript 内存机制

wmg12 min read
User Guide

JavaScript 内存机制

JS是单线程的语言,执行顺序肯定是顺序执行,但是JS 引擎并不是一行一行地分析和执行程序,而是一段一段地分析执行,会先进行编译阶段然后才是执行阶段。

JavaScript 内存机制

JS内存空间分为栈(stack)、堆(heap)、池(一般也会归类为栈中)。 其中栈存放变量,堆存放复杂对象,池存放常量。

基本数据类型(简单数据类型)

JS中的基本型共有五种:string,number,Boolean,undefined,null。分别对应:字符串类型,数字类型,布尔类型,undefined(变量声明未初始化),null(空对象或理解为空指针)。

引用数据类型

JS中的引用型:Array,Function,Object。但是实际上就是一种:Object型,没错,就是对象,毕竟Array,Function也是对象。JS一切皆对象这句话并不为过······

栈内存和堆内存

介绍完了基本型和引用型就可以真正的进入正题了。我们知道声明一个变量并且给它赋值这样的操作对于这两种类型而言没什么区别,但是对这两种类型的具体的操作却大不相同。
在JS中,栈内存用于存储基本型的变量值,堆内存用于存储引用型的值。这是为什么呢?因为JS这门语言和其他语言有一个不同之处:不允许直接访问内存的位置,也就是说不能直接操作对象的内存空间,操作的是对象的引用而已,那么引用可以理解为是一个指针,是一个具体的堆内存的地址。我写下这几行代码,并且附上一张图,让我们来详细的看一下:

var str = `我是字符串`,
    num = 1,
    bl = true,
    nu = null,
    un = undefined,
    obj = {
        name: 'reslicma'
    }

在内存中就发生了如下图这样的事情:

avatar

var a = 20;
var b = a;
b = 30;

// 这时a的值是多少?
var a = { name: '前端开发' }
var b = a;
b.name = '进阶';

// 这时a.name的值是多少
var a = { name: '前端开发' }
var b = a;
a = null;

// 这时b的值是多少

执行上下文

当控制器转到可执行的代码时,会进入该代码对应的执行上下文,可以理解为该代码对应的一个执行环境,就叫做执行上下文。

在JavaScript中运行环境有三种,分别是
  • 全局执行上下文:只有一个,浏览器中的全局对象就是 window 对象,this 指向这个全局对象。
  • 函数执行上下文:存在无数个,只有在函数被调用的时候才会被创建,每次调用函数都会创建一个新的执行上下文。
  • Eval 函数执行上下文: 指的是运行在 eval 函数中的代码,很少用而且不建议使用。

所以在一个JavaScript程序中,就会产生多个不同的执行上下文,这时候就需要用到前面提到的栈数据结构来管理了,我们称之为调用栈。当代码在执行过程中,遇到上面说的三种情况,就会产生三种执行上下文,然后分别压入调用栈中,等一个执行上下文执行完毕,弹出栈,才能执行下一个执行上下文中的代码,这就是栈结构的特点。

执行上下文的特点

  • 单线程,其实javascript就是单线程,所以很好理解。
  • 同步执行,同步就是按顺序,不能同时执行。
  • 全局上下文只有一个,它在浏览器关闭时才会弹出栈。
  • 函数的执行上下文的数目没有限制。
  • 每次某个函数被调用时,就会有新的执行上下文,即使是调用的自身函数。

执行上下文的生命周期

执行上下文分两个阶段创建:1)创建阶段; 2)执行阶段
1)创建阶段
  • 1、确定 this 的值,也被称为 This Binding。
  • 2、LexicalEnvironment(词法环境) 组件被创建。
  • 3、VariableEnvironment(变量环境) 组件被创建。

直接看伪代码可能更加直观

ExecutionContext = {
  ThisBinding = <this value>,     // 确定this
  LexicalEnvironment = { ... },   // 词法环境
  VariableEnvironment = { ... },  // 变量环境
}
This Binding
  • 全局执行上下文中,this 的值指向全局对象,在浏览器中this 的值指向 window对象,而在nodejs中指向这个文件的module对象。
  • 函数执行上下文中,this 的值取决于函数的调用方式。具体有:默认绑定、隐式绑定、显式绑定(硬绑定)、new绑定、箭头函数。
词法环境(Lexical Environment)

词法环境有两个组成部分:

  1. 环境记录:存储变量和函数声明的实际位置
  2. 对外部环境的引用:可以访问其外部词法环境

词法环境有两种类型:

  1. 全局环境:是一个没有外部环境的词法环境,其外部环境引用为 null。
  2. 函数环境:用户在函数中定义的变量被存储在环境记录中,包含了arguments 对象。
GlobalExectionContext = {  // 全局执行上下文
  LexicalEnvironment: {
    EnvironmentRecord: {
      Type: "Object",
      // 标识符绑定在这里
      outer: <null>
    }
  }
}

FunctionExectionContext = { // 函数执行上下文
  LexicalEnvironment: {
    EnvironmentRecord: {
      Type: "Declarative",
      // 标识符绑定在这里
      outer: <Global or outer function environment reference>
    }
  }
}
变量环境

变量环境也是一个词法环境,因此它具有上面定义的词法环境的所有属性。

在 ES6 中,词法环境和变量环境的区别在于前者用于存储函数声明和变量( let 和 const )绑定,而后者仅用于存储变量( var )绑定。

let a = 20;
const b = 30;
var c;

function multiply(e, f) {
  var g = 20;
  return e * f * g;
}

c = multiply(20, 30);

执行上下文如下所示

GlobalExectionContext = {
  ThisBinding: <Global Object>,
  LexicalEnvironment: {
    EnvironmentRecord: {
      Type: "Object",
      a: < uninitialized >,
      b: < uninitialized >,
      multiply: < func >
    }
    outer: <null>
  },
  VariableEnvironment: {
    EnvironmentRecord: {
      Type: "Object",
      c: undefined,
    }
    outer: <null>
  }
}

FunctionExectionContext = {
  ThisBinding: <Global Object>,
  LexicalEnvironment: {
    EnvironmentRecord: {
      Type: "Declarative",
      Arguments: {0: 20, 1: 30, length: 2},
    },
    outer: <GlobalLexicalEnvironment>
  },
  VariableEnvironment: {
    EnvironmentRecord: {
      Type: "Declarative",
      g: undefined
    },
    outer: <GlobalLexicalEnvironment>
  }
}

变量提升的原因:在创建阶段,函数声明存储在环境中,而变量会被设置为 undefined(在 var 的情况下)或保持未初始化(在 let 和 const 的情况下)。所以这就是为什么可以在声明之前访问 var 定义的变量(尽管是 undefined ),但如果在声明之前访问 let 和 const 定义的变量就会提示引用错误的原因。这就是所谓的变量提升。

执行阶段

此阶段,完成对所有变量的分配,最后执行代码。

如果 Javascript 引擎在源代码中声明的实际位置找不到 let 变量的值,那么将为其分配 undefined 值。

dome1
var scope = "global scope";
function checkscope(){
    var scope = "local scope";
    function f(){
        return scope;
    }
    return f();
}
checkscope();

第一段代码:

ECStack.push(<checkscope> functionContext);
ECStack.push(<f> functionContext);
ECStack.pop();
ECStack.pop();

第二段代码:

ECStack.push(<checkscope> functionContext);
ECStack.pop();
ECStack.push(<f> functionContext);
ECStack.pop();

内存空间管理

垃圾回收

进行前端开发时几乎不需要关心内存问题,V8限制的内存几乎不会出现用完的情况,而且我们只要关闭了浏览器,一切都结束。如果是node后端,后端程序往往进行更加复杂的操作,加上长期运行在服务器不重启,如果不关注内存管理,积少成多就会导致内存泄漏。

node中的内存第一个部分还是和上面的一样,有栈、堆、运行时环境,另外还有一个缓冲区存放Buffer。你可以通过process.memoryUsage()查看node里面进程内存使用情况。堆中的对象,被划分为新生代和老生代,他们会被不同的垃圾回收机制清理掉。

node内存处理机制

新生代用Scavenge算法进行垃圾回收,利用复制的方式实现内存回收的算法。他的过程是:

  • 将新生代的总空间一分为二,只使用其中一个,另一个处于闲置,等待垃圾回收时使用。使用中的那块空间称为From,闲置的空间称为To。
  • 当触发垃圾回收时,V8将From空间中所有存活下来的对象复制到To空间。
  • From空间所有应该存活的对象都复制完成后,原本的From空间将被释放,成为闲置空间,原本To空间则成为使用中空间,也就是功能交换。
  • 如果某对象已经经历一次新生代垃圾回收而且第二次依旧存活,或者To空间已经使用了25%,都会晋升至老生代。

avatar

标记法垃圾回收机制

老生代利用了标记-清除(后面又加上了标记-整理)的方式进行垃圾回收。在标记阶段(周期比较大)遍历堆中的所有对象,标记活着的对象,在随后的清除阶段中,只清除没有被标记的对象。

分别对应着三种颜色:白、灰、黑。遍历的时候,主要是利用DFS。刚刚开始的时候,所有的对象都是白色。从根对象开始遍历,遍历过的对象会变成灰色,放入一个额外开辟的双端队列中。标记阶段的每次循环,垃圾回收器都会从双端队列中取出一个对象染成黑对象,并将邻接的对象染色为灰。

引用计数

另一种不太常见的垃圾回收策略是引用计数。引用计数的含义是跟踪记录每个值被引用的次数。当这个引用次数变成0时,则说明没有办法再访问这个值了,因而就可以将其所占的内存空间给收回来。

该方式会引起内存泄漏的原因是它不能解决循环引用的问题:

function sample(){
    var a={};
    var b={};
    a.prop = b;
    b.prop = a;
}

这种情况下每次调用sample()函数,a和b的引用计数都是2,会使这部分内存永远不会被释放,即内存泄漏。

闭包

闭包的概念各有各的说法,简单来说,就是外部访问内部变量,而且内部临时开辟的内存空间不会被垃圾回收。查找值的时候沿着作用域链查找,找到则停止。

各种书对于闭包的解释:

  • 《权威指南》:函数对象通过作用域链相互关联起来,函数内部变量都可以保持在函数的作用域中,有权访问另一个函数作用域中的变量。
  • 《忍者秘籍》:一个函数创建时允许自身访问并操作该自身函数以外的变量所创建的作用域。
  • 《你不知道的js》:是基于词法的作用域书写代码时所产生的结果,当函数记住并访问所在的词法作用域,闭包就产生了。

闭包的产生,会导致内存泄漏。前面已经说到,js具有垃圾回收机制,如果发现变量被不使用将会被回收,而闭包相互引用,让他不会被回收,一直占据着一块内存。

// r被垃圾回收
function a(){
  var r = 1
  var s = 1
  return function count(){
    s++
    console.log(s)
  }
}
var b = a() // 在谷歌浏览器看他的调用栈,发现闭包里面没有r了

我们也听说一句话,尽量避免全局变量。其实也是这样的道理,不要过度利用闭包。用得越多,栈越深,变量越不能被回收。

浏览器的全局对象为window,关闭浏览器自然一切结束。Node中全局对象为global,如果global中有属性已经没有用处了,一定要设置为null,因为只有等到程序停止运行,才会销毁。

avatar


作者:wmg
链接:https://github.com/wmgShadow/-/blob/master/README.md
来源:GitHub.com