由于字符串、对象和数组没有固定大小,所以当他们的大小已知时,才能对他们进行动态的存储分配。JavaScript 序每次创建字符串、数组或对象时,解释器都必须分配内存来存储那个实体。只要像这样动态地分配了内存,最终都要释放这些内存以便他们能够被再用,否则,JavaScript 的解释器将会消耗完系统中所有可用的内存,造成系统崩溃。——《JavaScript 权威指南》
在 C 和 C++ 之类的语言中,需要手动来管理内存的,这也是造成许多不必要问题的根源。幸运的是,在编写 JavaScript 的过程中,内存的分配以及内存的回收完全实现了自动管理。
JavaScript 通过 自动垃圾收集机制 实现内存的管理。垃圾回收机制通过垃圾收集器每隔固定的时间段(周期性)找出那些不再需要继续使用的变量,执行一次释放占用内存的操作。
什么是不再需要继续使用的值?
不再需要继续使用的变量也就是生命周期结束的变量。
null
),这样在下一次垃圾回收的时候,就能去释放这个变量上一次指向的值JavaScript 有两种策略实现垃圾回收机制:
引用计数法: 跟踪记录每个值被引用的次数,当声明了一个变量并将一个引用类型赋值给该变量时,则这个值的引用次数就是 1
,如果这个值再被赋值给另一个变量,则引用次数加 1
。相反,如果包含对这个值引用的变量又取得了另外一个值,则这个值的引用次数就减 1
。当这个引用次数变成 0
时,则说明没有办法再访问这个值了,因而就可以将其所占的内存空间给收回来。这样,垃圾收集器下次再运行时,它就会释放那些引用次数为 0
的值所占的内存。
这种垃圾收集方式存在一个比较大的问题就是循环引用,就是说对象 a
包含一个指向 b
的指针,对象 b
也包含一个指向 a
的引用。 这就可能造成大量内存得不到回收,也就是内存泄漏,这是因为它们的引用次数永远不可能是 0
。
🌰 代码示例:
function problem() {var a = new Object();var b = new Object();a.ref = b;b.ref = a;}
浅大小(shallow size):对象自身所存储的原生值及其他必要数据的大小。 留存大小(retained size):对象自身的浅大小和它支配的所有对象的浅大小的总和。
引用计数法无法解决循环引用问题:
function fn() {var x = {};var y = {};x.a = y;y.a = x;}
标记清除法:当程序执行流入到一个函数中时,会创建该函数的执行上下文,执行上下文中的变量都会被标记为 进入环境,从逻辑上讲,永远不能释放 进入执行环境 变量所占用的内存。因为只要执行流进入相应的执行上下文,就可能会用到这些变量。
标记清除的工作流程:
手动释放内存:
let a = 1;a = null;
a = null
其实仅仅只是做了一个释放引用的操作,让变量 a
原本对应的值失去引用,脱离执行环境,这个值会在下一次垃圾收集器执行操作时被找到并释放。而在适当的时候解除引用,是为页面获得更好性能的一个重要方式。
JavaScript 引擎的垃圾回收机制是标记清除法,判断内存是否可回收的依据是可达性,它是对引用计数法的改良,对象间的循环引用问题不会引起回收问题,因为判断是否可回收的依据是变量是否可达。这种算法下存在一个根节点,它始终不会被回收,称为 GC Root,比如 JavaScript Runtime 的全局对象,在浏览器中叫 window
以及 DOM 树根节点都是 GC Root。程序间对象的引用关系形成了节点的图,凡事能够从 GC Root 出发,沿着引用关系可以访达的对象被标记为活跃对象,而那些和 GC Root 孤立的对象就会被回收,可以发现代码中引用数为 0 的对象一定无法从 GC Root 访达,也就是说某个对象引用计数法认为它应该被回收的话,那么标记清除法也会将其回收,但是和 GC Root 孤立的对象,它在代码中的引用数不一定是 0,比如说对象属性的循环引用,它们都不可达,且都和 GC Root 孤立,但它们的引用数不一定是 0。所以说标记清除算法可以取代引用计数算法。
堆栈溢出:指内存空间已经被申请完,没有足够的内存提供了。
内存泄漏:指申请的内存执行完后没有及时的清理或者销毁,占用空闲内存,内存泄漏过多的话,就会导致后面的进程申请不到内存。因此内存泄漏会导致内部内存溢出。
内存泄漏 --可能导致--> 堆栈溢出
在传统的编程软件中,比如 C 语言中,需要使用 malloc
来申请内存空间,再使用 free
来释放掉,需要手动清除。而 JavaScript 中有自己的垃圾回收机制,一般常用的垃圾收集方法就是标记清除法。
常见的内存泄漏的原因:
解决方法:
全局变量不会被当成垃圾回收,我们在编码中应该尽量避免声明全局变量。
<body><button onclick="grow()">Global Var</button><script type="text/javascript">function LargeObj() {this.largeArr = new Array(1000_0000);}var x = [];function grow() {var o = new LargeObj();x.push(0);}</script></body>
当我们使用 默认绑定,this
会指向全局对象。
🔧 解决方法: 在函数内使用严格模式或手动释放全局变量的内存。
调试方式:More Tools -> Developer Tools -> Performance/Memory
,一般现在 Performance
面板录制页面内存占用情况随时间变化的图像,对内存泄漏有个直观的判断,然后在 Memory
面板定位问题发生的位置
DOM 节点的内存被回收要满足两点:DOM 节点在 DOM 树上被移除,并且代码中没有对他的引用。内存泄漏发生在节点从 DOM 上被删除了,但代码中留存着对它的 JS 引用,我们称这种为分离的 DOM 节点。
实现分离的 DOM 引用的内存泄漏示例:
<body><button>移除列表</button><ul id="list"><li>项目1</li></ul><script type="text/javascript">var button = document.getElementById('button');var list = document.getElementById('list');button.addEventListener('click', function () {list.remove();});</script></body>
可以通过堆快照(Heap Snapshot),调试路径 Memory -> Heap Snapshot -> Take Snapshot
,堆快照可以直接告诉我们是否存在分离的 DOM 节点,只要在顶部过滤框 filter 输入 detached
,如果过滤出东西,说明存在分离的 DOM 节点。
对于上例,可以把 list
节点放到点击节点的回调中,这样当回调函数返回后,局部变量会被销毁。
闭包也会造成内存泄漏,是因为函数实例上的隐式指针会留存实例创建环境下的作用域对象。
<body><button onclick="closure()">Closure</button><script type="text/javascript">var func = [];function outer() {var someText = new Array(1000_0000);return function inner() {return someText;};}function closure() {funcs.push(outer());}</script></body>
注意:并非该代码一定有什么问题,只是说明闭包会带来内存占用,不合理的内存占用才会被定性为内存泄漏。
调试方式:More Tools -> Developer Tools -> Memory -> Allocation instrumentation on timeline
。
当不需要 setInterval
或者 setTimeout
时,定时器没有被清除,定时器的回调函数以及内部依赖的变量都不能被回收,造成内存泄漏。
const someResource = getData();// node、someResource 存储了大量数据 无法回收const timerId = setInterval(function () {const node = document.getElementById('Node');if (node) {// 定时器也没有清除node.innerHTML = JSON.stringify(someResource);}}, 1000);clearInterval(timerId);
🔧 解决方法:在定时器完成工作的时候,手动清除定时器。
使用 console.log
语句打印调试信息,因为控制台要始终保持他们的引用,以便随时查看,所以他们的内存也无法被回收,所以建议生产环境下去除控制台打印。