浅析js中栈和堆
引言
因为JavaScript具有自动垃圾回收机制,周期性会检查没有使用的变量, 进行回收释放,所以对于前端开发来说,内存空间并不是一个经常被提及的概念,很容易被大家忽视。特别是很多新同学在进入到前端之后,会对内存空间的认知比较模糊。但在JS中内存管理是一个至关重要的环节,它直接影响到程序的性能与稳定性,而要深入理解JavaScript的内存管理机制,就不得不提及两个基本概念:栈(Stack)和堆(Heap)。
栈(Stack)和堆(Heap)
在JS中,每一个数据都需要一个内存空间。内存空间又被分为两种,栈内存(stack)与堆内存(heap) 。
- 栈(stack):是栈内存的简称,栈是自动分配相对固定大小的内存空间,并由系统自动释放,栈数据结构遵循先进后出的原则。基本数据类型:Null、Undefined、Number、Boolean、String、Symbol。
- 堆(heap):是堆内存的简称,堆是动态分配内存,内存大小不固定,也不会自动释放,堆数据结构是一种无序的树状结构,同时它还满足key-value键值对的存储方式;我们只用知道key名,就能通过key查找到对应的value。引用数据类型:Object、Function、Array。
为什么会有栈内存和堆内存之分?
为什么要有两种空间呢?为什么不全放在栈中呢?这好比一个书店有一堆书,你去找肯定不方便,这时候你可以把书整理在书架,记录在册,根据书名去找自己想要的书,这不是很方便。JS的引用数据类型,也是一样,比如数组Array,它们值的大小是不固定的。如果全用栈来维护,执行期间,如果栈过大,所有数据都放在栈里面,会导致栈里面的数据取用不方便,会影响上下文的切换效率,进而影响整个程序的执行效率。所以才有栈和堆两种内存空间。
引用数据类型的值是保存在堆内存中的对象。JS不允许直接访问堆内存中的位置,因此我们不能直接操作对象的堆内存空间。都是通过栈里面的指针去操作堆,我们可以粗浅地理解为保存在栈内存中的一个地址,该地址与堆内存的实际值相关联。
通过上面这张图我们能直观的理解栈,堆工作原理。
栈、堆的实际应用
深浅拷贝
-
浅拷贝: 如果属性是基本类型,拷贝的就是基本类型的值,如果属性是引用类型,拷贝的就是内存地址(新旧对象共享同一块内存),所以如果其中一个对象改变了这个地址,就会影响到另一个对象(只是拷贝了指针,使得两个指针指向同一个地址,这样在对象在调用函数析构的时,会造成同一份资源析构2次)
浅拷贝举例
上述例子,我只是obj2直接复制了obj1的值,然后修改obj2的值,然后obj1的值也变了。这就是我们常见的浅拷贝例子,如下图当对obj2进行拷贝时,拷贝的只是指针,而这两个指针指向的是同一个地址,导致不管操作那一份数据都会影响另一份数据
-
深拷贝: 深拷贝是将一个对象从内存中完整的拷贝一份出来,从堆内存中开辟一个新的区域存放新对象(新旧对象不共享同一块内存),且修改新对象不会影响原对象(深拷贝采用了在堆内存中申请新的空间来存储数据,这样每个可以避免指针悬挂)
常见深拷贝的实现方式JSON.parse(JSON.stringify(obj))
当我们用JSON.parse(JSON.stringify(obj))进行赋值时我们可以看到obj1的值并没有受到影响。用JSON.stringify将对象转成JSON字符串,在用JSON.parse把字符串解析成新的对象,而这里新的对象就会开辟新的栈,如下图,这样我们去操作值并不会互相影响
但是需要注意的是JSON.parse(JSON.stringify(obj))实现深拷贝时也会一些特殊类型数据如Date、RegExp、undefined等特殊类型在序列化和反序列化过程中会丢失其原始类型信息,被转换为字符串或其他类型。
举个例子
let obj = { a: 0, b: false, c: true, d: new Date(), e: undefined, f: null, g: Infinity, h: function (){ console.log(123) }, i: /\d/ } let obj1 = JSON.parse(JSON.stringify(obj)) console.log(obj) console.log(obj1)运行结果
1、如果obj里面存在时间对象,JSON.parse(JSON.stringify(obj))之后,时间对象变成了字符串。\ 2、如果obj里有RegExp、Error对象,则序列化的结果将只得到空对象。\ 3、如果obj里有函数,undefined,则序列化的结果会把函数, undefined丢失。\ 4、如果obj里有NaN、Infinity和-Infinity,则序列化的结果会变成null。\ 5、JSON.stringify()只能序列化对象的可枚举的自有属性。如果obj中的对象是有构造函数生成的, 则使用JSON.parse(JSON.stringify(obj))深拷贝后,会丢弃对象的constructor。\ 6、如果对象中存在循环引用的情况也无法正确实现深拷贝。
以上,如果拷贝的对象不涉及上面讲的情况,可以使用JSON.parse(JSON.stringify(obj))实现深拷贝,但是涉及到上面的情况,可以考虑使用如下方法实现深拷贝
// 检测数据类型的功能函数 const checkedType = (target) => Object.prototype.toString.call(target).replace(/[object (\w+)]/, "$1").toLowerCase(); // 实现深拷贝(Object/Array)遍历对象、数组直到里边都是基本数据类型,然后再去复制,就是深度拷贝 const clone = (target) => { let result; let type = checkedType(target); if(type === 'object') result = {}; else if(type === 'array') result = []; else return target; for (let key in target) { if(checkedType(target[key]) === 'object' || checkedType(target[key]) === 'array') { result[key] = clone(target[key]); } else { result[key] = target[key]; } } return result; }
clone 方法遍历对象,数组直到里边都是基本类型,然后再去复制,就是深拷贝。
垃圾回收
在 JavaScript 内存管理中有一个概念叫做 可达性 ,就是那些以某种方式可访问或者说可用的值,它们被保证存储在内存中,反之不可访问则需回收。至于如何回收,其实就是怎样发现这些不可达的对象(垃圾)它并给予清理的问题, JavaScript 垃圾回收机制的原理就是定期找出那些不再用到的内存(变量),然后释放其内存。
举个简单的例子
let user = {
name: "张三"
};
这里全局变量 user 引用了对象 {name:'张三'} user.name 属性存储一个原始值,所以它被写在对象内部。
如果 user 的值被重写了,这个引用就没了:
user = null;
现在 user.name 变成不可达的了。因为没有引用了,就不能访问到它了。垃圾回收器会认为它是垃圾数据并进行回收,然后释放内存。
垃圾回收策略主要有以下几种方式。
标记清除
将整个垃圾回收操作分成标记和清除两个阶段完成。第一个阶段会遍历所有对象,找到活动对象标记。第二个阶段仍然遍历所有对象,把那些身上没有标记的对象进行清除。还会把第一个阶段的标记抹掉,便于GC下次正常工作。通过两次遍历行为,把我们当前的垃圾空间进行回收,最终交给相应的空闲列表去维护。
- 优点:实现比较简单,打标记也无非打与不打两种情况,这使得一位二进制位(0和1)就可以为其标记,非常简单
- 缺点:在清除垃圾之后,剩余对象的内存位置是不变的,就会导致空闲内存空间不连续,这样就出现了内存碎片,并且由于剩余空间不是整块,就需要内存分配的问题
标记整理
标记整理可以看作是标记清除的增强,标记阶段的操作和标记清除一致。清除阶段会先执行整理,移动对象位置,地址变为连续,这样回收后就可以最大化利用空间。
- 优点:解决内存碎片化的问题
- 缺点:性能消耗大,每一次清楚之前,都要去整理,合并
引用计数
引用计数是一种更直接的垃圾回收策略,每个对象都有一个引用计数器,计数器记录着当前有多少个引用指向该对象,当一个新引用被创建时,对象的计数器加一;当引用被删除或失去作用域时,计数器减一;计数器为零的对象被视为不可达,可以被回收。
function f1(){
var a ={}//a的引用次数0
var b=a//a的引用次数1
var c=a //a的引用次数2
var b={}//a的引用次数1
var c=[]//a的引用次数0
}
- 优点:引用计数在引用值为 0 时,也就是在变成垃圾的那一刻(不可达)就会被回收,所以它可以立即回收垃圾,没有明显的停顿。
- 缺点:存在循环引用的问题,即两个或多个对象相互引用但不再被外部引用时,它们的计数器不会降为零,从而导致内存泄漏。
JavaScript V8 引擎的垃圾回收机制
V8引擎作为Chrome和Node.js等环境下的JavaScript运行时,采用了更为复杂的垃圾回收策略,以适应JavaScript的动态特性,主要包括:
分代式垃圾回收(Generational Collection)
V8将内存分为新生代(Young Generation)和老生代(Old Generation)两部分:
- 新生代:频繁分配和回收的对象存放在这里,使用“复制算法”进行回收。新生代被分为两个相等大小的区域,当一个区域填满时,存活的对象被复制到另一个区域(空闲区空间占用超过了 25%,会被直接晋升到老生代空间中),未被复制的空间则直接回收。当一个对象经过多次复制后依然存活,它将会被认为是生命周期较长的对象,随后会被移动到老生代中,采用老生代的垃圾回收策略进行管理。
- 老生代:长期存活或大对象存储在此,采用“标记清除”或“标记整理”算法。标记清除可能导致内存碎片,而标记整理在清除后会重新整理内存,减少碎片化。
并行回收
它指的是垃圾回收器在主线程上执行的过程中,开启多个辅助线程,同时执行同样的回收工作。
增量标记与懒性清理
- 增量标记:为了避免长时间的暂停,V8会在JavaScript执行的间隙逐步进行标记工作,而不是一次性完成,这样可以将垃圾回收的开销分散到多个小的周期内。
- 懒性清理:在完成标记阶段后,并不立即进行内存清理,而是等到有足够空闲时间或内存压力时才执行清理操作,进一步减少对主线程的影响。
这些策略的综合运用,使得V8能在保持高性能的同时有效管理内存。
常见释放内存方法
并不是说有了垃圾回收机制,就意味着开发人员通常不需要手动释放内存。你还是需要关注内存占用,在某些特定情况下,如果出现了内存泄漏或者希望更加精细地管理内存,可以采取以下策略来帮助垃圾回收机制更有效地工作,间接地促进内存的释放,以下是开发中常见的内存方式释放。
-
清理监听器和定时器
移除不再需要的事件监听器和定时器,防止它们持续持有对DOM元素或对象的引用。
element.removeEventListener('click', someHandler);
clearInterval(someInterval);
-
避免循环引用
特别是当涉及到闭包和复合对象时,确保没有形成不必要的循环引用,这可能导致垃圾回收器无法回收相关对象。
-
Console
在一些小团队中可能项目上线也不清理这些 console,殊不知这些 console 也是隐患,同时也是容易被忽略的,我们之所以在控制台能看到数据输出,是因为浏览器保存了我们输出对象的信息数据引用,也正是因此未清理的 console 如果输出了对象也会造成内存泄漏。
-
不正当使用闭包
-
解除引用
闭包
简单来说,闭包是一种保护私有变量的机制,在函数执行时形成私有的作用域,保护里面的私有变量不受外界干扰。直观的说就是形成一个不销毁的栈环境。
举个例字
function foo() {
var name = '张三';
function getName() {
console.log(name);
}
return getName;
}
console.log(name); // 报错:name is not defined
var hello = foo();
hello(); // 输出:张三
根据作用域及作用域链的概念就可知 foo() 函数外获取不到函数内定义的 name,所以第一个输出会报错,那为什么同样是在函数外部的 hello() 可以获取到 name?因为闭包(closure)的存在。
就像上面代码中 foo() 函数将 getName() 这个内部函数当作值返回了,此时,这个返回的值就相当于一个可以访问这个函数 getName()词法作用域中的变量 name 的通道,通过这个通道获取到的所有变量就是闭包。
我们从栈堆方向理解下闭包
词法作用域规定内部函数总是可以访问外部函数的变量,所以任何时候在 getName() 函数中都可以获取到 name,正因为如此,当把 foo() 函数的执行结果 getName 赋值给 hello 时,虽然 foo() 函数已经执行完毕,但其内的变量 name 并没有被销毁掉,仍可以通过 getName 直接或间接访问。foo() 函数执行完后调用栈情况:
当 foo() 函数执行完后其执行上下文就会从调用栈弹出,但 name 变量还保存于内存中,特殊的是,只能通过 foo 返回的 getName 才能访问它。可以通过 Chrome 开发者工具的 Soures 面板打断点来查看闭包:
闭包常见实用场景
- 封装私有变量和方法:闭包可以用于创建私有变量和方法,防止它们被外部访问和修改。通过在外部函数中定义变量和方法,并返回内部函数作为接口,外部无法直接访问内部变量和方法,从而实现了封装。
- 高阶函数和函数柯里化:在函数式编程中,闭包可以用于实现高阶函数和函数柯里化。通过闭包,可以将函数作为参数传递给其他函数,或者将函数作为返回值返回,从而实现更加灵活和可复用的函数组合。
- 防抖节流函数:主要的原理就是在闭包内缓存一个定时器 setTimeout id
总结
理解JavaScript中的栈与堆的概念有助于我们更好地掌握代码运行背后的原理,合理地优化内存使用策略,进而提升应用程序的整体表现力。通过精心设计我们的编程逻辑以减少不必要的内存开销,亦或是高效利用闭包特性处理那些需要跨作用域持久化的状态问题等等,这些都是基于对栈与堆工作机理透彻了解后的自然产物。
推荐阅读
招贤纳士
政采云技术团队(Zero),Base 杭州,一个富有激情和技术匠心精神的成长型团队。规模 500 人左右,在日常业务开发之外,还分别在云原生、区块链、人工智能、低代码平台、中间件、大数据、物料体系、工程平台、性能体验、可视化等领域进行技术探索和实践,推动并落地了一系列的内部技术产品,持续探索技术的新边界。此外,团队还纷纷投身社区建设,目前已经是 google flutter、scikit-learn、Apache Dubbo、Apache Rocketmq、Apache Pulsar、CNCF Dapr、Apache DolphinScheduler、alibaba Seata 等众多优秀开源社区的贡献者。
如果你想改变一直被事折腾,希望开始折腾事;如果你想改变一直被告诫需要多些想法,却无从破局;如果你想改变你有能力去做成那个结果,却不需要你;如果你想改变你想做成的事需要一个团队去支撑,但没你带人的位置;如果你想改变本来悟性不错,但总是有那一层窗户纸的模糊……如果你相信相信的力量,相信平凡人能成就非凡事,相信能遇到更好的自己。如果你希望参与到随着业务腾飞的过程,亲手推动一个有着深入的业务理解、完善的技术体系、技术创造价值、影响力外溢的技术团队的成长过程,我觉得我们该聊聊。任何时间,等着你写点什么,发给 zcy-tc@cai-inc.com
微信公众号
文章同步发布,政采云技术团队公众号,欢迎关注