第四章:變量、作用域和內存問題
本章內容:
- 理解基本類型和引用類型
- 理解執行環境
- 理解垃圾回收機制
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
)不僅有助于消除循環引用現象,對垃圾回收也有好處。為了確保有效的回收內存,應該及時解除不再使用的全局對象、全局對象屬性以及循環變量的引用。