前端交流圈

JavaScript 内存机制

2022-12-01  本文已影响0人  lulu_c

每种编程语言都有它的内存管理机制,比如C语言这样的底层语言,有原生内存管理接口,像malloc()动态的分配内存空间free()释放动态分配的内存空间。开发人员使用这些接口可以显式分配和释放操作系统的内存。

JS作为一门高级语言,JS并不像底层语言C那样拥有对内存操作的完全掌控。相对地,JavaScript会在创建变量(对象、字符串)时自动分配内存,并在这些变量不被使用时自动释放内存,这个过程被称为垃圾回收

内存生命周期

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

JS 内存模型

JavaScript中的内存分配是由js引擎完成的,内存空间分为两种:栈内存(stack) 与 堆内存(heap), 而JavaScript的数据类型也分为两大类, 分别是基本数据类型和引用数据类型。

基础数据类型与栈内存

JS中的基础数据类型,这些值都有固定的大小,往往都保存在栈内存中(闭包除外),由系统自动分配存储空间。我们可以直接操作保存在栈内存空间的值,因此基础数据类型都是按值访问数据,在栈内存中的存储与使用方式类似于数据结构中的堆栈数据结构,遵循后进先出的原则。

基础数据类型:

Number、String、Null、Boolean、Undefiend、Symbol(ES6新增)

简单理解栈的存取方式,我们可以通过类比乒乓球盒子来分析。


这种乒乓球的存放方式与栈中存取数据的方式如出一辙。处于盒子中最顶层的乒乓球5,它一定是最后被放进去,但可以最先被拿出来。而我们想要拿出底层的乒乓球1,就必须将上面的4个乒乓球取出来,让乒乓球1处于盒子顶层。这就是栈空间先进后出,后进先出的特点。

引用数据类型与堆内存

JS的引用数据类型,比如数组Array,它们值的大小是不固定的。引用数据类型的值是保存在堆内存中的对象。JavaScript不允许直接访问堆内存中的位置,因此我们不能直接操作对象的堆内存空间。在操作对象时,实际上是在操作对象的引用而不是实际的对象。因此,引用类型的值都是按引用访问的。这里的引用,我们可以粗浅地理解为保存在变量对象中的一个地址,该地址与堆内存的实际值相关联。

特点:不连续的内存区域,容量较大,读取速度慢(因为引用地址在堆中,多了一次中转,所以读取速度自然会比栈要慢)。随意读取,类似于图书馆书架上的书,喜欢哪本拿哪本。

熟知的引用数据类型:

Object、Array、Date、RegExp、Function 等。

var a1 = 0;   // 变量对象
var a2 = 'this is string'; // 变量对象
var a3 = null; // 变量对象

var b = { m: 20 }; // 变量b存在于变量对象中,{m: 20} 作为对象存在于堆内存中
var c = [1, 2, 3]; // 变量c存在于变量对象中,[1, 2, 3] 作为对象存在于堆内存中
image.png

当我们要访问堆内存中的引用数据类型时,实际上我们首先是从变量对象中获取了该对象的地址引用(或者地址指针),然后再从堆内存中取得我们需要的数据。

接下来,我们通过下面的例子来加深对JS内存的理解

var a = 20;
var b = a;
b = 30;

var m = { a: 10, b: 20 };
var n = m;
n.a = 15; 
image.png

在变量对象中的数据发生复制行为时,系统会自动为新的变量分配一个新值。var b = a执行之后,a与b虽然值都等于20,但是他们其实已经是相互独立互不影响的值了。具体如图。所以我们修改了b的值以后,a的值并不会发生变化。


image.png

通过var n = m执行一次复制引用类型的操作。引用类型的复制同样也会为新的变量自动分配一个新的值保存在变量对象中,但不同的是,这个新的值,仅仅只是引用类型的一个地址指针。当地址指针相同时,尽管他们相互独立,但是在变量对象中访问到的具体对象实际上是同一个。

内存回收

垃圾回收是一种内存管理机制,就是将不再用到的内存及时释放,以防内存占用越来越高,防止卡顿甚至进程崩溃。在JavaScript中有自动化的垃圾回收机制,自动回收过期无效的变量。

在JavaScript中内存垃圾回收是由js引擎自动完成的。实现垃圾回收的关键在于如何确定内存不再使用,也就是确定对象是否无用。主要有两种方式:*引用计数标记清除

# 引用计数算法

引用就是指向某一地址的指针。我们可简单将引用视为一个对象访问另一个对象的路径。(这里的对象是一个宽泛的概念,泛指JS环境中的实体)。

引用计数算法定义就是以内存不再使用为标准,就是看一个对象是否有指向它的引用。如果没有其他对象指向它了,说明该对象已经不再需了,可以进行回收。

下面来看个例子:

