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

引言

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

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

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

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

01 前端內存使用概述

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

內存結構

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

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

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

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

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

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

實驗環境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等等。

拿 Int8Array 來舉例,這個對象可拆分為三個部分:Int、8、Array

首先這是一個數組,這個數據裡存儲的是有符號的整形數據,每條數據佔 8 個比特位,及該數據裡的每個元素可表示的最大數值是 2^7 = 128 , 最高位為符號位。

// 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

// 普通數組
function arrayFunc(){

}

// dataView
function dataViewFunc(){

}

// typedArray
function typedArrayFunc(){

}

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

worker.onmessage = function getMessageFromWorker(e){

};

var msg = [1, 2, 3];

worker.postMessage(msg);

// worker
onmessage = function(e){

};

function increaseData(data){

}

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

worker.onmessage = function(e){
// 傳回到主線程已經被計算過的數據

// 和傳統的 postMessage 方式對比,發現主線程的原始數據發生了改變

};

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

};

function increaseData(arrayData){

}


分享到:


相關文章: