JavaScript的垃圾收集機制

zhangfaliang發表於2019-03-19

小夥伴們,好久不見最近偷懶一直沒有更新文章,今天我就給大家分享一下,我在紅皮書上看到的“JavaScript的垃圾回收機制”

  • 作用域

  • 垃圾收集

    • 標記清除
    • 引用計數
    • 效能問題
    • 管理記憶體

小夥伴們我們們想要透徹的瞭解垃圾回收機制,那麼我們們就需要知道為什麼要有存在垃圾回收機制,垃圾回收機制是用來解決的什麼,帶著這些疑問我們們看下面的文章。

作用域

什麼是執行環境? 執行環境是JavaScript中最為重要的一個概念,執行環境定義了變數或者函式有權訪問的其他資料,決定了它們各自的行為。每個執行環境都有一個與之關聯的變數物件。環境中定義的所有變數和函式都儲存在這個物件中。雖然我們編寫程式碼的時候無法訪問這個物件,但是解析器在處理資料時會在後臺使用這個變數。

全域性執行環境是最外圍的一個執行環境,根據ECMAScript實現所在的宿主環境不同,表示執行環境的物件也不一樣。在web瀏覽器全域性被認為是window物件,因此所有的全域性變數和函式都是作為window物件的屬性和方法建立的。某個執行環境中的所有程式碼執行完畢後,該環境被銷燬,儲存在其中所有變數和函式定義隨之被銷燬(當瀏覽關閉或者關閉網頁的時候window就會被銷燬)。

每個函式都有自己的執行環境。當執行流進入一個函式時,函式的環境會被推入一個環境棧中。這個環境棧中就是作用域,而在函式執行之後,棧將其環境彈出,把控制權返回給之前的執行環境。

當程式碼在一個環境中執行時,會建立變數物件的一個作用域鏈,作用域鏈的用途,是保證對執行環境有權訪問的所有變數和函式是有序的訪問。作用域鏈的前端,始終都是當前執行的程式碼所在環境的變數物件。如果這個環境是函式,則將其活動物件作為變數物件。活動物件在最開始時只包含一個變數,即arguments物件(這個物件在全域性環境中是不存在的)。作用域鏈中的下一個變數物件來自包含(外部)環境,而在下一個變數物件則來自下一個包含環境。這樣一種延續到全域性執行環境;全域性執行環境的變數物件始終都是作用域鏈中的最後一個物件。

簡單來說其實作用域鏈是大的作用域中巢狀小的作用域,小的作用域巢狀更小的作用域,最小的作用域可以訪問它的父級作用域裡面的變數和方法,但是不可以訪問父級同級的作用域,簡單來說在最小的一個作用域中去查詢當前最小作用域中沒有的變數,那麼就會去最小作用域父級的環境中去查詢,如果能找到就直接使用,如果沒有的話接著去父級的父級去查詢,直到window前端最大作用域下去查詢,查不到的話,就會出問題(在使用的情況下,賦值的就會在window下面直接新增一個物件,且不會報錯),但是父級的不能訪問子級的變數

const color = 'blue';
const changeColor =()=>{
    const processColor={
        'red':'blue',
        'blue':'red'
    }
    return processColor(color)||''
}
console.log("Color is now " + changeColor());
複製程式碼

在這個簡單示例中,函式changeColor()的作用域包含兩個物件:它自己的變數物件(本身自帶arguments物件)和全域性環境的變數物件。可以在函式內部訪問外部變數color,就是因為可以在這個作用域中找到它。

此外,在區域性定義的變數可以和全域性的變數相互使用,如下面這個例項:

let color='blue'
const changeColor =()=>{
    const autherColor = 'red';
    const swapColor = ()=>{
        let tempColor = autherColor;
        aitherColor = color;
        //這是最小的作用域 可以訪問父級所有變數
    }
    // 這裡可以訪問父級 color變數和自己的autherColor、swapColor變數
    swapColor()
}
changeColor()
這裡只能訪問color

複製程式碼

以上程式碼共涉及3個執行環境: 全域性環境、changeColor的區域性環境和swapColor()的區域性環境。全域性環境中有一個變數Color和一個函式changeColor()。changeColor()的區域性環境中包含autherColor變數和swapColor函式,但它可以訪問全域性環境中的變數color,swapColor()的區域性環境只有一個變數tempColor,該變數只能在本環境中訪問到。無論是全域性環境還是changeColor的區域性環境都無權訪問tempColor,然而swapColor可以訪問其他兩個環境變數,因為這個全域性和changeColor都是swapChange的父級環境。看下面作用域鏈圖

JavaScript的垃圾收集機制
JavaScript的垃圾收集機制

