前端內存優化的探索與實踐

引言

標註是地圖最基本的元素之一,標明瞭地圖每一個位置或線路的名稱。在地圖 JSAPI 中,標註的展現效果及性能也是須要重點解決的問題。前端

新版地圖標註的設計中,引入了 SDF ( signed distance field)重構了整個標註部分的代碼。新的方式須要把標註的位置偏移,避讓,三角拆分等所有由前端進行計算,不只計算量激增,內存的消耗也成了重點關注的問題之一。web

例如,3D 場景下須要構建大量的頂點座標,一萬左右的帶文字的標註,數據量大約會達到 8 (attributes) 5 (1個圖標 + 4個字) 6(個頂點) 1E4 ,約爲 250w 個頂點,使用 Float32Array 存儲,須要的空間約爲 2.5E6 4(byte)空間(海量地圖標註 DEMO)。前端這樣大量的存儲消耗,須要對內存的使用十分當心謹慎。因而藉此機會研究了一下前端內存相關的問題,以便在開發過程當中作出更優的選擇,減小內存消耗,提升程序性能。算法

01 前端內存使用概述

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

內存結構數組

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

在前端中,被存儲在棧內的數據包括小數值型,string ,boolean 和複雜類型的地址索引。多線程

所謂小數值數據(small number), 即長度短於 32 位存儲空間的 number 型數據。chrome-devtools

一些複雜的數據類型,諸如 Array,Object 等,是被存在堆中的。若是咱們要獲取一個已存儲的對象 A,會先從棧中找到這個變量存儲的地址,再根據該地址找到堆中相應的數據。如圖:函數

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

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

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

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

    while(number--){
        obj.key = 1;
    }
}

實驗環境1:

mac OS/firefox v66.0.2
對比結果:

實驗環境2:

mac OS/safari v11.1(13605.1.33.1.2)

對比結果:

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

對象及數組的存儲

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

Object 存儲

首先了解一下,JS是如何存儲一個對象的。

JS在設計複雜類型存儲的時候面臨的最直觀的問題就是,選擇一種數據結構,須要在讀取,插入和刪除三個方面都有較高的性能。

數組形式的結構,讀取和順序寫入的速度最快,但插入和刪除的效率都很是低下;

鏈表結構,移除和插入的效率很是高,可是讀取效率太低,也不可取;

複雜一些的樹結構等等,雖然不一樣的樹結構有不一樣的優勢,但都繞不過建樹時較複雜,致使初始化效率低下;

綜上所屬,JS 選擇了一個初始化,查詢和插入刪除都能有較好,但不是最好的性能的數據結構 -- 哈希表。

哈希表

哈希表存儲是一種常見的數據結構。所謂哈希映射,是把任意長度的輸入經過散列算法變換成固定長度的輸出。

對於一個 JS 對象,每個屬性,都按照必定的哈希映射規則,映射到不一樣的存儲地址上。在咱們尋找該屬性時,也是經過這個映射方式,找到存儲位置。固然,這個映射算法必定不能過於複雜,這會使映射效率低下;但也不能太簡單,過於簡單的映射方式,會致使沒法將變量均勻的映射到一片連續的存儲空間內,而形成頻繁的哈希碰撞。

關於哈希的映射算法有不少著名的解決方案,此處再也不展開。

哈希碰撞

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

衆所周知,JS 的對象是可變的,屬性可在任意時候(大部分狀況下)添加和刪除。在最開始給一個對象分配內存時,若是不想出現哈希碰撞問題,則須要分配巨大的連續存儲空間。但大部分的對象所包含的屬性通常都不會很長,這就致使了極大的空間浪費。

可是若是一開始分配的內存較少,隨着屬性數量的增長,一定會出現哈希碰撞,那如何解決哈希碰撞問題呢?

對於哈希碰撞問題,比較經典的解決方法有以下幾種:

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

這幾種方式均各有優略,因爲本文不是重點講述哈希碰撞便再也不綴餘。

在 JS 中,選擇的是拉鍊法解決哈希碰撞。所謂拉鍊法,是將經過必定算法獲得的相同映射地址的值,用鏈表的形式存儲起來。如圖所示(以傾斜的箭頭代表鏈表動態分配,並不是連續的內存空間):

映射後的地址空間存儲的是一個鏈表的指針,一個鏈表的每一個單元,存儲着該屬性的 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 查看的性能截圖:

相同 size 對象的 Performance 對比圖:

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

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

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

02 視圖類型(連續內存)

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

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

ArrayBuffer

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

TypedArray

TypeArray 是一個統稱,他包含 Int8Array / Int16Array / Int32Array / Float32Array等等。詳細請見:

https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/TypedArray

拿 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]

其餘類型也都以此類推,能夠存儲的數據越長,所佔的內存空間也就越大。這也要求在使用 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

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;
}
}

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

實驗環境2:

mac OS/firefox v66.0.2

對比結果:

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

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

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

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

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

具體性能對比:
https://v8.dev/blog/dataview

03 共享內存(多線程通信)

共享內存介紹

說到內存還不得不提的一部份內容則是共享內存機制。

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);

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;

}

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

使用 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);

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;

}

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

性能對比

實驗環境1:

mac OS/chrome v73.0.3683.86,

10w 條數據

對比結果:

實驗環境2:

mac OS/chrome v73.0.3683.86,

100w 條數據

對比結果:

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

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

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

04 內存檢測及垃圾回收機制

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

內存檢測

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

使用內存檢測的方式參見:

https://developers.google.com...

垃圾回收機制

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

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

05 結束語

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

在數據量不是很大的狀況下,選擇操做更加靈活的普通數組;

在大數據量下,選擇一次性分配連續內存塊的類型數組或者 DataView;

不一樣線程間通信,數據量較大時採用 sharedBufferArray 共享數組;

使用 Memory來檢測是否存在內存問題,瞭解了垃圾回收機制,減小沒必要要的 GC 觸發的 CPU 消耗。

再結合咱們的地圖標註改版來講,爲了節省內存動態分配形成的消耗,量級巨大的數據均採用的 TypedArray 來存儲。另外,大部分的數據處理,也都在 worker 內進行。爲了減小 GC,將大量的循環內變量聲明所有改爲外部一次性的聲明等等,這些都對咱們的性能提高有了很大的幫助。

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


本文做者:高德技術小哥

閱讀原文

本文爲雲棲社區原創內容,未經容許不得轉載。

相關文章
相關標籤/搜索