一. 內存空間儲存
某些情況下,調用堆棧中函數調用的數量超出了調用堆棧的實際大小,瀏覽器會拋出一個錯誤終止運行。這個就涉及到內存問題了。
1. 數據結構類型
- 棧: 后進先出(LIFO)的數據結構
- 堆: 一種樹狀結構
- 隊列: 先進先出(FIFO)的數據結構
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的引用就是弱引用,不會被計入垃圾回收機制。
續篇 js內存深入學習(二)