该系列文章连载于公众号coderwhy和掘金XiaoYu2002中
- 对该系列知识感兴趣和想要一起交流的可以加个v好友:XiaoYu2002-AI,拉你进群参与共学计划,一起成长进步
- 课程对照进度:JavaScript高级系列13-15集(coderwhy)
脉络探索
- 在上一章节中,我们留下了五道面试题,我们今天要讲解这些题目,从而让我们对
作用域链
的了解真正过关- 以及对JS中回收内存的两种方法进行学习
- 引用计数
- 标记清除
- 对堆栈空间的划分进一步的明确
- 以及对JS中回收内存的两种方法进行学习
- 本章节中,更多的是对上一章所学内容进行印证和巩固,就让我们来看看,我们学到了多少!
一、面试题(作用域链)
- 既然是针对作用域链的题目,我们也会从
作用域链的角度
去针对性回答
1.1. 面试题1
var n = 100
function foo(){
n = 200
}
foo()
console.log(n)
//答案是200
- 对于这一类的题目来说,我们需要确定作用域的范围
- 变量位于哪一层作用域中
- 函数在哪一层作用域中执行
- 题目中只有一个变量n,我们首先确定变量n位于GO,也就是全局对象中。而目标函数的父级作用域就是全局作用域
- 在foo函数之中运行n = 200的赋值操作时,JS引擎会沿着作用域链去找这个变量,从当前层作用域开始,直到全局作用域为止(foo的父级作用域刚好就是,一开始确认了)。如果到最后还没找到,就会在全局作用域中
隐式的
创建一个全局变量。而全局变量的滥用会造成什么样的结果,我们在上一章节中var的缺陷
有进行分析 - 所以这道题就可以简单的解析了出来,在运行foo函数的时候,n会从foo函数的AO找到全局作用域的window(GO)中,最终在GO中找到了n,把200这个值给赋进去。所以答案是200
- 在foo函数之中运行n = 200的赋值操作时,JS引擎会沿着作用域链去找这个变量,从当前层作用域开始,直到全局作用域为止(foo的父级作用域刚好就是,一开始确认了)。如果到最后还没找到,就会在全局作用域中
- 但这个200并不是马上得出来了,我们来看看在内存之中,是如何表现的
- 首先变量会提升到当前所处作用域最前面
第一行
,在该案例中是放入全局对象GO之中(函数则放入对应AO中),此时的n为undefined - 紧接着在
第二行
我们对其进行了赋值,此时的n内容被赋值为了100 - 在第
六行
运行foo函数的时候,n被foo函数沿着作用域链找到,并赋值为200 - 在第七行中,打印结果就为200
- 首先变量会提升到当前所处作用域最前面
var n
n = 100
function foo(){
n = 200
}
foo()
console.log(n)
- 很明显,在第1、2、6行的时候,n的结果都发生了变化,从内存的表达过程来看是这样的,那不妨我们来测试一下
- 我们在第这三行的后面插入控制台打印,看是否真的为undefined、100、200
- 就结果的反馈来说,这内容替换的过程,在我们的眼里已经没有丝毫秘密可言了
- 如果能够做到这个程度,我想这道题就完全过关了
- 我们需要掌握的是这个变化的规律,如果只记住表面的话,将会顾此失彼。这里其实就可以出三种不同结果的面试题,而当内容再复杂一点,能出的题目是无穷的,所以我们需要掌握其中的变化规律,而非背题。因为不管题目怎么变,所考验的内容都是一样的,以不变应万变
图5-1 面试题1运行结果
1.2. 面试题2
function foo(){
console.log(m)
var m = "小余"
console.log(m);
}
var m = "coderwhy"
foo()
//结果如下
//undefined
//小余
- 还是刚才的步骤,先确认变量所处的作用域
- 变量只有m一个,但这跟上一题可就不一样了。我们的m变量,所处的作用域不同。一个位于foo作用域,一个位于全局作用域。我们在多层的嵌套作用域定义了同名的标识符,而作用域查找会在找到第一个匹配的标识符时停止
- 而作用域是会形成隔离的,形成"遮蔽效应",内部标识符遮蔽了外部标识符,在此基础上,我们来进行一下变量提升
var m
function foo(){
var m
console.log(m)
m = "小余"
console.log(m);
}
m = "coderwhy"
foo()
-
在这里,请注意,我们的变量查找顺序,是先从当前作用域范围开始找,没有找到才会沿着作用域链往上找
-
这个变量的查找是LHS和RHS引用都会遵循这个原则,变量名会沿着作用域链往上找,而赋值也会往作用域链找到所对应的变量进行赋值。
-
前者可以实现在函数内引用变量,能引用到函数外的变量。后者可以实现函数外声明变量,函数内对其进行赋值。直到在往上的过程找到目标为止
-
foo执行体:
声明的
m变量
会被放入foo函数的AO对象当中,而在AO对象的m变量,有一个undefined到"小余"的逐渐替换过程。我们两次打印时机刚好卡在了这里,这最终会造成了两次打印结果的完全不同第一次去拿
的时候,JS引擎刚把m变量
放上去,但还没进行赋值。因为赋值操作是不会被提升的,我们第一次打印代码在赋值操作前面,所以此时拿到的是默认放上去的undefined第二次去拿
的时候,m的赋值操作已经完成,此时拿到的内容就会是"小余"了 -
全局执行:
在这一段的代码中,最外层的声明m并没有派上用场,因为foo函数的内部进行获取的时候,在内部就已经拿到自己想要的了,就不会继续往外找了
同时印证了,在某个作用域内声明的变量,并不会影响到其他作用域声明的同名变量。原理也就在于,我们声明变量只会提升到当前作用域的最前面,而无法脱离当前作用域
-
-
最后,我们来看下,变化的转折点都在哪里吧!
- 在这些转变过程,我们可以随时控制台输出进行验证
var m//GO对象的m属性此时为undefined
function foo(){
var m//foo的AO对象的m属性,此时为undefined
console.log(m)//打印undefined
m = "小余"//foo的AO对象的m属性,此时被赋值为 小余
console.log(m);//找AO对象的m,找到了,打印,由于前一行代码被赋值了,不再是undefined,而是小余
}
m = "coderwhy"//GO对象此时的m为coderwhy
foo()
1.3. 面试题3
- foo1函数的执行结果,如果自身作用域内没有找到n,就会沿着父级作用域寻找,然后foo1是在foo2函数内调用的,父级作用域并不取决于在哪调用,而取决于我们函数体处于哪里,foo1的作用域是跟foo2的作用域平级的,他们的父级作用域都是最外层的全局作用域。
- 然后foo2内部首先自己创建出来一个AO对象,在AO对象里创建一个执行上下文,里面先对编译阶段的{n:undefined}进行赋值200,然后通过console.log进行了打印,接着调用了foo1()函数,这foo1()函数答案为一百,在上一步中我们已经进行分析了
- 接着就是调用了foo2(),先打印了foo2中赋值的200,再打印foo1中的100。最后打印了最外层的n,100。这里最外层的打印只能打印100,100如果注销掉就报错,因为显而易见的,全局作用域基本上已经是最大的作用域了,再往上就找不到了,而这个是不会向函数内部去往下找的,且函数执行完后,他的执行上下文就销毁掉了
var n = 100
function foo1(){
console.log("这是foo1内部",n);
}
function foo2(){
var n = 200
console.log("这是foo2内部",n);
foo1()
}
foo2()
console.log("这是最外层",n);
//执行结果顺序如下
//这是foo2内部 200
//这是foo1内部 100
//这是最外层 100
- 所以说这个函数体位置很重要,如果把foo1函数体塞到foo2里面的话,foo1就会先找到父级作用域foo2而不是全局作用域
1.4. 面试题4
- 首先最外层,一个GO对象(Global Object):{a:undefined,foo:0xa00},foo的0xa00是内存地址,然后a被赋值为100
- 然后到foo函数部分,生成AO对象,AO对象里面是执行上下文,首先a的内容肯定是先为undefined,接着就return了,后面的var a = 100都还没生效foo函数就结束了,在编辑器中会给出提示:检测到无法访问的代码。但是还是请注意,这个执行上下文中还是出现了a这个变量,虽然完全没有用上,但是他意味着我们的执行上下文中还是出现了a这个变量,阻止了我们向父级作用域继续寻找的道路,所以我们访问不到全局作用域的100
- 最后就只能返回undefined了
var a = 100
function foo(){
console.log(a)
return
var a = 200
}
foo()
//undefined
1.5. 面试题5
var a = b = 10会转化为两行代码
- var a = 10
- b = 10(没错,b没有被var声明),从右向左,先给b赋值
所以很显然,外面作用域是访问不到a,但是能访问到b的,不然我们把console.log(a)注释掉,就可以正常显示控制台信息的b为10了
- a访问不到的原因就在于变量提升上,只会提升到当前作用域的最前面,和突破不了自身作用域,到不了全局作用域
- 这个b为什么可以访问到,在面试题1中有详细的讲解
function foo(){
var a = b = 10
}
foo()
console.log(a);
console.log(b);
//会报错
1.6. 作用域补充
- 没有声明直接使用,严格来说,语法都错了,应该要报错的,因为我们甚至不知道这个变量是怎么来的(对数据来源的未知,在编程中是难以忍受的),但是JavaScript的语法太灵活了,他允许了这种写法(隐式声明),但是最好不要这样写,就当作了解就行
function foo(){
m = 200
}
foo()
console.log(m);
//200
二、JavaScript中的垃圾回收
- 因为内存的大小是有限的,所以当内存不再需要的时候,我们需要对其进行释放,以便腾出更多的内存空间
- 在手动管理内存的语言中,我们需要通过一些方式自己来释放不再需要的内存,比如free函数
- 但是这种管理的方式其实非常的低效,影响我们编写逻辑的代码的效率;
- 并且这种方式对开发者的要求也很高,并且一不小心就会产生内存泄露;
- 所以现在大部分现代的编程语言都是有自己的垃圾回收机制:
- 垃圾回收的英文是Garbage Collection,简称
GC
; - 对于那些不再使用的对象,我们都称之为垃圾,它需要被回收,以释放更多的内存空间
- 而我们的语言允许环境,比如Java的运行环境JVM,JavaScript的运行环境js引擎都会内存(内置) 垃圾回收器;
- 垃圾回收器我们也是简称GC,所以在很多地方你看到的GC其实是指垃圾回收器
- 垃圾回收的英文是Garbage Collection,简称
- 但是这里又出现了另外一个很关键的问题:
GC怎么知道哪些对象是不再使用的呢?
这里就要用到GC的实现以及对应的算法
2.1. 内存的分配方式
-
在前面章节中,我们简要的讲解了一下,在各种语言之中,都是如何操作内存的。有手动有自动
-
而内存的分配方式,我们也提到了堆和栈的两种概念,但到底都有哪些内容填入堆,哪些内容填入栈中。我们都还不够了解,所以在这里,我们进一步的总结
- JavaScript对于基本数据类型内存的分配会在执行时,直接在栈空间进行分配
- 对于复杂数据类型内存的分配会在堆内存中开辟一块空间,并且将这块空间的指针返回值变量引用。我们一般也称呼这个为引用类型
- 栈空间存放的是地址,真正的对象实例存放在堆空间中
-
-
如果大家对于全局对象GO以及AO所画的图还有印象,就会发现这个对象其实就是在堆当中的
- 但我们的变量不都是基础类型吗?怎么会在堆内存中
- 这是因为,在最外层包裹了一层
对象{}
,不然我们GO对象以及AO对象
这词汇后缀的对象
从哪来呢,对吧
图5-2 堆内存空间
2.1.1. 知识点补充
简单类型和复杂类型
-
简单类型
:又叫做基本数据类型或者值类型- 值类型: 简单数据类型/基本数据类型,在存储变量中存储的是值本身,因此叫做值类型
- String、number、Boolean、undefined、null
-
复杂类型
:又叫做引用类型- 引用类型: 复杂数据类型,在存储变量中存储的仅仅是地址(引用),因此叫做引用数据类型,通过new关键字创建的对象(系统对象、自定义对象),如Object、Array、Data等
2.2. 常见的GC算法
垃圾回收(GC)是自动管理内存的重要机制,主要目的是找出程序不再使用的内存块并释放它们
- 我们要讲解的两种在JS中常见的垃圾回收算法就是引用计数和标记清除
- 两种算法简单的理解就是,我是
如何判断你不再需要这块内存的
,遵循着两种不同的判断原则,但要做的事情是一样的
2.2.1. 引用计数(Reference counting)
-
引用计数是一种直观的垃圾回收方法,它追踪每个对象被引用的次数
-
它的基本思想是在对象中添加一个引用计数器
-
每当有一个指针引用该对象时,引用计数器就加一
-
当指针不再引用该对象时,引用计数器就减一
-
当引用计数器的值为0时,表示该对象不再被引用,可以被回收
-
-
引用计数算法的优点是简单,实时性强,对象一旦没有引用,内存就可以立即被释放,可以避免内存泄漏
-
但是引用计数算法也有一些缺点
-
最大的缺点是很难解决循环引用问题
-
如果两个对象相互引用(但它们不再被其他活跃的对象引用),它们的引用计数器永远不会为0,即使它们已经成为垃圾对象
-
这种情况下就陷入了死循环当中,引用计数算法就无法回收它们,导致内存泄漏
-
-
对象里面有一个专门的空间,叫做 retain count,专门记录有多少个指针指向自己的retain count(一个指向加1),默认为0,但通常最少是1,因为我们在栈里面存放的地址已经就指向堆内存了),这个计数器(retain count)是实时更新的,当这个计数器为0的时候,垃圾回收机制就知道这个对象已经没有人在使用了,就会触发回收机制销毁掉
var obj1 = {friend:obj2}
var obj2 = {friend:obj1}
//这样互相引用如果不obj1 = null结束的话,会产生内存泄漏的
图5-3 循环引用
2.2.2. 标记清除(mark-Sweep)
-
标记清除是JavaScript中最常用的垃圾回收机制,特别是在V8引擎之中,而其核心思想是可达性(Reachability)。算法的实现过程如下
工作原理:这种方法分为
“标记”
和“清除”
两个阶段- 设置一个根对象(root object),垃圾回收器会定期从这个根开始,找所有从根开始有引用到的对象
- 对于每一个找到的对象,标记为可达(mark),表示该对象正在使用中
- 对于所有没有被标记为可达的对象,即不可达对象,就认为是不可用的对象,需要被回收清除
- 回收不可达对象所占用的内存空间,并将其加入空闲内存池中,以备将来重新分配使用
-
标记清除算法可以很好地解决循环引用的问题,因为它只关注可达性,能有效处理循环引用的情况,只要对象不可达就可以被清除
- 而
可达性
这个名词主要在于可达
两个字,这可不是可达鸭,而是指可以到达
的含义 - 但标记和清除过程中,需要暂停程序执行(称为停顿时间),而且还需要遍历整个对象图,可能会影响程序性能,比如在需要快速响应的应用场景中
- 此外,标记清除算法还会造成内存碎片的问题,因为回收的内存空间不一定是连续的,导致大块的内存无法被分配使用
- 而
图5-4 标记清除算法
- 像这张图中,绿色的部分就是互相引用的不活跃部分,形成了垃圾对象,如果使用引用计数的话,这两个计数都为1,将无法回收
- 但通过标记清除,只有A(黄色部分)能够到达的部分才不会被清除掉。M与N两者自成孤岛,会被回收清除掉
2.2.3. 其他算法优化补充
JS引擎比较广泛的采用的就是可达性中的标记清除算法,当然类似于V8引擎为了进行更好的优化,它在算法的实现细节上也会结合一些其他的算法
标记整理(Mark-Compact)
- 和“标记-清除”相似;
- 不同的是,回收期间同时会将保留的存储对象搬运汇集到连续的内存空间,从而整合空闲空间,避免内存碎片化
分代收集(Generational collection)—— 对象被分成两组:“新的”和“旧的”
- 许多对象出现,完成它们的工作并很快死去,它们可以很快被清理;
- 那些长期存活的对象会变得“老旧”,而且被检查的频次也会减少;
增量收集(Incremental collection)
- 如果有许多对象,并且我们试图一次遍历并标记整个对象集,则可能需要一些时间,并在执行过程中带来明显的延迟。
- 所以引擎试图将垃圾收集工作分成几部分来做,然后将这几部分会逐一进行处理,这样会有许多微小的延迟而不是一个大的延迟;
闲时收集(Idle-time collection)
- 垃圾收集器只会在 CPU 空闲时尝试运行,以减少可能对代码执行的影响
- 这种算法通常用于移动设备或其他资源受限的环境,以确保垃圾收集对用户体验的影响最小
2.2.4. V8引擎的内存图
事实上,V8引擎为了提供内存的管理效率,对内存进行非常详细的划分。(详细参考视频学习)
这幅图展示了一个堆(heap)的内存结构,下面是对每个内存块的解释:
- Old Space(老生代):分配的内存较大,存储生命周期较长的对象,比如页面或者浏览器的长时间使用对象
- New Space(新生代):分配的内存较小,存储生命周期较短的对象,比如临时变量、函数局部变量等
- Large Object Space(大对象):分配的内存较大,存储生命周期较长的大型对象,比如大数组、大字符串等
- Code Space(代码空间):存储编译后的函数代码和 JIT 代码
- Map Space(映射空间):存储对象的属性信息,比如对象的属性名称、类型等信息
- Cell Space(单元格空间):存储对象的一些元信息,比如字符串长度、布尔类型等信息
这些不同的内存块都有各自的特点和用途,V8 引擎会根据对象的生命周期和大小将它们分配到不同的内存块中,以优化内存的使用效率
图5-5 V8引擎的内存图
后续预告
- JS中作用域链和内存管理,我们通过这两章节的学习,已经能够非常熟练了
- 但内存中的奥秘远还没结束,在前面章节的画图中,有说明图片中的内容并不是全部,而是先单独绘制其中的一部分,以免内容太密,不好找重点
- 在现有的基础上,我们是时候去研究闭包了,但JS中的闭包理念是比较难以理解的,而且该理念和函数是有着较为紧密的关系的
- 所以,我们下一章节会先学习函数的概念,JS中的函数到底是怎么样的,并抛出一些常见的
高阶函数
进行学习使用,比如filter过滤器、map映射、forEech迭代、find查找、reduce累加
- 高阶函数和普通函数的区别在哪里呢?又该如何使用呢?应用程度到底怎么样呢?
- 前两点我们放在下一章节进行详细讲解
- 应用程度(其中一方面):在React这一前端框架中,对于高阶函数的使用非常高频,几乎到处都是高阶函数的使用。后续想要学习React18或者19版本的同学,就需要好好学习,这对JS的要求较高。只要JS基础好,React从学习感官来说没准比Vue还简单
- 后续JavaScript高级知识技术会持续更新,如果喜欢我们的文章,欢迎关注、点赞、转发、评论,大家的支持是我们最大的动力