JavaScript js调用堆栈(三)
本文主要深入介绍JavaScript内存机制
内存模型
JS内存空间分为栈(stack),堆(heap),池(一般也会归类为栈中),其中栈存放变量,堆存放复杂对象,池存放常量。
注:闭包中的变量并不保存在栈内存中,而是保存在堆内存中,这就是函数之后为什么闭包还能引用函数内的变量的原因。
function A() { let a = 1 function B() { console.log(a) } return B }
闭包
的简单定义是:函数 A 返回了一个函数 B,并且函数 B 中使用了函数 A 的变量,函数 B 就被称为闭包。
函数 A Push调用栈后,函数 A 中的变量这时候是存储在堆上的,所以函数B依旧能引用到函数A中的变量。现在的 JS 引擎可以通过逃逸分析辨别出哪些变量需要存储在堆上,哪些需要存储在栈上。
下面重点讲解内存回收和内存泄漏
内存回收
JavaScript有自动垃圾收集机制,就是找出那些不在继续使用的值。然后释放其占用的内存。垃圾收集器会每隔固定的时间段就执行一次释放操作。最常用的是标记清除法,例如 a=null 其实只是一个释放引用的操作,让 a 原本对应的值失去引用,脱离执行环境,这个值会在下一次垃圾收集器执行操作时被找到并释放。并且在适当的时候解除引用,是为页面获得更好性能的一个重要方式。
局部变量和全局变量的回收
- 局部变量:在局部作用域中,当函数执行完毕,局部变量也就没有存在的必要了,因此垃圾收集器很容易做出判断并回收;
- 全局变量:全局变量什么时候需要自动释放内存内存空间很难判断,因此在开发中应尽量避免使用全局变量,以确保性能问题。
以Google的V8引擎为例,在V8引擎中所有的JAVASCRIPT对象都是通过堆来进行内存分配的
- 初始分配:当我们在代码中声明变量并赋值时,V8引擎就会在堆内存中分配一部分给这个变量;
- 继续申请:如果已申请的内存不足以存储这个变量时,V8引擎就会继续申请内存,直到堆的大小达到了V8引擎的内存上限为止(默认情况下,V8引擎的堆内存的大小上限在64位系统中为1464MB,在32位系统中则为732MB)。
V8引擎对堆内存中的JAVASCRIPT对象进行分代管理
- 新生代:新生代即存活周期较短的JAVASCRIPT对象,如临时变量、字符串等;
- 老生代:老生代则为经过多次垃圾回收仍然存活,存活周期较长的对象,如主控制器、服务器对象等。
一段代码分析一下垃圾回收:
function fun1() { var obj = {name: 'lhh', age: 21}; } function fun2() { var obj = {name: 'coder', age: 2} return obj; } var f1 = fun1(); var f2 = fun2();
在上述代码中,当执行 var f1 = fun1(); 的时候,执行环境会创建一个 {name:'lhh',age:21} 这个对象,当执行 var f2 = fun2(); 的时候,执行环境会创建一个 {name:'coder',age:2} 这个对象,然后在下一次垃圾回收执行的时候,会释放 {name:'lhh',age:21} 这个对象的内存,但并不会释放 {name:'coder',age:100} 这个对象的内存。这就是因为在 fun2() 函数中将 {name:'coder',age:2} 这个对象返回,并且将其引用赋值给了 f2 变量,又由于 f2 这个对象属于全局变量,所以在页面存在的情况下, f2 所指向的对象 {name:'coder',age:2} 是不会被回收的。(由于JavaScript语言的特殊性,例如闭包...,导致如何判断一个对象是否会被回收比较困难,以上仅供参考)
垃圾回收算法
对垃圾回收算法来说,核心思想就是如何判断内存已经不再使用,常用垃圾回收算法有下面两种。
-
引用计数(现代浏览器不再使用)
-
标记清除(常用)
引用计数
引用计数算法定义“内存不再使用”的标准很简单,就是看一个对象是否有指向它的引用。如果没有其他对象指向它了,说明该对象已经不再需要了。
// 创建一个对象person,他有两个指向属性age和name的引用 var person = { age: 12, name: 'aaaa' }; person.name = null; // 虽然设置为null,但因为person对象还有指向name的引用,因此name不会回收 var p = person; person = 1; //原来的person对象被赋值为1,但因为有新引用p指向原person对象,因此它不会被回收 p = null; //原person对象已经没有引用,很快会被回收
由上面可以看出,引用计数算法是个简单有效的算法。但它却存在一个致命的问题:循环引用。如果两个对象相互引用,尽管他们已不再使用,垃圾回收器不会进行回收,导致内存泄露。
function cycle() { var o1 = {}; var o2 = {}; o1.a = o2; o2.a = o1; return "Cycle reference!" } cycle();
cycle
函数执行完成之后,对象o1
和o2
实际上已经不再需要了,但根据引用计数的原则,他们之间的相互引用依然存在,因此这部分内存不会被回收。所以现代浏览器不再使用这个算法。但IE依旧使用。
var div = document.createElement("div"); div.onclick = function() { console.log("click"); };
上面写法创建一个DOM元素并绑定一个点击事件,这里的变量div有事件处理函数的引用,同时事件处理函数也有div的引用(变量div可在函数内被访问),所以循环引用就出现了。
标记清除(常用)
标记清除算法将“不再使用的对象”定义为“无法达到的对象”。简单来说,就是从根部(在JS中就是全局对象)出发定时扫描内存中的对象。凡是能从根部到达的对象,都是还需要使用的,给予保留。那些无法由根部出发触及到的对象被标记为不再使用,稍后进行回收。
从这个概念可以看出,无法触及的对象包含了没有引用的对象这个概念(没有任何引用的对象也是无法触及的对象)。但反之未必成立。
算法由以下几步组成:
- 垃圾回收器创建了一个“roots”列表。roots 通常是代码中全局变量的引用。JavaScript 中,“window” 对象是一个全局变量,被当作 root 。window 对象总是存在,因此垃圾回收器可以检查它和它的所有子对象是否存在(即不是垃圾);
- 所有的 roots 被检查和标记为激活(即不是垃圾)。所有的子对象也被递归地检查。从 root 开始的所有对象如果是可达的,它就不被当作垃圾;
- 所有未被标记的内存会被当做垃圾,收集器现在可以释放内存,归还给操作系统了。
现代的垃圾回收器改良了算法,但是本质是相同的:可达内存被标记,其余的被当作垃圾回收。
根据这个概念,上面的例子可以正确被垃圾回收处理了。
当div与其事件处理函数不能再从全局对象出发触及的时候,垃圾回收器就会标记并回收这两个对象。
内存泄漏
例子:
对于主流浏览器来说,只需要切断需要回收的对象与根部的联系。最常见的内存泄露一般都与DOM元素绑定有关:
email.message = document.createElement(“div”); displayList.appendChild(email.message); // 稍后从displayList中清除DOM元素 displayList.removeAllChildren();
上面代码中,div
元素已经从DOM树中清除,但是该div
元素还绑定在email对象中,所以如果email对象存在,那么该div
元素就会一直保存在内存中。
概念:
对于持续运行的服务进程(daemon),必须及时释放不再用到的内存。否则,内存占用越来越高,轻则影响系统性能,重则导致进程崩溃。 对于不再用到的内存,没有及时释放,就叫做内存泄漏(memory leak)。
内存泄漏识别方法:
- 浏览器方法
- 打开开发者工具,选择 Memory
- 在右侧的Select profiling type字段里面勾选 timeline
- 点击左上角的录制按钮
- 在页面上进行各种操作,模拟用户的使用情况。
- 一段时间后,点击左上角的 stop 按钮,面板上就会显示这段时间的内存占用情况。
- 命令行方法
使用Node
提供的process.memoryUsage
方法
console.log(process.memoryUsage()); // 输出 { rss: 27709440, // resident set size,所有内存占用,包括指令区和堆栈 heapTotal: 5685248, // "堆"占用的内存,包括用到的和没用到的 heapUsed: 3449392, // 用到的堆的部分 external: 8772 // V8 引擎内部的 C++ 对象占用的内存 }
判断内存泄漏,以heapUsed
字段为准
WeakMap
ES6 新出的两种数据结构:WeakSet
和 WeakMap
,表示这是弱引用,它们对于值的引用都是不计入垃圾回收机制的。
const wm = new WeakMap(); const element = document.getElementById('example'); wm.set(element, 'some information'); wm.get(element) // "some information"
先新建一个 Weakmap
实例,然后将一个 DOM 节点作为键名存入该实例,并将一些附加信息作为键值,一起存放在 WeakMap
里面。这时,WeakMap
里面对element的引用就是弱引用,不会被计入垃圾回收机制。
四种常见的JS内存泄漏
1.意外的全局变量
未定义的变量会在全局对象创建一个新变量,如下:
function foo(arg) { bar = "this is a hidden global variable"; }
真相是:
function foo(arg) { window.bar = "this is an explicit global variable"; }
函数 foo
内部忘记使用 var
,实际上JS会把bar挂载到全局对象上,意外创建一个全局变量。
另一个意外的全局变量可能由 this
创建:
function foo() { this.variable = "potential accidental global"; } // Foo 调用自己,this 指向了全局对象(window) // 而不是 undefined foo();
解决方法:在 JavaScript 文件头部加上 'use strict'
,使用严格模式避免意外的全局变量,此时上例中的this指向undefined
。如果必须使用全局变量存储大量数据时,确保用完以后把它设置为 null 或者重新定义。与全局变量相关的增加内存消耗的一个主因是缓存。缓存数据是为了重用,缓存必须有一个大小上限才有用。高内存消耗导致缓存突破上限,因为缓存内容无法被回收。
2.被遗忘的计时器或回调函数
在 JavaScript 中使用 setInterval
非常常见:
var someResource = getData(); setInterval(function() { var node = document.getElementById('Node'); if(node) { // 处理 node 和 someResource node.innerHTML = JSON.stringify(someResource)); } }, 1000);
上面的例子表明,在节点node或者数据不再需要时,定时器依旧指向这些数据。所以哪怕当node节点被移除后,interval 仍旧存活并且垃圾回收器没办法回收,它的依赖也没办法被回收,除非终止定时器。
var element = document.getElementById('button'); function onClick(event) { element.innerHTML = 'text'; } element.addEventListener('click', onClick);
对于上面观察者的例子,一旦它们不再需要(或者关联的对象变成不可达),明确地移除它们非常重要。老的 IE 6 是无法处理循环引用的。因为老版本的 IE 是无法检测 DOM 节点与 JavaScript 代码之间的循环引用,会导致内存泄漏。
3.脱离DOM的引用
有时,保存 DOM 节点内部数据结构很有用。假如你想快速更新表格的几行内容,把每一行 DOM 存成字典(JSON 键值对)或者数组很有意义。此时,同样的 DOM 元素存在两个引用:一个在 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() { // 按钮是 body 的后代元素 document.body.removeChild(document.getElementById('button')); // 此时,仍旧存在一个全局的 #button 的引用 // elements 字典。button 元素仍旧在内存中,不能被 GC 回收。 }
此外还要考虑 DOM 树内部或子节点的引用问题。假如你的 JavaScript 代码中保存了表格某一个 <td>
的引用。将来决定删除整个表格的时候,直觉认为 GC 会回收除了已保存的 <td>
以外的其它节点。实际情况并非如此:此 <td>
是表格的子节点,子元素与父元素是引用关系。由于代码保留了 <td>
的引用,导致整个表格仍待在内存中。保存 DOM 元素引用的时候,要小心谨慎。
4.闭包
闭包的关键是匿名函数可以访问父级作用域的变量。
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)并无法降低内存占用。本质上,闭包的链表已经创建,每一个闭包作用域携带一个指向大数组的间接的引用,造成严重的内存泄漏。
解决方法:在 replaceThing
的最后添加 originalThing = null
推荐阅读:
https://juejin.im/post/5b10ba336fb9a01e66164346#comment
https://auth0.com/blog/four-types-of-leaks-in-your-javascript-code-and-how-to-get-rid-of-them//
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】凌霞软件回馈社区,博客园 & 1Panel & Halo 联合会员上线
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】博客园社区专享云产品让利特惠,阿里云新客6.5折上折
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步