前言
根據GitHut stats的統計資料顯示,javascript語言在Github中的活躍專案倉庫數量和總的push數量已經登上了榜首的位置,而且在越來越多的領域裡我們都能看見javascript持續活躍的身影和不斷前行的腳步
儘管我們正在越來越多的編寫Javascript程式碼,但是我們不一定真的瞭解它。編寫本系列專欄的目的就是深入到javascript的底層,瞭解其執行原理,幫助我們寫出更高效的程式碼,減少一些不必要的bug.
javascript程式碼的執行分為3個部分:runtime, js engine, event loop,執行時(runtime)提供了window,dom等API注入,js引擎負責記憶體管理,程式碼編譯執行,事件迴圈則負責處理我們的非同步邏輯,具體如下圖所示:
在這篇文章中我將主要探討javascript記憶體管理,呼叫棧以及如何處理記憶體洩漏問題。 在後續的文章中我會繼續介紹事件迴圈以及js引擎的執行機制。
記憶體管理
javascript自帶垃圾回收機制,它可以自動分配記憶體並回收不再使用的記憶體,這也使很多開發者認為我們沒有必要去關注js的記憶體管理。但是我相信我們在平時開發過程中都或多或少的遇到過記憶體洩漏問題,理解javascript的記憶體管理機制可以幫助我們解決此類問題並寫出更好的程式碼,而且作為一名程式設計師,我們也應當保持足夠的好奇心去了解我們寫出的程式碼在底層的執行原理。
記憶體是什麼
在進入具體探討之前,我們先來看下記憶體到底是什麼。記憶體從物理意義上是指由一系列電晶體構成的可以儲存資料的迴路,從邏輯的角度我們可以將記憶體看作是一個巨大的可讀寫的位元陣列。它儲存著我們編寫的程式碼以及我們在程式碼中定義的各類變數。對於很多靜態型別程式語言來說,在程式碼進入編譯階段時編譯器會根據變數宣告時指定的型別提前申請分配給該變數的記憶體(比如,整型變數對應的是4個位元組,浮點數對應8個位元組)。記憶體區域分為棧空間和堆空間兩部分,對於可以確定大小的變數,它們會被儲存在棧空間中,比如:
int n;
// 4 bytesint x[4];
// array of 4 elements, each 4 bytesdouble m;
// 8 bytes複製程式碼
還有一種型別的變數,不能在編譯階段就確定其需要多大的儲存區域,其佔用記憶體大小是在執行時確定的,比如:
int n = readInput();
// n的大小依賴於使用者的輸入複製程式碼
對於這一型別的變數,它們會被儲存在堆空間中。記憶體靜態分配(static allocation)與動態分配(Dynamic allocation)的區別如下所示:
記憶體生命週期
對於任何程式語言,記憶體的生命週期都基本一致,如下所示:
- 分配記憶體,在一些底層語言,比如c語言中我們也可以通過malloc() 和 free()函式來手動完成記憶體的分配和釋放。在javascript中這個過程是在我們做變數宣告賦值時自動完成的,比如:
var n = 374;
// allocates memory for a numbervar s = 'sessionstack';
// allocates memory for a string var o = {
a: 1, b: null
};
// allocates memory for an object and its contained valuesvar a = [1, null, 'str'];
// (like object) allocates memory for the // array and its contained valuesfunction f(a) {
return a + 3;
} // allocates a function (which is a callable object)// function expressions also allocate an objectsomeElement.addEventListener('click', function() {
someElement.style.backgroundColor = 'blue';
}, false);
// 函式呼叫觸發的記憶體分配var d = new Date();
// allocates a Date objectvar e = document.createElement('div');
// allocates a DOM element // 方法的呼叫也可以觸發var s1 = 'sessionstack';
var s2 = s1.substr(0, 3);
// s2 is a new string// Since strings are immutable, // JavaScript may decide to not allocate memory, // but just store the [0, 3] range.var a1 = ['str1', 'str2'];
var a2 = ['str3', 'str4'];
var a3 = a1.concat(a2);
// new array with 4 elements being// the concatenation of a1 and a2 elements複製程式碼
- 使用記憶體,變數完成記憶體分配之後我們的程式才可以使用它們,做一些讀或寫的操作。
- 釋放記憶體,當程式不需要再使用某些變數時,它們佔用的記憶體就會進行釋放,騰出空間。這裡最大的問題是如何判定哪些變數是需要被回收的,對於像javascript這樣的高階語言來說記憶體釋放過程是由垃圾回收器完成的,它用於確定可回收記憶體的方法主要有兩種:引用計數與標記清除。
引用計數
在討論該演算法前,我們先來看下什麼是引用(reference)。所謂引用是指一個物件與另一個物件的連線關係,如果物件A可以隱式或顯式的訪問物件B,那麼我們就可以說物件A擁有一個對物件B的引用,比如在javascript中一個object可以通過__proto__訪問到其prototype物件(隱式),也可以直接訪問其屬性(顯式)。
對於引用計數演算法來說,它判定一個目標是可以被回收的標誌就是該目標不再存在與其他物件的引用關係,比如:
var o1 = {
o2: {
x: 1
}
};
// 2 objects are created. // 'o2' is referenced by 'o1' object as one of its properties.// None can be garbage-collectedvar o3 = o1;
// the 'o3' variable is the second thing that // has a reference to the object pointed by 'o1'. o1 = 1;
// now, the object that was originally in 'o1' has a // single reference, embodied by the 'o3' variablevar o4 = o3.o2;
// reference to 'o2' property of the object. // This object has now 2 references: one as // a property. // The other as the 'o4' variableo3 = '374';
// The object that was originally in 'o1' has now zero // references to it. // It can be garbage-collected. // However, what was its 'o2' property is still // referenced by the 'o4' variable, so it cannot be // freed.o4 = null;
// what was the 'o2' property of the object originally in // 'o1' has zero references to it. // It can be garbage collected.複製程式碼
在上述示例中,o4就是可以被回收的,引用計數在大多數情況下都是沒什麼問題的,但是當我們遇到迴圈引用它就會遇到麻煩,比如:
function f() {
var o1 = {
};
var o2 = {
};
o1.p = o2;
// o1 references o2 o2.p = o1;
// o2 references o1. This creates a cycle.
}f();
複製程式碼
在上述示例中,o1,o2互相引用,使得彼此都不能被釋放。
標記清除演算法
標記清除判斷某個物件是否可以被回收的標誌是該物件不能再被訪問到。其執行過程總共分為三步:
- 確定根物件:在javascript中根物件主要是指全域性物件,比如瀏覽器環境中的window,node.js中的global。
- 從根物件開始遍歷子屬性,並將這些屬性變數標記為活躍型別,通過根物件不能訪問到的就標記為可回收型別。
- 根據第二步標記出來的結果進行記憶體回收。
標記清除的演算法執行示意圖如下所示:
標記清除的演算法比引用計數更優秀的地方在於它們對於可回收物件的判定方式上,一個物件不存在引用關係可以使該物件不能被訪問到,而反過來則不一定成立,比如之前的迴圈引用問題,當函式執行完成之後,o1,o2這兩個變數都不能通過window查詢到,在標記清除演算法下會被當作可回收型別。
記憶體洩漏
記憶體洩漏是指不再使用的記憶體區域沒有被回收,導致這一塊記憶體區域被白白浪費。雖然我們有前面提到的垃圾回收演算法,但是我們在日常開發過程中仍然會時時遇到記憶體洩漏的問題,主要有以下三種型別:
- 全域性變數
考慮以下這段程式碼:
function foo(arg) {
bar = "some text";
}複製程式碼
在這段程式碼中我們在函式foo裡給變數bar賦值了一個字串,但是我們沒有在該函式作用域內先宣告它,在javascript執行過程中,bar會被掛載到全域性變數window中(假設當前是瀏覽器環境),當作是window的屬性。這帶來的問題是即使函式foo執行完畢,該變數仍然是可訪問到的,其佔用的記憶體不會得到釋放,從而導致記憶體洩漏。
我們在日常開發過程中要儘量避免使用全域性變數,除了汙染全域性作用域的問題,記憶體洩漏也是一個不容忽視的因素。
2. 閉包
內部函式可以訪問其外部作用域內的變數,閉包為我們編寫javascript程式碼帶來前所未有的靈活性,但是閉包也有可能會帶來記憶體洩漏的風險。比如下面這段程式碼:
var theThing = null;
var replaceThing = function () {
var originalThing = theThing;
var unused = function () {
if (originalThing) // a reference to 'originalThing' console.log("hi");
};
theThing = {
longStr: new Array(1000000).join('*'), someMethod: function () {
console.log("message");
}
};
};
setInterval(replaceThing, 1000);
複製程式碼
在函式replaceThing中,函式unused會形成一個閉包並含有對originalThing的引用,一旦replaceThing執行,theThing會被賦予一個物件作為新值,在該物件中也會定義一個新的閉包someMethod, 這兩個閉包是在相同的父級作用域中建立的,因此它們會共享外部作用域。由於someMethod方法可以通過theThing在replaceThing外部訪問到,即使unused沒有被呼叫,它對變數originalThing的引用會使該作用域不會被回收。因為每個閉包作用域都含有對longstr的間接引用,這種狀態下會導致大量的記憶體洩漏。
3. dom引用
先來看下面這一段程式碼:
var elements = {
button: document.getElementById('button'), image: document.getElementById('image')
};
function doStuff() {
elements.image.src = 'http://example.com/image_name.png';
}function removeImage() {
// The image is a direct child of the body element. document.body.removeChild(document.getElementById('image'));
// At this point, we still have a reference to #button in the //global elements object. In other words, the button element is //still in memory and cannot be collected by the GC.
}複製程式碼
在上述程式碼中我們在兩個地方儲存了對image元素的引用,當函式removeImage執行時儘管image元素被刪除,但是全域性變數elements中仍然存在對button元素的引用,記憶體回收時不會將該元素回收。
除此之外還有另一種情況也值得引起注意。如果你存在對某個table cell(td 標籤)的引用,當你在dom樹中刪除它所屬的table但該引用並沒有刪除時也同樣會發生記憶體洩漏,垃圾回收器並不會像你所想的那樣回收所有隻保留cell,而是會將整個table都儲存在記憶體中,因為該table是cell的父節點,該cell依然會保持對其父節點的引用。
呼叫棧(call stack)
呼叫棧是記憶體中的一塊儲存區域,它負責記錄程式當前的執行位置,我們可以通過一個示例來看下呼叫棧的工作模式,先來看如下程式碼:
function multiply(x, y) {
return x * y;
}function printSquare(x) {
var s = multiply(x, x);
console.log(s);
}printSquare(5);
複製程式碼
當這段程式碼開始執行時,呼叫棧會隨著函式的呼叫發生變化
當printSquare被呼叫時它會先進棧,在函式執行過程中呼叫了函式multiply,函式multiply被壓入棧頂,執行完成之後出棧。再來看另外一個示例:
function foo() {
throw new Error('SessionStack will help you resolve crashes :)');
}function bar() {
foo();
}function start() {
bar();
}start();
複製程式碼
當這段程式碼執行時報錯提示如下所示:
根據報錯位置指示的函式名稱,我們可以對整個呼叫棧的順序一目瞭然。
呼叫棧的空間是有限的,當函式呼叫資訊超過該空間大小,就會發生常見的堆疊溢位的錯誤,比如:
function foo() {
foo();
}foo();
複製程式碼
它會不斷的呼叫自身,其呼叫棧儲存示意圖和執行報錯如下所示:
結語
本篇文章主要探討了javascript中的記憶體管理策略,介紹了記憶體的分配,記憶體的回收以及三種容易導致記憶體洩漏的場景還有程式碼執行用到的呼叫棧等等,屬於javascript中比較基礎但卻容易忽視的知識點,希望對您有所幫助。