上面的矩形圖表示特定的執行環境。其中,內部環境可以通過做作用域鏈訪問所有外部環境的變數,但是外部的不能訪問內部的任何變數和函式。這些環境之間的聯絡是線性、有次序的。每個環境都可以向上搜尋作用域鏈,來查詢變數和函式名;但是任何環境不能通過向下搜尋作用域鏈而進入另一個執行環境。 函式引數也被當做變數來對待,因此其訪問規則和環境變數相同

沒有塊級作用域

JavaScript沒有塊級作用域經常導致理解上的困惑。在其他類c的語言中,又花括號封閉的程式碼塊都有自己的作用域,因而支援根據條件來定於變數。例如,下面的程式碼在JavaScript中並不會得到想要的結果

if(true){
    var color = 'blue'
}
alert(color) // blue 呼叫alert都會做一操作 "變數.toString()""
複製程式碼

這裡是一個if語句中定義了變數color。如果在c、c++或者java中,color會在if語句執行完畢被銷燬。但是在javaScript中,if語句中的變數會將變數新增到當前的執行環境中。在使用for語句時尤其要牢記一差異,例如

for(var i=0;i<10;i++){
    dosomething(i);
}
alert(color)// 10 呼叫alert都會做一操作 "變數.toString()""
複製程式碼

對於塊級作用域的語言來說,for語句初始化變數的表示式所定義的變數,只會存在在於迴圈的環境變數中。而JavaScript來說,由for語句建立的變數i即使在for結束後,也依舊存在於迴圈外部的執行環境

1.宣告變數

使用var宣告變數會自動被新增到最近的環境中。在函式內部,最近的環境就是函式的區域性環境;如果初始化變數時沒有使用var宣告,該變數就會自動被新增到全域性環境,如下

function add (num1,num2){
    var sum=num1+num2;
    return sum;
}
var result  = add (10,20)// 30
alert(sum) // 由於sum不是有效的變數,會導致錯誤
複製程式碼

以上程式碼中的函式add()定義了sum區域性變數,該變數包含加法操作的結果。雖然結果值從函式中返回了,但變數sum在函式外部訪問不到的。如果省略這個例子中的var關鍵字,那麼當add()執行完畢後,那麼sum也將可以訪問到;

function add(num1,num2){
    sum = num1+num2;
    return sum;
}
var result = add(10,20);//30
alert(sum);//30
複製程式碼

這樣例子中的變數sum在被初始化賦值時沒有使用var關鍵字。於是,當呼叫完add()之後,新增到全域性環境的變數sum將繼續存在;即使函式已經執行完畢,後面的程式碼依舊可以訪問它。在編寫JavaScript程式碼過程中,不宣告而直接初始化變數是一個常見的錯誤做法,因為會導致意外。我們建議在初始化變數之前,一定要先宣告,這樣就可以避免類似問題。在嚴格模式下,初始化未經宣告的變數會導致錯誤

查詢識別符號

當中某個環境中為了讀取或寫入而引入識別符號時,必須通過搜尋來確定該識別符號實際代表什麼。搜尋過程從作用域的前端開始,(當前作用域最底層),向上逐級查詢與給定名字匹配的識別符號。如果在區域性環境中找到該識別符號,搜尋停止,變數就緒,如果區域性環境中沒有找到該變數名,則繼續沿作用域鏈上搜尋。搜尋過程將一直追溯到全域性環境的變數物件。如果全域性環境中也沒有找到這個識別符號,則意味著該變數尚未宣告

通過下面示例,可以理解查詢識別符號的過程:

var color = 'blue';
function getColor(){
    retur color;
}
console.log(getColor().toString())//blue
console.log(getColor().toString())===alert(getColor())
複製程式碼

呼叫本例中的函式getColor()時會引用變數color。為了確定color的值,將開始一個兩步的搜尋過程。首先,搜尋getColor的變數物件,查詢其中是否包含一個名為color的識別符號。在沒有找到的情況下,搜尋繼續到下一個變數物件(全域性變數的變數物件),然後在哪裡找到了名為color的識別符號,搜尋過程宣佈結束。

JavaScript的垃圾收集機制
JavaScript的垃圾收集機制
在搜尋過程中,如果存在一個區域性的變數定義,則就會停止搜尋,不會在進入下一個變數物件。如果在當前的區域性變數找到就不會去父級環境中查詢

垃圾回收集

JavaScript 具有自動垃圾收集機制,也就是說,執行環境會負責管理程式碼執行過程中使用的記憶體。而在c、c++之類的語言中,開發人員的一項基本的任務就是手動跟蹤記憶體的使用情況,這是創造許多問題的一個根源。在編寫JavaScript程式時,開發人員不用在擔心記憶體問題,所需記憶體的分配以及無用記憶體的回收完全實現了自動管理。這種垃圾回收機制的原因含簡單:找到那些不在繼續使用的變數,然後釋放佔其記憶體。為此,垃圾回收集器會按照固定的時間間隔(或程式碼執行中預定的收集時間),週期性的執行這一操作。

