内存管理概述
首先我们需要明确一点,无论是什么语言,所谓的内存管理,其实指的是堆内存的管理 (也就是堆内存的申请和释放);栈内存一般都是由操作系统进行管理 ,只有堆内存的管理才可能需要我们关注和参与。而且栈内存的管理也比较简单:将要执行前变量入栈,执行结束后变量出栈。
内存管理的方式根据语言设计的目标和哲学可以分成两种:手动管理和自动管理。
- 手动管理内存:在 C(malloc、free )、C++(new、delete )、Rust 这些语言中,需要我们手动申请和释放堆内存,
- 自动管理内存:而 JAVA、JavaScript、Python、Go 等,他们实现了垃圾回收机制,通过垃圾回收器帮我们自动释放那些不再需要的内存。也就是说,在这些语言中,我们只需要关注内存的申请 ,并不需要关注内存的释放。
但是一般这些实现垃圾回收机制的语言会进一步封装,屏蔽数据结构的具体实现(开发者不用关心什么数据类型低层到底是存在栈中还是存在堆中),让开发者专注于数据结构上层(业务层)设计与使用,比如 JavaScript。
v8 内存架构
内存架构图
新生代(Young generation)
分成两块相同大小的内存空间:From-Space 和 To-Space。由 Minor GC,采用 Scavenge 算法进行管理。
老生代(Old generation)
分成两块:引用区(old pointer space)和数据区(old data space)。由 Major GC,采用 Mark-Sweep-Compact(标记-清除-整理) 算法进行管理。
- old pointer space:包含具有指向其他对象的指针的幸存对象。
- old data space:包含仅包含数据的对象(没有指向其他对象的指针)。字符串、装箱数字和未装箱双精度数组在"新空间"中存活了两个次要 GC 周期后被移至此处(这也是普通数据类型的闭包存放的地方)。
优化 main thread 阻塞思路:Fragment Mark,Lazy Sweep、Lazy Compact
大对象空间(Large object space)
这是大于其他空间大小限制的对象所在的地方,存放一些特别大的对象,垃圾收集器永远不会移动大对象。
一般来说,如果一个对象大小超过了 128KB,那么它就可能被直接存入 Large object space。
代码空间(Code space)
这是即时(JIT)编译器存储编译后的代码块的地方。这是唯一具有可执行内存的空间(尽管 Codes 可能会在"大对象空间"中分配,并且这些空间也是可执行的)
元胞空间(Cell space)、属性元胞空间(Property cell space)和映射空间(Map space)
这些空间分别包含 Cells、PropertyCells 和 Maps。每个空间都包含大小相同的对象,并且对它们指向的对象类型有一些限制,这简化了收集。
堆栈内存如何配合使用
- 栈(stack)内存(static memory allocation):基本类型,对象的引用,闭包
- 堆(heap)内存(dynamic memory allocation):对象类型
栈内存保存基本数据类型 和对象数据类型的引用,堆内存保存对象数据类型。最开始 js 脚本在执行的时候,将全局环境压入执行栈。全局基本类型存入栈内存,对象类型存入堆内存,并在栈中保存对象的引用。当执行到函数调用时,将函数压入执行栈,变量声明采用同样的方式存放到对应的堆和栈中。当函数调用结束时,函数退栈,相应的函数中声明的在栈中的变量也退出栈内存。但是这里存在一个问题,如果函数调用结束产生了闭包,那么我们需要将闭包移动到堆内存中。
每一个线程有自己独立的 stack、heap 内存空间。
GC(垃圾回收器)
我们知道垃圾回收要解决的核心其实就是:怎么分辨哪些内存需要回收?
总的来说,有两种算法(思路)来解决这个分辨是否是垃圾的问题:引用计数 和标记清除。
这是两种不同的思路:
- 引用计数:它的关注点是变量本身 ,通过观察其他变量对自身引用(++)与解引用(--)的统计来判断该变量是否可以被释放。
- 标记清除:它的关注点可触达性(reachable )。假如我们认为 a 应该是可用的(不能释放 a 的内存),那么 a 引用到的变量就必须可用,如果要想 a 引用的变量可用,那么就必须保证 a 引用变量的引用变量可用,以此类推。也就是类似于多米诺骨牌 一样,要想 a 变量可用,那么就找出 a 相关的变量(横向遍历、深度递归),并且保证他们也必须可用。而我们一般认为执行栈栈底的变量是需要保证可用的,比如全局变量。
引用计数(已废弃)
思想
引用计数的基本思路很简单:就是统计变量使用者的数量,持有就加一,释放就减一,如果统计过程中发现变量的使用者数量是 0,那么可以说明这个变量没有人用了,此时就可以进行内存释放。
特性
了解了这个算法的思路,可以发现这种算法具有这些特点:
- 由于标记清除的运行时机是使用者持有或者释放,那么这种算法可以第一时间感知和释放不需要的内存(及时性)。
- 由于我们不知道一个变量的被引用的上限是多少,所以对于计数变量的内存分配是不好把握的,有一种思路就是先分配一个适中的大小,当超过的时候再动态扩大,类似于 js 动态数组的存储处理思路(计数变量的内存大小问题)。
- 引用计数还是一种局部的思路,那么这种思路对于循环引用的情况就会存在问题。比如 a 引用 b,b 又引用 a,从局部的角度看 a 和 b 是不应该释放,但是如果从全局的角度来看,如果全局不再需要使用 a、b 那么他们应该是需要被释放的(循环依赖是引用计数算法的困局,这就要求开发者不再使用那块内存的时候,手动解开循环引用)。
标记-清除-(整理)
思想
引用计数的思路是从使用者的角度出发,观察变量的使用者来确定变量是否可以释放;而标记清除则是一组已知对象指针(称为根集)出发(这包括执行堆栈 和全局对象 ),递归遍历变量引用(使用)链路,通过判断变量是否在这条链路上(可达性 reachable)来判断变量是否可以释放。
标记完成之后,需要使用的内存 和不再使用的内存通过标记就可以将它们区分出来,此时就可以进行内存了。被释放的内存地址通过一个叫做空闲表的数据结构记录起来,这样到后面需要申请新的内存时,就到空闲表里面去找一块出来就行。
整理这一块的工作是可选的 ,它指的是将稀疏的空闲内存碎片整理成一整块空闲内存,这样方便下次内存分配。当然,整理也是需要考虑一些权衡,因为整理需要进行内存复制,复制的成本也是相对比较高的,所以一般来说:我们只整理一些高度碎片化的内存。
特性
根据标记清除算法的实现思路,我们可以发现它具有这些特点:
- 标记清除过程和主线程其他任务的执行是冲突的,不能并行。但是遍历过程又是比较耗时的,因此可能会导致卡顿(阻塞问题)。
- 由于这种算法内存释放和内存回收是相互独立的,那么内存回收的运行时机和回收频率的把握将是一个问题(运行时机、运行频率问题)。
针对这两个问题,我们可以采取一些优化策略来解决。
v8 GC
v8 由两个垃圾回收器(GC),分别为 Major GC 和 Minor GC。Major GC 负责从整个堆(heap)中收集垃圾,而 Minor GC 负责收集新生代(young generation)中的垃圾。
世代算法:该算法基于 世代假设
,也就是假设大多数的变量都是临时变量,如果一个变量在一次检测中存活下来了,那么它在下一次检测时还存活的概率会比较大。那么如果通过多次检测(一般是两次)都存活,那么可以将其放置到另一种类的代中去(老生代)。
接下来我们主要讲一讲新生代(young generation)和老生代(old generation)中的垃圾回收算法。
新生代(Scavenge)
新生代将内存划分成相同大小的两个部分:From-Space(使用区)和 To-Space(休闲区)。新声明的变量会直接写入到 From-Space,同时记录变量的写入次数为 1(初次写入)。当 From-Space 内存满了的时候开始运行 GC,将存活的变量写入到 To-Space,同时变量的写入次数加一,完成之后 To-Space 与 From-Space 对调。等到下一次 From-Space 被写满,运行 GC 时,检查存活变量的写入次数,如果写入次数是 2,就直接存入老生代,而那些写入次数为 1 的才写入 To-Space。
老生代(Mark-Sweep-Compact)
老生代采用 Mark-Sweep-Compact 算法,该算法分成标记、清除、整理三个部分。
标记(增量)
关于标记,如果想要做到增量标记,可以采用三色标记法 和写屏障配合实现。
三色标记法指的是:黑白灰三色:
- 黑色:自身及其成员变量借被标记。
- 白色:未被标记的对象。
- 灰色:自身被标记,但是成员变量没有被标记。
具体算法:
最开始都是白色,接着从根节点开始标记成灰色,相关节点也标记成灰色。当相关节点遍历标记完成时,该节点就可以标记成黑色了。如果系统中没有灰色节点时,此时标记工作就完成了,可以进行下一步清除工作。
对于这种方式,中断修复就很简单了。对于每一次进来,只需要找出所有的灰色节点,从灰色节点开始继续处理就可以了。
写屏障 指的是:一旦有黑色对象引用白色对象,该机制会强制将引用的白色对象改为灰色,从而保证下一次增量 GC 标记阶段可以正确标记,这个机制也被称作强三色不变性。
清除(lazy)
将可以释放的内存统一记录到一张空闲表中去。
整理(lazy)
将高度碎片化的内存整理成一块连续的内存。
常见内存泄漏场景
"The main cause for leaks in garbage collected languages are unwanted references."
总的来说:内存泄漏的主要原因是没有及时释放不需要变量的引用。
场景一:意外泄漏到全局变量
js
function foo() {
// var arr = new Array(1e7).fill(123456789)
arr = new Array(1e7).fill(123456789)
this.arr = new Array(1e7).fill(123456789)
}
foo()
由于全局变量是常驻内存,也就是等到程序运行结束才会释放,这就导致 window.arr
或者 global.a
也要等到程序运行结束再释放。
解决方式是采用:'use strict'
场景二:忘记释放监视
在 js 中,注册事件处理函数是一种很常见的编程模式。当事件发生时,事件调用我们事先写好的事件处理函数。但是我们的事件一直处于监视中,所以事件处理函数的引用也会被监视者一直持有。那么此时事件处理函数所引用的变量时不能被回收的。
如果当事件不再需要监视时,忘记释放所持有的事件,那么就会导致内存泄漏。比如:
- 没有关闭 setInterval
js
const arr = new Array(1e7).fill(123456789)
setInterval(() => {
console.log(arr)
}, 1000)
- 没取消的事件监听
js
var element = document.getElementById('button')
const arr = new Array(1e7).fill(123456789)
function onClick(event) {
console.log(arr)
}
element.addEventListener('click', onClick)
解决方式其实就是移除监听。
场景三:忘记释放 DOM 引用
简单来说就是使用变量去保存 dom,当 dom 被移除时,记得释放那个存储 dom 的变量。
js
let dom = document.getElementById('button')
document.body.removeChild(dom)
// dom = null
解决方式:dom = null
场景四:错误的使用闭包
像这种情况下,Chrome 是很容易区分的,每次函数调用结束就可以确定 data 闭包可以被释放了。
js
const fn = () => {
let data = new Array(1e7).fill(123456789)
const getData = () => data
}
setInterval(() => {
fn()
// console.log(`${Math.ceil(performance.memory.usedJSHeapSize / 1024)} KB`)
}, 1000)
但是加上全局变量一起混用,在以前 Chrome 就搞不清了,会存在内存泄漏(不过这个问题现在已经修复了)。
js
let bigData = null
const fn = () => {
let data = bigData
const getData = () => data
bigData = new Array(1e7).fill(123456789)
}
setInterval(() => {
fn()
// console.log(`${Math.ceil(performance.memory.usedJSHeapSize / 1024)} KB`)
}, 1000)
那么这种情况内存肯定是一直在增加的,算不算泄漏,主要看需求。
js
const fn = () => {
let data = new Array(1e7).fill(123456789)
const getData = () => data
return getData
}
const list = []
setInterval(() => {
const getData = fn()
list.push(getData)
// console.log(`${Math.ceil(performance.memory.usedJSHeapSize / 1024)} KB`)
}, 1000)
由闭包问题导致的内存泄漏主要的解决方案就是:及时释放闭包引用(将引用到闭包的函数置为 null)。
总结
其实内存泄漏的场景还有很多,这里就不一一列举了。总的来说,内存泄漏产生的主要原因是变量的引用没有及时释放,这个问题在嵌套不深的情况下很容易发现,但是在嵌套比较深的情况下,就比较难发现了。总的来说,我们平时开发者应该要有这样的意识:及时释放不需要的变量引用,即使 v8 的 GC 会帮我们处理好绝大多数的情况。
如何排除内存泄漏
内存泄漏情况
本质上有两种类型的泄漏:导致内存使用周期性增加 的泄漏和发生一次但不再导致内存进一步增加的泄漏。
发生一次泄漏这种情况一般来说不算严重,除非泄漏的变量比较多、内存比较大,不过这种也很好排查。
接下来我们看一看周期性泄漏,这是一个相对比较严重的问题。
排除内存泄漏
排查内存泄漏的方法有很多,我们这里使用 Chrome 开发者工具的 Performance 和 Memory 面板来查看内存使用情况。
测试代码
html
<html lang="en">
<body>
<button onclick="normal()">普通操作</button>
<button onclick="start()">开始内存泄漏</button>
<button onclick="stop()">停止内存泄漏</button>
<script>
var x = []
let timer = null
const normal = () => {
x = new Array(1 ** Math.floor(Math.random() * 8)).fill(123456789)
// 其他操作
// 。。。
setTimeout(normal, 1000)
}
const start = () => {
x.push(new Array(1e5).fill(123456789))
timer = setTimeout(start, 1000)
}
const stop = () => {
clearInterval(timer)
}
</script>
</body>
</html>
正常情况
我们先看看正常情况下的内存使用情况,打开控制面板,选择 Performance 选项卡,点击面板左上角小圆圈按钮开始记录,然后点击 普通操作 按钮,等待一段时间后,点击停止按钮停止记录。中途我们需要大概十秒钟左右点击一下清除按钮,进行一下垃圾回收。
我们知道,每一次低谷代表运行了 GC,释放了临时变量。可以看到每一次的低谷基本上差不多,没有增长的趋势,所以这是正常的情况。
内存泄漏情况
接下来我们再来看看内存泄漏的情况,同样打开控制面板,选择 Performance 选项卡,点击面板左上角小圆圈按钮开始记录,然后点击 开始内存泄漏 按钮,等待一段时间后,点击停止按钮停止记录。中途我们需要大概十秒钟左右点击一下清除按钮,进行一下垃圾回收。
可以看到我们的低谷具有慢慢增高的趋势,说明存在内存泄漏。
找到内存泄漏
找到内存泄漏的方法有很多,我们这里简单看两种:对比快照和分析 timeline
对比快照的思路是:先在初始状态拍下一张快照,然后再在内存泄漏的情况下拍下一张快照,通过对比这两张快照来发现可疑的变量。
分析 timeline 的思路是:先在初始状态下录制一段 timeline,选择蓝色尖峰,这些代表内存分配,我们将选择设置为尽可能接近大峰值之一,通过观察它的内存分配情况发现可疑的变量。
WeakMap 测试
js
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms * 1000))
const consoleMemory = async () => {
global.gc()
await sleep(1) // 确保GC异步运行完
const memoryUsage = process.memoryUsage().heapUsed / 1024
console.log(`${memoryUsage} KB`)
}
const work = async () => {
await consoleMemory() // 2445.90625 KB
let wm = new WeakMap()
let a = {}
await consoleMemory() // 2694.0546875 KB
wm.set(a, new Array(1e7).fill(123456789))
await consoleMemory() // 80816.4140625 KB
a = null
await consoleMemory() // 2690.71875 KB
}
work()
// node --expose-gc gc.js
可以发现在 WeakMap 中,当我们 key 的引用被释放时,value 所引用的内存会自动释放。大家也可以自己测试一下 WeakSet。
需要注意的是:默认情况下,Node.js 是禁用 global.gc()方法的,需要在启动 Node.js 时使用--expose-gc 选项来启用该方法,也就是 node --expose-gc xxx.js
。
写在最后
以上是我个人的思考和整理,欢迎各位大佬交流和指正。
参考
Trash talk: the Orinoco garbage collector
Memory Management in V8, garbage collection and improvements
Demystifying memory management in modern programming languages
4 Types of Memory Leaks in JavaScript and How to Get Rid Of Them