【前端進階之路】內存基本知識

內存管理

本文以V8爲背景javascript

對以前的文章進行從新編輯,內容作了不少的調整,使其具備邏輯更加緊湊,內容更加全面。java

1. 基礎概念

1.1 生命週期

無論什麼程序語言,內存生命週期基本是一致的:node

  1. 分配你所須要的內存git

  2. 使用分配到的內存(讀、寫)程序員

  3. 不須要時將其釋放、歸還github

在全部語言中第一和第二部分都很清晰。最後一步在低級語言(例如C語言)中很清晰,可是在像JavaScript等高級語言中,這一步依賴於垃圾回收機制,通常狀況下不用程序員操心。web

1.2 堆與棧

咱們知道,內存空間能夠分爲棧空間和堆空間,其中算法

  1. 棧空間:由操做系統自動分配釋放,存放函數的參數值,局部變量的值等。其操做方式相似於數據結構中的棧。chrome

  2. 堆空間:通常由程序員分配釋放,這部分空間就要考慮垃圾回收的問題。segmentfault

1.3 基本類型與引用類型

在JavaScript中

  1. 基本類型:undefined,null,boolean,number,string,在內存中佔有固定的大小,他們的值保存在棧空間中,咱們經過按值來訪問。

  2. 引用類型:Object,Array,Function,則在堆內存中爲這個值分配空間,而後把它的內存地址保存在棧內存中。(區分變量和對象)

圖片描述

1.4 V8的變量存放

圖片描述

  • handle

    handle是指向對象的指針,在V8中,全部對象都是經過handle來引用,handle主要用於V8的垃圾回收機制。進一步的,handle分爲兩種:

    • 持久化(Persistent handle),存放在堆上

    • 本地化(Local handle),存放在棧上

  • scope

    scope是handle的集合,能夠包含若干個handle,這樣就無需將每一個handle逐次釋放,而是直接釋放整個scope。

  • context

    context是一個執行器環境,使用context能夠將相互分離的JavaScript腳本在同一個V8實例中運行,而不互相干涉。在運行JavaScript腳本時,須要顯示的指定context對象。

2. 垃圾回收

2.1 分代策略

腳本中,絕大多數對象的生存期很短,只有某些對象的生存期較長。爲利用這一特色,V8將堆進行了分代。對象起初會被分配在新生區。在新生區的內存分配很是容易:咱們只需保有一個指向內存區的指針,不斷根據新對象的大小對其進行遞增便可。當該指針達到了新生區的末尾,就會有一次清理(小週期),清理掉新生區中不活躍的死對象。對於活躍超過2個小週期的對象,則需將其移動至老生區。而在老生區則使用標記清除的算法來進行垃圾回收。V8經過分別對新生代對象和老生代對象使用不一樣的垃圾回收算法來提高來及回收的效率。這就是所謂的分代策略

默認狀況下,64位環境下的V8引擎的新生代內存大小32MB、老生代內存大小爲1400MB,而32位則減半,分別爲16MB和700MB

根據分代策略,V8將堆空間進行了分隔:

  • 新生區

大多數對象被分配在這裏,新生區是一個很小的區域,垃圾回收在這個區域很是頻繁,與其餘區域相獨立。

  • 老生指針區

這裏包含大多數可能存儲指向其餘對象的指針的對象,大多數在新生區存活了一段時間(2個週期)的對象都會被挪到這裏。

  • 老生數據區

這裏存放只包含原始數據的對象,這些對象沒有執行其餘對象的指針,例如字符串,數字數組等,它們在新生區存活了一段時間後會被移動到這裏。

  • 大對象區

每個區域都是由一組內存頁構成的。除大對象區的內存頁較大以外,每一個區的內存頁都是1MB大小,且按1MB內存對齊。對象超過必定大小時就會被放置到這個區,垃圾回收期從不移動這個區域的對象。

  • 代碼區

代碼對象,也就是包含JIT以後指令的對象,會被分配到這裏。這裏是惟一擁有執行權限的內存區。(若是代碼對象因過大而被放到大對象區,則該大對象所對應的內存也是可執行的。)

  • Cell區、屬性Cell區、Map區

這些區域存放Cell、屬性Cell和Map,每一個區域由於都是存儲相同大小的元素,所以內存結構很簡單,這裏也是爲了方便進行回收。

