js記憶體深入學習(一)
一. 記憶體空間儲存
某些情況下,呼叫堆疊中函式呼叫的數量超出了呼叫堆疊的實際大小,瀏覽器會丟擲一個錯誤終止執行。這個就涉及到記憶體問題了。
1. 資料結構型別
- 棧: 後進先出(LIFO)的資料結構
[圖片上傳失敗...(image-7d9019-1551537480616)] - 堆: 一種樹狀結構
- 佇列: 先進先出(FIFO)的資料結構
[圖片上傳失敗...(image-27a9b8-1551537480616)]
2. 變數的存放
JS記憶體空間分為棧(stack)、堆(heap)、池(一般也會歸類為棧中)。 其中棧存放變數,堆存放複雜物件,池存放常量,所以也叫常量池。
1、基本型別 --> 儲存在棧記憶體中,因為這些型別在記憶體中分別佔有固定大小的空間,通過按值來訪問。基本型別一共有6種:Undefined、Null、Boolean、Number 、String和Symbol
2、引用型別 --> 儲存在堆記憶體中,因為這種值的大小不固定,因此不能把它們儲存到棧記憶體中,但記憶體地址大小的固定的,因此儲存在堆記憶體中,在棧記憶體中存放的只是該物件的訪問地址。當查詢引用型別的變數時, 先從棧中讀取記憶體地址, 然後再通過地址找到堆中的值。對於這種,我們把它叫做按引用訪問。

