JavaScript是如何工作的(二)

對於任何一個程序員來說, 最關注的兩個問題無非就是:時間複雜度和空間複雜度。第一部分介紹了V8爲改進JavaScript執行時間所做的速度提升和優化, 第二部分則將着重介紹內存管理方面

內存堆

每當你在 JavaScript 程序中定義了一個變量、常量或者對象時,你都需要一個地方來存儲它。這個地方就是內存堆。

當遇到語句 var a = 10 的時候,內存會分配一個位置用於存儲 a 的值

可用內存是有限的,而複雜的程序可能有很多變量和嵌套對象,因此合理地使用可用內存非常重要。

和諸如 C 這種需要顯式分配和釋放內存的語言不同,JavaScript 提供了自動垃圾回收機制。一旦對象/變量離開了上下文並且不再使用,它的內存就會被回收並返還到可用內存池中。

在 V8 中,垃圾回收器的名字叫做 Orinoco,它的處理過程非常高效。

標記與清除算法

 

我們通常會使用這種簡單有效的算法來判定可以從內存堆中安全清除的對象。算法的工作方式正如其名:將對象標記爲可獲得/不可獲得,並將不可獲得的對象清除。

垃圾回收器週期性地從根部或者全局對象開始,移向被它們引用的對象,接着再移向被這些對象引用的對象,以此類推。所有不可獲得的對象會在之後被清除。

內存泄漏

雖然垃圾回收器很高效,但是開發者不應該就此將內存管理的問題束之高閣。管理內存是一個很複雜的過程,哪一塊內存不再需要並不是單憑一個算法就能決定的。

內存泄漏指的是,程序之前需要用到部分內存,而這部分內存在用完之後並沒有返回到內存池。

下面是一些會導致你的程序出現內存泄漏的常見錯誤:

全局變量:如果你不斷地創建全局變量,不管有沒有用到它們,它們都將滯留在程序的整個執行過程中。如果這些變量是深層嵌套對象,將會浪費大量內存。

var a = { ... }
var b = { ... }
function hello() {
  c = a;  // 這是一個你沒有意識到的全局變量
}

如果你試圖訪問一個此前沒有聲明過的變量,那麼將在全局作用域中創建一個變量。在上面的例子中,c 是沒有使用 var 關鍵字顯式創建的變量/對象。

事件監聽器:爲了增強網站的交互性或者是製作一些浮華的動畫,你可能會創建大量的事件監聽器。而用戶在你的單頁面應用中移向其他頁面時,你又忘記移除這些監聽器,那麼也可能會導致內存泄漏。當用戶在這些頁面來回移動的時候,這些監聽器會不斷增加。

var element  = document.getElementById('button');
element.addEventListener('click', onClick)

Intervals 和 Timeouts:當在這些閉包中引用對象時,除非閉包本身被清除,否則不會清除相關對象。

setInterval(() => {
  // 引用對象
}
// 這時候忘記清除計時器
// 那麼將導致內存泄漏!

移除 DOM 元素:這個問題很常見,類似於全局變量導致的內存泄漏。DOM 元素存在於對象圖內存和 DOM 樹中。用例子來解釋可能會更好:

var terminator = document.getElementById('terminate');
var badElem = document.getElementById('toDelete');
terminator.addEventListener('click', function()  {memory
  badElem.remove();
});

在你通過 id = ‘terminate’ 點擊了按鈕之後,toDelete 會從 DOM 中移除。不過,由於它仍然被監聽器引用,爲這個對象分配的內存並不會被釋放。

var terminator = document.getElementById('terminate');
terminator.addEventListener('click', function()  {
  var badElem = document.getElementById('toDelete');
  badElem.remove();
});

badElem 是局部變量,在移除操作完成之後,內存將會被垃圾回收器回收。

 

調用棧

棧是一種遵循 LIFO(先進後出)規則的數據結構,用於存儲和獲取數據。JavaScript 引擎通過棧來記住一個函數中最後執行的語句所在的位置。

function multiplyByTwo(x) {
  return x*2;
}
function calculate() {
  const sum = 4 + 2;
  return multiplyByTwo(sum);
}
calculate()
var hello = "some more code follows"

1.引擎瞭解到我們的程序中有兩個函數
2.運行 calculate() 函數
3.將 calculate 壓棧並計算兩數之和
4.運行 multiplyByTwo() 函數
5.將 multiplyByTwo 函數壓棧並執行算術計算 x*2
6.在返回結果的同時,將 multiplyByTwo() 從棧中彈出,之後回到 calculate() 函數
7.在 calculate() 函數返回結果的同時,將 calculate() 從棧中彈出,繼續執行後面的代碼

棧溢出

 

在不對棧執行彈出的情況下,可連續壓棧的數目取決於棧的大小。如果超過了這個界限之後還不斷地壓棧,最終會導致棧溢出。chrome 瀏覽器將會拋出一個錯誤以及被稱爲棧幀的棧快照。

遞歸:遞歸指的是函數調用自身。遞歸可以大幅度地減少執行算法所花費的時間(時間複雜度),不過它的理解和實施較爲複雜。

下面的例子中,基本事件永遠不會執行,lonley 函數在沒有返回值的情況下不斷地調用自身,最終會導致棧溢出。

function lonely() {
 if (false) {
  return 1;  // 基本事件
 }
 lonely();   // 遞歸調用
}

爲什麼 JavaScript 是單線程的?

一個線程代表着在同一時間段內可以單獨執行的程序部分的數目。要想查看一門語言是單線程的還是多線程的,最簡單的方式就是了解它有多少個調用棧。JS 只有一個,所以它是單線程語言。

這樣不是會阻礙程序運行嗎?如果我運行多個耗時的阻塞操作,例如 HTTP 請求,那麼程序必須得在每一個操作得到響應之後才能執行後面的代碼。

爲了解決這個問題,我們需要找到一種可以在單線程下異步完成任務的辦法。事件循環就是用來發揮這個作用的。

事件循環

到現在爲止,我們談到的內容大多包含在 V8 裏面,但是如果你去查看 V8 的代碼庫,你會發現它並不包含例如 setTimeout 或者 DOM 的實現。事實上,除了運行引擎之外,JS 還包括瀏覽器提供的 Web API,這些 API 用於拓展 JS。

結論

關於製作一門編程語言,其實還有很多內容,並且語言的實現在這些年也是不斷變化的。我希望這兩篇博客可以幫助你成爲一名更好的 JS 程序員,並且接受 JS 中那些晦澀難懂的內容 。對於諸如“V8”,“事件循環”,“調用棧”這樣的術語,你現在應該熟悉了。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章