跳到主要内容

V8内存模型

内存区分类

栈区:

保存变量名和内存地址的键值对

注意:仅声明一个变量,或将此变量赋值为undefined时内存地址为空


堆区:

堆区是V8引擎为开发者提供的可直接操作的内存区 => 引用数据类型的操作

注意:预置对象null有固定且唯一的内存地址,变量赋值为null就指向这块内存地址

手动释放内存可以设置变量为null或undefined


常量区:

Number、String和Boolean这三类值都是存储在常量区

注意:常量区所有的值都是不可变的,所有相同的常量值在常量区都是唯一的

理解:

一、当为一个常量重新赋值的时候,会在常量区新开辟一块内存来保存新的常量,原变量指向这块新内存

二、相同的常量只有一个引用地址,所以在使用===时才会为true

js为这三种类型定义了对应的包装类型,用于将其封装成引用类型

new String('hello') Number(12) Boolean(true)

实际上每当JavaScript引擎读取一个基本数据类型时,它都会在内存创建对应的引用类型,这也是我们能在一个基本数据类型上调用对象方法的原因

'hello'.toUpperCase()

函数定义区:

存储定义的所有函数

定义函数的两种方式:声明式引用式

本质区别:

  • 声明式定义的函数不会在栈区生成对应的函数名(因为不是变量),引擎直接在函数定义区定义这类函数

  • 引用式定义的函数在栈区生成一个变量来保存这个函数的地址

调用方式不同表现:

  • 声明式在预扫描阶段进行函数提升,可以在函数定义之前调用该函数
  • 引用式使用var声明存在变量提升,但是并不会赋值,不可以在函数定义之前调用该函数

注意:浏览器会以通过栈区变量引用的函数优先(优先搜索栈区),如果搜索不到或为undefined,则会去函数定义区搜索


函数缓存区:

函数运行所用的内存区,当V8引擎需要执行一个函数时,它就会在函数缓存区开辟一块内存,保存该函数运行所需要存储的状态和变量。通常情况下,函数一旦运行完毕,浏览器就会回收这块内存,但闭包是个例外。

function f(){
var a = 'hello';
return function(){
//返回的函数里需引用该函数外的一个变量
console.log(a);
}
}
var g = f();

函数执行完成以后返回一个匿名函数,正常应该可以释放这块分配的缓存区,但是变量g持有的匿名函数还可以访问缓存区中定义的变量,如果释放执行f()时分配的内存,a也释放,执行g()就会出错,所以引擎不会释放该缓存区,这样就在函数的缓存区形成了一个闭包,为运行函数分配的内存中的变量在外部无法直接访问,唯一的方式就是通过匿名函数,这样就保护了函数f内部定义的变量,并且向外暴露了指定的接口

通过闭包来模拟块级作用域保护数据

(function(){})()
(function(global,factory){
factory(global)
})(window,function(global){
let hello = 'hello'
let sayHello = ()=>[
console.log(hello)
]
let myMethod = function(){
return {
sayHello
}
}
window.amyMethod = myMethod
})
console.log(window.amyMethod())
amyMethod().sayHello()

闭包里面定义的hello,只能由暴露的sayHello方法来访问,这样我们就把这个匿名函数的内部保护了起来,只通过一个全局对象向外提供接口,这样就很好地避免了命名冲突。这也是ES5中模拟块级作用域的通用方法

内存分配

划分为新生代和老生代

QQ截图20220219102146

新生代:短时间存活的新变量会存在新生代中,新生代的总内存量极小,由两个reserved_semispace_size_构成,按机器位数不同,reserved_semispace_size_在64位系统上分别为16M和8M,所以新生代内存的最大值在64位系统和32位系统上分别为32MB和16MB。

新生代一分为二,每一部分空间称为semispace,只有一个处于使用的Form空间,另一个处于闲置状态的To空间

老生代:生存时间比较长的变量,会转存到老生代,老生代占据了几乎所有内存

intptr_t MaxReserved() {
return 4* reserved_semispace_size_ + max_old_genration_size_
}

