前端性能问题-内存优化的探索与实践

发表于 JS 分类,标签:

前端性能问题-内存优化的探索与实践

一、内存结构

内存分为堆(heap)和栈(stack),堆内存存储复杂的数据类型,栈内存则存储简单数据类型,方便快速写入和读取数据。在访问数据时,先从栈内寻找相应数据的存储地址,再根据获得的地址,找到堆内该变量真正存储的内容读取出来。

在前端中,被存储在栈内的数据包括小数值型,string ,boolean 和复杂类型的地址索引。

所谓小数值数据(small number), 即长度短于 32 位存储空间的 number 型数据。

一些复杂的数据类型,诸如 Array,Object 等,是被存在堆中的。如果我们要获取一个已存储的对象 A,会先从栈中找到这个变量存储的地址,再根据该地址找到堆中相应的数据。如图:

memory-constructor

image.png

测试代码

// 栈
var a;
function inStack(){
   let number = 1E5 * 10000;
   while(number--){
       a = number;
   }
}
//  堆
var obj = {key:1};
function inHeap(){
   let number = 1E5 * 10000;
   while(number--){
       obj.key = number;
   }
}

//  堆(深度)
var objDeep = {key:{key:{key:{key:{key:{}}}}}};
function inHeapDeep(){
   let number = 1E5 * 10000;
   while(number--){
       objDeep.key.key.key.key = number;
   }
}

console.time('Stack');inStack();  console.timeEnd('Stack')
console.time('Heap');inHeap();  console.timeEnd('Heap')
console.time('inHeapDeep');inHeapDeep();  console.timeEnd('inHeapDeep')

结果

浏览器chrome:版本 84.0.4147.105(正式版本) (64 位)

Stack: 589.427001953125 ms
VM688:31 Heap: 821.780029296875 ms
VM688:32 inHeapDeep: 1530.547119140625 ms

结论:

  • 简单的数据类型由于存储在栈中,读取写入速度相对复杂类型(存在堆中)会更快些。  【!important】

    • 栈中的读写速度快于堆的读写速度

  • 浅层级的堆是快于深层级的堆 【!important】



二、内存生命周期

不管什么程序语言,内存生命周期基本是一致的:  

  1. 分配你所需要的内存

    • JavaScript 的内存分配,值的初始化

      var n = 123; // 给数值变量分配内存
      var s = "azerty"; // 给字符串分配内存
  2. 使用分配到的内存(读、写)

    • 使用值的过程实际上是对分配内存进行读取与写入的操作。

    • 读取与写入可能是写入一个变量或者一个对象的属性值,甚至传递函数的参数。

  3. 不需要时将其释放\归还

    • 引用计数垃圾收集

    • 标记-清除算法

    • 大多数内存管理的问题都在这个阶段。在这里最艰难的任务是找到“哪些被分配的内存确实已经不再需要了”。它往往要求开发人员来确定在程序中哪一块内存不再需要并且释放它。

    • 垃圾回收

所有语言第二部分都是明确的。第一和第三部分在底层语言中是明确的,但在像JavaScript这些高级语言中,大部分都是隐含的。



三、ArrayBuffer

ArrayBuffer 代表分配的一段定长的连续内存块。但是我们无法直接对该内存块进行操作,只能通过 TypedArray 和 DataView 来对其操作。

TypedArray

TypeArray 是一个统称,他包含 Int8Array / Int16Array / Int32Array / Float32Array等等。详细

拿 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 性能对比

// 普通数组
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;
   }
}


console.time('arrayFunc');arrayFunc();  console.timeEnd('arrayFunc')
console.time('dataViewFunc');dataViewFunc();  console.timeEnd('dataViewFunc')
console.time('typedArrayFunc');typedArrayFunc();  console.timeEnd('typedArrayFunc')


结果

浏览器chrome:版本 84.0.4147.105(正式版本) (64 位)

arrayFunc: 27.319091796875ms
dataViewFunc: 3.909912109375ms
typedArrayFunc: 3.27001953125ms

结论:

  • 所以在条件允许的情况下,开发者还是尽量使用 TypedArray 来达到更好的性能效果。



四、共享内存(多线程通讯)

共享内存介绍

说到内存还不得不提的一部分内容则是共享内存机制。

JS 的所有任务都是运行在主线程内的,通过上面的视图,我们可以获得一定性能上的提升。但是当程序变得过于复杂时,我们希望通过 webworker 来开启新的独立线程,完成独立计算。

开启新的线程伴随而来的问题就是通讯问题。webworker 的 postMessage 可以帮助我们完成通信,但是这种通信机制是将数据从一部分内存空间复制到主线程的内存下。这个赋值过程就会造成性能的消耗。

而共享内存,顾名思义,可以让我们在不同的线程间,共享一块内存,这些现成都可以对内存进行操作,也可以读取这块内存。省去了赋值数据的过程,不言而喻,整个性能会有较大幅度的提升。

使用原始的 postMessage 方法进行数据传输

main.js

// main.js
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('./worker.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

// 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 中改变了数据以后,主线程的原始数据也被改变了。



五、如何避免内存泄漏

  • 记住一个原则:不用的东西,及时归还, 也就是将不用的变量人为的销毁

  • 减少不必要的全局变量,使用严格模式避免意外创建全局变量。

  • 在你使用完数据后,及时解除引用

    • setTimeout、 setInterval

    • 闭包中的变量

    • dom引用

    • 定时器清除

    • 观察者模式在添加通知后,没有及时清理掉

  • 组织好你的逻辑,避免死循环等造成浏览器卡顿,崩溃的问题。


知乎-前端内存优化的探索与实践;

MDN-内存管理




0 篇评论

发表我的评论