在 node-v4.x 以後,區域進行了合併爲:新生區,老生區,大對象區,Map區,Code區

此外,對於一個對象所佔的內存空間,也涉及兩個概念:shallow sizeretained size

  • shallow size就是對象自己佔用內存的大小,不包含其引用的對象。常規對象(非數組)的shallow size有其成員變量的數量和類型決定

  • retained size是該對象本身的shallow size,加上從該對象能直接或間接訪問到對象的shallow size之和。換句話說,retained size是該對象被GC以後所能回收到內存的總和。

這兩個概念在使用chrome的開發工具中會看到。

垃圾回收釋放的內存即爲Retained Size的大小。

2.2 新生區的半空間分配策略

新生代使用半空間(Semi-space)分配策略,其中新對象最初分配在新生代的活躍半空間內。一旦半空間已滿,一個Scavenge操做將活躍對象移出到其餘半空間中,被認爲是長期駐存的對象,並被晉升爲老生代。一旦活躍對象已被移出,則在舊的半空間中剩下的任何死亡對象被丟棄。

具體的以下:

YG被平分爲兩部分空間From和To,全部內存從To空間被分配出去,當To滿時,開始觸發GC。

例如說:

某時刻,To已經爲A、B和C分配了內存,當前它只剩下一小塊內存未分配。而From全部的內存都空閒着。

圖片描述

此時,一個程序須要爲D分配內存,但D須要的內存大小超出了To未分配的內存,此時觸發GC,頁面中止執行

圖片描述

接着From和To進行對換,即原來的To空間被標誌爲From,From被標誌爲To。而且把活的變量值(B)標誌出來,而垃圾(A、C)未被標誌,它們將會被清掉。

圖片描述

活躍的變量(B)會被複制到To空間,而垃圾(A、C)則被回收。同時,D被分配到To空間,最後的狀況以下。

圖片描述

至此,整個GC完成,此過程當中頁面會阻塞,因此要儘量的快。

2.2.1 對象的晉升

當一個新生代的對象在知足必定條件下,會重新生代被移到老生代,這就是對象的晉升。具體的移動的標準有兩種

  1. 對象從From空間複製到To空間時,會檢查它的內存地址來判斷這個對象是否經歷過一次新生代的清理結果,若是是(說明存活了兩個週期了),則賦值到老生代中,不然則賦值到To空間中。

  2. 對象從From空間複製到To空間時,若是To空間已經被使用了超過25%,那麼這個對象直接被複制到老生代。

2.3 老生代

V8在老生代中採用Mark-Sweep和Mark-Compact相結合的垃圾回收策略。

2.3.1 標記

標記-清除算法分爲標記和清除兩個階段。

標記階段,全部堆上的活躍對象都會被標記,每一個內存頁有一個用來標記對象的位圖,位圖中的每一位對應的內存頁中的一個字,這個位圖須要佔據必定的空間。另外還有兩位用來標記對象的狀態:

  • 若是一個對象爲白對象,表示還未被垃圾回收器發現

  • 若是一個對象爲灰對象,表示已經被垃圾回收器發現,但其鄰接對象還沒有處理

  • 若是一個對象爲黑對象,表示已經被垃圾回收器發現,其鄰接對象已所有處理

那麼這裏怎麼理解標記的過程?這就必須知道:內存管理方式實際上基於的概念。

GC Root是內存的根節點,在瀏覽器中它是window,在Nodejs中則是global對象

  • 圖的節點名稱是建立它的構造函數名

  • 圖的邊是引用它的屬性名或者變量名

有不少內部的GC Root對用戶來講都不是很重要,從應用的角度來講有下面幾種狀況:

  • 全局變量或者全局函數會一直被window這種全局對象所指向,它們會一直佔據着內存

  • DOM節點只有在被javascript對象引用的狀況下,會留在內存中。

  • 在進行debug或者console的時候,可能會因爲保留了上下文,致使本該被釋放的對象被保留下來。

實際上,標記的過程正是以由GC Root創建的圖爲基礎,來實現對象的標記,標記算法的核心是深度優先搜索,大體實現以下:

  1. 初始時,位圖爲空,全部對象都是白對象。

  2. 從根對象(GC Root)到達的對象會被染爲灰色,放到一個單獨的雙端隊列中。

  3. 標記階段,每次都會從雙端隊列中取出一個對象,並將其轉變爲黑對象,其鄰接對象轉變爲灰,而後把其鄰接對象加入到雙端隊列中。

  4. 若是雙端隊列爲空或者全部對象都變成黑對象,則結束。

