第四章:变量、作用域和内存问题
本章内容:
- 理解基本类型和引用类型
- 理解执行环境
- 理解垃圾回收机制
4.1 基本类型和引用类型
ECMAScript中的变量包含两种不同类型的值: 基本类型值和引用类型值
基本类型有:Undefined、Null、Boolean、Number、String。
这五种数据类型是按值访问的。
引用类型的值是保存在内存中的对象。 javascript不允许直接访问内存位置。
引用类型的值按引用访问的。
4.1.1 动态属性
// 创建一个引用类型
var person = new Object();
person.name = 'zhangzhuo';
alert(person.name); //zhangzhuo
// 创建一个基本类型
var name = 'zhangzhuo';
alert(name.toUpperCase()); //ZHANGZHUO
name.age = 18;
alert(name.age); //error
这里虽然name能调用String.toUpperCase是因为基本类型自动创建了基本包装类型String的实例。但在该行运行后便清空了。
基本类型不能添加属性。
不可变的基本类型与可变的引用类型
4.1.2 复制变量值
从一个变量从另外一个变量复制基本类型值和引用类型时,也存在不同。
复制基本类型:
var num1 = 20;
var num2 = num1;
num2 = 30;
在变量对象中的数据发生复制行为时,系统会自动为新的变量分配一个新值。var num2 = num1
执行之后,num1与num2虽然值都等于20,但是他们其实已经是相互独立互不影响的值了。具体如图。所以我们修改了num2的值以后,num1的值并不会发生变化。
复制引用类型:
var obj1 = {a:10,b:15};
var obj2 = obj1;
obj1.a = 20;
alert(obj2.a); // 20
我们通过var obj1 = obj2
执行一次复制引用类型的操作。引用类型的复制同样也会为新的变量自动分配一个新的值保存在变量对象中,但不同的是,这个新的值,仅仅只是引用类型的一个地址指针。当地址指针相同时,尽管他们相互独立,但是在变量对象中访问到的具体对象实际上是同一个。如图所示。
因此当我改变obj1时,obj2也发生了变化。这就是引用类型的特性。
4.1.3 传递参数
ECMAScript中所有函数的参数均是按值传递。也就是说,会把函数外部的值复制给函数内部的参数,就把值从一个变量复制给另一个变量相同。
在向参数传递引用类型的时候,其实会把这个值在内存的地址复制给局部变量。
// demo1 传递基本类型
function addTen(num){
num += 10;
return num;
}
var count = 20;
var result = addTen(count);
alert(count); // 20
alert(result); // 30
从demo1可知道,传递的count变量,数字20被复制给了变量num。num的数值增加了10,并不会影响外层的count。
// demo2 传递引用类型
function setName(obj){
obj.name = 'zhangzhuo';
}
var person = new Object();
setName(person);
alert(person.name); // zhangzhuo
person变量的内存值复制给了obj。obj和person指向同一个对象,所以当函数内部改变obj的属性的时候,person也会发生了变化。
证明:对象是传值而不是传引用
//demo3 证明对象是传值
function setName(obj){
obj.name = 'zhangzhuo';
obj = new Object();
obj.name = 'dudu';
}
var person = new Object();
setName(person);
alert(person.name); // zhangzhuo
如果person是传递引用,那么person就会指向name为'dudu'的新对象。但是,访问person.name的时候显示仍然是zhangzhuo。说明对象是传值而非传递引用。
4.1.4 检测类型
检测一个基本类型可以用typeof
:
检测引用类型(判断变量是什么类型的对象),ECMAScript提供了instanceof
(原理:根据原型链来识别)。用法:
result = variable instanceof constructor; // 返回值 true or false
// eg:
alert(person instanceof Object); //变量person是Object吗
alert(colors instanceof Array); //变量colors是Array吗
如果使用instanceof检测基本类型,会返回false。因为基本类型不是对象。
延伸阅读1: 理解内存分配
堆与栈
栈是一种FIFO(Last-In-First-Out)后进先出的数据结构,在javascript中我们可以用Array模拟。
var arr = []; // 创建一个栈
array.push('apple'); // 压入一个元素apple ['apple']
array.push('orange'); // 压入一个元素orange ['apple','orange']
array.pop(); // 弹出orange ['apple']
array.push('banana'); // 压入一个元素banana ['apple','banana']
基本类型值是存储在栈中的简单数据段,也就是说,他们的值直接存储在变量访问的位置。
堆是存放数据的一种离散数据结构,在javascript中,引用值是存放在堆中的。
那为什么引用值要放在堆中,而原始值要放在栈中,不都是在内存中吗,为什么不放在一起呢?那接下来,让我们来探索问题的答案!
function Person(id,name,age){
this.id = id;
this.name = name;
this.age = age;
}
var num = 10;
var bol = true;
var str = "abc";
var obj = new Object();
var arr = ['a','b','c'];
var person = new Person(100,"zhangzhuo",25);
然后我们来看一下内存分析图:
变量num,bol,str为基本数据类型,它们的值,直接存放在栈中,obj,person,arr为复合数据类型,他们的引用变量存储在栈中,指向于存储在堆中的实际对象。
由上图可知,我们无法直接操纵堆中的数据,也就是说我们无法直接操纵对象,但我们可以通过栈中对对象的引用来操作对象,就像我们通过遥控机操作电视机一样,区别在于这个电视机本身并没有控制按钮。
现在让我们来回答为什么引用值要放在堆中,而原始值要放在栈中的问题:
记住一句话:能量是守衡的,无非是时间换空间,空间换时间的问题
堆比栈大,栈比堆的运算速度快,对象是一个复杂的结构,并且可以自由扩展,如:数组可以无限扩充,对象可以自由添加属性。将他们放在堆中是为了不影响栈的效率。而是通过引用的方式查找到堆中的实际对象再进行操作。相对于简单数据类型而言,简单数据类型就比较稳定,并且它只占据很小的内存。不将简单数据类型放在堆是因为通过引用到堆中查找实际对象是要花费时间的,而这个综合成本远大于直接从栈中取得实际值的成本。所以简单数据类型的值直接存放在栈中。
4.2 执行环境和作用域
执行函数
执行环境(execution context, 有的地方也翻译为执行上下文)是javascript中最重要的一个概念。执行环境定义了变量或者函数有权访问的其他数据。每个执行环境都有一个与之关联的变量对象(variable object),环境中所有定义的变量和函数都保存在这个对象中。
全局执行环境是最外围的一个执行环境。
每个函数都有自己的执行环境,当执行流进入一个函数的时候,函数的环境就会被推入一个环境栈中,而这个函数执行完毕后,栈将其环境弹出,把控制权返回之前的执行环境。ECMAScript程序中的执行流就是由这个方便的机制控制着。
延伸阅读2: 理解执行环境
每次当控制器转到可执行代码的时候,就会进入当前代码的执行环境,它会形成一个作用域。JavaScript中的运行环境大概包括三种情况。
- 全局环境:JavaScript代码运行起来会首先进入该环境;
- 函数环境:当函数被调用执行时,会进入当前函数中执行代码 ;
- evel: (不建议使用,忽略);
因此在一个JavaScript程序中,必定会产生多个执行环境,在我的上一篇文章中也有提到,JavaScript引擎会以栈的方式来处理它们,这个栈,我们称其为函数调用栈(call stack)。栈底永远都是全局环境,而栈顶就是当前正在执行的环境。
当代码在执行过程中,遇到以上三种情况,都会生成一个执行环境,放入栈中,而处于栈顶的环境执行完毕之后,就会自动出栈。为了更加清晰的理解这个过程,根据下面的例子,结合图示给大家展示。
执行上下文可以理解为函数执行的环境,每一个函数执行时,都会给对应的函数创建这样一个执行环境。
var color = 'blue';
function changeColor() {
var anotherColor = 'red';
function swapColors() {
var tempColor = anotherColor;
anotherColor = color;
color = tempColor;
}
swapColors();
}
changeColor();
我们用ECStack来表示处理执行环境的的堆栈。我们很容易知道,第一步,首先是全局环境入栈。
全局环境入栈之后,其中的可执行代码开始执行,直到遇到了changeColor()
,这一句激活函数changeColor
创建它自己的执行环境,因此第二步就是changeColor的执行环境入栈。
changeColor的环境入栈之后,控制器开始执行其中的可执行代码,遇到swapColors()
之后又激活了一个执行环境。因此第三步是swapColors的执行上下文入栈。
在swapColors的可执行代码中,再没有遇到其他能生成执行环境的情况,因此这段代码顺利执行完毕,swapColors的环境从栈中弹出。
swapColors的执行环境弹出之后,继续执行changeColor的可执行代码,也没有再遇到其他执行环境,顺利执行完毕之后弹出。这样,ECStack中就只身下全局环境了。
全局上下文在浏览器窗口关闭后出栈。
详细了解了这个过程之后,我们就可以对执行上下文总结一些结论了。
- js是单线程的;
- 同步执行,只有栈顶的环境处于执行中,其他上下文需要等待
- 全局环境只有唯一的一个,它在浏览器关闭时出栈
- 函数的执行环境的个数没有限制
- 每次某个函数被调用,就会有个新的执行环境为其创建,即使是调用的自身函数,也是如此。
为了巩固一下执行环境的理解,我们再来绘制一个例子的演变过程,这是一个简单的闭包例子。
function f1(){
var n=999;
function f2(){
alert(n);
}
return f2;
}
var result=f1();
result(); // 999
因为f1中的函数f2在f1的可执行代码中,并没有被调用执行,因此执行f1时,f2不会创建新的上下文,而直到result执行时,才创建了一个新的。具体演变过程如下。 (入栈相当于要执行代码)
作用域和作用域链
作用域:
- 在JavaScript中,我们可以将作用域定义为一套规则,这套规则用来管理引擎如何在当前作用域以及嵌套的子作用域中根据标识符名称进行变量查找。
- 作用域与执行环境是完全不同的两个概念。我知道很多人会混淆他们,但是一定要仔细区分。
- JavaScript中只有全局作用域与函数作用域(因为eval我们平时开发中几乎不会用到它,这里不讨论)。
JavaScript代码的整个执行过程,分为两个阶段,代码编译阶段与代码执行阶段。编译阶段由编译器完成,将代码翻译成可执行代码,这个阶段作用域规则会确定。执行阶段由引擎完成,主要任务是执行可执行代码,执行上下文在这个阶段创建。
作用域链:
作用域链,是由当前环境与上层环境的一系列变量对象组成,它保证了当前执行环境对符合访问权限的变量和函数的有序访问。
当代码在一个环境中执行的时候,会创建变量对象和一个作用域链(scope chain)。是保证对执行环境有权访问所有变量和函数的有序访问。作用域链的前端,始终是当前的执行环境的变量对象。如果这个环境是函数,则将其变量对象(activation object)作为活动对象。变量对象最开始只包含一个变量,即arguments对象(这个对象在全局环境中是不存在的)。
标识符的解析是沿着作用域链一级一级地搜索标识符的过程。搜索过程始终是从作用域链的前端开始。
var color = 'blue';
function changeColor(){
if(color === 'blue'){
color = 'red';
} else {
color = 'blue';
}
}
changeColor();
alert(color); //red
在这个例子中,函数changeColor的作用域链包含两个对象,它自己的变量对象arguments和全局环境的变量对象。可以在函数内部访问变量color,就是因为可以在作用域链找到它。
延伸阅读3: 作用域与作用域链
在访问一个变量的时候,就必须存在一个可见性的问题,这就是作用域。更深入的说,当访问一个变量或者调用一个函数的时候,javaScript引擎将不同执行位置上的变量对象按照规则构建一个链表。在访问一个变量的时候,先从链表的第一个变量对象中查找,如果没有则在第二个变量对象中查找,直到搜索结束。这也就形成了作用域链的概念。
延伸阅读4: 变量对象详解
当调用一个函数时(激活),一个新的执行环境就会被创建。而一个执行环境的生命周期可以分为两个阶段。
- 创建阶段
在这个阶段中,执行上下文会分别创建变量对象,建立作用域链,以及确定this的指向。
- 代码执行阶段
创建完成之后,就会开始执行代码,这个时候,会完成变量赋值,函数引用,以及执行其他代码。
变量对象(Variable Object)
变量对象的创建,依次经历了以下几个过程。
- 建立arguments对象。检查当前执行环境中的参数,建立该对象下的属性与属性值。
- 检查当前执行环境的函数声明,也就是使用function关键字声明的函数。在变量对象中以函数名建立一个属性,属性值为指向该函数所在内存地址的引用。如果函数名的属性已经存在,那么该属性将会被新的引用所覆盖。
- 检查当前执行环境中的变量声明,每找到一个变量声明,就在变量对象中以变量名建立一个属性,属性值为undefined。如果该变量名的属性已经存在,为了防止同名的函数被修改为undefined,则会直接跳过,原属性值不会被修改。
许多读者在阅读到这的时候会因为下面的这样场景对于“跳过”一词产生疑问。既然变量声明的foo遇到函数声明的foo会跳过,可是为什么最后foo的输出结果仍然是被覆盖了?
function foo() { console.log('function foo') }
var foo = 20;
console.log(foo); // 20
其实只是大家在阅读的时候不够仔细,因为上面的三条规则仅仅适用于变量对象的创建过程。也就是执行环境的创建过程。而foo = 20
是在执行环境的执行过程中运行的,输出结果自然会是20。对比下例。
console.log(foo); // function foo
function foo() { console.log('function foo') }
var foo = 20;
// 上例的执行顺序为
// 首先将所有函数声明放入变量对象中
function foo() { console.log('function foo') }
// 其次将所有变量声明放入变量对象中,但是因为foo已经存在同名函数,因此此时会跳过undefined的赋值
// var foo = undefined;
// 然后开始执行阶段代码的执行
console.log(foo); // function foo
foo = 20;
根据这个规则,理解变量提升就变得十分简单了。
在上面的规则中我们看出,function声明会比var声明优先级更高一点。为了帮助大家更好的理解变量对象,我们结合一些简单的例子来进行探讨。
// demo01
function test() {
console.log(a);
console.log(foo());
var a = 1;
function foo() {
return 2;
}
}
test();
在上例中,我们直接从test()的执行环境开始理解。全局作用域中运行test()
时,test()的执行上下文开始创建。为了便于理解,我们用如下的形式来表示
// 创建过程
testEC = {
// 变量对象
VO: {},
scopeChain: {}
}
// 因为本文暂时不详细解释作用域链,所以把变量对象专门提出来说明
// VO 为 Variable Object的缩写,即变量对象
VO = {
arguments: {...}, //注:在浏览器的展示中,函数的参数可能并不是放在arguments对象中,这里为了方便理解,我做了这样的处理
foo: <foo reference> // 表示foo的地址引用
a: undefined
}
未进入执行阶段之前,变量对象中的属性都不能访问!但是进入执行阶段之后,变量对象转变为了活动对象,里面的属性都能被访问了,然后开始进行执行阶段的操作。
这样,如果再面试的时候被问到变量对象和活动对象有什么区别,就又可以自如的应答了,他们其实都是同一个对象,只是处于执行环境的不同生命周期。不过只有处于函数调用栈栈顶的执行环境中的变量对象,才会变成活动对象。
// 执行阶段
VO -> AO // Active Object
AO = {
arguments: {...},
foo: <foo reference>,
a: 1,
this: Window
}
因此,上面的例子demo1,执行顺序就变成了这样
function test() {
function foo() {
return 2;
}
var a;
console.log(a);
console.log(foo());
a = 1;
}
test();
再来一个例子,巩固一下我们的理解。
// demo2
function test() {
console.log(foo);
console.log(bar);
var foo = 'Hello';
console.log(foo);
var bar = function () {
return 'world';
}
function foo() {
return 'hello';
}
}
test();
// 创建阶段
VO = {
arguments: {...},
foo: <foo reference>,
bar: undefined
}
// 这里有一个需要注意的地方,因为var声明的变量当遇到同名的属性时,会跳过而不会覆盖
// 执行阶段
VO -> AO
VO = {
arguments: {...},
foo: 'Hello',
bar: <bar reference>,
this: Window
}
延伸阅读5: 详细图解作用域链与闭包
作用域链,是由当前环境与上层环境的一系列变量对象组成,它保证了当前执行环境对符合访问权限的变量和函数的有序访问。
为了帮助大家理解作用域链,我我们先结合一个例子,以及相应的图示来说明。
var a = 20;
function test(){
var b = a + 10;
function innerTest(){
var c = 10;
return b + c;
}
return innerTest();
}
console.log(test());
在上面的例子中,全局,函数test,函数innerTest的执行上下文先后创建。我们设定他们的变量对象分别为VO(global),VO(test), VO(innerTest)。而innerTest的作用域链,则同时包含了这三个变量对象,所以innerTest的执行上下文可如下表示。
innerTestEC = {
VO: {...}, // 变量对象
scopeChain: [VO(innerTest), VO(test), VO(global)], // 作用域链
}
我们可以直接用一个数组来表示作用域链,数组的第一项scopeChain[0]为作用域链的最前端,而数组的最后一项,为作用域链的最末端,所有的最末端都为全局变量对象。
很多人会误解为当前作用域与上层作用域为包含关系,但其实并不是。以最前端为起点,最末端为终点的单方向通道我认为是更加贴切的形容。如图。
注意,因为变量对象在执行上下文进入执行阶段时,就变成了活动对象,这一点在上一篇文章中已经讲过,因此图中使用了AO来表示。Active Object
是的,作用域链是由一系列变量对象组成,我们可以在这个单向通道中,查询变量对象中的标识符,这样就可以访问到上一层作用域中的变量了。
小结:
javascript变量可以保存两种类型的值:基本类型值与引用类型值。基本类型的值源于以下五种基本数据类型:Undefined、Null、Boolean、Number、String。基本类型的值与引用类型的值具有以下的特点:
- 基本类型值在内存中占据固定大小空间,因此被保存在栈内存中;
- 从一个变量向另一个变量复制基本类型的值,会创建该值得副本;
- 引用类型的值是对象,保存在堆内存中;
- 包含引用类型的变量实际上包含的并不是对象本身,而是指向该对象的指针;
- 从一个变量向另一个变量复制引用类型的值,复制其实是指针,因此两个变量最终会指向同一个对象;
- 确定一个值是哪种基本类型可以用typeof操作符,而确定一个值是哪种引用类型用instanceof操作符;
所有的变量(包括基本类型和引用类型)都存在一个执行环境中,这个执行环境决定了变量的生命周期,以及哪一部分代码可以访问其中的变量。以下是关于执行环境的总结:
- 执行环境有全局执行环境和函数执行环境之分;
- 每次进入一个新的执行环境,都会创建一个用于搜索变量和函数的作用域链,和一个变量对象;
- 通过作用域链,函数中的执行环境不仅能够访问函数作用域中的变量,而且有权访问其父环境,乃至全局执行环境;
- 变量的执行环境有助于确定应该何时释放内存;
javascript是一门具有自动垃圾回收机制的编程语言,开发人员不必关心内存分配和回收问题。以下有关回收的总结:
- 离开作用域的值被自动标记为可以回收,因此将在垃圾收集期间删除;
-
标记清除
是目前最流行的垃圾回收算法,这种算法的思想是给当前不使用的值加上标记,然后再回收; - 另外一种垃圾收集算法是
引用计数
,这种算法的思想是跟踪记录所有值被引用的次数,IE旧版本使用这种算法; - 当代码存在循环引用的时候,
引用计数
算法就会导致问题; - 接触变量的引用(
x = null
)不仅有助于消除循环引用现象,对垃圾回收也有好处。为了确保有效的回收内存,应该及时解除不再使用的全局对象、全局对象属性以及循环变量的引用。