前端为啥要关注内存
任何一个程序的运行都需要分配一定的空间,对于一个页面来说,如果一些不再使用的内存但没有及时释放,我们称之为内存泄漏,一次内存泄漏似乎不会造成大的影响,但内存泄漏堆积会造成内存溢出。我们的内存有限,网页端的承载量也是有限的,大量内存溢出会导致页面占用内存过大,引起卡顿,甚至无响应
JS的内存机制
我们先来看下JS的数据类型:
原始数据类型: 字符串(String)、数字(Number)、布尔(Boolean)、空对象(Null)、未定义(Undefined)、Symbol
引用数据类型: Object
内存空间: 栈内存(stack)、堆内存(heap)
- 对于原始数据类型,都有固定大小,保存于栈内存,是系统自动分配存储空间,属于被频繁使用的数据
// 定义三个变量
let a = 10
let b = 'hello'
let c = true
- 对于引用数据类型,他们占据空间大,大小不固定,栈内存的容量较小,大量的内存分配操作会导致栈溢出,将会影响程序运行的性能;所以它保存于堆内存中,并在栈中存储了堆内存对应的地址。
function fn() {
let a = 1
let b = 2
console.log(a+b)
}
- 简单来说:
栈内存适合存放生命周期短、占用空间小且固定的数据。
堆内存适合存放生命周期长,占用空间较大或占用空间不固定的数据。
垃圾回收
垃圾回收即我们常说的 GC(Garbage collection),由于栈内存由操作系统直接管理,所以当我们提到 GC 时指的都是堆内存的垃圾回收;js引擎会找到那些不再使用的变量,然后释放其所占内存,垃圾回收器会周期性的处理
JS中使用垃圾回收机制来自动管理内存,垃圾回收是一把双刃剑:
- 优势:可以大幅简化程序的内存管理代码,降低程序员的负担,减少因长时间运转而带来的内存泄露问题。
- 不足:意味着程序员将无法掌控内存。JavaScript没有暴露任何关于内存的APl。我们无法强迫其进行垃圾回收,更无法干预内存管理
了解内存的更多细节可以帮助我们写出性能更好,稳定性更高的代码。
在进入下一节之前,咱先普及俩概念:
可达性(Reachability)
在 JavaScript 中,可达性指的是一个变量是否能够直接或间接通过全局对象访问到,如果可以那么该变量就是可达(Reachable),否则就是不可达的(Unreachable)。
上图中的节点 9 和节点 10 均无法通过节点 1(根节点)直接或间接访问,所以它们都是不可达的,可以被安全地回收。
内存泄漏(Memory leak)
内存泄露指的是程序运行时由于某种原因未能释放那些不再使用的内存,造成内存空间的浪费。
轻微的内存泄漏或许不太会对程序造成什么影响,但是一旦泄露变严重,就会开始影响程序的性能,甚至导致程序的崩溃。
垃圾回收算法
引用计数(最初级,已经被弃用的垃圾回收算法)
每次引用加一,被释放时减一,跟踪记录每个值被引用的次数,如果一个值的引用次数是0,就表示这个值不再用到了,就可以将其内存空间回收
let obj1 = { a: 10 }; //Step1
let obj2 = { a: 10 }; //Step2
obj1 = null; //Step3
obj2 = null; //Step4
但是它有一个致命的缺点,就是无法处理循环引用的情况
let obj1 = { a: 10 }; //Step1
let obj2 = { b: 10 }; //Step2
obj1.a = obj2; //Step3
obj2.b = obj1; //Step4
obj1 = null; //Step5
obj2 = null; //Step6
标记清除(应用广泛)
标记清除指的是当变量进入环境时,这个变量标记为“进入环境”;而当变量离开环境时,则将其标记为“离开环境”,最后 垃圾回收器完成内存清除工 作,销毁并回收那些被标记为“离开环境”的值所占用的内存空间。
v8内存管理机制
采用分代回收策略,将内存分为新生代和老生代,对新生代和老生代采用不同的垃圾回收机制来提升效率。
新生代空间
该空间中的对象为存活时间较短的对象,大多数的对象被分配在这里,这个区域很小但是垃圾回特别频繁
会把堆内存分为两部分,from空间(使用中)和to空间(闲置)
垃圾回收机制生效时,会检查from空间,当obj2需要被回收时,把obj1复制到to空间,然后空间反转,垃圾回收时,会清空to空间,这个操作称为复制收集。
对象晋升
如果存在以下两种情况,存活对象就会被复制到老生代空间中;
- to 空间内存占用比例超过 25%,会直接将from中对象晋升到老生代空间 (保证下次新对象有足够的空间可分配)
- 对象已经在新生代空间中经过一轮存活
老生代空间
该空间中的对象为存活时间长或常驻内存对象,大多数从新生代晋升的对象会被移动到这里
老生代空间是连续结构,如图
老生代空间中由标记清除和标记合并共同完成
标记清除:
将需要回收的变量进行标记,垃圾回收机制运行时直接释放相应的空间
由上图我们可知,标记清除后,会出现内存不连续问题,这时候就要用到标记合并
标记合并:
将存活的对象移动到一边,将需要被回收的对象移动到另一边,然后对需要被回收的对象区域进行整体的垃圾回收,如图
小结:
在一开始看这个的时候我就在想,为啥要这么麻烦,分两个生代去回收?
分代回收的基本假设:绝大部分对象的生命周期都非常短暂,存活时间短。
“分代收集”算法,分为新生代和老生代,这样就可以根据各个生代的特点采用最适当的收集算法。在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。而老生代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用“标记-清理”或“标记-整理”算法来进行回收。
内存优化:
虽然我们写代码的时候一般不会直接接触内存管理,但下面这些注意事项可以让我们避免引起内存问题,甚至提升代码的性能。
- 全局变量(Global variable)
全局变量的访问速度远不及局部变量,应尽量避免定义非必要的全局变量。
在我们实际的项目开发中,难免会需要去定义一些全局变量,但是我们必须谨慎使用全局变量,因为全局变量永远都是可达的,所以全局变量永远不会被回收。
那应该怎么做呢?
当一个全局变量不再需要用到时,记得解除其引用(置空),好让垃圾回收器可以释放这部分内存。 - 闭包
只要我们一直持有闭包函数,那么它的变量就不会被释放。
那应该怎么做?
我们应该避免滥用闭包,并且谨慎使用闭包!当不再需要时记得解除闭包函数的引用,让闭包函数以及引用的变量能够被回收。
function getCounter() {
let count = 0;
function counter() {
return ++count;
}
return counter;
}
// closure 是一个闭包函数
let closure = getCounter();
closure(); // 1
closure(); // 2
closure(); // 3
只要我们一直持有变量(函数) closure,那么变量 count 就不会被释放
closure = null;
// 变量 count 安息了