下面我們來分析一下函式中區域性變數的生命週期。區域性變數只在函式執行的過程中存在。而這個過程中,會為區域性變數在棧(或堆)記憶體上分配相應的空間,以便儲存它們的值。然後在函式中使用這些變數,直到函式執行結束。此時,區域性變數就沒有存在的必要;單非所有的情況下都這麼容易就能得出結論。垃圾回收器必須跟蹤那個變數有用那個變數沒有用,對於不在有用的變數打上標記,以備將來收回其佔用的記憶體。由於標識無用變數的策略會導致異常,但是瀏覽器中的實現,則通常有兩個策略。

標記清楚

JavaScript中最常用的垃圾收集方式是標記清楚(mark-and-sweep)。當變數進入環境(例如,在函式中宣告一個變數)時,就將變數標記為“進入環境”。邏輯上講,永遠不能釋放進入環境的變數所佔的記憶體,因為只要執行流入相應的環境,就有可能會用到它們,而變數離開環境時,將其標註為“離開環境”。

可以使用任何的方式來標記變數。比如,可以通過翻轉某個特殊的位來記錄一個變數發生的變化。說到底,如何標記一個變數其實並不重要,關鍵的在於採用什麼策略。

垃圾收集器在執行的時候會給存在記憶體的所有變數都加上標記(當然,可以使用任何標記方式)。然後,它會去掉環境中的變數以及被環境中的變數引用的變數標記。而在此之後再被加上標記的變數將視為準備刪除的變數,原因是環境中的變數已經無法訪問到這個變數了。最後,垃圾收集器完成記憶體清楚的工作,銷燬那些帶標記的值並回收它們所佔用的記憶體空間。2008年為止,IE、Firefox、Opera、Chrome和Safari的JavaScript實現使用的都是標記清楚方式的垃圾收集策略,只不過垃圾收集的時間間隔有所不同

引用計數

另一種不太常見的垃圾收集策略叫做引用計數(reference counting)。 引用計數的含義是跟蹤記錄每個值被引用的次數。當宣告一個變數並將一個引用型別的值賦給變數時,則這個值的引用次數就是1.如果同一個值被賦值給另一個變數,則該值的引用次數加1.相反,如果包含對這個值引用的變數又取得了另一個值,則這個值的引用次數減1。當這個值的引用次數變成0時,則說明沒有辦法再次訪問這個值了,因而就可以將其佔用的記憶體空間回收回來。這樣,當垃圾收集器下次執行的時,它就會釋放那些引用次數為0的值所佔用的記憶體。

Netscape Navigator3.0 是最早使用引用技術策略的瀏覽器,但是很快就發現一個嚴重的問題:迴圈引用。迴圈引用指的是物件A中包含物件B的指標,而物件B中也包含一個指標物件A的引用。看下面的例子

function problem(){
    var objectA = new Object();
    var objectB = new Object();
    objectA.someOtherObject = objectB;
    obhectB.someOtherObject = objectA;
}
複製程式碼

在這個例子中,objectA 和 objectB 通過各自的屬性相互引用;也就是說,這兩個物件的引用次數都是2.在採用標記清楚策略的實現中,由於函式執行後,這兩個物件都離開了作用域,因此這種相互引用不是問題,但是採用計數策略的實現中,當函式執行後,objectA和objectB還將繼續存在,因為他們的引用次數永遠不會是0.加入這個函式被重複多次呼叫,就會導致大量的記憶體得不到回收。為此。Netscape Navigator4.0 放棄了引用計數方式,轉而採用了標記清楚來實現其垃圾回收機制,可是引用計數導致的麻煩並未就此終結。

我們知道,IE中有一部分物件並不是原生JavaScript物件,例如, BOM、DOM中的物件就是使用c++以COM(Component Object Model ,元件物件模型)物件的形式實現的,而COM物件的垃圾回收集機制採用的就是引用計數策略。因此,即使IE的JavaScript引擎使用標記清除策略來實現的,單數JavaScript訪問COM物件依然是基於計數策略的。只要是涉及到COM物件的使用就有可能存在迴圈引用的問題,為了解決避免這個迴圈引用的問題,在IE9一下的版本不要是類似一下的操作

var element = document.getElementById('some_elelment');
var myObject = new Object();
myObject.element = element;
element.someObject =element 
// 如果使用最好有一下操作 ,手動清楚
myObject=null;
element=null;
複製程式碼

