内存机制
JS有两种数据类型,分别是原始数据类型(String、Number、Boolean、Null、Undefined、Symbol
)和引用数据类型(Object
)。
而存放这两种数据类型的内存又可以分为两部分,分别是栈内存(Stack)和堆内存(Heap)。
这里说下栈内存和堆内存的区别:
|栈内存|堆内存|
|:–:|:–:|
|先进后出,后进先出|无序存储,根据引用直接获取|
|存储原始数据类型|存储引用数据类型|
|存储的值大小固定|存储的值大小不固定,可动态调整|
|按值访问|按引用访问|
|可以直接操作|不允许直接操作|
|空间小,但运行效率高|空间大,但运行效率相对低|
不知道大家有没有过这样一个疑问:Js 声明变量时,底层是怎么实现这个声明过程的,或者说是怎么存储这个变量的,感兴趣的话可以看看下面这两篇文章:
内存生命周期
不管什么程序语言,内存生命周期基本是一致的:
- 分配你所需要的内存
- 使用分配到的内存(读、写)
- 不需要时将其释放/归还
JS的内存分配
- 值的初始化
在定义变量时就完成了内存分配 - 使用值
使用值的过程实际上是对分配内存进行读取与写入的操作。这个操作可能是写入一个变量或者一个对象的属性值,甚至传递函数的参数。 - 释放不再需要的内存
内存泄漏
定义
不再用到的内存,没有及时释放,就叫做内存泄漏(memory leak),会导致内存溢出。
内存溢出:指程序申请内存时,没有足够的内存供申请者使用。例如,给一块存储int类型数据的存储空间,但却存储long类型的数据,那么结果就是内存不够用,此时就会报错,即所谓的内存溢出。
常见 JavaScript 内存泄露
- 意外的全局变量
①未定义的变量会在全局对象创建一个新变量。在浏览器中,全局对象是 window 。
function foo(arg) {
bar = "this is a hidden global variable";
}
// 实际上是:
function foo(arg) {
window.bar = "this is an explicit global variable";
}
如果 bar 是一个应该指向 foo 函数作用域内变量的引用,但是你忘记使用 var 来声明这个变量,这时就会创建一个全局变量。
②另一种意外的全局变量可能由 this 创建:
function foo() {
this.variable = "potential accidental global";
}
foo();
函数自身发生了调用,this 指向全局对象(window)
为了防止这种错误的发生,可以在你的 JavaScript 文件开头添加 'use strict';
语句。这个语句实际上开启了解释 JavaScript 代码的严格模式,可以避免创建意外的全局变量。
总结:
- 全局变量的注意事项:如果你必须使用全局变量来存储很多的数据,请确保在使用过后将它设置为
null
或者将它重新赋值。常见的和全局变量相关的引发内存消耗增长的原因就是缓存。(缓存存储着可复用的数据) - 解决方法:①避免创建全局变量;②使用严格模式,在 JavaScript 文件头部或者函数的顶部加上 use strict。
- 闭包引起的内存泄漏
原理:闭包可以读取函数内部的变量,然后让这些变量始终保存在内存中。如果在使用结束后没有将局部变量清除,就可能导致内存泄露。
var leaks = (function(){
var leak = 'xxxxxx'; // 被闭包所引用,不会被回收
return function(){
console.log(leak);
}
})()
解决方法:将事件处理函数定义在外部,解除闭包,或者在定义事件处理函数的外部函数中。
// 比如:在循环中的函数表达式,能复用最好放到循环外面。
// 不要这样:
for(var k = 0; k < 10; k++) {
var t = function(a) {
// 创建了10次函数对象
console.log(a)
}
t(k)
}
// 这样比较好:
function t(a) {
console.log(a)
}
for(var k = 0; k < 10; k++) {
t(k)
}
t = null
- 没有清理的 DOM 元素引用
原因:虽然别的地方删除了,但是对象中还存在对 dom 的引用。
// 在对象中引用DOM
var elements = {
btn:document.getElementById('btn'),
}
function doSomeThing() {
elements.btn.click()
}
function removeBtn() {
// 将body中的btn移除,也就是移除DOM树中的btn
document.body.removeChild(document.getElementById('button'))
// 但是此时全局变量 elements 还是保留了对 btn 的引用,btn 还是存在于内存中,不能被 GC 回收
}
解决方法:手动删除,elements.btn = null。
没有移除计时器或回调函数
// 定时器中有 dom 的引用,即使 dom 删除了,但是定时器还在,所以内存中还是有这个 dom。 // 定时器 var serverData = loadData() setInterval(function() { var renderer = document.getElementById('renderer') if(renderer) { renderer.innerHTML = JSON.stringify(serverData) } }, 5000) // 观察者模式 var btn = document.getElementById('btn') function onClick(element) { element.innerHTML = "I'm innerHTML" } btn.addEventListener('click', onClick)
解决方法:①手动删除定时器和 dom;②removeEventListener 移除事件监听
循环引用
当出现了一个含有DOM对象的循环引用时,就会发生内存泄露。
内存泄露的解决方案
下面的方案是从网上搜索到的资料,本人并没有实践过(主要是还不懂),仅供大家参考:
- 显式类型转换
- 避免事件导致的循环引用
- 不影响返回值地打破循环引用
- 延迟appendChild
- 代理DOM对象
更加具体的解决方法可点击这里:传送门
V8 垃圾回收机制
上文有提到:内存会发生泄露是因为没有及时释放多余的内存。那么问题来了,应该由谁去释放多余的内存,又是怎么释放内存的?答案是 V8 垃圾回收机制。在了解这个机制之前,我们先来认识下什么是 V8 。认识V8
V8 是 Google 采用 C++ 编写的开源 JavaScript 引擎。采用即时编译,直接翻译成机器语言,并且使用了如内联缓存(inline caching)等方法来提高性能。有了这些功能,JavaScript程序在V8引擎下的运行速度媲美二进制程序。
V8内存设限:64bit操作系统上不超过1.5G,32bit操作系统上不超过800M 这么设限为了浏览器使用内存足够,内部还有垃圾运行机制,时间也在用户感知的合理范围
目前V8垃圾回收采用增量标记算法需要50ms,采用非增量标记算法需要1s
这里先停一下,康康这篇文章再往下会更好:前端面试:谈谈 JS 垃圾回收机制
ddd,如果已经看完了上面这篇文章,就让我们继续往下吧~
什么是垃圾
一般来说没有被引用的对象就是垃圾,就是要被清除。 有个例外如果几个对象引用形成一个环,互相引用,但根访问不到它们,这几个对象也是垃圾,也要被清除。
V8垃圾回收策略
基本思路:确定哪个变量不会再使用,然后释放它占用的内存。这个过程是周期性的,即垃圾回收程序每隔一定时间(或者说在代码执行过程中某个预定的收集时间)就会自动运行。值得注意的是,垃圾回收过程是一个近似且不完美的方案,因为某块内存是否还有用–属于“不可判定的”问题,这意味着靠算法是解决不了的。
我们以函数中局部变量的正常生命周期为例。函数中的局部变量会在函数执行时存在,此时,栈(或堆)内存会分配空间以保存相应的值。当函数在内部使用了变量,然后退出,此时,就不再需要那个局部变量了,它占用的内存可以释放了。但垃圾回收程序并不知道哪个局部变量是要被释放的,因此,程序必须跟踪记录哪个变量还会使用,以及哪个变量不会再使用。
原始数据都是由程序语言自身控制的,这里的回收还是指主要存活在堆区的对象数据,这个过程是离不开内存操作的,那在这种情况下是如何对垃圾进行回收的?答案是:
- 采用分代回收的思想
- 内存分为新生代存储区、老生代存储区
- 针对不同代采用不同的 GC 算法
下面针对 GC 算法展开讨论。
GC
相关概念:
GC:垃圾回收机制的简写,垃圾回收期完成具体的工作,可以找到内存中的垃圾、并释放和回收空间
GC 算法:是 GC 工作时查找和回收所遵循的规则
常见 GC 算法:
引用计数(不太常用)
- 核心思想:跟踪记录每个值被引用的次数,每次引用的时候加一,被释放时减一,如果一个值的引用次数变成 0 了,就可以将其内存空间回收。
- 实现原理:
- 引用计数器
- 当引用关系改变时修改引用数字
- 当引用数字为0时立即回收
- 实例:
const user1 = {age:11} const user2 = {age:12} const user3 = {age:13} const nameList = [user1.age, user2.age, user.age] function fn() { const num1 = 1; const num2 = 2; num3 = 3; } fn();
- 当函数调用过后,
num1
和num2
在外部不能使用,引用数为 0,会被回收; num3
是挂载在window上的,所以不会被回收;user1
、user2
、user3
被nameList
引用,所以引用数不为 0,故不会被回收 ;
- 当函数调用过后,
- 优缺点:
引用计数算法 内容 优点 1.发现垃圾时立即回收
2.最大限度减少程序暂停,让空间不会有被占满的时候缺点 1.无法回收循环引用的对象
2.资源消耗开销大(对所有对象进行数值的监控和修改,本身就会占用时间和资源)- 举一栗子说明上面缺点中无法回收循环应用对象的情况:
obj1 和 obj2 因为互相有引用,所以计数器并不为 0 ,fn 调用结束之后依旧无法回收这两个对象function fn() { const obj1 = {} const obj2 = {} obj1.name = obj2 obj2.name = obj1 return 'hello world' } fn();
- 举一栗子说明上面缺点中无法回收循环应用对象的情况:
标记清除(最常用)
- 核心思想:当变量进入执行上下文时(比如在函数内部声明一个变量时),这个变量会被加上存在于上下文中的标记;而在上下文中的变量,从逻辑上来说,永远不应该释放它们的内存,因为只要上下文中的代码在运行,就有可能用到它们;当变量离开上下文时,也会被加上离开上下文的标记【也有的说法是标记被清除,不过《JavaScript 高级程序设计》中写的是前者,就以该书为主吧】。于是当垃圾回收时就会销毁那些带标记的值并回收他们的内存空间。
- 实现原理:分 标记 和 清除 两个阶段完成
- 第一阶段:遍历所有对象找活动对象(可达对象)进行标记(层次用递归进行操作)
- 有一组基本的固有可达值,由于显而易见的原因无法删除。例如:
- 本地函数的局部变量和参数
- 当前嵌套调用链上的其他函数的- 变量和参数
- 全局变量
- 还有一些其他的,内部的这些值称为根。
- 如果引用或引用链可以从根访问任何其他值,则认为该值是可访问的
- 例如,如果局部变量中有对象,并且该对象具有引用另一个对象的属性,则该对象被视为可达性, 它引用的那些也是可以访问的。
- 有一组基本的固有可达值,由于显而易见的原因无法删除。例如:
- 第二阶段:遍历所有对象,清除没有标记的对象,并抹掉第一个阶段标的标记
- 注意:js中的标记是标记所有的变量,清除掉被标记为离开状态的变量;而老生代中的标记使标记存活的变量,清除没有被标记的变量。(什么是老生代?后面会讲到的)
- 收尾:回收相应空间,将回收的空间加到空闲链表中,方便后面的程序申请空间使用
- 第一阶段:遍历所有对象找活动对象(可达对象)进行标记(层次用递归进行操作)
- 优缺点:
标记清除算法 内容 优点 1.相对于引用计数算法来说,解决了对象循环引用的问题。因为局部作用域里面的内容无法被标记,所以即使有引用还是会被清除掉
2.回收速度较快缺点 1.空间链表地址不连续(空间碎片化),不能进行空间最大化使用
2.不会立即回收垃圾对象,清除的时候程序是停止工作的- 下面是空间链表地址不连续的图示,可以更好的帮我们理解这个缺点是怎么肥事:
标记整理
核心思想:在 标记 和 清除 中间,添加了内存空间的 整理
实现原理:(标记整理可以看做是标记清除的 增强)
- 标记阶段:与标记清除一致
- 整理阶段:清除垃圾前先执行整理操作,移动对象位置,在地址上产生连续
- 最后留出了整个的空闲空间
流程图示:
优缺点:
标记整理算法 内容 优点 相较标记清除算法减少了碎片化空间 缺点 不会立即回收垃圾对象,清除的时候程序是停止工作的
空间复制
- 新生代区域垃圾回收使用空间换时间
- 主要采用复制算法,要有空闲空间存在,当然新生代本身空间小,分出来的复制的空间更小,所以浪费这点空间换取时间的效率是微不足道的
- 老生代区域垃圾回收不适合复制算法,老生代空间大一分为二,会造成一半的空间浪费,存放数据多复制时间长。
分代回收 (一定会用)
- 新生代对象回收
新生代 —— 就是指存活时间较短的对象,例如:一个局部作用域中,只要函数执行完毕之后变量就会回收。
- 主要使用算法:采用赋值算法 + 标记整理算法
- 回收过程:
- 新生代内存区分为两个等大小空间,使用空间为From,空闲空间为To
- 如果需要申请空间使用,回收步骤如下:
- 首先会将所有活动对象存储于From空间,这个过程中To是空闲状态。
- 当From空间使用到一定程度之后就会触发GC操作,这个时候会进行标记整理——对活动对象进行标记并移动位置将使用空间变得连续。
- 将活动对象拷贝至To空间,拷贝完成之后活动空间就有了备份,这个时候就可以考虑回收操作了。
- 把From空间完成释放,回收完成。
- 对From和To名称进行调换,继续重复之前的操作。
- 这种算法的缺点是:只能使用堆内存的一半。
- 总结:使用From -> 触发GC标记整理 -> 拷贝到To -> 回收From -> 名称互换 -> 重复之前
- 晋升
- 定义:拷贝的过程中某个对象的指代在老生代空间,就可能出现晋升。 晋升就是将新生代对象移动至老生代。
- 什么时候触发晋升操作?
- 1.一轮GC之后还存活的新生代对象就需要晋升
- 2.在拷贝过程中,To空间的使用率超过25%,将这次的活动对象都移动至老生代空间
- Q:为什么设置25%这个阈值
- A:当这次回收完成后,这个To空间会变为From空间,接下来的内存分配将在这个空间中进行。如果占比过高,会影响后续的内存分配。
- 老生代对象回收
老生代 —— 就是指存活时间较长的对象,例如:全局对象,闭包变量数据。
- 主要使用算法:主要采用标记清除 (首要) 、标记整理、增量标记算法
- V8内存空间一分为二,分为新生代存储区和老生代存储区
- 左边小空间用于存储新生代对象
- 64bit操作系统上不超过32M
- 32bit操作系统上不超过16M
- 右边较大空间用于存储老生代对象
- 64bit操作系统上不超过1.6G
- 32bit操作系统上不超过700M
- 左边小空间用于存储新生代对象
- 新生代对象回收
标记增量 (提高效率用)
- 将一整段的垃圾回收操作标记拆分成多个小段完成回收,主要是为了实现程序和垃圾回收的交替完成,这样进行 效率优化 带来的时间消耗更加的合理。
- 优化垃圾回收:看图可以将垃圾回收分成两个部分,一个是程序的执行,一个是垃圾的回收。当垃圾回收的时候其实会阻塞程序的执行,所以中间会有空档期。
这篇文章也挺好,小伙伴们可以看看:传送门