這個算法實現起來仍是蠻繁瑣的,從的角度來看,其實標記的過程其實是區分活節點和垃圾節點的過程。

  • 從GC Root開始遍歷圖,全部能到達的節點稱爲活節點。

  • GC Root不能到達的節點,該節點就成爲垃圾,將會被回收。

標記結束後,全部的對象非黑(活躍節點)即白(垃圾節點)。

標記時間取決於必須標記的活躍對象的數目,對於一個大的web應用,整個堆棧的標記可能須要超過100ms。因爲全停頓會形成了瀏覽器一段時間無響應,因此V8使用了一種增量標記的方式標記活躍對象,將完整的標記拆分紅不少小的步驟,每作完一部分就停下來,讓JavaScript的應用線程執行一會,這樣垃圾回收與應用線程交替執行。V8可讓每一個標記步驟的持續時間低於5ms。

舉個例子:

window.ob = 2;
window.oa = {
    b1 : 3,
    b2 : {
        c1 : 4,
        c2 : "字符串"
    }
};
window.ob = undefined;

圖片描述

例如圖中灰色的節點,它原來表明ob變量值,當window.ob = undefined後,此節點與GC Root鏈接的路徑ob被切斷了,它就成了垃圾,將會被回收。

2.3.2 清除(Sweep)

因爲標記完成後,全部對象都已經被標記,即不是活躍對象就是死亡對象,堆上有多少空間已經肯定。清除時,垃圾回收器會掃描連續存放的死對象,將其變成空閒空間。這個任務是由專門的清掃線程同步執行。

2.3.3 整理(Compact)

標記清除有一個問題就是進行一次標記清楚後,內存空間每每是不連續的,會出現不少的內存碎片。若是後續須要分配一個須要內存空間較多的對象時,若是全部的內存碎片都不夠用,將會使得V8沒法完成此次分配,提早觸發垃圾回收。

標記整理正是爲了解決標記清除所帶來的內存碎片的問題。標記整理在標記清除的基礎進行修改,將其的清除階段變爲緊縮極端。在整理的過程當中,將活着的對象向內存區的一段移動,移動完成後直接清理掉邊界外的內存。緊縮過程涉及對象的移動,因此效率並非太好,可是能保證不會生成內存碎片。

2.4 垃圾回收總結

  1. 新生代對象的Scavenge,這一般是快速的;

  2. 經過增量方式的標記步驟,依賴於須要標記的對象數量,時間能夠任意長;

  3. 完整垃圾回收,這可能須要很長的時間;

  4. 帶內存緊縮的完整垃圾回收,這也可能須要很長的時間,須要進行內存緊縮。

3. 內存問題

3.1 內存泄漏

內存泄漏是指計算機可用內存的逐漸減小,緣由一般是程序持續沒法釋放其使用的臨時內存。

先來一個最簡單的DOM泄漏的例子

var el = document.getElementById("_p");
el.mark = "marked";

//移除P
function removeP() {
    el.parentNode.removeChild(el);
    // el = null;
}

程序很是簡單,只是把id爲_p的HTML元素從頁面移除,在移除以前從GC Root遍歷此P元素有兩條路可走。在執行removeP()以後,按理來講該元素應該成爲垃圾,所佔有的內存應該被釋放掉,可是因爲還存在這路徑el沒有被切斷,p元素佔有的內存沒法被釋放,致使了內存泄漏。

圖片描述

3.2 內存佔用過多

這個問題很容易理解。例如使用事件代理來減小事件監聽的函數,從而減小內存分配的開銷。

3.3 gc卡頓

若是你的頁面垃圾回收很頻繁,那說明你的頁面可能內存使用分配太頻繁了。頻繁的GC可能也會致使頁面卡頓。

在一些框架中,若是建立一個大對象以後,可能不會很快就將其釋放,而是會緩存起來,直到沒有用處爲止。

4. chrome dev tools

在使用Chrome進行內存分析的時候,要先在chrome菜單-》工具,或者直接按shift+esc,找到內存管理器,而後選上JavaScript使用的內存(JavaScipt Memory)。

4.1 timeline

