js垃圾回收机制

在js中创建一个变量时,会自动分配内存空间,当变量不再被使用时,垃圾回收机制会自动释放相应的内存空间。

如何判断一个变量不在被使用?方法有两种:

一、引用计数法:

引用计数的判断原理很简单,就是看一份数据是否还有指向它的引用,若是没有任何对象再指向它,那么垃圾回收器就会回收,其策略是跟踪记录每个变量值被使用的次数

  • 当声明了一个变量并且将一个引用类型赋值给该变量时,这个值的引用次数就为 1

  • 如果同一个值又被赋给另一个变量,那么引用数加 1

  • 如果该变量的值被其他的值覆盖了,则引用次数减 1

  • 当这个值的引用次数变为 0 的时候,说明没有变量在使用,这个值没法被访问了,回收空间,垃圾回收器会在运行的时候清理掉引用次数为 0 的值占用的内存

引用计数存存在一个致命的缺陷,当对象间存在循环引用时,引用次数始终不会为0,因此垃圾回收器不会释放它们。

1
2
3
4
5
6
7
function f() {
    var o1 = {};
    var o2 = {};
    o1.a = o2; // o1 引用 o2
    o2.a = o1; // o2 引用 o1
    return;
};
 
在 IE8 以及更早版本的 IE 中, BOM 和 DOM中的对象并不是原生的JS对象,而是使用C++以 COM对象的形式实现的,而 COM对象的垃圾收集机制采用的就是引用计数策略。因此,即使 IE 的 JavaScript引擎是使用标记清除策略来实现的,但JavaScript访问的COM对象依然是基于引用计数策略的。换句话说,只要在IE8及以下版本中涉及 COM对象,就会存在循环引用的问题。下面这个简单的例子,展示了使用 COM对象导致的循环引用问题;
1
2
3
4
var element = document.getElementById("some_element");
var myObject = new Object{);
myObject. element = element;
element.someObject = myObject;
1
这个例子在一个DOM元素(element)与一个原生 JavaScript对象(myobject)之间创建了循环引用。而想要解决循环引用,需要将引用地址置为 null 来切断变量与之前引用值的关系,当垃圾收集器下次运行时,就会删除这些值并回收它们占用的内存。
1
2
myObject.element = null;
element,SomeObject = null;

二、标记清除法:

标记清除(Mark-Sweep),目前在 JavaScript引擎 里这种算法是最常用的,到目前为止的大多数浏览器的 JavaScript引擎 都在采用标记清除算法。

此算法可以分为两个阶段,一个是标记阶段(mark),一个是清除阶段(sweep)。

标记阶段,垃圾回收器会从根对象开始遍历(在js中,通常认定全局对象window做为根)。每一个可以从根对象访问到的对象都会被添加一个标识,于是这个对象就被标识为可到达对象。

清除阶段,垃圾回收器会对堆内存从头到尾进行线性遍历,如果发现有对象没有被标识为可到达对象,那么就将此对象占用的内存回收,并且将原来标记为可到达对象的标识清除,以便进行下一次垃圾回收操作。

 

在标记阶段,从根对象1可以访问到B,从B又可以访问到E,那么B和E都是可到达对象,同样的道理,F、G、J和K都是可到达对象。

在回收阶段,所有未标记为可到达的对象都会被垃圾回收器回收。

标记清除法会导致内存碎片化。由于空闲内存块是不连续的,容易出现很多空闲内存块,假设我们新建对象分配内存时需要大小为 size,由于空闲内存是间断的、不连续的,则需要对空闲内存列表进行一次单向遍历找出大于等于 size 的块才能为其分配(如下图)

那如何找到合适的块呢?我们可以采取下面三种分配策略

  • First-fit,找到大于等于 size 的块就立即返回

  • Best-fit,遍历整个空闲列表,返回大于等于 size最小分块

  • Worst-fit,遍历整个空闲列表,找到最大的分块,然后切成两部分,一部分 size 大小,并将该部分返回

 

这三种策略里面 Worst-fit 的空间利用率看起来是最合理,但实际上切分之后会造成更多的小块,形成内存碎片,所以不推荐使用,对于 First-fit 和 Best-fit 来说,考虑到分配的速度和效率 First-fit 是更为明智的选择,但即便是使用 First-fit 策略,其操作仍是一个 O(n) 的操作,最坏情况是每次都要遍历到最后,同时因为碎片化,大对象的分配效率会更慢

三、v8引擎的垃圾回收机制

Chrome 浏览器所使用的 V8 引擎采用的分代回收策略,该策略通过区分「临时」与「持久」对象;多回收「临时对象区」,少回收「持久对象区」,减少每次需遍历的对象,从而减少每次GC的耗时。

「临时」与「持久」对象也被叫做作「新生代」与「老生代」对象。

 

