目录
内存管理
在JavaScript编程中,内存管理大概分成三个步骤,也是内存的声明周期:
- 分配你所需系统内存的空间
- 使用分配到的内存进行读写操作
- 不需要使用内存时,将空间进行释放和归还
与其它手动管理内存的语言不一样的是,在JavaScript中,当我们创建变量时,系统会给对象进行自动分配对应的内存空间以及闲置资源回收
对于基础数据类型和引用数据类型,符合直觉的内存分配机制如下:
- 简单数据类型内存保存在固定的栈空间中,可直接通过值进行访问
- 引用数据类型的值大小不固定,其引用地址保存在栈空间、引用所指向的值保存在堆空间中,需要通过引用进行访问
符合直觉的内存分配机制主要是基于连续分配方式,即按程序的大小来确定所分配内存空间的大小,并且为程序分配连续的内存空间。 这种分配方式简单直观,容易理解和实现。
在连续分配方式中,内存分配的基本原则包括:
- 位置:为程序分配连续的内存空间,按顺序存储在内存中。
- 尺寸:按程序的大小来确定所分配内存空间的大小。
连续分配方式的优点包括:
- 实现简单:内存分配和回收相对简单,易于管理。
- 地址空间连续:程序可以直接使用连续的地址空间,访问效率高。
然而,连续分配方式也存在一些缺点:
- 碎片问题:容易产生内部碎片和外部碎片。内部碎片是指分配给某进程的内存区域中没有被用到的部分,而外部碎片是指还没有被分配出去但无法再利用的小块内存。
- 利用率低:对于大小不一的进程,内存利用率较低,尤其是使用固定分区时。
为了解决连续分配方式的缺点,可以采用离散分配方式,如分段、分页等存储管理方式。这些方式允许将内存分散分配给不同的进程,提高内存的利用率,但增加了管理的复杂性。
栈内存中的基本数据类型,可以直接通过操作系统进行处理,而堆内存中的引用数据类型的值大小不确定,因此,需要JS的引擎通过垃圾回收机制进行处理。
可达性
JavaScript中内存管理的主要概念是可达性
简单来说,“可达性”值就是哪些以某种方式可访问或可用的值,它们被保证存储在内存中
🌰 一个简单的例子
// user 具有对象的引用
let user = {
name: "John"
}
👆 这里箭头表示一个对象引用,全局变量 user 引用对象 {name:"John"} ,如果user的值被覆盖,则引用丢失 user = null
👆 现在对象 {name:"John"} 变成了不可达的状态,没有办法访问它,垃圾回收器将丢弃其数据并释放内存。
内存回收机制
现在浏览器最常使用的垃圾回收机制是标记清除,老版本浏览器常用的机制是引用计数,由于引用计数方式无法解决循环引用怎么回收的问题,所以逐渐被淘汰。
垃圾回收算法:垃圾收集器按照固定的事件间隔,周期性的寻找哪些不再使用的变量,然后将其清除或释放内存。
在浏览器的发展历史上有两种解决策略:
- 标记清除
- 引用计数
■ 标记清除(Mark-Sweep)
标记清除(Mark-Sweep)是JavaScript中最常用的垃圾回收方式。首先它会遍历堆内存上所有的对象,分别给它们打上标记,然后在代码执行过程结束之后,对所使用过的变量取消标记。在清除阶段再把具有标记的内存对象进行整体清除,从而释放内存空间。
大致过程如下:
- 垃圾收集器在运行时会给内存中的所有变量都加上一个标记
- 然后从各个根对象开始遍历,把还在被上下文变量引用的变量标记去掉标记
- 清理所有带有标记的变量,销毁并回收它们所占用的内存空间
- 最后垃圾回收程序做一次内存清理
使用标记清除策略最重要的优点在于简单,无非是标记和不标记的差异。通过标记清除后,剩余的对象内存位置是不变的,也导致空闲内存空间是不连续的(如上图),这就造成了出现内存碎片的问题。对于标记清除产生的内存碎片,还是需要通过标记整理策略进行解决。
优点
- 实现简单,标记情况无非是打与不打的两种情况,通过二进制(0和1)就可以为其标记。
- 能够回收循环引用的对象。
- 是v8引擎使用最多的算法。
缺点
- 内存碎片化、分配速度慢。
在清除垃圾之后,剩余对象的内存位置是不变的,就会导致空闲内存空间不连续。这样就出现了内存碎片,并且由于剩余空间不是整块,就需要内存分配的问题。
👇 图示标记清除的过程
👇 第一步标记根
👇 然后标记他们的引用以及子孙代的引用
👇 现在进程中不能访问的对象被认为是不可访问的,将被删除
小结:标记清除
当变量进入执行环境是,就标记这个变量为“进入环境”。从逻辑上讲,永远不能释放进入环境的变量所占用的内存,因为只要执行流进入相应的环境,就可能会用到他们。当变量离开环境时,则将其标记为“离开环境”。
垃圾收集器在运行的时候会给存储在内存中的所有变量都加上标记。然后,它会去掉环境中的变量以及被环境中的变量引用的标记。而在此之后再被加上标记的变量将被视为准备删除的变量,原因是环境中的变量已经无法访问到这些变量了。最后。垃圾收集器完成内存清除工作,销毁那些带标记的值,并回收他们所占用的内存空间。
我们用个例子,解释下这个方法:
var m = 0,n = 19 // 把 m,n,add() 标记为进入环境。
add(m, n) // 把 a, b, c标记为进入环境。
console.log(n) // a,b,c标记为离开环境,等待垃圾回收。
function add(a, b) {
a++
var c = a + b
return c
}
- 标记整理算法 -
标记结束后,标记整理(Mark-Compact)算法会将不需要清理的对象向内存的一端移动,最后清理掉边界的内存。
■ 引用计数(Reference Counting)
引用计数(Reference Counting)是一种不常见的垃圾回收策略,顾名思义,就是针对值为引用类型数据的变量进行计数。思路是对每个值都记录其的引用次数,具体如下:
- 当变量进行声明并赋值后,值的引用数为1
- 当同一个值被赋值给另一个变量时,引用书+1
- 当保存该值引用的变量被其它值覆盖时,引用数-1
- 当该值的引用数为0时,表示无法再访问该值了,此时就可以放心的将其清除并回收内存
💥 但有循环引用问题如下
function problem(){
let objectA = new Object();
let objectB = new Object();
objectA.somOtherObject = objectB;
objectB.anotherObject = objectA;
}
如上例子中,objectA 和 objectB 通过各自的属性相互引用,意味着它们的引用数都是2,在标记清理策略下,objectA 和 objectB 虽然互相引用了对方,但没有其他引用指向这两个对象,它们将会被标记为不可达,并最终会被垃圾回收器清除。
小结:引用计数
所谓"引用计数"是指语言引擎有一张"引用表",保存了内存里面所有的资源(通常是各种值)的引用次数。如果一个值的引用次数是0,就表示这个值不再用到了,因此可以将这块内存释放
如果一个值不再需要了,引用数却不为0,垃圾回收机制无法释放这块内存,从而导致内存泄漏。
var arr = [1, 2, 3, 4];
arr = [2, 4, 5]
console.log('浪里行舟');
上面代码中,数组[1, 2, 3, 4]是一个值,会占用内存。变量arr是仅有的对这个值的引用,因此引用次数为1。尽管后面的代码没有用到arr,它还是会持续占用内存。至于如何释放内存,我们下文介绍。
第三行代码中,数组[1, 2, 3, 4]引用的变量arr又取得了另外一个值,则数组[1, 2, 3, 4]的引用次数就减1,此时它引用次数变成0,则说明没有办法再访问这个值了,因而就可以将其所占的内存空间给收回来。
但是引用计数有个最大的问题: 循环引用
function func() {
let obj1 = {};
let obj2 = {};
obj1.a = obj2; // obj1 引用 obj2
obj2.a = obj1; // obj2 引用 obj1
}
当函数 func 执行结束后,返回值为 undefined,所以整个函数以及内部的变量都应该被回收,但根据引用计数方法,obj1 和 obj2 的引用次数都不为 0,所以他们不会被回收。
要解决循环引用的问题,最好是在不使用它们的时候手工将它们设为空。上面的例子可以这么做:
obj1 = null;
obj2 = null;
V8对于垃圾回收机制的优化
垃圾回收机制也称
Garbage Collection
简称GC。在JavaScript中拥有自动的垃圾回收机制,通过一些回收算法,找出不再使用引用的变量或属性,由JS引擎按照固定时间间隔周期性的释放其所占的内存空间。在C/C++中需要程序员手动完成垃圾回收。
大多数浏览器都是基于标记清除算法,V8的垃圾回收机制对其进行了一些优化加工处理——分代收集。
V8的垃圾回收策略主要基于分代式垃圾回收机制,V8中将堆内存分为新生代(Young Space)和老生代(Old Space)两区域,采用不同的策略管理垃圾回收。
- 新生代的对象为存活时间较短的对象,简单来说就是新产生的对象,通常只支持 1~8M 的容量
- 老生代的对象为存活时间较长或常驻内存的对象,简单来说就是经历过新生代垃圾回收后还存活下来的对象,容量通常比较大。
代际假说观点认为,大部分对象在内存中有用的时间很短,一经分配内存,很快就变得不可访问;少量数据持续活跃,活的很久。
基于代际假说,V8 把堆分为老生代
和新生代
,新生代中存放的是生存时间短的对象(1—8M空间),老生代中存放的是生存时间久的对象(空间比较大)。针对新生代和老生代,V8 使用不用的回收机制,以便更高效的进行垃圾回收。使用
主垃圾回收器
负责
回收老生代的垃圾数据,使用副垃圾回收器
负责
回收新生代的垃圾数据。
垃圾回收原理
- 标记空间中的活动对象(还在使用的对象)和非活动对象(可以回收的对象)。
- 标记完成之后,统一清理内存中所有被标记的可回收对象,回收这部分对象占据的内存。
- 内存整理:频繁回收对象之后,内存中存在大量不连续空间(内存碎片),如果要分配较大连续内存时,可能出现内存不足的情况,所以需要将内存碎片成连续的空间。
新生代内存回收
在64位操作系统下分配为32MB,因为新生代中的变量存活时间短,不太容易产生太大的内存压力
通常采用 Scavenge 的算法进行垃圾回收,就是将新生代内存进行一份为二,正在被使用的内存空间称为使用区(或称对象区),而限制状态的内存空间称为空闲区
- 新加入的对象都会存放在使用区,当使用区快写满时就进行一次垃圾清理操作。
- 在开始进行垃圾回收时,新生代回收器会对使用区内的对象进行标记
- 标记完成后,需要对使用区内的活动对象拷贝到空闲区进行排序
- 而后进入垃圾清理阶段,将非活动对象占用的内存空间进行清理
- 最后对使用区和空闲区进行交换,使用区->空闲区,空闲区->使用区
♠ 副垃圾回收器
主要负责新生代的垃圾回收。新生代空间较小,里面的对象一般较小;回收频繁。回收机制采用 Scavenge 算法。该算法把新生代空间划半分为对象区域和空闲区域。
新加入的对象存到对象区域,当对象区域快被写满时,执行一次垃圾回收。在回收过程中,对对象区域里的对象做标记,标记完成后,把存活的对象复制到空闲区域中并有序的排列起来(消除内存碎片),把无用的对象清理掉。
复制完成后,对象区域和空闲区域进行角色翻转,对象区域变成空闲区域,空闲区域变成对象区域。这样这两块区域可以无限重复使用下去。
复制操作需要时间成本,因此为了执行效率,新生区的空间一般设置的比较小。
经过两次垃圾回收依然存活的对象,会被移动到老生区中,以防止过多的存活对象挤满新生区。
老生代内存回收
对内存空间较大的不适合上述Scavenge算法,此时应使用标记清除和标记整理的策略进行老生代内存中的垃圾回收。
老生区的里的对象一般占用空间大(因复制操作耗时所以不适合使用 Scavenge 算法),存活的时间比较长。
回收机制采用的是 标记-清除(Mark-Sweep)算法。从一组根元素开始,递归遍历这组根元素,在遍历过程中能到达的元素称为活动对象,没有达到的元素判断标记为垃圾数据。
标记完成之后,执行清除过程。
如上图,清除后会产生大量不连续的内存碎片。为了避免内存碎片,进化出了另一种算法:标记-整理(Mark-Compact),标记完成后,让所有存活的对象移向内存一端,然后直接清理掉端边界外的数据。
● 小结 ● JavaScript的内存回收机制
JavaScript 的内存管理主要是通过垃圾回收机制来自动清理不再使用的变量和对象。这里有两种主要的垃圾回收策略:标记清除和引用计数。
-
引用计数 (不再使用):当一个对象被创建时,系统会记录一个被引用的次数。如果该对象的引用次数变为0,或者在代码中没有任何途径可以访问到这个对象,那么这个对象就会被垃圾回收器回收。但是,这种方法有一个问题,就是容易产生循环引用,导致内存无法释放。
-
标记清除 (现在使用):当变量进入执行环境(比如函数中),就将这个变量标记为“进入环境”。当变量离开执行环境时,就将其标记为“离开环境”。垃圾收集器在运行时会给存储在内存中的所有变量都加上标记。然后,它会去掉环境中的变量以及被环境中的变量引用的标记。在此之后再被加上标记的变量将被视为已不再使用,最后的垃圾收集器回收未被使用的变量所占用的内存空间。
示例1:
function foo() {
var a = 10; // 被标记且进入环境
var b = a; // b和a都被标记且进入环境
a = 20; // a重新赋值,a与进入环境的变量10断开关系,10被标记为离开环境
bar(b); // b传入函数,b被标记为离开环境
} // 函数执行完毕,foo中的所有变量被标记为离开环境
示例2:
function foo() {
var a = 10; // 被标记 且进入环境
var b = a; // 被标记 且进入环境
var c = 15; // 被标记 且进入环境
// 一些代码...
b = null; // 解除b对a的引用
c = null; // 解除c的引用
}
foo(); // 执行完毕后,a和c将被标记为清除
在上面的例子中,
a
和c
虽然原本是引用类型,但是在函数执行完毕后,它们被赋值为null
,这样就解除了与原始值的引用,在下一次垃圾收集时,它们所占用的内存将被释放。
注意:在JavaScript中,并不是所有的引用类型数据在离开执行环境后就会被立即回收,只有当运行了垃圾收集程序后,才会回收那些不再被引用的数据。
JavaScript 引擎可能会使用分代垃圾收集(generational garbage collection),这是一种优化策略,它认为大部分对象只在很短的时间内是可达的,因此,垃圾收集器会按照对象存在的时间长短来分配垃圾收集的频率。
最后,JavaScript 中内存泄漏的常见场景以及处理内存——内存泄漏优化的常见方法包括:
其中内存溢出问题,往往是变量或函数引用未被清除,导致其所关联的dom/对象/事件/样式等占据的内存无法被回收引起的。
1、不再使用的对象
解决:解除引用——将不再使用的对象设置为 null
,可以手动帮助垃圾回收器回收内存。
2、意外的全局变量引起的内存泄露
解决:使用严格模式避免
① 未声明的变量
由于使⽤未声明的变量,⽽意外的创建了⼀个全局变量,⽽使这个变量⼀直留在内存中⽆法被回收。也就是说,一些非严格模式下的局部变量可能会变成全局变量,而全局变量不会被回收;
当我们在一个函数中给一个变量赋值但是却没有声明它时:
function fn () {
a = "Actually, I'm a global variable"
}
此时变量a
相当于是window
对象下的一个变量:
function fn () {
window.a = "Actually, I'm a global variable"
}
② 使用this
创建的变量
还有一种情况是这样的:
function fn () {
this.a = "Actually, I'm a global variable"
}
fn();
我们知道, 这里this
的指向是window
, 因此此时创建的a
变量也会被挂载到window
对象下.
避免此情况的解决办法是在 JavaScript 文件头部或者函数的顶部加上 'use strict'
, 开启严格模式, 使得this
的指向为undefined
, 这样就可以避免了。
3、脱离 DOM 的引⽤ —— 游离的dom节点
被清理的DOM元素的引用被保留:获取⼀个 DOM 元素的引⽤,⽽在后⾯这个元素被删除了,由于⼀直保留了对这个元素的引⽤,所以它也⽆法被回收。也就是说虽然DOM被删掉了,但对象中还存在对DOM的引用。
解决: 将对象赋值为null
// 在对象中引用DOM
var elements = {
btn: document.getElementById('btn')
}
function doSomeThing () {
elements.btn.click();
}
function removeBtn () {
// 将body中的btn移除, 也就是移除 DOM树中的btn
document.body.removeChild(document.getElementById('btn'));
// 但是此时全局变量elements还是保留了对btn的引用, btn还是存在于内存中,不能被GC回收
}
如果删除了某个dom节点,但仍有变量对此节点存在引用关系,则这个dom就会变成游离状态,也就是不存在于document上了,但由于引用关系未消失,导致所占据的内存始终未被回收。
上面👆这种情况, 可以手动将引用给清除: elements.btn = null
4、被遗忘的定时器或回调函数
设置了 setInterval 或 setTimeout定时器,⽽忘记取消它,如果循环函数有对外部变量的引⽤的话,那么这个变量会被⼀直留在内存中,⽽⽆法被回收。
解决: 清理定时器clearInterval 或 clearTimeout——定时器赋值给timerId,使用clearInterval(timerId) 或 clearTimeout(timeId)手动清理。
var serverData = loadData()
setInterval(function() {
var renderer = document.getElementById('renderer')
if(renderer) {
renderer.innerHTML = JSON.stringify(serverData)
}
}, 5000)
上面的例子🌰中, 节点renderer引用了serverData.在节点renderer或者数据不再需要时,定时器依旧指向这些数据。所以哪怕当renderer节点被移除后,interval 仍旧存活并且垃圾回收器没办法回收,它的依赖也没办法被回收,除非终止定时器。
所以说如果设置了setTimeout或setInterval定时器,但没有使用clearTimeout/clearInterval清除定时器,定时器的回调函数以及内部依赖的变量都不能被回收,从而造成内存泄漏。
还有一个就是关于观察者模式的案例:
var btn = document.getElementById('btn');
function onClick (element) {
element.innerHTMl = "I'm innerHTML"
}
// 注册事件
btn.addEventListener('click', onClick);
// 注销事件,才能保证事件占据的内存被回收掉
btn.removeEventListener('click', onClick);
对于上面观察者的例子,一旦它们不再需要(或者关联的对象变成不可达),明确地移除它们非常重要。老的 IE 6 是无法处理循环引用的。因为老版本的 IE 是无法检测 DOM 节点与 JavaScript 代码之间的循环引用,会导致内存泄漏。
但是,现代的浏览器(包括 IE 和 Microsoft Edge)使用了更先进的垃圾回收算法(标记清除),已经可以正确检测和处理循环引用了。即回收节点内存时,不必非要调用 removeEventListener 了。
5、闭包引起的作用域未释放
闭包可以阻止某些对象被垃圾回收器回收,因为闭包内的变量被外部代码引用。那么该如何解决呢?我们可以将活动对象赋值为null即可。
闭包函数执行完成后,作用域中的变量不会被回收,可能会导致内存泄漏。所以,此时由于活动对象被引用,使得闭包内的变量不会被释放。
首先让我们明确一点: 闭包的关键就是匿名函数能够访问父级作用域中的变量
来看一个简单的例子🌰:
function fn () {
var a = "I'm a";
return function () {
console.log(a);
};
}
因为变量a
被fn()
函数内的匿名函数所引用, 因此这种变量是不会被回收的.
那就有人问了, 即使这样会造成什么不妥吗? 在上面👆这个案例中当然看不出有什么. 若是将情况变得复杂一些呢?
var globalVar = null; // 全局变量
var fn = function () {
var originVal = globalVar; // 局部变量
var unused = function () { // 未使用的函数
if (originVal) {
console.log('call')
}
}
globalVar = {
longStr: new Array(1000000).join('*'),
someThing: function () {
console.log('someThing')
}
}
}
setInterval(fn, 100);
先请你花上一分钟看看上面的案例, 你会发现:
- 每次调用fn函数的时候都会产生一个新的对象originVal;
- 变量unused是一个引用了originVal的闭包;
- unused虽然未被使用,但是它引用的originVal迫使它留在内存中, 并不会被回收。
解决办法是: 可以在fn
的最底部, 将originVal
设置成null
。
6、一次性加载大量数据
解决:使用循环和分页数据处理——对于大量数据的处理,使用分页或者其他策略避免一次性加载大量数据,防止内存泄漏。
let obj = {
name: 'example',
data: []
};
// 在不需要obj的时候解除引用
obj = null; // 这样可以帮助垃圾回收器回收obj所占用的内存
请注意,垃圾收集器只管理 JavaScript 引擎分配的内存,不管理由浏览器或其他宿主环境分配的内存。
参考:常见的几种内存泄漏的方式 | 内存泄露 | 内存泄漏的常见场景
参考:使用chrome的devtools查找内存溢出问题
参考:JavaScript面试必备之垃圾回收机制和内存泄漏详解
● 参考资料 ●
JavaScript 内存回收机制 - 八十易 - 博客园 | JavaScript 基础篇 - 垃圾回收机制 - 博客园
JS垃圾回收机制【图文解析】- 知乎| JavaScript 垃圾回收机制_js内存回收机制-优快云博客
深入理解 JS 垃圾回收_基础知识_脚本之家 | JS中的垃圾回收与内存泄漏示例详解_脚本之家
JavaScript进阶-常见内存泄露及如何避免,内存泄露的识别方法 - 博客园
跟我学习javascript的垃圾回收机制与内存管理_javascript技巧_脚本之家
前端高频面试题之JS中堆和栈的区别和浏览器的垃圾回收机制_脚本之家
JavaScript面试必备之垃圾回收机制和内存泄漏详解_javascript技巧_脚本之家