前言
每种编程语言都有它的内存管理机制,比如简单的C有低级的内存管理基元,像malloc()
,free()
。而对于JavaScript来说,会在创建变量(对象,字符串等)时分配内存,并且在不再使用它们时“自动”释放内存,这个自动释放内存的过程称为垃圾回收。
因为自动垃圾回收机制的存在,让大多Javascript开发者感觉他们可以不关心内存管理,所以会在一些情况下导致内存泄漏。
内存生命周期
JS 环境中分配的内存有如下声明周期:
- 内存分配:当我们申明变量、函数、对象的时候,系统会自动为他们分配内存
- 内存使用:即读写内存,也就是使用变量、函数等
- 内存回收:使用完毕,由垃圾回收机制自动回收不再使用的内存
JavaScript 的内存分配
一般来说JS的内存空间分为栈(stack)
、堆(heap)
、池
(一般也会归类为栈中)。
其中栈存放变量,堆存放复杂对象,池存放常量,所以也叫常量池。
通俗点说就是:栈是存放基本数据类型及对象变量的指针的,堆是存放引用数据类型的。
堆内存与栈内存是有区别的:
栈内存运行效率比堆内存高,空间相对推内存来说较小,反之则是堆内存的特点。所以将构造简单的原始类型值放在栈内存中,将构造复杂的引用类型值放在堆中而不影响栈的效率。
数据类型:
基本数据类型:String
、Number
、Boolean
、Null
、Undefined
引用类型:Object
1. 基本数据类型与栈
基本数据类型保存在栈内存中,因为基本数据类型占用空间小、大小固定,通过按值来访问,属于被频繁使用的数据。(需要注意的是闭包中的基本数据类型变量不保存在栈内存中,而是保存在堆内存中
)
举个栗子:
为了不让程序员费心分配内存,JavaScript 在定义变量时就完成了内存分配。(在变量对象中执行数据复制的时候,其实系统会自动为新的变量分配一个新的值,所以a与b其实已经是完全独立的两个变量,只是值一样而已。)
let a = 20; // 给数值变量分配内存
let b = a; // 给数值变量分配内存
b = 30
console.log(a,b) // a = 20 b = 30
2. 引用数据类型与堆
引用数据类型存储在堆内存中,因为引用数据类型占据空间大、大小不固定。 如果存储在栈中,将会影响程序运行的性能;
引用数据类型在栈中存储了指针,该指针指向堆中该实体的起始地址。 当解释器寻找引用值时,会首先检索其在栈中的地址,取得地址后从堆中获得实体。
举个栗子:
var m = {
a:10,
b:20
};
var n = m;
n.a = 15;
console.log(m.a); //15
垃圾回收
为什么要回收?程序的运行需要内存,只要程序提出要求,操作系统或者运行时就必须提供内存,那么对于持续运行的服务进程,必须要及时释放内存,否则,内存占用越来越高,轻则影响系统性能,重则就会导致进程崩溃。
什么是内存泄漏?不再用到的内存,没有及时释放,就叫做内存泄漏(memory leak)。
有些语言(比如 C 语言)必须手动释放内存,程序员负责内存管理。
垃圾回收算法
最常见的两种垃圾回收算法:引用计数
与 标记清除
1. 引用计数
这种方式常常会引起内存泄漏,低版本的IE使用这种方式。
原理:跟踪记录每个值被引用的次数。(引用计数算法定义“内存不再使用”的标准很简单,就是看一个对象是否有指向它的引用。 如果没有其他对象指向它了,说明该对象已经不再需了。)
工作流程:
- 当声明了一个变量并且将一个引用类型赋值给该变量的时候这个值的引用次数就为 1。
- 如果同一个值又被赋给另一个变量,那么引用数加 1。
- 如果该变量的值被其他的值覆盖了,则引用次数减 1。
- 当引用次数变成0时,说明没办法访问这个值了。
- 当垃圾收集器下一次运行时,它就会释放引用次数是0的值所占的内存。
let a = new Object() // 此对象的引用计数为 1(a引用)
let b = a // 此对象的引用计数是 2(a,b引用)
a = null // 此对象的引用计数为 1(b引用)
b = null // 此对象的引用计数为 0(无引用)
... // GC 回收此对象
目前看起来此方式很简单明了,但是很快我们就会遇到一个问题:循环引用,即对象 A 有一个指针指向对象 B,而对象 B 也引用了对象 A ,如下:
function test(){
let A = new Object();
let B = new Object();
A.b = B;
B.a = A;
}
// 对象 A 和 B 通过各自的属性相互引用着,按照上文的引用计数策略,它们的引用数量都是 2,
// 按理来说在函数 test 执行完成之后,对象 A 和 B 是要被清理的
// 但由于它们的引用数量不会变成 0,故使用引用计数则不会被清理。
// 假如此函数在程序中被多次调用,那么就会造成大量的内存不会被释放
综上所述,引用计数算法是个简单有效的算法。但它却存在一个致命的问题:循环引用。如果两个对象相互引用,尽管他们已不再使用,垃圾回收器不会进行回收,导致内存泄露。
2. 标记清除
原理: 是当变量进入环境时,将这个变量标记为“进入环境”。当变量离开环境时,则将其标记为“离开环境”。标记“离开环境”的就回收内存。
工作流程:
- 垃圾收集器会在运行的时候会给存储在内存中的所有变量都加上标记。
- 清除「进入环境中的变量的标记」或者「被进入环境中的变量所引用的变量的标记」。
- 那些还存在标记的变量被视为准备删除的变量。
- 最后垃圾收集器会执行最后一步内存清除的工作,销毁那些带标记的值并回收它们所占用的内存空间。
标记清除算法也有一个很大的缺点,就是在清除之后,剩余的对象内存位置是不变的,也会导致空闲内存空间是不连续的,出现了 内存碎片(如下图),并且由于剩余空闲内存不是一整块,它是由不同大小内存组成的内存列表,这就牵扯出了内存分配的问题
假设我们新建对象分配内存时需要大小为 size,由于空闲内存是间断的、不连续的,则需要对空闲内存列表进行一次单向遍历找出大于等于 size 的块才能为其分配(如下图)
那如何找到合适的块呢?我们可以采取下面三种内存分配策略:
-
First-fit
,找到大于等于 size 的块立即返回 -
Best-fit
,遍历整个空闲列表,返回大于等于 size 的最小分块 -
Worst-fit
,遍历整个空闲列表,找到最大的分块,然后切成两部分,一部分 size 大小,并将该部分返回
分类 | First-fit | Best-fit | Worst-fit |
---|---|---|---|
优点 | 分配的速度和效率更高 | -- | 空间利用率看起来是最合理 |
缺点 | -- | -- | 切分之后会造成更多的小块, 形成内存碎片, 所以不推荐使用 |
3. 引用计数PK
标记清除
分类 | 引用计数 | 标记清除 |
---|---|---|
优点 |
回收时机: 引用计数算法:引用计数在引用值为 0 时,也就是在变成垃圾的那一刻就会被回收,所以它可以立即回收垃圾 标记清除算法:需要每隔一段时间进行一次,那在应用程序(JS脚本)运行过程中线程就必须要暂停去执行一段时间的 GC,另外,标记清除算法需要遍历堆里的活动以及非活动对象来清除,而引用计数则只需要在引用时计数就可以了。 |
算法: 比较简单,打标记也无非打与不打两种情况,这使得一位二进制位(0和1)就可以为其标记,非常简单 |
缺点 | 1. 它需要一个计数器,而此计数器需要占很大的位置,因为我们也不知道被引用数量的上限 2. 无法解决循环引用无法回收的问题 |
1. 内存碎片化,空闲内存块是不连续的,容易出现很多空闲内存块,还可能会出现分配所需内存过大的对象时找不到合适的块 2. 分配速度慢,因为即便是使用 First-fit 策略,其操作仍是一个 O(n) 的操作,最坏情况是每次都要遍历到最后,同时因为碎片化,大对象的分配效率会更慢 |
补充:
常见内存泄漏的原因:
(1)全局变量引起的内存泄露
(2)闭包引起的内存泄露:慎用闭包
(3)dom清空或删除时,事件未清除导致的内存泄漏
(4)循环引用带来的内存泄露
(5)未被清空的定时器
参考文献:
https://www.jianshu.com/p/3d6b82f5242c
https://juejin.cn/post/6981588276356317214
https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Memory_Management
https://juejin.cn/post/6844903615300108302