Skip to main content

内存管理

为什么要管理内存

减少浏览器负担

内存过大会让浏览器压力过大,导致浏览器卡顿

Node 端 - 做服务

内存如果不够,服务就会中断

内存的数据存储

栈内存

线性的,后进先出

堆内存

非线性,不连续的

存储普通类型的变量 - 栈内存

let a = 1
let b = 2
  • 先入栈 a-123
  • 再入栈 b-10

变量直接指向值,变量和值放在一起

存储引用类型的变量 - 堆栈

let a = { a1: 123 }
let b = a
let c = [1, 2, 3]
let d = function() { console.log(123) }
{ a2: 666 }
  • 声明引用类型变量 a 的时候,先在堆内存中开辟一个地址存放变量(变量本体),再把这个地址赋值给变量,即入栈 a-0x00000000
  • a b 指向同一地址,所以入栈 b-0x00000000
  • 同声明 a, 入栈 c-0x00000005
  • 同声明 a, 入栈 d-0x00000008
  • 直接定义一个引用类型,在堆内存中开辟一个地址存放变量(变量本体)

V8 内存的管理

V8到底有多大

  • 64 位下是1.4G(标准)
  • 32 位下是 700MB (标准)
  • 根据浏览器不同会有些许扩容
  • 新版本 Node 也会有自动调用 C++ 内存进行内扩容(动态扩容)

新生代和老生代

比如我们 64 位 1.4G 的内存,分为两块

  • 新生代:短时间存活的新变量会存在新生代中,新生代的内存量极小,64 位下大概是 32MB, 32 位则减半。新生代平均分成两块相等的内存空间,叫做semispace,每块内存大小8MB(32位)16MB(64位)
  • 老生代:存活时间比较长的变量会转存到老生代,老生代占据了几乎所有内存(常驻),64 位下大概是 1400MB

新生代

新生代使用 Scavenge GC 垃圾回收算法,该算法实现时主要采用 Cheney 算法。主要处理存活周期短的对象中的可访问对象。

Cheney 算法使用了 semi-space 半空间的设计,将内存一分为二,始终只使用一半的空间,一块 From-Space 是使用空间,另一块 To-Space 是空闲空间

初始的时候 to 空间是空的,新定义的变量都在 from 空间中

|<- 新生代 ->|<-           老生代            ->|
|-----|-----|--------------------------------|
From To
  • 内存先进入 From(新生代在 From 中分配对象)
  • 等到 From 空间使用到一定程度之后,新生代的 GC 会启动(在垃圾回收阶段检查并按需复制 From 中可访问对象到 To 或老生代)
  • From 释放不可访问对象占用的内存空间,达到清空效果,回收完成
  • 再将 FromTo 的进行互换,即 From => To, To => From

如果一个对象已经经历过一次回收,就需要进入老生代

新生代发现本次复制后,会占用超过 25% 的 To 空间,更替时就直接进入老生代

总结就是重复 复制-清空 的过程

老生代

Mark-Sweep - 标记清除

标记清除,分为标记和清除两个阶段

  • 标记阶段:遍历所有可访问对象,GC 会从一组已知的对象指针(称为根集,包括执行堆栈和全局对象等)中,进行递归标记可访问存活的对象
  • 清除阶段:清除没有被标记的对象,将不可访问的对象留下的内存空间,添加到空闲链表的过程。未来为新对象分配内存时,可以从空闲链表中进行再分配
Mark-Compact - 整理

主要处理存活周期长的对象中的不可访问对象, 主要使用 Cheney 复制算法

清除完对象后,内存内部的对象就不连续了,这样无法很好的利用内存地址(需要连续的内存),比如大的对象无法塞入刚释放的小内存中,提前触发垃圾回收

Mark-Compact 移动这些对象,让他们变紧凑,即标记清除对象后内存空间会出现内存碎片,当碎片超过一定限制后会启动压缩算法,将存活的可访问对象向内存一端移动,直到所有对象都移动完成,然后清理不需要的内存