// 创建一个对象person,他有两个指向属性age和name的引用
var person = {
    age: 22,
    name: 'ifcode'
};

person.name = null; // 虽然设置为null,但因为person对象还有指向name的引用,因此name不会回收

var p = person; 
person = 1;         //原来的person对象被赋值为1,但因为有新引用p指向原person对象,因此它不会被回收

p = null;           //原person对象已经没有引用,很快会被回收

由上面例子可以看出,引用计数算法是个简单有效的算法。但它却存在一个致命的问题:循环引用。如果两个对象相互引用,尽管他们已不再使用,垃圾回收器不会进行回收,导致内存泄露。

function cycle() {
    var o1 = {};
    var o2 = {};
    o1.a = o2;
    o2.a = o1; 
    
    return "Cycle reference!"
}

cycle();

上面我们申明了一个cycle方程,其中包含两个相互引用的对象。在调用函数结束后,对象o1和o2实际上已离开函数范围,因此不再需要了。但根据引用计数的原则,他们之间的相互引用依然存在,因此这部分内存不会被回收,内存泄露不可避免了。

正是因为有这个严重的缺点,这个算法在现代浏览器中已经被下面要介绍的标记清除算法所取代了。但绝不可认为该问题已经不再存在了,因为还占有大量市场的IE6、IE7使用的正是这一算法。在需要照顾兼容性的时候,某些看起来非常普通的写法也可能造成意想不到的问题:

var div = document.createElement("div");
div.onclick = function() {
    console.log("click");
};

现在虽然有各种框架,很少直接操作doml ,但上面这种JS写法很简单却存在问题。创建一个DOM元素并绑定一个点击事件,这里有什么问题呢?请注意,变量div有事件处理函数的引用,同时事件处理函数也有div的引用!(div变量可在函数内被访问)。一个循序引用出现了,按上面所讲的算法,该部分内存无可避免地泄露了。

标记清除算法

上面说过,现代的浏览器已经不再使用引用计数算法了。现代浏览器通用的大多是基于标记清除算法的某些改进算法,总体思想都是一致的。

标记清除算法将“不再使用的对象”定义为“无法达到的对象”。简单来说,就是从根部(在JS中就是全局对象)出发定时扫描内存中的对象。凡是能从根部到达的对象,都是还需要使用的。那些无法由根部出发触及到的对象被标记为不再使用,稍后进行回收。

从这个概念可以看出,无法触及的对象包含了没有引用的对象这个概念(没有任何引用的对象也是无法触及的对象)。但反之未必成立。

根据这个概念,上面的例子可以正确被垃 圾回收处理了。当div与其时间处理函数不能再从全局对象出发触及的时候,垃圾回收器就会标记并回收这两个对象。

image.png

内存管理友好的JS代码

如果还需要兼容老旧浏览器,那么就需要注意代码中的循环引用问题。或者直接采用保证兼容性的库来帮助优化代码。
对现代浏览器来说,唯一要注意的就是明确切断需要回收的对象与根部的联系。有时候这种联系并不明显,且因为标记清除算法的强壮性,这个问题较少出现。最常见的内存泄露一般都与DOM元素绑定有关:

email.message = document.createElement(“div”);
displayList.appendChild(email.message);

// 稍后从displayList中清除DOM元素
displayList.removeAllChildren();

div元素已经从DOM树中清除,也就是说从DOM树的根部无法触及该div元素了。但是请注意,div元素同时也绑定了email对象。所以只要email对象还存在,该div元素将一直保存在内存中。

内存泄露

对于持续运行的服务进程(daemon),必须及时释放不再用到的内存。否则,内存占用越来越高,轻则影响系统性能,重则导致进程崩溃。 不再用到的内存,没有及时释放,就叫做内存泄漏。

如果内存占用基本平稳,接近水平,就说明不存在内存泄漏。

image

反之,就是内存泄漏了。

image
console.log(process.memoryUsage());
//{
  //rss: 101568512,
  //heapTotal: 72605696,
  //heapUsed: 51070584,
  //external: 5819790,
  //arrayBuffers: 4286309
//}

process.memoryUsage返回一个对象,包含了 Node 进程的内存占用信息。该对象包含四个字段,单位是字节,含义如下:

rss(resident set size):所有内存占用,包括指令区和堆栈。
heapTotal:"堆"占用的内存,包括用到的和没用到的。
heapUsed:用到的堆的部分。
external: V8 引擎内部的 C++ 对象占用的内存。
arrayBuffers: ArrayBufferSharedArrayBuffer 分配的内存,包括所有 Node.js Buffer

判断内存泄漏,以heapUsed字段为准。

参考:
https://juejin.cn/post/6844903801191661575#heading-13
http://www.ruanyifeng.com/blog/2017/04/memory-leak.html
https://juejin.cn/post/6844903801191661575#heading-9

上一篇下一篇

猜你喜欢

热点阅读