默认情况下,V8堆内存的最大值在64位系统上为1464MB,32位系统上则为732MB

为什么要这样设计?

  1. 1.4G对于浏览器脚本来说足够使用
  2. 回收垃圾是阻塞式的,也就是进行垃圾回收的时候会中断代码的执行,以1.5GB的垃圾回收堆内存为例,V8做一次垃圾回收需要50毫秒以上,做一次非增量式的垃圾回收甚至需要1秒以上

缺点:这样单个node进程的情况下,无法将一个2GB的文件读入内存中进行字符串分析处理,计算机的内存资源无法得到充分的使用

垃圾回收

V8的分代式垃圾回收

新生代回收算法(Scavenge下的Cheney):复制-清空,当分配对象时,先从From空间进行分配。当开始垃圾回收时,会检查From空间中存活对象,这些存活的对象的变量复制到to空间,把From空间清空,然后From和To空间角色互换,这样可以提升回收速度,典型的牺牲空间换时间

垃圾回收中晋升老生代

  1. 新生代发现本次复制后,会占用To空间超过25%的空间,则这个对象直接晋升到老生代空间中
  2. 这个变量已经经历过一次复制,经过复制依然存活会被认为是生命周期较长的对象

老生代回收算法

标记清除法(Mark-Sweep):

  1. 将所有的指针(变量名)和分配出去的内存打上标记。
  2. 从栈区开始查找所有可用的变量名,清除这些变量名的标记,并且清除它们指向的内存的标记。
  3. 标记清除结束后,回收所有仍然带有标记的内存(说明没有一个有效的变量名指向这块内存)。

Scavenge中只复制活着的对象,而Mark-Sweep只清理死亡对象。活着的对象在新生代中只占较小部分,死对象在老生代中只占小部分。

Mark-Sweep最大的问题是在进行一次标记清除回收后,内存空间会出现不连续的状态。这种内存碎片会对后续内存分配造成问题,需要进行整理,为此演变标记整理

标记整理(Mark-Compact):

差别在于对象在标记为死亡后,在整理的过程中,将活着的对象往一段移动,移动完成后,直接清理掉边界的内存,也就是直接清除活着的对象后面的内存区域完成回收

V8在回收策略中两者是结合使用的。在空间不足以对从新生代中晋升过来的对象进行分配时才使用Mark-Compact

为了避免出现JavaScript应用逻辑与垃圾回收看到不一致的情况,垃圾回收的3种基本算法需要将应用逻辑暂停下来,在执行完垃圾回收再恢复执行应用逻辑,这种行为被称为“全停顿”(stop-this-world)。

真正应用中一次小垃圾回收只收新生代,老生代全停顿的时间较长,为了降低停顿时间,V8先从标记入手,将原本要一口气停顿完成的动作改为增量标记(incremental marking),垃圾回收与应用逻辑交替执行直到标记阶段完成, 另外V8还引入了延迟清理(lazy sweeping)与增量式整理(incremental compaction)让清理和整理动作变成增量式

什么数据会被回收

  1. 全局变量会直到程序执行完毕(浏览器卸载页面)才会回收
  2. 普通变量在失去引用后会被回收

一个变量b在使用后后续将不在使用被回收

function a(){
let b = 123
console.log(b)
}
a()

闭包可以保持一个变量不会被回收

let mycache = (function(){
let cache = {}
return {
get:function(){
return cache
}
set:function(){}
...
}
})()
//cache不会被回收

什么时候会触发回收

  1. 新生代区满触发自己回收
  2. 新生代晋级老生代发现本次添加超过阈值老生代回收
  3. 大对象直接存放到老生代空间已经不足,触发回收
  4. 强制通知系统进行回收
  5. 宏任务结束后和开始前都会清理(见后文检测)

如何检测内存

浏览器:window.performance.memory

Node端:process.memoryUsage()

使用node来检测内存:

设置老生代与新生代

node --max-old-space-size=800 --max-semi-space-size=500 index.js

