前端內存探索

引言

最近在開發地圖標註圖層時,因爲在 3D 場景下須要構建大量的頂點座標(一萬左右的帶文字的標註,數據量大約會達到 8 * 5 * 6 * 1E4 ,約爲 250w 個頂點數據 ),所以須要對內存的使用十分當心。藉此機會研究了一下前端內存相關的問題,以便在開發過程當中作出更優的選擇,減小內存使用,提升程序性能。前端

前端內存使用概述

首先咱們來了解一下內存的結構。web

內存結構

內存分爲堆(heap)和棧(stack)。棧內存儲簡單數據類型,方便快速寫入和讀取數據。堆內存則能夠存儲複雜的數據類型。在訪問數據時,先從棧內尋找相應數據的存儲地址,再根據得到的地址,找到堆內該變量真正存儲的內容讀取出來。算法

在前端中,被存儲在棧內的數據包括小數值型,string ,boolean 和複雜類型的地址索引。
所謂小數值數據(small number), 即長度短於 32 位存儲空間的 number 型數據。
一些複雜的數據類型,諸如 Array,Object 等,是被存在堆中的。若是咱們要獲取一個已存儲的對象 A,會先從棧中找到這個變量存儲的地址,再根據該地址找到堆中相應的數據。如圖:chrome

heap-stack-pic.png

簡單的數據類型因爲存儲在棧中,讀取寫入速度相對複雜類型(存在堆中)會更快些。下面的 Demo 對比了存在堆中和棧中的寫入性能:數組

function inStack(){
    let number = 1E5;
    var a;

    while(number--){
        a = 1;
    }
}

var obj = {};
function inHeap(){
    let number = 1E5;

    while(number--){
        obj.key = 1;
    }
}
複製代碼
Copy

實驗環境1:mac OS/firefox v66.0.2
對比結果:markdown

heap-stack-ff.png

實驗環境2:mac OS/safari v11.1(13605.1.33.1.2)
對比結果:數據結構

heap-stack-safari.png

在每一個函數運行 10w 次的數據量下,能夠看出在棧中的寫入操做是快於堆的。多線程

對象及數組的存儲

在 JS 中,一個對象能夠任意添加和移除屬性,彷佛沒有限制(實際上須要不能大於 2^32 個屬性)。而 JS 中的數組,不只是變長的,能夠隨意添加刪除數組元素,每一個元素的數據類型也能夠徹底不同,更不通常的是,這個數組還能夠像普通的對象同樣,在上面掛載任意屬性,這都是爲何呢?chrome-devtools

Object 存儲

首先了解一下,JS 是如何存儲一個對象的。
JS 在設計複雜類型存儲的時候面臨的最直觀的問題就是,選擇一種數據結構,須要在讀取,插入和刪除三個方面都有較高的性能。
數組形式的結構,讀取和順序寫入的速度最快,但插入和刪除的效率都很是低下;
鏈表結構,移除和插入的效率很是高,可是讀取效率太低,也不可取;
複雜一些的樹結構等等,雖然不一樣的樹結構有不一樣的優勢,但都繞不過建樹時較複雜,致使初始化效率低下;
綜上所屬,JS 選擇了一個初始化,查詢和插入刪除都能有較好,但不是最好的性能的數據結構 -- 哈希表。函數

哈希表

哈希表存儲是一種常見的數據結構。所謂哈希映射,是把任意長度的輸入經過散列算法變換成固定長度的輸出。
對於一個 JS 對象,每個屬性,都按照必定的哈希映射規則,映射到不一樣的存儲地址上。在咱們尋找該屬性時,也是經過這個映射方式,找到存儲位置。固然,這個映射算法必定不能過於複雜,這會使映射效率低下;但也不能太簡單,過於簡單的映射方式,會致使沒法將變量均勻的映射到一片連續的存儲空間內,而形成頻繁的哈希碰撞。
關於哈希的映射算法有不少著名的解決方案,此處再也不展開。

哈希碰撞

所謂哈希碰撞,指的是在通過哈希映射計算後,被映射到了相同的地址,這樣就造成了哈希碰撞。想要解決哈希碰撞,則須要對一樣被映射過來的新變量進行處理。

衆所周知,JS 的對象是可變的,屬性可在任意時候(大部分狀況下)添加和刪除。在最開始給一個對象分配內存時,若是不想出現哈希碰撞問題,則須要分配巨大的連續存儲空間。但大部分的對象所包含的屬性通常都不會很長,這就致使了極大的空間浪費。
可是若是一開始分配的內存較少,隨着屬性數量的增長,一定會出現哈希碰撞,那如何解決哈希碰撞問題呢?
對於哈希碰撞問題,比較經典的解決方法有以下幾種:

  • 開放尋址法;
  • 再哈希法
  • 拉鍊法