經過Timeline的內存模式,能夠在宏觀上觀察到web應用的內存狀況,通常咱們須要關注的點:

  1. GC的時間長度是否正常?

  2. GC頻率是否正常?過於頻繁會致使卡頓

  3. 內存趨勢圖是否正常?

  4. DOM趨勢圖是否正常?

這些關注點均可以在timeline的內存視圖中看到,如圖

圖片描述

timeline統計的內存變化主要有:

  • js heap:堆空間

  • documents:文檔計數

  • node:dom節點數

  • event listener:事件監聽器

  • CPU:在手機端暫時沒有

此外還能夠經過event log看到這期間頁面執行的操做

4.2 profile

profile面板咱們關注的是Take Heap SnapshotRecode Heap Allocations

圖片描述

profile使用必須知道的:

  1. 標誌爲黃色的表示可能內存泄漏

  2. 標誌爲紅色表示應該是發生內存泄漏

在profile中的幾個概念:

  1. (global property):全局對象,還有全局對象引用的對象

  2. (closure):閉包,這裏須要關注一下

  3. (compiled code):V8會先代碼編譯成特定的語言,再執行

  4. (array,string,number,regexp):這些內置對象的引用

  5. HTML..Element:dom對象的引用

4.2.1 Take Heap Snapshot

使用快照,必須知道:

  1. 每次進行快照時,chrome都會先自動執行一個gc

  2. 只有活躍的值,纔會反映在快照裏

快照有三個視圖,它們分別有各自的做用

  1. Summary View

    默認是以概要視圖顯示的,顯示了對象總數,能夠展開顯示具體內容

  2. Comparison View

    該視圖用來對照不一樣的快照來找到快照之間的差別

  3. Containment View

    在這個視圖中,包括三個點

    • DOMWindow objects:js中的全局對象

    • GC Root:VM垃圾回收所使用的GC Root

    • Native Object:被放置到VM中的內置對象

    好吧。暫時不知道有什麼用?之後再補充。

4.2.2 Recode Heap Allocations

這個功能能夠動態監控,經過次工具能夠看到

  1. 何時分配了內存,剛剛分配的內存會以深藍色的柱子表示,柱子越高,內存越大

  2. 何時回收了內存,內存被回收的時候,柱子變爲灰色

4.3 實踐

例子1:timeline來查看正常的內存

圖片描述

例子2:經過timeline來發現內存泄漏

圖片描述

能夠看到隨着時間的增加,頁面佔用的內存愈來愈多,

在這種狀況下就能夠懷疑有內存泄漏了,也有多是瀏覽器尚未進行gc,這個時候咱們能夠強制進行垃圾回收(垃圾筒圖標)

反覆測試,若是發現不管怎麼樣,內存一直在增加,那麼估計你就遇到內存泄漏的問題了。

若是頁面中DOM節點的數量一直在攀升,那麼確定出現DOM泄漏了

圖片描述

例子3:驗證快照以前會進行gc

function Test (s) {
    this.s = s;
}
var _test1 = new Test("__________test___1_________");
var _test2 = new Test("__________test___2_________");
new Test("你看不到我,就是這麼神奇");

圖片描述

例子4:經過snapshot來發現內存泄漏

  1. 打開例子以後,先進行一次快照

  2. 點擊action,表明這用戶的交互

  3. 再進行一次快照

  4. 使用comparison視圖,對比兩次快照,如圖

圖片描述

能夠看到,action以後,內存的數量是增長的(注意,已經gc過了),這說明web應用極有內存泄漏。

一個原則就是找到本不該該存在卻還存在的那些值。

例子5:經過內存分配的狀況來分析

圖片描述

點擊藍色的柱子,能夠看到詳細的狀況,來進行分析

例子6:經過timeline來分析gc過於頻繁致使卡頓的問題

圖片描述

此例子在移動手機的瀏覽器進行測試,頁面仍是相對簡單,在比較複雜的移動web應用,這種狀況仍是比較危險的,可能會致使頁面卡死。

參考

  1. MDN:內存管理

  2. Chrome開發者工具之JavaScript內存分析

  3. Google V8的垃圾回收引擎

  4. 測試例子

  5. 如何編寫避免垃圾開銷的實時Javascript代碼

  6. 詳解js變量、做用域及內存

  7. V8 concept

  8. 淺談V8引擎中的垃圾回收機制

  9. 使用 Google V8 引擎開發可定製的應用程序

  10. a tour of v8 garbage collection

相關文章
相關標籤/搜索