JS - 内存和内存泄漏

本文详细介绍了JavaScript内存的生命周期,包括静态和动态内存分配,以及V8引擎中栈和堆内存的特点。V8的垃圾收集器采用标记清除法解决循环引用问题。内存泄漏的原因和常见类型,如全局变量、计时器、对象引用和闭包,也被深入探讨。此外,提到了内存膨胀和频繁GC问题以及如何避免。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

一、内存的生命周期

内存的生命周期可以分为三个部分

  1. 分配你所需要的内存
  2. 使用分配到的内存(读、写)
  3. 不需要时将其释放\归还
1. 内存

内存的分配方式分为:

  • 静态内存分配
  • 动态内存分配

区别:

静态内存动态内存
固定大小大小不固定
在编译时执行在运行时执行
分配给栈分配给堆
先进先出没有特定的顺序
2. 使用

读写基本变量或对象的属性、传参等操作,都涉及到了内存的使用。

3. 释放

对于不再使用的内存,应当及时释放。

二、 V8内存结构
1.栈内存

栈用于静态内存分配,它具有以下特点:

  1. 操作数据快,因为是在栈顶操作
  2. 数据必须是静态的,数据大小在编译时是已知的
  3. 多线程应用程序中,每个线程可以有一个栈
  4. 堆的内存管理简单,且由操作系统完成
  5. 栈大小有限,可能发生栈溢出(Stack Overflow)
  6. 值大小有限制
2. 堆内存

堆用于动态内存分配,与栈不同,程序需要使用指针在堆中查找数据。它的特点是:

  1. 操作速度慢,但容量大
  2. 可以将动态大小的数据存储在此处
  3. 堆在应用程序的线程之间共享
  4. 因为堆的动态特性,堆管理起来比较困难
  5. 值大小没有限制
三、V8内存回收
1. 回收栈内存

V8会通过移动记录当前执行状态的指针(ESP) 来销毁该函数保存在栈中的执行上下文。

2. 回收堆内存

V8中的垃圾收集器(Garbage Collector),它的工作是:跟踪内存的分配和使用,以便当分配的内存不再使用时,自动释放它。并且,这个垃圾收集器是分代的,也就是说,堆中的对象按其年龄分组并在不同阶段清除。

回收堆内存有两种方法:

  1. 引用计数法
  2. 标记清除法(所有现代浏览器都使用了标记清除垃圾回收算法)

1. 引用计数法

内存引用(Memory References)是引用计数法中的一个重要概念。在内存管理的上下文中,如果一个对象可以隐式或显式访问另一个对象,则称该对象引用另一个对象。 例如,JavaScript对象能够引用其原型(隐式引用)和其属性的值(显式引用)。

引用计数法的思想是:一旦对某个对象的引用数为0,则把这个对象视为可收集垃圾(Garbage Collectible)。

var o1 = {
  o2: {
    x: 1
  }
};
// 2 objects are created. 
// 'o2' is referenced by 'o1' object as one of its properties.
// None can be garbage-collected

var o3 = o1; // the 'o3' variable is the second thing that 
            // has a reference to the object pointed by 'o1'. 
                                                       
o1 = 1;      // now, the object that was originally in 'o1' has a         
            // single reference, embodied by the 'o3' variable

var o4 = o3.o2; // reference to 'o2' property of the object.
                // This object has now 2 references: one as
                // a property. 
                // The other as the 'o4' variable

o3 = '374'; // The object that was originally in 'o1' has now zero
            // references to it. 
            // It can be garbage-collected.
            // However, what was its 'o2' property is still
            // referenced by the 'o4' variable, so it cannot be
            // freed.

o4 = null; // what was the 'o2' property of the object originally in
           // 'o1' has zero references to it. 
           // It can be garbage collected.

引用计数法存在着一个缺点——循环引用。在下面的例子中,两个对象被创建,并互相引用,形成了一个循环。然而,由于它们互相都有至少一次引用,所以它们不会被回收。

