任何编程语言,在代码执行的过程中都是需要给它分配内存的,不同的是某些编程语言需要开发人员手动管理内存(例如:C、C++ 等),某些编程语言可以自动管理内存(例如:JS、Java、Python 等)。
不管以什么方式来管理内存,内存的管理都有以下的生命周期:
- 申请:申请需要的内存。
- 使用:使用分配好的内存。
- 销毁:不再需要的内存对其进行销毁。
JS 的内存结构分为栈内存和堆内存。
JS 中创建的基本类型值、引用类型值都会占用内存,JS 引擎会在定义数据时会自动为其分配内存空间。对于基本类型值,会直接在栈内存中为其分配空间;对于引用类型值,会在堆内存中开辟一块空间,并且将这块空间的指针(内存地址)返回给栈内存中的变量让其引用。
内存:内存条通电后产生的可存储数据的临时空间(硬盘是永久空间)。
变量: 可变化的量,由变量名和变量值组成。
数据:存储在内存中代表特定的信息,本质上就是010101…。
每个变量都会占用一块内存空间,变量名用来标识内存,以便查找对应的内存,变量值就是内存中保存的数据。基本类型值确实是在栈内存中为其分配空间和操作的,但是由于 JS 有 AO、GO 等概念,因此也会给在堆内存中的这些对象添加上对应的属性。
栈内存中的数据,在其所在的执行上下文从执行上下文栈中弹出后,就会自动被销毁,释放其所占的空间;堆内存中的数据,是使用垃圾回收机制来进行回收的。
垃圾回收机制(Garbage Collection、GC):
因为内存的大小是有限的,所以当内存中的数据不再需要的时候,需要对其进行释放,以腾出更多的空间。
JS 引擎内置了垃圾回收器,具有垃圾自动回收机制。
function fn() {
var obj = {}
}
fn() // 函数执行完之后,变量名 obj 会自动释放,变量值所指向的对象会在后面的某个时候由垃圾回收器回收
常见的 GC 算法:
- 引用计数(Reference Counting):当一个对象有一个引用指向它时,那么它的引用就 +1;当它的引用变为 0 时,表示它已经没有再被用到了,就将其销毁,释放其所占用的内存空间。
Swift、OC 使用的就是引用计数算法。
// 当 var obj = {} 创建一个对象时,在堆内存中就会为其开辟一块空间来存储对象 {} 中的数据,其中有一个属性 returnCount 来表示被引用的次数,会将堆内存中的内存地址返回给栈内存的变量 obj 来引用。此时,由于变量 obj 引用了 对象{} 一次,对象 {} 中的 returnCount 为 1
var obj = {}
// 将 obj 赋值给 obj1,也就是将返回的内存地址赋值给了 obj1,此时,obj1 也引用了对象 {},对象 {} 中的 returnCount 加 1 为 2
var obj1 = obj
// 将 null 赋值给 obj1,也就是 obj1 不再引用对象 {} 了,此时,对象 {} 中的 returnCount 减 1 为 1
obj1 = null
引用计数算法有一个很大的弊端就是会产生循环引用。
// 此时,第一个对象和第二个对象的引用计数都为 2。因为第一个对象被变量 obj1 和 obj2.info 引用了两次,第二个对象被变量 obj2 和 obj1.info 引用了两次
var obj1 = {}
var obj2 = {}
obj1.info = obj2
obj2.info = obj1
// 将 obj1 和 obj2 都指向 null。此时,第一个对象和第二个对象的引用计数都为 1,因为第一个对象虽然不再被变量 obj1 引用了,但是 obj2.info 还引用它,第二个对象虽然不再被变量 obj2 引用了,但是 obj1.info 还引用它
obj1 = null
obj2 = null
// 必须再手动清除一下
obj1.info = null
obj2.info = null
- 标记清除(Mark Sweep):标记清除算法的核心思想是可达性。会设置一个根对象,垃圾回收器定期从这个根对象开始,找有引用到的对象并对其进行标记,对于那些没有被标记的对象,就认为是没有被引用的对象,对其进行销毁,释放所占用的内存空间。
JS 引擎使用的就是标记清除算法。在 JS 中,根对象就是 Global Object。
- 标记清除算法可以很好地解决循环引用的问题。
V8 引擎针对垃圾回收机制还进行了一些优化,在标记清除算法的基础上还结合了一些其他的算法:
- 标记整理:标记整理和标记清除相似,不同的是,回收期间同时会将保留的对象搬运汇集到连续的内存空间,从而整合空闲空间,避免内存碎片化。
例如:2 和 4 的内存空间被释放。没有标记整理的话,2 和 4 被释放的内存空间是不连续的,导致无法为 6 分配一块内存空间。有标记整理的话,1、3、5 被搬运到联系的内存空间,就可以为 6 分配出一块内存空间了。
- 分代收集:对象被分成新生代和旧生代两组。大部分对象都可以看作是新生代的对象,它们被创建完成工作后很快就会死去,而那些长期存活的对象可以看做旧生代的对象,可以减少一些对它们的检查频次。
如果没有分代收集,垃圾收集器每次都需要对所有对象进行检查;有了分代收集,对于那些长期存活的老旧的对象,就可以减少一些检查的频次。- 增量收集:如果试图一次遍历并标记整个对象集,那么有很多对象的话,就可能会需要一些时间,并在执行过程中带来明显的延迟。所以 V8 引擎将垃圾收集工作分成几部分来做,对这几部分逐一进行处理,这样会有许多微信的延迟而不是一个大的延迟。
- 闲时收集:垃圾收集器只会在 CPU 空间时尝试运行,以减少对代码执行可能造成的影响。
解除引用:
垃圾收集器是周期性运行的,会导致整个程序的性能问题。
Javascript 在进行内存管理和垃圾收集时面临的一个主要问题就是分配给 Web 浏览器的可用内存数量通常比分配给桌面应用系统的少。一般来说,这样做的目的主要是出于安全方面的考虑,防止运行 Javascript 的网页耗尽全部系统内存而导致系统崩溃。
内存限制问题不仅会影响变量分配内存,同时还会影响调用栈以及在一个线程中能够同时执行的语句数量,因此确保占用最少的内存可以让页面获得更好的性能。
而优化内存的最佳方案就是为执行中的代码只保存必要的数据,一旦数据不再有用,那么就将其设置为 null 来释放引用,手动释放内存。这个做法叫做解除引用,适用于大多数全局变量和全局函数(内置对象除外)。
局部变量会在其所在的执行上下文出栈时自动被解除引用。
内存溢出与内存泄漏:
内存溢出:
内存溢出是一种程序运行出现的错误,当程序运行需要的内存超过了剩余的内存时,就会抛出内存溢出的错误。
内存泄漏:
内存泄漏是指那些永远都不会再使用到的对象,垃圾收集器不知道它们需要进行清理而一直保留着,占用的内存没有及时释放。
内存泄漏多了就容易导致内存溢出。
虽然有垃圾回收机制,但在编写代码的时候,有些情况还是会造成内存泄漏:
- 意外的全局变量:因为只有当应用程序退出,全局执行上下文才会出栈,全局变量才会被销毁,所以要减少不必要的全局变量。
解决方法:或者手动释放全局变量的内存;在函数内部避免创建不必要的全局变量。
function fn() {
name = 'Lee' // 函数内部不必要的全局变量
}
fn()
- 被遗忘的定时器:当不需要
setInterval()
或者setTimeout()
时,定时器没有被手动清除。
解决方法:在定时器完成工作的时候,手动清除定时器。 - 闭包:闭包可以维持函数内部的局部变量,使其得不到释放,造成内存泄漏。
解决方法:少用闭包;在函数执行完之后,回收闭包,及时释放内存;
function fn1() {
var n = 1
function fn2() {
n++
console.log(n)
}
return fn2
}
var f = fn1()
f()
f = null // 手动清除闭包
- 没有清理 DOM 元素引用。
解决方法:手动解除 DOM 引用。
<div id='app'>哈哈</div>
var app = document.getElementById('app')
document.body.removeChild(app) // dom删除了
console.log(app) // 但是还存在引用,没有被回收,因此能打印出这个 div
- console 保存大量数据在内存中:过多的 console,比如定时器的 console 会导致浏览器卡死。
解决方法:合理利用 console,线上项目尽量少的使用 console。