深入JavaScript系列(五):JS与内存

2,852 阅读9分钟

一、内存是什么

我们现在常用的计算机都属于 冯·诺依曼体系计算机, 计算机硬件由 控制器、运算器、存储器、输入设备、输出设备 五大部分组成。

我们通常所说的内存就是 存储器

常用的内存都是易失性存储器(需要通过不断加电刷新来保持数据,一旦断电就会导致数据丢失),所以需要一种容量大、低成本的非易失性存储器来进行数据的存储,这就是外存,例如磁带、软盘、硬盘、光盘、闪存卡、U盘等。可以将外存理解为输入输出设备,因为外存是需要通过I/O接口进行数据存取的,而内存是由CPU直接寻址的。外存中的程序需要通过I/O接口调入内存中才可以运行。

内存就是程序运行的地方,其实程序本质上就是指令和数据的集合。所以说内存是指令和数据的临时存储器,然后CPU对内存中的指令和数据进行处理。

二、内存的使用

不管什么程序语言,其运行都依赖内存,内存生命周期基本是一致的:

  1. 分配所需要的内存
  2. 使用分配到的内存(读、写)
  3. 不需要时将其释放\归还

在JavaScript中,第一步和第三步由js引擎完成的,对于编程人员是隐藏的。但是这并不意味着我们不需要了解JavaScript中的内存机制,了解内存机制有助于我们写出更优雅、性能更好的代码。

三、JavaScript的内存模型

JavaScript数据类型有基本类型和引用类型两大类,基本类型有Undefined、Null、Boolean、Number、String、Symbol六中,引用类型有Object,所有的JavaScript变量值将会是七种的其中之一。这些数据类型在内存中是怎样存储的?我们来看一下JavaScript的内存模型。

说是JavaScript的内存模型其实不太准确,只是便于理解。由于JavaScript中的内存分配是由js引擎完成的,所以更准确的描述是js引擎的内存模型

一个运行中的程序总是与内存中的一部分空间相对应。这部分空间叫做 Resident Set (驻留集)。V8(一种JS引擎) 组织内存的方式如下图:

各部分作用如下:

  • Code Segment : 存放正在被执行的代码
  • Stack : 栈内存,存放标识符、基本类型值及引用类型变量的堆地址
  • Heap : 堆内存,存放引用类型值

为什么内存要如此分配?

  • 基本类型变量:标识符与值都存放在栈内存中(数据大小固定,由系统自动分配内存空间)。
  • 引用类型变量:栈内存中存放标识符与指向堆内存中值的地址,堆内存中存放具体值(数据大小可变,例如对象可随意增删属性,分配内存的大小取决于代码)。

四、变量传递

看到有些文章中说基本类型变量复制按值传递,引用类型变量复制按引用传递,又有的说引用类型变量复制按共享传递。总之对新手不太友好,这里我们站在内存层面来解释就比较好解释了。

我们可以理解为JavaScript变量的拷贝都是按栈内存内的值传递,这里栈内存内的值对于基本类型变量来说就是其值,对于引用类型来说就是一个指向堆内存中实际值的地址。

我们来看一个简单的例子理解一下:

let p1 = {name: 'logan'}
let p2 = p1
// p1 和 p2 在栈内存中存放的引用地址相同,都指向堆内存中存放对象 {name: 'logan'}
// 但是这两个引用地址却是相互独立的,并不存在引用关系

// 本质上是对堆内存中的对象进行修改,所以会同时影响p1和p2
p2.name = 'jason'
console.log(p1) // 输出:{name: 'jason'}
console.log(p2) // 输出:{name: 'jason'}

// 这一步是直接修改了栈内存内标识符p2对应值,并不会影响p1
p2 = 3
console.log(p1) // 输出:{name: 'jason'}

函数的参数传递与变量复制传递表现一致,也是按栈内存内的值进行传递,因为本质上来说,函数传参就是把传入的实参拷贝赋值给形参。

五、垃圾回收

垃圾回收是一种内存管理机制,就是将不再用到的内存及时释放,以防内存占用越来越高,导致卡顿甚至进程崩溃。

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

1. 引用计数(reference counting)

这是IE6、7采用的一种比较老的垃圾回收机制。引用计数确定对象是否无用的方法是对象是否被引用。如果没有引用指向对象,对象就可以被回收。我们结合代码来理解:

// 堆内存创建了一个对象{a: 1},我们记为ObjA,变量obj1指向ObjA,ObjA引用次数为1
let obj1 = {
    a: 1
}
// obj2 拷贝 obj1 的地址,也指向ObjA,ObjA引用次数为2
let obj2 = obj1
// 解除obj1对ObjA的引用,ObjA引用次数减一,为1
obj1 = 3
// 解除obj2对ObjA的引用,ObjA引用次数减一,为0,可以被回收
obj2 = 'logan'