function f() {
  var o1 = {};
  var o2 = {};
  o1.p = o2; // o1 references o2
  o2.p = o1; // o2 references o1. This creates a cycle.
}

f();

2. 标记清除法

标记清除法把“对象是否不再需要”简化定义为“对象是否可以获得”。

标记清除法的两个步骤:

  1. 标记:从根节点开始寻找可以到达的节点,并标记这些节点。

  2. 清除:垃圾回收器释放未标记的内存。

标记清除法解决了循环引用的问题。在之前的示例代码中,函数调用返回之后,两个对象从全局对象出发无法获取。因此,它们将会被垃圾回收器回收。

四、内存分配
1. 值的初始化

JavaScript 在定义变量时就完成了内存分配。

var n = 123; // 给数值变量分配内存
var s = "azerty"; // 给字符串分配内存

var o = {
  a: 1,
  b: null
}; // 给对象及其包含的值分配内存

// 给数组及其包含的值分配内存(就像对象一样)
var a = [1, null, "abra"];

function f(a){
  return a + 2;
} // 给函数(可调用的对象)分配内存

// 函数表达式也能分配一个对象
someElement.addEventListener('click', function(){
  someElement.style.backgroundColor = 'blue';
}, false);
2. 通过函数调用分配内存

有些函数调用结果是分配对象内存:

var d = new Date(); // 分配一个 Date 对象

var e = document.createElement('div'); // 分配一个 DOM 元素

有些方法分配新变量或者新对象:

var s = "azerty";
var s2 = s.substr(0, 3); // s2 是一个新的字符串
// 因为字符串是不变量,
// JavaScript 可能决定不分配内存,
// 只是存储了 [0-3] 的范围。

var a = ["ouais ouais", "nan nan"];
var a2 = ["generation", "nan nan"];
var a3 = a.concat(a2);
// 新数组有四个元素,是 a 连接 a2 的结果
五、内存泄漏

内存泄漏(Memory Leak)是指程序中己动态分配的堆内存由于某种原因程序未释放或无法释放,造成系统内存的浪费,导致程序运行速度减慢甚至系统崩溃等严重后果。而且内存泄露一直持续到浏览器关闭。

简单的说,就是指应用程序已经不再需要的内存,由于某种原因未返回给操作系统或者空闲内存池,导致系统变慢、卡顿、高延迟。。

六 常见的内存泄露
1. 隐式的全局变量
function test() {
  a = 'test';
}

相当于

function test() {
  window.a = 'test';
}

本来test函数调用完毕后,函数内部变量占用的内存是会被回收的,但是由于注册的是一个全局变量,导致a变量所占用的内存不会被回收。为了防止这种错误的发生,我们可以在js代码头部位置加上’use strict’;以严格模式来编写代码。

还有一种情况

function foo() {
    this.a = 'test';
}
foo();

当构造函数被直接调用,或者匿名函数里的this,在非严格模式也指向 window。

2. 被遗忘的计时器或回调函数

定时器 setInterval或者setTimeout在不需要的时候没有被clear,导致定时器的回调函数及其内部依赖的变量都不能被回收,造成内存泄漏,比如我们在react的componentDidMount中使用了定时器,那么在componentWillUnmount中一定要记得清除定时器,不然就会造成内存泄漏。

setInterval(function() {
    var node = document.getElementById('Node');
    if(node) {
        node.innerHTML = JSON.stringify(someResource));
    }
}, 1000);

如果后续id为Node的节点被移除了,定时器里的node变量仍然持有其引用,导致游离的DOM子树无法释放

clearTimeout(t);
clearInterval(t);

回调函数的场景与timer类似:

var element = document.getElementById('button');

function onClick(event) {
    element.innerHtml = 'text';
}

element.addEventListener('click', onClick);

element.removeEventListener('click', onClick);
element.parentNode.removeChild(element);

移除节点之前应该先移除节点身上的事件监听器,因为IE6没处理DOM节点和JS之间的循环引用(因为BOM和DOM对象的GC策略都是引用计数),可能会出现内存泄漏,现代浏览器已经不需要这么做了,如果节点无法再被访问的话,监听器会被回收掉