因为新生代中占少数的是可访问对象,老生代中占少数的是不可访问对象,所以 Scavenge GC 垃圾回收算法 和 Mark-Compact 算法配合十分高效

Incremental Marking - 增量标记

上面提到的标记阶段需要遍历所有可访问对象,对于大型的堆内存来说,可能需要几百毫秒才能完成一次标记。在这期间会造成停顿,阻碍主线程的执行,一般来说老生代存在大量存活的对象,标记阶段的堆内存遍历会造成一定的卡顿,影响用户体验。所以 v8 引入了增量标记。

增量标记中,先标记堆内存中的一部分对象,然后暂停,将执行权重新交给 JS 主线程,等待主线程任务执行完毕后再从原来暂停标记的地方继续标记,知道标记完整个堆内存,即把垃圾回收分段,穿插在 JS任务中间执行,尽可能少的影响 JS 主线程任务,避免应用卡顿,提升应用性能

拓展

得益于增量标记的好处,v8 又引入了延迟清理(lazy sweeping)和增量式整理(incremental compaction)
为了充分利用多核 CPU 性能,也引入了平行标记、并发标记和并行清理,进一步减少垃圾回收对主线程的影响,提升性能
平行标记发生在主线程和工作线程上,应用程序在整个平行标记阶段暂停,是全停顿遍历标记的多线程版本
并发标记主要发生在工作线程上,并发标记进行时,主线程的 JS 可以继续运行

什么时候触发回收

1.执行完一次代码

let a = 1
let b = 2
console.log(a)
setTimeout(() => {
b++
console.log(b)
// 回收一次
}, 2000)
// 回收一次

2.内存不足

let size = 30 * 1024 * 1024
let arr1 = new Array(size)
testMemory()
let arr2 = new Array(size)
testMemory()
let arr3 = new Array(size)
testMemory()
let arr4 = new Array(size)
testMemory()
let arr5 = new Array(size)
testMemory()
let arr6 = new Array(size)
testMemory()

判断一个变量可以回收的标准

    1. 全局变量直到程序执行完毕,才会回收
    1. 普通变量失去引用时会被回收
function testMemory() {
let memory = process.memoryUsage().heapUsed
console.log(memory / 1024 / 1024 + 'mb')
}

let size = 30 * 1024 * 1024
let arr1 = new Array(size)
testMemory()
(function() {
let arr2 = new Array(size)
testMemory()
let arr3 = new Array(size)
testMemory()
let arr4 = new Array(size)
testMemory()
})()
let arr5 = new Array(size)
testMemory()
let arr6 = new Array(size)
testMemory()

// node --max-old-space-size=1000 xx.js 指定最大老生代空间

如何检测内存

浏览器

window.performance.memory
MemoryInfo 
{
totalJSHeapSize: 95959081,
usedJSHeapSize: 90730505,
jsHeapSizeLimit: 4294705152
}

Node

process.memoryUsage()
> process.memoryUsage()
{
rss: 35344384, // node 总占用内存,不仅有 v8引擎占用,还有 C++ 程序占用
heapTotal: 4784128, // v8 引擎总内存
heapUsed: 3360920, // v8 引擎使用内存
external: 1582927, // C++ 分配给 v8 的额外内存
arrayBuffers: 9405 // node 14 版本后的新特性,arrayBuffers 的占用内存
}
  • 手动触发垃圾回收:global.gc
  • 设置内存:
    • --max-old-space-size 老生代空间最大值
    • --max-new-space-space 新生代空间最大值

如何优化内存

  • 尽量不要定义全局变量,定义后需要及时手动释放
  • 注意闭包

为什么 v8 要设计为 1.4g

  • 1.4g 对于浏览器够用
  • 回收是阻塞式的,即垃圾回收时会中断代码执行