這幾種方式均各有優略,因爲本文不是重點講述哈希碰撞便再也不綴餘。
在 JS 中,選擇的是拉鍊法解決哈希碰撞。所謂拉鍊法,是將經過必定算法獲得的相同映射地址的值,用鏈表的形式存儲起來。如圖所示(以傾斜的箭頭代表鏈表動態分配,並不是連續的內存空間):

哈希拉鍊法.png

映射後的地址空間存儲的是一個鏈表的指針,一個鏈表的每一個單元,存儲着該屬性的 key, value 和下一個元素的指針;

這種存儲的方式的好處是,最開始不須要分配較大的存儲空間,新添加的屬性只要動態分配內存便可
對於索引,添加和移除都有相對較好的性能;

經過上述介紹,也就解釋了這個小節最開始提出的爲什麼 js 的對象如此靈活的疑問。

Array 存儲

JS 的數組爲什麼也比其餘語言的數組更加靈活呢?由於 JS 的 Array 的對象,就是一種特殊類型的數組
所謂特殊類型,就是指在 Array 中,每個屬性的 key 就是這個屬性的 index;而這個對象還有 .length 屬性;還有 concat, slice, push, pop 等方法;
因而這就解釋了

  1. 爲什麼 JS 的數組每一個數據類型均可以不同?
    由於他就是個對象,每條數據都是一個新分配的類型連入鏈表中;
  2. 爲什麼 JS 的數組無需提早設置長度,是可變數組?
    答案同上;
  3. 爲什麼數組能夠像 Object 同樣掛載任意屬性?
    由於他就是個對象;

等等一系列的問題。

內存攻擊

固然,選擇任何一種數據存儲方式,都會有其不利的一面。這種哈希的拉鍊算法在極端狀況下也會形成嚴重的內存消耗。
咱們知道,良好的散列映射算法,能夠講數據均勻的映射到不一樣的地址。但若是咱們掌握了這種映射規律而將不一樣的數據都映射到相同的地址所對應的鏈表中去,而且數據量足夠大,將形成內存的嚴重損耗,讀取和插入一條數據會中了鏈表的天生的缺陷而變的異常的慢最終拖垮內存。這就是咱們所說的內存攻擊。

構造一個 JSON 對象,使該對象的 key 大量命中同一個地址指向的列表,附件爲 JS 代碼,只包含了一個特地構造的對象(參考其餘構造示例),圖二爲利用 Performance 查看的性能截圖:

hashAttack.js.zip

哈希碰撞攻擊.png

相同 size 對象的 Performace 對比圖:

哈希碰撞攻擊-normal.png

根據 Performance 的截圖來看,僅僅是 load 一個 size 爲 65535 的對象,居然足足花費了 40 s!而相同大小的非共計數據的運行時間可忽略不計。

若是被用戶利用了這個漏洞,構建更長的 JSON 數據,能夠直接把服務端的內存打滿,致使服務不可用。這些坑都須要開發者有意識的避免。

但從本文的來看,這個示例也很好的驗證了咱們上面所說的對象的存儲形式。

視圖類型(連續內存)

經過上面的介紹與實驗能夠知道,咱們使用的數組其實是僞數組。這種僞數組給咱們的操做帶來了極大的方便性,但這種實現方式也帶來了另外一個問題,及沒法達到數組快速索引的極致,像文章開頭時所說的上百萬的數據量的狀況下,每次新添加一條數據都須要動態分配內存空間,數據索引時都要遍歷鏈表索引形成的性能浪費會變得異常的明顯。

好在 ES6 中,JS 新提供了一種得到真正數組的方式:ArrayBuffer,TypedArray 和 DataView

ArrayBuffer

ArrayBuffer 表明分配的一段定長的連續內存塊。可是咱們沒法直接對該內存塊進行操做,只能經過 TypedArray 和 DataView 來對其操做。

TypedArray

TypeArray 是一個統稱,他包含 Int8Array / Int16Array / Int32Array / Float32Array / 。。。
等等。詳細請見: developer.mozilla.org/en-US/docs/…

拿 Int8Array 來舉例,這個對象可拆分爲三個部分:Int、八、Array
首先這是一個數組,這個數據裏存儲的是有符號的整形數據,每條數據佔 8 個比特位,及該數據裏的每一個元素可表示的最大數值是 2^7 = 128 , 最高位爲符號位。

