JS内存空间与闭包
一、JS内存空间
JS有7种数据类型:基本数据类型(Boolean、String、Number、undefined、null、Symbol)和引用数据类型(Object)。其中,基本数据类型都是按值访问的,它们被保存在栈数据结构中,每一个变量都对应一个具体的变量值,我们可以直接操作保存在变量中的实际的值;而对于引用数据结构,在栈中保存的值是指向堆中对象的指针,JavaScript不允许直接访问堆内存中的位置,因此我们不能直接操作对象的堆内存空间,而只能通过栈中的变量地址来访问到堆内存中的对象。
借一张图来理解一下:
上图中,a1
,a2
,a3
都是基本数据类型,在栈内存空间中保存着具体的值;而c
,d
是引用数据类型,分别保存的是指向堆内存空间中数组[1,2,3]
和对象{m:20}
的地址指针。因此当我们要访问堆内存中的引用数据类型时,实际上我们首先是从变量对象中获取了该对象的地址引用(或者地址指针),然后再从堆内存中取得我们需要的数据。
深、浅拷贝
当我们在拷贝基本数据类型的时候,就相当于在栈内存中copy了一份变量-值,此时修改拷贝后的变量是不会影响到原变量的;由于对象保存在堆内存中,而栈内存保存的是指向这个对象的指针,所以当我们拷贝引用数据类型的时候,只是拷贝了一份这个对象的地址,实际拷贝变量引用的还是原变量所指向的对象,当我们操作拷贝变量的时候,就会修改原变量的内容,这就是浅拷贝。
浅拷贝的方法有:
- ES5方法:Array.prototype.concat() , Array.prototype.slice()
- ES6方法:扩展运算符 ...obj , Object.assign({}, obj)
深拷贝的方法有:
- JSON.parse(JSON.stringify(obj)) JSON序列化,不适用于undefined,null和Infinity
- 递归拷贝,检测到数据类型为object,则递归拷贝其属性
- JQuery中的 $.extend() 方法
二、JS的内存空间管理
JS内存管理主要针对的是局部变量,例如函数中声明和使用的变量。其内存生命周期为:
- 开辟所需要的内存
- 使用分配到的内存(读、写)
- 不需要时(函数结束)将其释放、归还
JS有自动的垃圾回收机制,垃圾收集器需要跟踪每个变量,找出那些不再继续使用的值,然后释放其占用的内存。垃圾收集器会每隔固定的时间段就执行一次释放操作。JS的垃圾回收机制主要有两种:标记清除法,引用计数法
-
标记清除法:是JS最常用的垃圾收集方式。IE/Firefox/Opera/Chrome和Safari使用的都是标记清除法,只是垃圾收集的时间间隔互不相同。标记清除法的原理是,垃圾收集器在运行是会给存储在内存中的变量都加上标记,当变量进入环境时,标记会被去掉,在此之后,再一次被打上标记的白能量将被视为准备删除的变量,最后,垃圾收集器完成内存清理工作,销毁那些带标记的值并回收它们所占用的内存空间。
-
引用计数法:Netscape Navigator 3.0最早使用的就是引用技术策略的浏览器。引用计数的含义是跟踪记录每个值被引用的次数,当声明了一个变量并将一个引用类型值赋给该变量时,这个值的引用次数+1,如果包含这个值引用变量又取得了另外一个值,那么引用次数-1.当这个值的引用次数变为0时,就会被当成垃圾清理掉。但是引用计数法容易造成循环引用,因为循环引用时,两者的变量引用次数都不会为0,这样垃圾收集器就没有办法判定这个是否被使用完了,就会造成内存泄漏。为了避免这类问题,在引用完变量后要手动切断两个对象的关联,为变量赋值为null。
-
V8中的GC算法是分代式垃圾回收机制,将内存(堆)分为新生代和老生代两部分。新生代中的对象一般存活时间较短,内存空间分为两部分,分别为 From 空间和 To 空间。新分配的对象会被放入 From 空间中,当 From 空间被占满时,新生代 GC 就会启动了。算法会检查 From 空间中存活的对象并复制到 To 空间中,如果有失活的对象就会销毁。当复制完成后将 From 空间和 To 空间互换,这样 GC 就结束了。老生代中的对象一般存活时间较长且数量也多,新生代中的对象如果经历过GC的话,就会被移到老生代空间中。如果To 空间的对象占比大小超过 25 %,会将对象从新生代空间移到老生代空间中。老生代空间中,通常会先标记存活对象,失活对象被清理;对于清除对象后会造成堆内存出现碎片的情况,当碎片超过一定限制后会启动压缩算法。在压缩过程中,将活的对象像一端移动,直到所有对象都移动完成然后清理掉不需要的内存。
三、执行上下文(Execution Context)
每次当控制器转到可执行代码的时候,就会进入一个执行上下文。执行上下文可以理解为当前代码的执行环境,它会形成一个作用域。JavaScript中的运行环境大概包括三种情况。
- 全局环境:JavaScript代码运行起来会首先进入该环境
- 函数环境:当函数被调用执行时,会进入当前函数中执行代码
- eval(不建议使用,可忽略)
因此在一个JavaScript程序中,必定会产生多个执行上下文,JavaScript引擎会以栈的方式来处理它们,这个栈,称为函数调用栈(call stack)。栈底永远都是全局上下文,而栈顶就是当前正在执行的上下文。
当代码在执行过程中,遇到以上三种情况,都会生成一个执行上下文,放入栈中,而处于栈顶的上下文执行完毕之后,就会自动出栈。
例如:
var color = 'blue';
function changeColor() {
var anotherColor = 'red';
function swapColors() {
var tempColor = anotherColor;
anotherColor = color;
color = tempColor;
}
swapColors();
}
changeColor();
那么在这段程序的执行过程中,函数执行栈中的上下文变化情况应该为:
全局上下文在浏览器窗口关闭后出栈。
对于执行上下文的总结:
- 单线程
- 同步执行,只有栈顶的上下文处于执行中,其他上下文需要等待
- 全局上下文只有唯一的一个,它在浏览器关闭时出栈
- 函数的执行上下文的个数没有限制
- 每次某个函数被调用,就会有个新的执行上下文为其创建,即使是调用的自身函数,也是如此。
四、变量对象与活动对象
js是单线程语言,在浏览器中一个页面永远只有一个线程在执行js脚本代码。js引擎执行过程主要分为三个阶段,分别是语法分析,预编译和执行阶段:
- 语法分析: 分别对加载完成的代码块进行语法检验,语法正确则进入预编译阶段;分析该js脚本代码块的语法是否正确,如果出现不正确会向外抛出一个语法错误(syntaxError),停止改js代码的执行,然后继续查找并加载下一个代码块;如果语法正确,则进入到预编译阶段。
- 预编译:通过语法分析阶段后,进入预编译阶段,则创建变量对象(创建arguments对象(函数运行环境下),函数声明提前解析,变量声明提升),确定作用域链以及this指向。
- 执行阶段:创建变量对象完成后,执行代码
当调用一个函数时,首先对函数进行语法分析,如果没有语法错误,就进入预编译阶段,一个新的执行上下文就会被创建,后被压入函数调用栈。而一个执行上下文的生命周期可以分为两个阶段。
- 创建阶段 在这个阶段中,执行上下文会分别创建变量对象,建立作用域链,以及确定this的指向。
- 代码执行阶段 创建完成之后,就会开始执行代码,这个时候,会完成变量赋值,函数引用,以及执行其他代码。
什么是变量对象(VO, Variable Object)
变量对象的创建,依次经历了以下几个过程。
- 建立arguments对象。检查当前上下文中的参数,建立该对象下的属性与属性值。
- 检查当前上下文的函数声明,也就是使用function关键字声明的函数。在变量对象中以函数名建立一个属性,属性值为指向该函数所在内存地址的引用。如果函数名的属性已经存在,那么该属性将会被新的引用所覆盖。
- 检查当前上下文中的变量声明,每找到一个变量声明,就在变量对象中以变量名建立一个属性,属性值为undefined。如果该变量名的属性已经存在,为了防止同名的函数被修改为undefined,则会直接跳过,原属性值不会被修改。
所以所谓的变量提升,就是在变量对象创建的过程中,首先把检查var
,function
声明的变量,在变量对象中分别创建以变量名、函数名为属性名的属性,属性值为undefined
。并且根据变量对象创建的过程来看,函数声明的检查是处在变量声明的检查之前的,所以function
的提升会优先于var
的提升。
var、function 与 let、const
都知道var,function存在变量提升,而存在let、const声明的是块级作用域,不存在变量提升,所以let和const会存在暂时性死区的情况,即在变量声明之前使用会抛出 "x is not defined" 的错误。如下
let x = 'global'
{
console.log(x) // Uncaught ReferenceError: x is not defined
let x = 1
}
但是根据变量对象创建的过程,在创建了argument对象后,就会检查函数声明和变量声明,也就是说let是会变量提升的。那么怎么理解块级作用域里暂时性死区的存在呢?知乎专栏:我用了两个月的时间才理解 let 给出了对于这个问题的思考。
对于let的变量创建、初始化和赋值过程,可以理解为以下几步:
{
let x = 1
x = 2
}
- 找到所有用 let 声明的变量,在环境中「创建」这些变量
- 开始执行代码(注意现在还没有初始化)
- 执行 x = 1,将 x 「初始化」为 1(这并不是一次赋值,如果代码是 let x,就将 x 初始化为 undefined)
- 执行 x = 2,对 x 进行「赋值」
所以说在初始化之前,变量是不能被访问的,所以一旦被访问,就会抛出错误。而对于var声明的变量,创建过程中自动初始化为undefined,所以是可以访问的,function声明的函数,在创建过程中自动初始化为为指向函数的指针。对于const来说,由于它是不能被赋值的,所以只存在创建和初始化两个过程,与let相同的是在环境中创建了变量后没有立即初始化,而是等到函数执行了以后进行初始化。
什么是活动对象(AO, Active Object)
变量对象是在执行上下文创建过程中创建的一个保存变量声明的对象,未进入执行阶段之前,变量对象中的属性都不能访问,但是进入执行阶段之后,变量对象转变为了活动对象(AO),里面的属性都能被访问了,然后开始进行执行阶段的操作。
VO和AO的区别来看,它们其实都是同一个对象,只是处于执行上下文的不同生命周期。不过只有处于函数调用栈栈顶的执行上下文中的变量对象,才会变成活动对象。全局上下文中的变量对象,就是window对象,this也指向window。
五、作用域
在传统编译语言的流程中,程序中的一段源代码在执行之前会经历三个步骤,统称为“编译”。
- 分词/词法分析(Tokenizing/Lexing):这个过程会将由字符组成的字符串分解成(对编程语言来说)有意义的代码块,这些代 码块被称为词法单元(token)。例如,考虑程序var a = 2;。这段程序通常会被分解成 为下面这些词法单元:var、a、=、2 、;。空格是否会被当作词法单元,取决于空格在 这门语言中是否具有意义。
- 解析/语法分析(Parsing): 这个过程是将词法单元流(数组)转换成一个由元素逐级嵌套所组成的代表了程序语法结构的树。这个树被称为“抽象语法树”(Abstract Syntax Tree,AST)。
- 代码生成:将 AST 转换为可执行代码的过程称被称为代码生成。这个过程与语言、目标平台等息息相关。
但是相比于上述的编译语言来说,JS引擎要复杂得多。例如,在语法分析和代码生成阶段有特定的步骤来对运行性能进行优化,包括对冗余元素进行优化等。
什么是作用域
在JavaScript中,我们可以将作用域定义为一套规则,这套规则用来管理引擎如何在当前作用域以及嵌套的子作用域中根据标识符名称进行变量查找。
JS所采用的的作用域模型是词法作用域。在编译步骤中,首先进行的是词法分析,词法作用域就是定义在词法阶段的作用域,也就是说,词法作用域是由在写代码时将变量和块作用域写在哪里来决定的,因此当词法分析器处理代码时会保持作用域不变(大部分情况下是这样的)。
作用域与执行上下文的区别
- JavaScript代码的整个执行过程,分为两个阶段,代码编译阶段与代码执行阶段。编译阶段由编译器完成,将代码翻译成可执行代码,这个阶段作用域规则会确定。
- 执行阶段由引擎完成,主要任务是执行可执行代码,执行上下文在这个阶段创建。
作用域链
作用域链,是由当前环境与上层环境的一系列变量对象组成,它保证了当前执行环境对符合访问权限的变量和函数的有序访问。作用域查找始终从运行时所处的最内部作用域开始,逐级向外或者说向上进行,直到遇见第一个匹配的标识符为止,此时作用域查找停止。这种作用域查找方式以链式呈现,所以称为作用域链。
六、闭包
闭包是一种特殊的对象。它由两部分组成。执行上下文(代号A),以及在该执行上下文中创建的函数(代号B)。B执行时,如果访问了A中变量对象中的值,那么闭包就会产生。一个例子:
var fn = null;
function foo() {
var a = 2;
function innnerFoo() {
console.log(a);
}
fn = innnerFoo; // 将 innnerFoo的引用,赋值给全局变量中的fn
}
function bar() {
fn(); // 此处的保留的innerFoo的引用
}
foo();
bar(); // 2
如果闭包不存在的话,也就是 innerFoo() 的引用没有赋值给fn,在 foo() 执行后,foo()的执行上下文弹出函数调用栈,因为其中的变量和函数没有被引用,就被垃圾回收器认定为垃圾进行销毁。但是在 foo() 中,全局执行上下文中的fn获取了 innerFoo() 的引用,也就是说,innerFoo() 仍然在执行环境中,那么根据浏览器的垃圾回收机制,它就不会被当做垃圾回收。而 innerFoo() 中能够访问的变量(包括它自己的作用域中的变量和它作用域链上的变量)同样会被引用,所以也不会被回收。所以,通过闭包,我们可以在其他的执行上下文中,访问到函数的内部变量。
虽然上述例子中的闭包被保存在了全局变量中,但是闭包的作用域链并不会发生任何改变。在闭包中,能访问到的变量,仍然是作用域链上能够查询到的变量。
var fn = null;
function foo() {
var a = 2;
function innnerFoo() {
console.log(c); // 在这里,试图访问函数bar中的c变量,会抛出错误
console.log(a);
}
fn = innnerFoo; // 将 innnerFoo的引用,赋值给全局变量中的fn
}
function bar() {
var c = 100;
fn(); // 此处的保留的innerFoo的引用
}
foo();
bar();
innerFoo() 的作用域链,仍然是innerFoo -> foo -> window,这是在词法分析的时候就已经确定了,词法作用域是静态作用域。
七、this
在执行上下文的创建阶段,会分别生成变量对象,建立作用域链,确定this指向。其中变量对象与作用域链我们都已经仔细总结过了,而这里的关键,就是确定this指向。
this的指向,是在函数被调用的时候确定的。并且在函数执行过程中,this一旦被确定,就不可更改了。如果调用者函数,被某一个对象所拥有,那么该函数在调用时,内部的this指向该对象。如果函数独立调用,那么该函数内部的this,则指向undefined。但是在非严格模式中,当this指向undefined时,它会被自动指向全局对象
this有四种绑定方式:
- 默认绑定:当作为普通函数调用时,this指向全局对象window,严格模式下this为undefined。
- 隐式绑定:当作为对象的方法被调用时,就会发生隐式绑定,this指向调用该方法的对象。
'use strict'
var a = 20;
var obj = {
a: 10,
c: this.a + 20,
fn: function () {
return this.a;
}
}
console.log(obj.c); // 40
console.log(obj.fn()); //10
因为单独的{}
是不会形成新的作用域的,因此c: this.a + 20
这里的this.a,由于并没有作用域的限制,所以它仍然处于全局作用域之中。所以这里的this其实是指向的window对象。而fn中的this由于存在调用函数的作用域,this绑定的是obj对象,所以console.log(obj.fn());
的结果是obj中的a。
另一个例子:
'use strict';
var a = 20;
function foo () {
var a = 1;
var obj = {
a: 10,
c: this.a + 20,
fn: function () {
return this.a;
}
}
return obj.c;
}
console.log(foo()); // 报错
console.log(window.foo()); // 40
在严格模式下,函数独立调用,那么该函数内部的this,则指向undefined。所以console.log(foo()) 会报错;而console.log(window.foo()) 中,foo被window调用,所以this绑定的是全局变量 a=20 。如果把 "use strict" 去掉,则两个log打印的都是40。
另外需要注意的是,this只会绑定离它最近的对象,也就是调用该方法的对象。
var name = "freedom";
var obj = {
name = "father",
getName: function() {
return this.name;
},
child = {
name = "child",
getName: function() {
return this.name;
}
}
}
console.log(obj.getName()); //father
console.log(obj.child.getName()); //child
- 显式绑定:调用call(), apply(), bind()时,就能指向this要绑定的对象。显示绑定的优先级仅次于new绑定
- 构造函数绑定:当用new运算符来调用构造函数时,会创建一个新的对象,构造函数中的this会与这个新的对象绑定在一起。
call,apply,bind区别
- 三者都是用于显式绑定this
- call 和 apply的区别是,call传入的是参数序列,而apply传入的是参数数组:
call(obj, ...args), apply(obj, args) - call 和 bind传入的参数是一样的,但是bind返回的是函数,需要调用:
call(obj, ...args), bind(obj, ...args)() 。所以一般bind可以用在回调函数绑定this中。
new运算符做了些什么
var mynew = function(){
// 获取参数上下文的第一个参数
var func = Array.prototype.shift.call(arguments);
// 判断第一个参数是否是函数,不是函数则抛出错误
if (typeof func !== 'function') {
throw 'the first argument must be function';
}
// 创建一个对象,绑定func原型
var obj = Object.create(func.prototype);
// 执行构造函数,绑定this
var ret = func.bind(obj,...arguments)();
// 返回对象
return (ret instanceof Object)? ret:obj;
}
参考文献
https://yangbo5207.github.io/wutongluo/
《你不知道的JS(上)》