缺点:无法处理循环引用

什么意思呢,我们结合代码理解,先看正常情况下引用计数的工作:

function func() {
    // 堆内存创建对象{a: 1},记为ObjA,变量foo指向ObjA,ObjA引用次数为1
    let foo = {a: 1}
    // 堆内存创建空对象,记为ObjB,变量bar指向ObjB,ObjB引用次数为1
    let bar = {}
    // 其属性x指向ObjA,ObjA引用次数为2
    bar.x = foo
    
    // 当函数执行完毕返回时
    // 变量bar生命周期结束,ObjB引用次数减一,为0,可被回收,故对其内部进行回收
    // bar.x生命周期结束,ObjA引用次数减一,为1
    // 变量foo生命周期结束,ObjA引用次数减一,为0,可被回收
}

但是如果两个对象之间存在循环引用,引用计数就会无法处理:

function func() {
    // 堆内存创建对象{a: 1},记为ObjA,变量foo指向ObjA,ObjA引用次数为1
    let foo = {a: 1}
    // 堆内存创建空对象,记为ObjB,变量bar指向ObjB,ObjB引用次数为1
    let bar = {}
    // 变量foo属性x指向ObjB,ObjB引用次数为2
    foo.x = bar
    // 变量bar属性x指向ObjA,ObjA引用次数为2
    bar.x = foo
    
    // 当函数执行完毕返回时
    // 变量bar生命周期结束,ObjB引用次数减一,为1,不可被回收
    // 变量foo生命周期结束,ObjA引用次数减一,为1,不可被回收
}

优点:确定性

引用计数其实也是有优点的,那就是对象一定会在最后一个引用失效的时候销毁,也就是说垃圾回收的时机在代码内是可控的,所以对于对延时比较敏感的场合比较适用。

2. 标记清除(mark and sweep)

从 2012 年起,所有现代浏览器都使用了标记清除的垃圾回收方法。

标记清除的工作原理简化后就是:从垃圾收集根(root)对象(在JavaScript中为全局环境记录)开始,标记出所有可以获得的对象,然后清除掉所有未标记的不可获得的对象。

也就是说,标记清除确定对象是否无用的方法是对象是否可以被获得

现代浏览器对JavaScript垃圾回收算法的改进都是基于标记清除算法的改进,并没有改进标记清除算法本身和它对“对象是否可以被获得”的简化定义。

关于垃圾回收的更多内容,可阅读浅谈V8引擎中的垃圾回收机制

六、内存泄漏

内存泄漏(Memory Leak) 是指程序中己动态分配的堆内存由于某种原因程序未释放或无法释放,造成系统内存的浪费,导致程序运行速度减慢甚至系统崩溃等严重后果。

说到内存泄漏,不得不提一些文章内说闭包会造成内存泄漏,要尽量少用。其实这个观点是错误的,我们运用闭包说到底就两点目的:一是变量私有化,二是延长变量生命周期。 所以说 闭包并不会造成内存泄漏,而是正常的内存使用。

如何避免内存泄漏?一句话:及时解除无用引用。 例如不再需要的闭包、定时器及全局变量等。说到底还是个人编程习惯的好坏,多说无益,列太多的条条框框反而显得繁琐。

识别内存泄漏

  1. 打开Chrome浏览器开发者工具的Performance面板
  2. 选项栏中勾选Memory选项
  3. 点击左上角录制按钮(实心圆状按钮)
  4. 在页面上进行正常操作
  5. 一段时间后,点击Stop,观察面板上的数据

如图所示,内存占用如果整体平稳,说明不存在内存泄漏。

如果内存占用只升不降,或者整体呈一直升高的趋势,说明存在内存泄漏。

内存泄漏定位

如果发现页面存在内存泄漏,我们可以在下方内存图点击对应的内存异常处,然后点击下方面板内的Event Log面板,可以查看代码内具体发生了什么,见下图:

我们发现原来是调用了grow函数

let x = []
function grow() {
    x.push(new Array(1000000).join('x'))
}
document.getElementsByClassName('title-h2')[0].addEventListener('click', grow)

当然,上面的代码只是为了模拟,究竟是否为内存泄漏要看变量x我们是否需要用到,一旦不需要,我们应该解除其引用。

系列文章

深入ECMAScript系列目录地址(持续更新中...)

欢迎前往阅读系列文章,如果喜欢或者有所启发,欢迎 star,对作者也是一种鼓励。

菜鸟一枚,如果有疑问或者发现错误,可以在相应的 issues 进行提问或勘误,与大家共同进步。