// TypedArray
var typedArray = new Int8Array(10);

typedArray[0] = 8;
typedArray[1] = 127;
typedArray[2] = 128;
typedArray[3] = 256;

console.log("typedArray","   -- ", typedArray );
//Int8Array(10) [8, 127, -128, 0, 0, 0, 0, 0, 0, 0]
複製代碼
Copy

其餘類型也都以此類推,能夠存儲的數據越長,所佔的內存空間也就越大。這也要求在使用 TypedArray 時,對你的數據很是瞭解,在知足條件的狀況下儘可能使用佔較少內存的類型。

DataView

DataView 相對 TypedArray 來講更加的靈活。每個 TypedArray 數組的元素都是定長的數據類型,如 Int8Array 只能存儲 Int8 類型;可是 DataView 卻能夠在傳遞一個 ArrayBuffer 後,動態分配每個元素的長度,即存不一樣長度及類型的數據。

// DataView
var arrayBuffer = new ArrayBuffer(8 * 10);

var dataView = new DataView(arrayBuffer);

dataView.setInt8(0, 2);
dataView.setFloat32(8, 65535);

// 從偏移位置開始獲取不一樣數據
dataView.getInt8(0);
// 2
dataView.getFloat32(8);
// 65535
複製代碼
Copy

TypedArray 與 DataView 性能對比

DataView 在提供了更加靈活的數據存儲的同時,最大限度的節省了內存,但也犧牲了一部分性能,一樣的 DataView 和 TypedArray 性能對好比下:

// 普通數組
function arrayFunc(){
    var length = 2E6;
    var array = [];
    var index = 0;

    while(length--){
        array[index] = 10;
        index ++;
    }
}

// dataView
function dataViewFunc(){
    var length = 2E6;
    var arrayBuffer = new ArrayBuffer(length);
    var dataView = new DataView(arrayBuffer);
    var index = 0;

    while(length--){
        dataView.setInt8(index, 10);
        index ++;
    }
}

// typedArray
function typedArrayFunc(){
    var length = 2E6;
    var typedArray = new Int8Array(length);
    var index = 0;

    while(length--){
        typedArray[index++] = 10;
    }
}
複製代碼
Copy

實驗環境1:mac OS/safari v11.1(13605.1.33.1.2)
對比結果:

dataview-typedArray-safari.png

實驗環境2:mac OS/firefox v66.0.2
對比結果:

dataview-typedArray-ff.png

在 Safari 和 firefox 下,DataView 的性能還不如普通數組快。因此在條件容許的狀況下,開發者仍是儘可能使用 TypedArray 來達到更好的性能效果。

固然,這種對比並非一成不變的。好比谷歌的 V8 引擎已經在最近的升級版本中,解決了 DataView 在操做時的性能問題。

DataView 最大的性能問題在於將 JS 轉成 C++ 過程的性能浪費。而谷歌將該部分使用 CSA( CodeStubAssembler)語言重寫後,能夠直接操做 TurboFan(V8 引擎)來避免轉換時帶來的性能損耗。

實驗環境3:mac OS/chrome v73.0.3683.86
對比結果:

dataview-typedArray-chrome.png

可見在 chrome 的優化下,DataView 與 TypedArray 性能差距已經不大了,在需求須要變長數據保存的狀況下,DataView 會比 TypedArray 節省更多內存。

具體性能對比: v8.dev/blog/datavi…

共享內存(多線程通信)

共享內存介紹

說到內存還不得不提的一部份內容則是共享內存機制。
JS 的全部任務都是運行在主線程內的,經過上面的視圖,咱們能夠得到必定性能上的提高。可是當程序變的過於複雜時,咱們但願經過 webworker 來開啓新的獨立線程,完成獨立計算。
開啓新的線程伴隨而來的問題就是通信問題。webworker 的 postMessage 能夠幫助咱們完成通訊,可是這種通訊機制是將數據從一部份內存空間複製到主線程的內存下。這個賦值過程就會形成性能的消耗。
而共享內存,顧名思義,可讓咱們在不一樣的線程間,共享一塊內存,這些現成均可以對內存進行操做,也能夠讀取這塊內存。省去了賦值數據的過程,不言而喻,整個性能會有較大幅度的提高。

使用原始的 postMessage 方法進行數據傳輸

  • main.js
// main
var worker = new Worker('./worker.js');

worker.onmessage = function getMessageFromWorker(e){
    // 被改造後的數據,與原數據對比,代表數據是被克隆了一份
    console.log("e.data","   -- ", e.data );
    // [2, 3, 4]

    // msg 依舊是本來的 msg,沒有任何改變
    console.log("msg","   -- ", msg );
    // [1, 2, 3]
};

