前端内存探索

引言

最近在开发地图标注图层时,因为在 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 作的优化),但原理基本相同。因此若是在不一样的时期和不一样的平台上想要获得相对准确的性能分析,仍是本身手动写测试用例来的靠谱【手动狗头】。

相关文章
相关标签/搜索