天天看點

前端記憶體優化的探索與實踐

作者 | 見秋
前端記憶體優化的探索與實踐

标注是地圖最基本的元素之一,标明了地圖每個位置或線路的名稱。在地圖 JSAPI 中,标注的展示效果及性能也是需要重點解決的問題。

新版高德地圖示注的設計中,引入了 SDF ( signed distance field)重構了整個标注部分的代碼。新的方式需要把标注的位置偏移,避讓,三角拆分等全部由前端進行計算,不僅計算量激增,記憶體的消耗也成了重點關注的問題之一。

前端記憶體優化的探索與實踐

例如,3D 場景下需要建構大量的頂點坐标,一萬左右的帶文字的标注,資料量大約會達到 8 (attributes)* 5 (1個圖示 + 4個字)* 6(個頂點) * 1E4 ,約為 250w 個頂點,使用 Float32Array 存儲,需要的空間約為 2.5E6 * 4(byte)空間(海量地圖示注 DEMO)。

前端這樣大量的存儲消耗,需要對記憶體的使用十分小心謹慎。于是借此機會研究了一下前端記憶體相關的問題,以便在開發過程中做出更優的選擇,減少記憶體消耗,提高程式性能。

前端記憶體使用概述

首先我們來了解一下記憶體的結構。

記憶體結構

記憶體分為堆(heap)和棧(stack),堆記憶體存儲複雜的資料類型,棧記憶體則存儲簡單資料類型,友善快速寫入和讀取資料。在通路資料時,先從棧内尋找相應資料的存儲位址,再根據獲得的位址,找到堆内該變量真正存儲的内容讀取出來。

在前端中,被存儲在棧内的資料包括小數值型,string ,boolean 和複雜類型的位址索引。

所謂小數值資料(small number), 即長度短于 32 位存儲空間的 number 型資料。

一些複雜的資料類型,諸如 Array,Object 等,是被存在堆中的。如果我們要擷取一個已存儲的對象 A,會先從棧中找到這個變量存儲的位址,再根據該位址找到堆中相應的資料。如圖:

前端記憶體優化的探索與實踐

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

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 資料,可以直接把服務端的記憶體打滿,導緻服務不可用。這些地方都需要開發者有意識的避免。

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

視圖類型(連續記憶體)

通過上面的介紹與實驗可以知道,我們使用的數組實際上是僞數組。這種僞數組給我們的操作帶來了極大的友善性,但這種實作方式也帶來了另一個問題,及無法達到數組快速索引的極緻,像文章開頭時所說的上百萬的資料量的情況下,每次新添加一條資料都需要動态配置設定記憶體空間,資料索引時都要周遊連結清單索引造成的性能浪費會變得異常的明顯。

好在 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、8、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;
    }
}           
前端記憶體優化的探索與實踐
前端記憶體優化的探索與實踐

在 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

共享記憶體(多線程通訊)

共享記憶體介紹

說到記憶體還不得不提的一部分内容則是共享記憶體機制。

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 的消息傳遞

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);           
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 中改變了資料以後,主線程的原始資料也被改變了。

性能對比

mac OS/chrome v73.0.3683.86,

10w 條資料

前端記憶體優化的探索與實踐

mac OS/chrome v73.0.3683.86,

100w 條資料

前端記憶體優化的探索與實踐

從對比圖中來看,10w 數量級的資料量,sharedArrayBuffer 并沒有太明顯的優勢,但在百萬資料量時,差異變得異常的明顯了。

SharedArrayBuffer 不僅可以在 webworker 中使用,在 wasm 中,也能使用共享記憶體進行通信。在這項技術使我們的性能得到大幅度的提升時,也沒有讓資料傳輸成為性能瓶頸。

但比較可惜的一點是,SharedArrayBuffer 的相容性比較差,隻有 chrome 68 以上支援,firefox 在最新版本中雖然支援,但需要使用者主動開啟;在 safari 中甚至還不支援該對象。

記憶體檢測及垃圾回收機制

為了保證記憶體相關問題的完整性,不能拉下記憶體檢測及垃圾回收機制。不過這兩個内容都有非常多介紹的文章,這裡不再詳細介紹。

記憶體檢測

介紹了前端記憶體及相關性能及使用優化後。最重要的一個環節就是如何檢測我們的記憶體占用了。chrome 中通常都是使用控制台的 Memory 來進行記憶體檢測及分析。

使用記憶體檢測的方式參見:

https://developers.google.com/web/tools/chrome-devtools/memory-problems/heap-snapshots?hl=zh-cn

垃圾回收機制

JS 語言并不像諸如 C++ 一樣需要手動配置設定記憶體和釋放記憶體,而是有自己一套動态 GC 政策的。通常的垃圾回收機制有很多種。

前端用到的方式為标記清除法,可以解決循環引用的問題:

https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Memory_Management#

垃圾回收

結束語

在了解了前端記憶體相關機制後,建立任意資料類型時,我們可以在貼近場景的情況下去選擇更合适的方式保有資料。例如:

  • 在資料量不是很大的情況下,選擇操作更加靈活的普通數組;
  • 在大資料量下,選擇一次性配置設定連續記憶體塊的類型數組或者 DataView;
  • 不同線程間通訊,資料量較大時采用 sharedBufferArray 共享數組;
  • 使用 Memory來檢測是否存在記憶體問題,了解了垃圾回收機制,減少不必要的 GC 觸發的 CPU 消耗。

再結合我們的地圖示注改版來說,為了節省記憶體動态配置設定造成的消耗,量級巨大的資料均采用的 TypedArray 來存儲。另外,大部分的資料處理,也都在 worker 内進行。為了減少 GC,将大量的循環内變量聲明全部改成外部一次性的聲明等等,這些都對我們的性能提升有了很大的幫助。

最後,這些性能測試的最終結果并非一成不變(如上面 chrome 做的優化),但原理基本相同。是以,如果在不同的時期和不同的平台上想要得到相對準确的性能分析,還是自己手動寫測試用例來得靠譜。

前端記憶體優化的探索與實踐

關注「Alibaba F2E」

把握阿裡巴巴前端新動向