為了解決上面的問題 IE9把BOM和DOM物件都轉換成了真正的JavaScript物件,這樣就避免了兩種垃圾收集演算法並存導致的問題,也消除了常見的記憶體洩露。

效能問題

垃圾收集器是週期性執行的,而且如果變數分配的記憶體數量很可觀,那麼回收工作也是相當大的。這種情況下,確定垃圾收集時間間隔是一個非常重要的問題。說道垃圾收集器多長時間執行一次,不禁讓人聯絡到IE因此而名聲狼藉的效能問題。IE的垃圾收集器是根據記憶體分配執行的,具體一點就是256個變數,4096個物件(或者陣列)字面量和陣列元素(slot)或者64kb的字串。達到上述任何一個臨界值,垃圾收集器就會執行。這種實現方式的問題在於,如果一個指令碼包含那麼多變數,那麼該指令碼很有可能會在其生命週期中一直保有那麼多的變數。這樣一來,垃圾收集器不得不頻繁地執行。結果,由此引發的嚴重效能問題促使IE7重新寫了其垃圾收集例程。

隨著IE7的釋出,JavaScript引擎的垃圾收集例程改變了工作方式:觸發垃圾收集的變數分配、字面量和陣列元素的臨界值被調整為動態修正。IE7中的各項臨界值在初始化的與IE6相等。如果垃圾收集例程回收的記憶體分配量低於15%,則變數、字面量和陣列元素的臨界就會加倍。如果回收例程在85%的記憶體分配量,則將各種臨界值重置到預設值。這一看似簡單的調整,也極大地提升了IE在執行包含大量JavaScript的頁面時的效能,實際上,在有的瀏覽器上可以觸發垃圾收集過程,但我們不建議讀者這樣做。在IE中,呼叫window.CollectGarbage()方法就會立即執行垃圾收集。在Opera7以及更高的版本中,呼叫window.opera.collect()也會啟動垃圾收集歷程

管理記憶體

使用具備垃圾收集機制的語言編寫程式,開發人員一般不必操心記憶體管理的問題,但是,JavaScript在進行記憶體管理及垃圾收集時面臨的問題還是有點與眾不同的。其中一個主要的問題,就是分配web瀏覽器可用記憶體的數量通常要比分配桌面應用程式少。這樣做的目的主要是出於安全方面的考慮,目的是防止執行JavaScript的網頁耗盡全部系統記憶體而導致系統崩潰。記憶體限制問題不僅會影響給變數分配記憶體,同時還影響呼叫棧以及在一個執行緒中能夠同時執行的語句數量。

因此,確保佔用最少的記憶體可以讓頁面獲得更好的效能。而優化記憶體佔用的最佳方式,就是為執行中的程式碼只儲存必要的資料。一單不在使用,最好使用null來釋放其引用----這個叫做解除引用(dereferencing)。這一做法適用於大多數全域性變數和全域性物件。區域性變數會在它們離開執行環境時自動被解除引用,如下面的例子:

function createPerson(name){
    var localPerson = new Object();
    localPerson.name = name;
    return localPerson;
}
var globalPerson = createPerson('Nicholas');
// 手動解除 globalPerson的引用
globalPerson = null;
複製程式碼

在這個例子中,變數globalPerson取得了createPerson()函式返回的值。在createPerson()函式內部,我們建立了一個物件並將其賦給區域性變數localPerson,然後又為該物件新增一個屬性name。最後,當呼叫這個函式時,localPerson以函式值的形式返回並賦值給全域性變數globalPerson。由於localPerson在createPerson()執行完畢後就離開了其執行環境,因此無需我們顯式地去為它解除引用。但是對於全域性變數globalPerson而言,則需要我們在不使用它的時候手工為它解釋引用,這也是上面例子中最後一行程式碼的目的。

不過,解除一個值的引用並不意味著自動回收該值所佔用的記憶體。解除引用的真正作用是讓值脫離執行環境,以便垃圾收集器下次執行時將其收回。

小結 JavaScript變數可用來儲存兩種型別的值:基本型別值和應用型別值,基本型別的值源自以下5中基本型別:Undefined、Null、Boolean、Number、String。基本型別值和引用型別值有一下特點:

  • 基本型別值在記憶體中佔據固定大小的空間,因此儲存在棧記憶體中
  • 從一個變數向另一個變數複製基本型別的值,會建立這個值的副本;
  • 引用型別的值是物件,儲存在堆記憶體中中;
  • 包含引用型別值的變數實際上包含的並不是物件的本身,而是一個指向該物件的指標;
  • 從一個變數向另一個變數複製引用型別的值,複製的其實是指標,因此兩個變數最終指向同一個物件

相關文章