1. 新生代的特点:

  • 通常把小的对象分配到新生代
  • 新生代的垃圾回收比较频繁
  • 通常存储容量在1~8M

2. 新生代-Scavenge算法

该算法将新生代分为两部分,一部分叫做from(对象区域),另一部分叫做to(空闲区域),新加入的对象首先存放在from区域;

from区域写满的时候,对from区域开始进行垃圾回收。首先对from区域的垃圾进行标记(红色代表标记为垃圾);

将存活的对象复制到to区域中,并且有序地排列起来,复制后的to区域就没有内存碎片了;

清空from区域;

from区域和to区域进行反转,也就是原来的from区域变为to区域,原来的to区域变成from区域。

Scavenge算法在时间效率上有着优异的表现,缺点是只能使用堆内存中的一半,如果存储容量过大,就会导致每次清理的时间过长,效率低,因此经过两次垃圾回收之后依然存活的对象会晋升为老生代对象,另外还有一种情况,如果复制一个对象到空闲区时,空闲区空间占用超过了 25%,那么这个对象会被直接晋升到老生代空间中,设置为 25% 的比例的原因是,当完成 Scavenge 回收后,空闲区将翻转成对象区域,继续进行对象内存的分配,若占比过大,将会影响后续内存分配。

 

3. 老生代的特点:

  • 对象占用空间大
  • 对象存活时间长


4. 老生代-标记整理法

  • 标记:和标记 - 清除的标记过程一样,从一组根元素开始,递归遍历这组根元素,在这个遍历过程中,能到达的元素标记为活动对象;
  • 整理:让所有存活的对象都向内存的一端移动

 

5. 何时执行垃圾回收?

由于 JavaScript 是运行在主线程之上的,一旦执行垃圾回收算法,都需要将正在执行的 JavaScript 脚本暂停下来,待垃圾回收完毕后再恢复脚本执行。我们把这种行为叫做全停顿(Stop-The-World)。

为了降低老生代的垃圾回收而造成的卡顿,V8 将标记过程分为一个个的子标记过程,同时让垃圾回收标记和 JavaScript 应用逻辑交替进行,直到标记阶段完成,我们把这个算法称为增量标记(Incremental Marking)算法

 

 四、哪些情况容易引起内存泄漏

JS 内存泄露通常是由于对象被错误地持有,导致垃圾回收(GC)无法释放它们。以下是常见的内存泄露情况:

1. 挂载了太多的全局变量

全局变量等同于在window上添加属性,因此在函数执行完毕,依旧能够访问到它,因此不能够被回收。

2. 闭包

闭包可以在外部引用内部的变量,可能会导致外部作用域的变量无法释放

1
2
3
4
5
6
7
function leakyClosure() {
    let bigData = new Array(1000000).fill("leak");
    return function () {
        console.log(bigData.length);
    };
}
let leaky = leakyClosure(); // bigData 无法被回收

显式设置 bigData = null 解除引用

 

3. DOM 引用未清理

如果 JS 代码持有对已删除的 DOM 元素的引用,内存无法回收:

1
2
3
4
let div = document.createElement("div");
document.body.appendChild(div);
let divRef = div;
document.body.removeChild(div); // DOM 删除了,但 divRef 还引用着

显式设置divRef = null解除引用。

4. 定时器(setInterval / setTimeout)未清理

定时器中引用了外部变量,但未手动 clearInterval,导致变量无法回收:

1
2
3
4
5
6
7
function startTimer() {
    let data = new Array(1000000).fill("leak");
    setInterval(() => {
        console.log(data.length);
    }, 1000);
}
startTimer(); // data 永远不会被释放

解决:在适当的时机 clearInterval()

5.事件监听器未移除

如果给 DOM 绑定了事件监听器,但在删除元素时未移除,可能会导致泄露:

1
2
3
4
5
let btn = document.getElementById("myButton");
btn.addEventListener("click", function () {
    console.log("Clicked!");
});
document.body.removeChild(btn); // btn 被移除,但监听器仍然存在

6. Map 和Set的泄露
值使用了引用类型的变量,变量设置为null,但map实例还存在

1
2
3
4
let map = new Map();
let obj = { key: "value" };
map.set(obj, "some data");
obj = null// 但 map 仍然持有 obj 的引用

解决方案:使用 WeakMap

1
2
3
4
let weakMap = new WeakMap();
let obj = { key: "value" };
weakMap.set(obj, "some data");
obj = null; // WeakMap 不会阻止垃圾回收

 

五、如何分析内存泄露

 

  

posted @   我是格鲁特  阅读(1869)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· Manus爆火,是硬核还是营销?
· 终于写完轮子一部分:tcp代理 了,记录一下
· 别再用vector<bool>了!Google高级工程师:这可能是STL最大的设计失误
· 单元测试从入门到精通
· 震惊!C++程序真的从main开始吗?99%的程序员都答错了
点击右上角即可分享
微信分享提示