變數的存放
在計算機的資料結構中,棧比堆的運算速度快,Object是一個複雜的結構且可以擴充套件:陣列可擴充,物件可新增屬性,都可以增刪改查。將他們放在堆中是為了不影響棧的效率。而是通過引用的方式查詢到堆中的實際物件再進行操作。所以查詢引用型別值的時候先去棧查詢再去堆查詢。
例子:
<script> var a = {n:1}; var b = a; a.x = a = {n:2}; console.log(a.x);// --> undefined console.log(b.x);// --> {n:2} </script>
解析:
-
var a = {n:1}; var b = a;
在這裡a指向了一個物件{n:1}(我們姑且稱它為物件A),b指向了a所指向的物件,也就是說,在這時候a和b都是指向物件A的。 -
a.x = a = {n:2};
- 我們知道js的賦值運算順序永遠都是從右往左的,不過由於“.”是優先順序最高的運算子,所以這行程式碼先“計算”了a.x。a指向的物件{n:1}新增了屬性x(雖然這個x是undefined的)
- 依循“從右往左”的賦值運算順序先執行 a={n:2} ,這時候,a指向的物件發生了改變,變成了新物件{n:2}(我們稱為物件B)
- 接著繼續執行 a.x=a, 由於一開始js已經先計算了a.x,便已經解析了這個a.x是物件A的x,所以在同一條公式的情況下再回來給a.x賦值,所以應理解為物件A的屬性x指向了物件B。
另外, 閉包中的變數並不儲存中棧記憶體中,而是儲存在堆記憶體中,這也就解釋了函式之後之後為什麼閉包還能引用到函式內的變數。
function A() { let a = 1 function B() { console.log(a) } return B }
函式 A 彈出呼叫棧後,函式 A 中的變數這時候是儲存在堆上的,所以函式B依舊能引用到函式A中的變數。現在的 JS 引擎可以通過逃逸分析辨別出哪些變數需要儲存在堆上,哪些需要儲存在棧上。
二. 記憶體空間管理
1. 記憶體生命週期
JavaScript的記憶體生命週期是
1、分配你所需要的記憶體
2、使用分配到的記憶體(讀、寫)
3、不需要時將其釋放、歸還
JavaScript有自動垃圾收集機制,垃圾收集器會每隔一段時間就執行一次釋放操作,找出那些不再繼續使用的值,然後釋放其佔用的記憶體。
- 區域性變數和全域性變數的銷燬
- 區域性變數:區域性作用域中,當函式執行完畢,區域性變數也就沒有存在的必要了,因此垃圾收集器很容易做出判斷並回收。
- 全域性變數:全域性變數什麼時候需要自動釋放記憶體空間則很難判斷,所以在開發中儘量避免使用全域性變數。
- 以Google的V8引擎為例,V8引擎中所有的JS物件都是通過堆來進行記憶體分配的
- 初始分配:當宣告變數並賦值時,V8引擎就會在堆記憶體中分配給這個變數。
- 繼續申請:當已申請的記憶體不足以儲存這個變數時,V8引擎就會繼續申請記憶體,直到堆的大小達到了V8引擎的記憶體上限為止。
- V8引擎對堆記憶體中的JS物件進行分代管理
- 新生代:存活週期較短的JS物件,如臨時變數、字串等。
- 老生代:經過多次垃圾回收仍然存活,存活週期較長的物件,如主控制器、伺服器物件等。
2. 垃圾回收演算法
- 2.1 引用計數(現代瀏覽器不再使用)
引用計數演算法簡單理解,就是看一個物件是否有指向它的引用。如果沒有其他物件指向它了,說明該物件已經不再需要了。
// 建立一個物件person,他有兩個指向屬性age和name的引用 var person = { age: 12, name: 'aaaa' }; person.name = null; // 雖然name設定為null,但因為person物件還有指向name的引用,因此name不會回收 var p = person; person = 1;//原來的person物件被賦值為1,但因為有新引用p指向原person物件,因此它不會被回收 p = null;//原person物件已經沒有引用,很快會被回收
引用計數有一個致命的問題,那就是 迴圈引用
如果兩個物件相互引用,儘管他們已不再使用,但是垃圾回收器不會進行回收,最終可能會導致記憶體洩露。
function cycle() { var o1 = {}; var o2 = {}; o1.a = o2; o2.a = o1; return "cycle reference!" } cycle();
cycle函式執行完成之後,物件o1和o2實際上已經不再需要了,但根據引用計數的原則,他們之間的相互引用依然存在,因此這部分記憶體不會被回收。所以現代瀏覽器不再使用這個演算法。
但是IE依舊使用,如下,變數div有事件處理函式的引用,同時事件處理函式也有div的引用,因為div變數可在函式內被訪問,所以迴圈引用就出現了。
var div = document.createElement("div"); div.onclick = function() { console.log("click"); };
- 2.2 標記清除(常用)
標記清除演算法將“不再使用的物件”定義為“無法到達的物件”。即從根部(在JS中就是全域性物件)出發定時掃描記憶體中的物件,凡是能從根部到達的物件,保留。那些從根部出發無法觸及到的物件被標記為不再使用,稍後進行回收。所以像上面的例子,雖然是迴圈引用,但從全域性來說並沒有被使用到,所以就可以正確被垃圾回收處理了。
演算法由以下幾步組成:
- 垃圾回收器建立了一個“roots”列表。roots通常是程式碼中全域性變數的引用。JavaScript 中,“window”物件是一個全域性變數,被當作 root 。window物件總是存在,因此垃圾回收器可以檢查它和它的所有子物件是否存在(即不是圾);
- 所有的 roots 被檢查和標記為啟用(即不是垃圾)。所有的子物件也被遞迴地查。從 root 開始的所有物件如果是可達的,它就不被當作垃圾。
- 所有未被標記的記憶體會被當做垃圾,收集器現在可以釋放記憶體,歸還給操作繫了。
對於主流瀏覽器來說,只需要切斷需要回收的物件與根部的聯絡。但可能還存在著與DOM元素繫結有關的記憶體問題:
email.message = document.createElement(“div”); displayList.appendChild(email.message); // 稍後從displayList中清除DOM元素 displayList.removeAllChildren();
上面程式碼中,div元素已經從DOM樹中清除,但是該div元素還繫結在email物件中,所以如果email物件存在,那麼該div元素就會一直儲存在記憶體中。如果不再需要使用的話,需要手動設定email.message = null。
另外ES6 新出的兩種資料結構:WeakSet 和 WeakMap,表示這是弱引用,它們對於值的引用都是不計入垃圾回收機制的。
const wm = new WeakMap(); const element = document.getElementById('example'); wm.set(element, 'some information'); wm.get(element) // "some information"
先新建一個 Weakmap 例項,然後將一個 DOM 節點作為鍵名存入該例項,並將一些附加資訊作為鍵值,一起存放在 WeakMap 裡面。這時,WeakMap 裡面對element的引用就是弱引用,不會被計入垃圾回收機制。