3. 对象引用

DOM元素添加的属性是一个对象的引用

// 添加
var obj = {}; 
document.querySelector('selector').property = obj; 

// 卸载
window.onunload = function() {
   document.querySelector('selector').property = null;
}

对象内部属性是对象引用

a = {p: {x: 1}};
b = a.p;
delete a.p;

执行这段代码之后b.x的值依然是1.由于已经删除的属性引用依然存在,因此在JavaScript的某些实现中,可能因为这种不严谨的代码而造成内存泄露。所以在销毁对象的时候,要遍历属性中属性,依次删除。

4. 脱离 DOM 的引用
var elements = {
    button: document.getElementById('button'),
    image: document.getElementById('image'),
    text: document.getElementById('text')
};

function doStuff() {
    image.src = 'http://some.url/image';
    button.click();
    console.log(text.innerHTML);
}

function removeButton() {
    document.body.removeChild(document.getElementById('button'));
}

在上面这种情况中,我们对#button的保持两个引用:一个在DOM树中,另一个在elements对象中。 如果将来决定回收#button,则需要使两个引用均不可访问。在上面的代码中,由于我们只清除了来自DOM树的引用,所以#button仍然存在内存中,而不会被GC。

经常会缓存DOM节点引用(性能考虑或代码简洁考虑),但移除节点的时候,应该同步释放缓存的引用,否则游离子树无法释放

另一个更隐蔽的场景是:

var select = document.querySelector;
var treeRef = select("#tree");
var leafRef = select("#leaf");
var body = select("body");

body.removeChild(treeRef);

//#tree can't be GC yet due to treeRef
treeRef = null;

//#tree can't be GC yet due to indirect
//reference from leafRef

leafRef = null;
//#NOW can be #tree GC

在这里插入图片描述
游离子树上任意一个节点引用没有释放的话,整棵子树都无法释放,因为通过一个节点就能找到(访问)其它所有节点,都给标记上活跃,不会被清除

5. 闭包
var theThing = null;
var replaceThing = function () {
  var originalThing = theThing;
  var unused = function () {
    if (originalThing)
      console.log("hi");
  };
  theThing = {
    longStr: new Array(1000000).join('*'),
    someMethod: function () {
      console.log(someMessage);
    }
  };
};
setInterval(replaceThing, 1000);

代码片段做了一件事情:每次调用 replaceThing ,theThing 得到一个包含一个大数组和一个新闭包(someMethod)的新对象。同时,变量 unused 是一个引用 originalThing 的闭包(先前的 replaceThing 又调用了 theThing )。思绪混乱了吗?最重要的事情是,闭包的作用域一旦创建,它们有同样的父级作用域,作用域是共享的。someMethod 可以通过 theThing 使用,someMethod 与 unused 分享闭包作用域,尽管 unused从未使用,它引用的 originalThing 迫使它保留在内存中(防止被回收)。当这段代码反复运行,就会看到内存占用不断上升,垃圾回收器(GC)并无法降低内存占用。本质上,闭包的链表已经创建,每一个闭包作用域携带一个指向大数组的间接的引用,造成严重的内存泄露。

七、其它内存问题

除了内存泄漏,还有两种常见的内存问题:

  • 内存膨胀

  • 频繁GC

内存膨胀是说占用内存太多了,但没有明确的界限,不同设备性能不同,所以要以用户为中心。了解什么设备在用户群中深受欢迎,然后在这些设备上测试页面。如果体验很差,那么页面可能存在内存膨胀的问题

频繁GC就是频繁页面暂停,可以通过优化存储结构(避免造大量的细粒度小对象)、缓存复用(比如用享元工厂来实现复用)等方式来解决频繁GC问题


参考链接

V8中JavaScript的内存管理与垃圾回收
Chrome 内存剖析工具概览
JS内存泄漏排查方法

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值