本文正在参加「金石计划」
javascript语言不同于c语言(使用malloc等内存分配函数获取内存即是从堆中分配内存)和C++有以malloc包为基础垃圾收集器,而是在创建变量时通过自动内存管理实现内存分配和闲置资源回收,并且这个过程是周期性的(每隔一定时间会自动运行)。而问题是,垃圾回收程序必须跟踪记录某个内存是否中的变量是否要被清除,毕竟靠算法时判定不来的?
那么接下来,就得好好聊聊了。
一、v8引擎的内存如何分配?
在v8引擎内部实际上有栈内存和堆内存两大块空间。栈内存是线性连续存储的,并且栈空间的分配和回收都是由系统来做的,栈空间的大小也是固定的,而堆内存还划分为多个不同的区,其中有新生代、老生代、大对象空间、代码空间等,如下图所示:
v8引擎的内存大小实际上和操作系统有关。如果操作系统以64位存储,那么新生代空间时64MB,老生代空间是1400MB,Node环境下是1.4G;而32位存储下,新生代的空间是32MB,老生代的空间是700MB,Node环境的空间大小是0.7G。如下表所示:
64位操作系统 | 32位操作系统 | |
---|---|---|
v8引擎内存大小 | 1.4G(1464MB) | 0.7G(732MB) |
新生代空间 | 64MB | 32MB |
老生代空间 | 1400MB | 700MB |
最新版的Node(V14)内存为2GB,内存空间可扩充 |
通过以上我们需要解决的问题是:
- 栈内存是如何回收的?
- 堆中的数据是如何回收的?
- 为什么需要知道内存大小?
- 为什么新生代和老圣代内存大小不一样?
- 新生代和老生代的回收算法一致嘛?为什么?
二、关于内存
之所以要关注内存,是为了防止页面占用内存过大,引起客户端卡顿,甚至无响应;Node使用的也是v8,内存对于后端服务更容易造成内存溢出(后端宕机)。
三、栈内存如何回收
当函数执行完毕,js引擎是通过移动ESP(ESP:记录当前执行状态的指针)来销毁函数保存在栈当中的执行上下文的,栈顶的空间会被自动回收,不需要V8引擎的垃圾回收机制出面。然而,堆内存的大小是不固定的,那堆内存中的数据是如何回收的呢?
四、堆内存如何回收
了解堆内存是如何回收的之前,先了解以下概念:
1、新生代和老生代
新生代(副垃圾回收器)和老生代(主垃圾回收器)都还划分为两个区域,新生代划分为是Semi space From
和Semi space To
,简称一个From
区域,一个To区域
,以64位操作系统存储的话,两个区域各占32MB。
而老生代划分为Old pointer space
和Pld data space
,以64位操作系统存储的话,两个区域各占700MB。
另一方面,新生代用于存放生存时间较短的对象,而老生代用于存放时间长的对象。那么问题来了,如何判定一个对象存活时间是否长久?
2、代际假说和分代收集
(1)大部分对象在内存中存在的时间很短,对象已经分配内存,很快就会变得不可访问。例如,在函数作用域内部定义的变量,只要这些变量没有被引用,那么等函数执行完毕,虽然这些变量不会立马被垃圾回收,但是当垃圾回收机制需要使用这块内存的时候,这些变量的内存就会被回收。
(2)不死的对象,会活得更久。怎么样才算是不死的对象,实际上,被循环引用的对象就是不死的对象,因为被循环引用,意味着永远也不会被垃圾回收机制回收。
3、垃圾回收器的流程
(1)标记空间中活动的对象和非活动的对象
(2)回收非活动对象的所占据的内存
(3)内存中整理(将内存碎片整理成连续的内存空间)
垃圾回收机会的大致流程就是这样,实际上也还有一些细节。
五、垃圾回收器的原理
1、新生代的回收
其实,实现垃圾回收机制的算法有很多种,但是比较常用的就是标记整理和引用计数。新生代简单来说就是搬运(或者说是复制),即使用Scavenge算法
将新生代两个区域无限翻转互换。新生代区域=对象区域+空闲区域或者说新生代区域=From
+To
。这就意味着,总有一半的区域是空闲的,浪费的。
接下来看看Scanvenge
算法的流程:
1)新对象进入对象区域
(2)对象区域快要存满时进行垃圾回收
(3)对象区域中剩下的对象转移到空闲区域
(4)两个区域对调
对象晋升策略
来了:
经过两次垃圾回收还存活的对象,会被移入老生代区。如图所示:
2、老生代的回收
(1)标记整理(mark-and-compact)
Javascript最常用的垃圾回收策略就是标记清理(mark-and-sweep),顾名思义,当变量进入执行上下文,这个变量会被加上存在于执行上下文的标记,这样垃圾回收机制可能永远不会释放存在于执行上下文的变量,而当变量离开执行上下文时,也会给变量添加离开执行上下文的标记,这样的话,等到垃圾回收程序做一次内存清理的时候,首先会进行广度扫描,把相关联的变量节点使用存在执行上下文的标记,然后就会回收带离开执行上下文标记的内存。如下图所示:
标记清理后很明显的一个缺点就是,内存是一个连续的内存,但是经过标记清理后,留下很多碎片化的内存,这样的话,就会导致内存的浪费。另外,在整个过程其实是
全停顿标记:js是运行在主线程上的,一旦垃圾回收生效,js脚本就会暂停执行,等到垃圾回收完成,再继续执行
。
所以经过优化,不再使用标记清理,而是标记整理,一字之差,但是效果就完全不一样了。如下图所示:
这么一看,标记整理后的内存就不是碎片化了,也即是说,标记整理中的整理,实际上是针对碎片的整理。另外,这个过程也不是
全停顿标记
,而是增量标记和三色标记法
。所谓增量标记就是,不再像标记清理里面的,一次性标记所有进入执行上下文的变量,而是当垃圾回收程序运行时,将GC Root
相关的下一个节点标记h黑色
,然后执行代码,等到下一次垃圾回收程序运行时,看见这个被标记黑色
的节点,则再将与其相关的节点标记为黑色,本身标记成白色.....,如此往复,等到最后,根据三色标记,整理后清理不再被需要的变量,释放内存,如下图所示:
我们知道,javascrript和nodejs底层都是c++实现的,那么nodejs的内存使用情况又是如何?这就要扯皮到process.memoryUsage()方法
:
process.memoryUsage()方法是进程模块的内置方法,提供有关Node.js程序的当前进程或运行时的信息。内存使用情况方法返回一个对象,该对象以Node.js进程的字节数描述内存使用情况,用法如下:
process.memoryUsage()
参数:此方法不接受任何参数:
返回值:此方法返回一个带有内存使用说明的对象
试一试:
let process = require('process')
console.log(process.memoryUsage())
另外,我们也可以手动扩充内存大小,如下:
node --max_old_space_size=5000 index.js
(Old Space的单位是MB,New Space的单位是KB)
node --max_new_space_size=5000 index.js
(2)引用计数
引用计数的大致过程是:对每个变量值都记录被引用的次数,声明变量并赋引用值时,引用数为1;如果同一个值被赋值给另外一个变量,那么引用数加1。类似地,如果保存该值引用的变量被其他值给覆盖了,那么引用数减1,当一个值的引用数为0时,就说明垃圾回收程序可以回收变量了。但是这个有一个很严重的问题就是,循环引用的变量的引用数永远也不会为0。举个例子:
let obj = {
name: 'hello world',
age: 19,
}
let data = {
gender: 'female',
}
obj['gender'] = data
data['other'] = obj