设置老生代值规律:知道大小大于当前内存的使用量就可以在进行一次写入,后续写入就会报错

node --max-old-space-size=1203 index.js
function testMemory(){
let memory = process.memoryUsage()
// console.log(memory)
/*
{
rss: 18878464,
heapTotal: 4468736,
heapUsed: 2620504,
external: 880204,
arrayBuffers: 9898
}
*/
console.log(memory.heapUsed/1024/1024 + 'mb')
}
let size = 30 * 1024 * 1024;
testMemory()
let arr1 = new Array(size)
testMemory()
let arr2 = new Array(size)
testMemory()
let arr3 = new Array(size)
testMemory()//722.9090194702148MB 727.7890625
let arr4 = new Array(size)
testMemory()//962.9096145629883MB 971.79296875
let arr5 = new Array(size)
testMemory()//1202.9410934448242MB 1219.796875
let arr6 = new Array(size)
testMemory()//1442.2205352783203MB 1443.80078125

测试1:设置值为723-962

结果:后两次无法写入,相对于自动进行了一次扩容

测试2:测试执行时机

let size = 30 * 1024 * 1024;
let arr0 = new Array(size)
testMemory(0)
setTimeout(()=>{
let arr1 = new Array(size)
testMemory(1)
})
new Promise((resolve)=>{
let arr2 = new Array(size)
resolve(arr2);//如果不使用arr2,写入arr4前清除
testMemory(2)
}).then((res)=>{
let arr3 = new Array(size)
testMemory(3)
//console.log(arr4)
arr1 = undefined
})
testMemory(2)
let arr4 = new Array(size)
testMemory(4)
/*
标记0 242.5782012939453MB 244.27734375
标记2 482.7175521850586MB 485.78125
标记2 482.9697265625MB 486.03515625
标记4 722.9692153930664MB 728.2890625
标记3 722.9762954711914MB 732.2890625
标记1 482.32247161865234MB 491.78515625
*/

以上结果说明了在进入微任务前执行了一次清理,把arr4清理了,如果后续有打印就不会清理arr4,最后剩下arr2和arr1

通过内存模型解决js中的疑惑

关于==和===

  • 引用类型仅比较内存地址是否相等
  • 非引用类型===需要值和类型都相同,==在类型不同时进行类型转换然后比较值

注意:堆区只有一个null,使用同一个地址,null === null

变量与函数提升

JavaScript引擎在执行代码之前,会首先扫描一次代码,将通过函数声明(即function f())定义的函数预先保存在函数定义区,并将所有通过var声明的变量先添加到栈中。注意,经过提升的函数此时已经可以直接调用了,但是变量的值仍然是undefined(变量提升并不会为其赋值,因为还没有执行代码)。这个过程就是变量和函数提升。

关键字let与const

let关键字:let声明的变量不会发生变量提升,所以在使用let定义变量之前,如果调用这个变量,就会报错

let关键字定义的变量只在所在的块级作用域内有效,因此我们不需要通过闭包来模拟块级作用域了。

const关键字:定义一个常量,核心是变量指向的内存地址是不可变的,对于引用类型可以对数据进行操作,另外const声明变量必须初始化

函数也是对象

# 两者定义函数是等价的
var f = function(msg){console.log(msg)}
var f = new Function("console.log(msg)", msg);

说明函数可以直接调用为对象定义的一些公共方法

利用这个特性,可以对函数的运算值进行缓存

function f(a){
// 假设经历一系列计算
let result = [a]
console.log(result)
f[a] = result;//将要返回的值缓存
return result;
}
console.log(f('a'))
let b = f['a']?f['a']:f('a')
console.log(b);
console.dir(f);//result只打印了一次,并且函数f上存储了属性a

this指向问题

普通函数与构造函数的根本区别是是否通过new关键字来调用

this是一个指向调用当前函数的对象的指针

注意:在全局环境定义的所有函数和变量默认都属于全局对象

调用普通函数是为了获得一个计算结果,而调用构造函数则是为了获得一块可操作的内存