前端内存优化的探索与实践
引言
標注是地圖最基本的元素之一,標明了地圖每個位置或線路的名稱。在地圖 JSAPI 中,標注的展示效果及性能也是需要重點解決的問題。
新版地圖標注的設計中,引入了 SDF ( signed distance field)重構了整個標注部分的代碼。新的方式需要把標注的位置偏移,避讓,三角拆分等全部由前端進行計算,不僅計算量激增,內存的消耗也成了重點關注的問題之一。
例如,3D 場景下需要構建大量的頂點坐標,一萬左右的帶文字的標注,數(shù)據(jù)量大約會達到 8 (attributes)?5 (1個圖標 + 4個字)?6(個頂點)?1E4 ,約為 250w 個頂點,使用 Float32Array 存儲,需要的空間約為 2.5E6?4(byte)空間(海量地圖標注 DEMO)。前端這樣大量的存儲消耗,需要對內存的使用十分小心謹慎。于是借此機會研究了一下前端內存相關的問題,以便在開發(fā)過程中做出更優(yōu)的選擇,減少內存消耗,提高程序性能。
01 前端內存使用概述
首先我們來了解一下內存的結構。
內存結構
內存分為堆(heap)和棧(stack),堆內存存儲復雜的數(shù)據(jù)類型,棧內存則存儲簡單數(shù)據(jù)類型,方便快速寫入和讀取數(shù)據(jù)。在訪問數(shù)據(jù)時,先從棧內尋找相應數(shù)據(jù)的存儲地址,再根據(jù)獲得的地址,找到堆內該變量真正存儲的內容讀取出來。
在前端中,被存儲在棧內的數(shù)據(jù)包括小數(shù)值型,string ,boolean 和復雜類型的地址索引。
所謂小數(shù)值數(shù)據(jù)(small number), 即長度短于 32 位存儲空間的 number 型數(shù)據(jù)。
一些復雜的數(shù)據(jù)類型,諸如 Array,Object 等,是被存在堆中的。如果我們要獲取一個已存儲的對象 A,會先從棧中找到這個變量存儲的地址,再根據(jù)該地址找到堆中相應的數(shù)據(jù)。如圖:
簡單的數(shù)據(jù)類型由于存儲在棧中,讀取寫入速度相對復雜類型(存在堆中)會更快些。下面的 Demo 對比了存在堆中和棧中的寫入性能:
function inStack(){let number = 1E5;var a;while(number--){a = 1;} }var obj = {}; function inHeap(){let number = 1E5;while(number--){obj.key = 1;} }實驗環(huán)境1:
mac OS/firefox v66.0.2
對比結果:
實驗環(huán)境2:
mac OS/safari v11.1(13605.1.33.1.2)
對比結果:
在每個函數(shù)運行 10w 次的數(shù)據(jù)量下,可以看出在棧中的寫入操作是快于堆的。
對象及數(shù)組的存儲
在JS中,一個對象可以任意添加和移除屬性,似乎沒有限制(實際上需要不能大于 2^32 個屬性)。而JS中的數(shù)組,不僅是變長的,可以隨意添加刪除數(shù)組元素,每個元素的數(shù)據(jù)類型也可以完全不一樣,更不一般的是,這個數(shù)組還可以像普通的對象一樣,在上面掛載任意屬性,這都是為什么呢?
Object 存儲
首先了解一下,JS是如何存儲一個對象的。
JS在設計復雜類型存儲的時候面臨的最直觀的問題就是,選擇一種數(shù)據(jù)結構,需要在讀取,插入和刪除三個方面都有較高的性能。
數(shù)組形式的結構,讀取和順序寫入的速度最快,但插入和刪除的效率都非常低下;
鏈表結構,移除和插入的效率非常高,但是讀取效率過低,也不可取;
復雜一些的樹結構等等,雖然不同的樹結構有不同的優(yōu)點,但都繞不過建樹時較復雜,導致初始化效率低下;
綜上所屬,JS 選擇了一個初始化,查詢和插入刪除都能有較好,但不是最好的性能的數(shù)據(jù)結構 -- 哈希表。
哈希表
哈希表存儲是一種常見的數(shù)據(jù)結構。所謂哈希映射,是把任意長度的輸入通過散列算法變換成固定長度的輸出。
對于一個 JS 對象,每一個屬性,都按照一定的哈希映射規(guī)則,映射到不同的存儲地址上。在我們尋找該屬性時,也是通過這個映射方式,找到存儲位置。當然,這個映射算法一定不能過于復雜,這會使映射效率低下;但也不能太簡單,過于簡單的映射方式,會導致無法將變量均勻的映射到一片連續(xù)的存儲空間內,而造成頻繁的哈希碰撞。
關于哈希的映射算法有很多著名的解決方案,此處不再展開。
哈希碰撞
所謂哈希碰撞,指的是在經(jīng)過哈希映射計算后,被映射到了相同的地址,這樣就形成了哈希碰撞。想要解決哈希碰撞,則需要對同樣被映射過來的新變量進行處理。
眾所周知,JS 的對象是可變的,屬性可在任意時候(大部分情況下)添加和刪除。在最開始給一個對象分配內存時,如果不想出現(xiàn)哈希碰撞問題,則需要分配巨大的連續(xù)存儲空間。但大部分的對象所包含的屬性一般都不會很長,這就導致了極大的空間浪費。
但是如果一開始分配的內存較少,隨著屬性數(shù)量的增加,必定會出現(xiàn)哈希碰撞,那如何解決哈希碰撞問題呢?
對于哈希碰撞問題,比較經(jīng)典的解決方法有如下幾種:
- 開放尋址法
- 再哈希法
- 拉鏈法
這幾種方式均各有優(yōu)略,由于本文不是重點講述哈希碰撞便不再綴余。
在 JS 中,選擇的是拉鏈法解決哈希碰撞。所謂拉鏈法,是將通過一定算法得到的相同映射地址的值,用鏈表的形式存儲起來。如圖所示(以傾斜的箭頭表明鏈表動態(tài)分配,并非連續(xù)的內存空間):
映射后的地址空間存儲的是一個鏈表的指針,一個鏈表的每個單元,存儲著該屬性的 key, value 和下一個元素的指針;
這種存儲的方式的好處是,最開始不需要分配較大的存儲空間,新添加的屬性只要動態(tài)分配內存即可;
對于索引,添加和移除都有相對較好的性能;
通過上述介紹,也就解釋了這個小節(jié)最開始提出的為何JS 的對象如此靈活的疑問。
Array 存儲
JS 的數(shù)組為何也比其他語言的數(shù)組更加靈活呢?因為 JS 的 Array 的對象,就是一種特殊類型的數(shù)組!
所謂特殊類型,就是指在 Array 中,每一個屬性的 key 就是這個屬性的 index;而這個對象還有 .length 屬性;還有 concat, slice, push, pop 等方法;
于是這就解釋了:
為何 JS 的數(shù)組每個數(shù)據(jù)類型都可以不一樣?
因為他就是個對象,每條數(shù)據(jù)都是一個新分配的類型連入鏈表中;為何 JS 的數(shù)組無需提前設置長度,是可變數(shù)組?
答案同上;為何數(shù)組可以像 Object 一樣掛載任意屬性?
因為他就是個對象;等等一系列的問題。
內存攻擊
當然,選擇任何一種數(shù)據(jù)存儲方式,都會有其不利的一面。這種哈希的拉鏈算法在極端情況下也會造成嚴重的內存消耗。
我們知道,良好的散列映射算法,可以講數(shù)據(jù)均勻的映射到不同的地址。但如果我們掌握了這種映射規(guī)律而將不同的數(shù)據(jù)都映射到相同的地址所對應的鏈表中去,并且數(shù)據(jù)量足夠大,將造成內存的嚴重損耗。讀取和插入一條數(shù)據(jù)會中了鏈表的缺陷,從而變得異常的慢,最終拖垮內存。這就是我們所說的內存攻擊。
構造一個 JSON 對象,使該對象的 key 大量命中同一個地址指向的列表,附件為 JS 代碼,只包含了一個特意構造的對象(引用出處),圖二為利用 Performance 查看的性能截圖:
相同 size 對象的 Performance 對比圖:
根據(jù) Performance 的截圖來看,僅僅是 load 一個 size 為 65535 的對象,竟然足足花費了 40 s!而相同大小的非共計數(shù)據(jù)的運行時間可忽略不計。
如果被用戶利用了這個漏洞,構建更長的 JSON 數(shù)據(jù),可以直接把服務端的內存打滿,導致服務不可用。這些地方都需要開發(fā)者有意識的避免。
但從本文的來看,這個示例也很好的驗證了我們上面所說的對象的存儲形式。
02 視圖類型(連續(xù)內存)
通過上面的介紹與實驗可以知道,我們使用的數(shù)組實際上是偽數(shù)組。這種偽數(shù)組給我們的操作帶來了極大的方便性,但這種實現(xiàn)方式也帶來了另一個問題,及無法達到數(shù)組快速索引的極致,像文章開頭時所說的上百萬的數(shù)據(jù)量的情況下,每次新添加一條數(shù)據(jù)都需要動態(tài)分配內存空間,數(shù)據(jù)索引時都要遍歷鏈表索引造成的性能浪費會變得異常的明顯。
好在 ES6 中,JS 新提供了一種獲得真正數(shù)組的方式:ArrayBuffer,TypedArray 和 DataView
ArrayBuffer
ArrayBuffer 代表分配的一段定長的連續(xù)內存塊。但是我們無法直接對該內存塊進行操作,只能通過 TypedArray 和 DataView 來對其操作。
TypedArrayTypeArray 是一個統(tǒng)稱,他包含 Int8Array / Int16Array / Int32Array / Float32Array等等。
拿 Int8Array 來舉例,這個對象可拆分為三個部分:Int、8、Array
首先這是一個數(shù)組,這個數(shù)據(jù)里存儲的是有符號的整形數(shù)據(jù),每條數(shù)據(jù)占 8 個比特位,及該數(shù)據(jù)里的每個元素可表示的最大數(shù)值是 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]``` 其他類型也都以此類推,可以存儲的數(shù)據(jù)越長,所占的內存空間也就越大。這也要求在使用 TypedArray 時,對你的數(shù)據(jù)非常了解,在滿足條件的情況下盡量使用占較少內存的類型。DataViewDataView 相對 TypedArray 來說更加的靈活。每一個 TypedArray 數(shù)組的元素都是定長的數(shù)據(jù)類型,如 Int8Array 只能存儲 Int8 類型;但是 DataView 卻可以在傳遞一個 ArrayBuffer 后,動態(tài)分配每一個元素的長度,即存不同長度及類型的數(shù)據(jù)。// DataView
var arrayBuffer = new ArrayBuffer(8 * 10);
var dataView = new DataView(arrayBuffer);
dataView.setInt8(0, 2);
dataView.setFloat32(8, 65535);
// 從偏移位置開始獲取不同數(shù)據(jù)
dataView.getInt8(0);
// 2
dataView.getFloat32(8);
// 65535
// 普通數(shù)組
function arrayFunc(){
}
// dataView
function dataViewFunc(){
}
// typedArray
function typedArrayFunc(){
}
實驗環(huán)境1: mac OS/safari v11.1(13605.1.33.1.2) 對比結果:實驗環(huán)境2:mac OS/firefox v66.0.2對比結果:在 Safari 和 firefox 下,DataView 的性能還不如普通數(shù)組快。所以在條件允許的情況下,開發(fā)者還是盡量使用 TypedArray 來達到更好的性能效果。當然,這種對比并不是一成不變的。比如,谷歌的 V8 引擎已經(jīng)在最近的升級版本中,解決了 DataView 在操作時的性能問題。DataView 最大的性能問題在于將 JS 轉成 C++ 過程的性能浪費。而谷歌將該部分使用 CSA( CodeStubAssembler)語言重寫后,可以直接操作 TurboFan(V8 引擎)來避免轉換時帶來的性能損耗。實驗環(huán)境3: mac OS / chrome v73.0.3683.86 對比結果:可見在 chrome 的優(yōu)化下,DataView 與 TypedArray 性能差距已經(jīng)不大了,在需求需要變長數(shù)據(jù)保存的情況下,DataView 會比 TypedArray 節(jié)省更多內存。具體性能對比: https://v8.dev/blog/dataview# 03 共享內存(多線程通訊)共享內存介紹說到內存還不得不提的一部分內容則是共享內存機制。JS 的所有任務都是運行在主線程內的,通過上面的視圖,我們可以獲得一定性能上的提升。但是當程序變得過于復雜時,我們希望通過 webworker 來開啟新的獨立線程,完成獨立計算。開啟新的線程伴隨而來的問題就是通訊問題。webworker 的 postMessage 可以幫助我們完成通信,但是這種通信機制是將數(shù)據(jù)從一部分內存空間復制到主線程的內存下。這個賦值過程就會造成性能的消耗。而共享內存,顧名思義,可以讓我們在不同的線程間,共享一塊內存,這些現(xiàn)成都可以對內存進行操作,也可以讀取這塊內存。省去了賦值數(shù)據(jù)的過程,不言而喻,整個性能會有較大幅度的提升。使用原始的 postMessage 方法進行數(shù)據(jù)傳輸main.js// main
var worker = new Worker('./worker.js');
worker.onmessage = function getMessageFromWorker(e){
// 被改造后的數(shù)據(jù),與原數(shù)據(jù)對比,表明數(shù)據(jù)是被克隆了一份 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){
};
function increaseData(data){
for(let i = 0; i < data.length; i++){data[i] += 1; }return data;}
由上述代碼可知,每一個消息內的數(shù)據(jù)在不同的線程中,都是被克隆一份以后再傳輸?shù)?。?shù)據(jù)量越大,數(shù)據(jù)傳輸速度越慢。使用 sharedBufferArray 的消息傳遞main.jsvar worker = new Worker('./sharedArrayBufferWorker.js');
worker.onmessage = function(e){
// 傳回到主線程已經(jīng)被計算過的數(shù)據(jù)
// 和傳統(tǒng)的 postMessage 方式對比,發(fā)現(xiàn)主線程的原始數(shù)據(jù)發(fā)生了改變
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.jsonmessage = 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;}
通過共享內存?zhèn)鬟f的數(shù)據(jù),在 worker 中改變了數(shù)據(jù)以后,主線程的原始數(shù)據(jù)也被改變了。性能對比實驗環(huán)境1:mac OS/chrome v73.0.3683.86, 10w 條數(shù)據(jù)對比結果:實驗環(huán)境2:mac OS/chrome v73.0.3683.86,100w 條數(shù)據(jù)對比結果:從對比圖中來看,10w 數(shù)量級的數(shù)據(jù)量,sharedArrayBuffer 并沒有太明顯的優(yōu)勢,但在百萬數(shù)據(jù)量時,差異變得異常的明顯了。SharedArrayBuffer 不僅可以在 webworker 中使用,在 wasm 中,也能使用共享內存進行通信。在這項技術使我們的性能得到大幅度的提升時,也沒有讓數(shù)據(jù)傳輸成為性能瓶頸。但比較可惜的一點是,SharedArrayBuffer 的兼容性比較差,只有 chrome 68 以上支持,firefox 在最新版本中雖然支持,但需要用戶主動開啟;在 safari 中甚至還不支持該對象。# 04 內存檢測及垃圾回收機制為了保證內存相關問題的完整性,不能拉下內存檢測及垃圾回收機制。不過這兩個內容都有非常多介紹的文章,這里不再詳細介紹。內存檢測介紹了前端內存及相關性能及使用優(yōu)化后。最重要的一個環(huán)節(jié)就是如何檢測我們的內存占用了。chrome 中通常都是使用控制臺的 Memory 來進行內存檢測及分析。使用內存檢測的方式參見:https://developers.google.com/web/tools/chrome-devtools/memory-problems/heap-snapshots?hl=zh-cn垃圾回收機制JS 語言并不像諸如 C++ 一樣需要手動分配內存和釋放內存,而是有自己一套動態(tài) GC 策略的。通常的垃圾回收機制有很多種。前端用到的方式為標記清除法,可以解決循環(huán)引用的問題: https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Memory_Management#垃圾回收# 05 結束語在了解了前端內存相關機制后,創(chuàng)建任意數(shù)據(jù)類型時,我們可以在貼近場景的情況下去選擇更合適的方式保有數(shù)據(jù)。例如:在數(shù)據(jù)量不是很大的情況下,選擇操作更加靈活的普通數(shù)組;在大數(shù)據(jù)量下,選擇一次性分配連續(xù)內存塊的類型數(shù)組或者 DataView;不同線程間通訊,數(shù)據(jù)量較大時采用 sharedBufferArray 共享數(shù)組;使用 Memory來檢測是否存在內存問題,了解了垃圾回收機制,減少不必要的 GC 觸發(fā)的 CPU 消耗。再結合我們的地圖標注改版來說,為了節(jié)省內存動態(tài)分配造成的消耗,量級巨大的數(shù)據(jù)均采用的 TypedArray 來存儲。另外,大部分的數(shù)據(jù)處理,也都在 worker 內進行。為了減少 GC,將大量的循環(huán)內變量聲明全部改成外部一次性的聲明等等,這些都對我們的性能提升有了很大的幫助。最后,這些性能測試的最終結果并非一成不變(如上面 chrome 做的優(yōu)化),但原理基本相同。所以,如果在不同的時期和不同的平臺上想要得到相對準確的性能分析,還是自己手動寫測試用例來得靠譜。
原文鏈接
本文為云棲社區(qū)原創(chuàng)內容,未經(jīng)允許不得轉載。
總結
以上是生活随笔為你收集整理的前端内存优化的探索与实践的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: Logtail 混合模式:使用插件处理文
- 下一篇: 手把手教你D2C,走向前端智能化