var msg = [1, 2, 3];

 worker.postMessage(msg);
複製代碼
Copy
  • worker.js
// worker
onmessage = function(e){
    var newData = increaseData(e.data);
    postMessage(newData);
};

function increaseData(data){

    for(let i = 0; i < data.length; i++){
        data[i] += 1;
    }

    return data;
}
複製代碼
Copy

由上述代碼可知,每個消息內的數據在不一樣的線程中,都是被克隆一份之後再傳輸的數據量越大,數據傳輸速度越慢

使用 sharedBufferArray 的消息傳遞

  • main.js
var worker = new Worker('./sharedArrayBufferWorker.js');

worker.onmessage = function(e){
    // 傳回到主線程已經被計算過的數據
    console.log("e.data","   -- ", e.data );
      // SharedArrayBuffer(3) {}

    // 和傳統的 postMessage 方式對比,發現主線程的原始數據發生了改變
    console.log("int8Array-outer","   -- ", int8Array );
      // Int8Array(3) [2, 3, 4]
};

var sharedArrayBuffer = new SharedArrayBuffer(3);
var int8Array = new Int8Array(sharedArrayBuffer);

int8Array[0] = 1;
int8Array[1] = 2;
int8Array[2] = 3;

worker.postMessage(sharedArrayBuffer);
複製代碼
Copy
  • worker.js
onmessage = function(e){
    var arrayData = increaseData(e.data);
    postMessage(arrayData);
};

function increaseData(arrayData){
    var int8Array = new Int8Array(arrayData);
    for(let i = 0; i < int8Array.length; i++){
        int8Array[i] += 1;
    }

    return arrayData;
}
複製代碼
Copy

經過共享內存傳遞的數據,在 worker 中改變了數據之後,主線程的原始數據也被改變了

性能對比

實驗環境1:mac OS/chrome v73.0.3683.86, 10w 條數據
對比結果:

sharedMemory-10w.png

實驗環境2:mac OS/chrome v73.0.3683.86, 100w 條數據
對比結果:

sharedMemory-100w.png

從對比圖中來看,10w 數量級的數據量,sharedArrayBuffer 並無太明顯的優點,但在百萬數據量時,差別變的異常的明顯了。

SharedArrayBuffer 不只能夠在 webworker 中使用,在 wasm 中,也能使用共享內存進行通訊。在這項技術使咱們的性能獲得大幅度的提高時,也沒有讓數據傳輸成爲性能瓶頸。

但比較惋惜的一點是,SharedArrayBuffer 的兼容性比較差,只有 chrome 68 以上支持,firefox 在最新版本中雖然支持,但須要用戶主動開啓;在 safari 中甚至還不支持該對象。

內存檢測及垃圾回收機制

爲了保證內存相關問題的完整性,不能拉下內存檢測及垃圾回收機制。
不過這兩個內容都有很是多介紹的文章,這裏再也不詳細介紹。

內存檢測

介紹了前端內存及相關性能及使用優化後。最重要的一個環節就是如何檢測咱們的內存佔用了。chrome 中一般都是使用控制檯的 Memory 來進行內存檢測及分析。

使用內存檢測的方式參見:
developers.google.com/web/tools/c…

垃圾回收機制

JS 語言並不像諸如 C++ 同樣須要手動分配內存和釋放內存,而是有本身一套動態 GC 策略的。
一般的垃圾回收機制有不少種。

前端用到的方式爲標記清除法,能夠解決循環引用的問題:
developer.mozilla.org/zh-CN/docs/…

結束語

在瞭解了前端內存相關機制後,建立任意數據類型時,咱們能夠在貼近場景的狀況下去選擇更合適的方式保有數據。例如,

  • 在數據量不是很大的狀況下,選擇操做更加靈活的普通數組;
  • 在大數據量下,選擇一次性分配連續內存塊的類型數組或者 DataView;
  • 不一樣線程間通信,數據量較大時採用 sharedBufferArray 共享數組;
  • 使用 Memory來檢測是否存在內存問題,瞭解了垃圾回收機制,減小沒必要要的 GC 觸發的 CPU 消耗。

最後的最後,這些性能測試的最終結果並不是一成不變(如上面 chrome 作的優化),但原理基本相同。因此若是在不一樣的時期和不一樣的平臺上想要獲得相對準確的性能分析,仍是本身手動寫測試用例來的靠譜【手動狗頭】。

相關文章
相關標籤/搜索