5.JS高级-作用域链面试题和垃圾回收

1,392 阅读20分钟

该系列文章连载于公众号coderwhy和掘金XiaoYu2002中

  • 对该系列知识感兴趣和想要一起交流的可以加个v好友:XiaoYu2002-AI,拉你进群参与共学计划,一起成长进步
  • 课程对照进度:JavaScript高级系列13-15集(coderwhy)

脉络探索

  • 在上一章节中,我们留下了五道面试题,我们今天要讲解这些题目,从而让我们对作用域链的了解真正过关
    • 以及对JS中回收内存的两种方法进行学习
      1. 引用计数
      2. 标记清除
    • 对堆栈空间的划分进一步的明确
  • 本章节中,更多的是对上一章所学内容进行印证和巩固,就让我们来看看,我们学到了多少!

一、面试题(作用域链)

  • 既然是针对作用域链的题目,我们也会从作用域链的角度去针对性回答

1.1. 面试题1

var n = 100
function foo(){
    n = 200
}
foo()
console.log(n)
//答案是200
  • 对于这一类的题目来说,我们需要确定作用域的范围
    1. 变量位于哪一层作用域中
    2. 函数在哪一层作用域中执行
  • 题目中只有一个变量n,我们首先确定变量n位于GO,也就是全局对象中。而目标函数的父级作用域就是全局作用域
    1. 在foo函数之中运行n = 200的赋值操作时,JS引擎会沿着作用域链去找这个变量,从当前层作用域开始,直到全局作用域为止(foo的父级作用域刚好就是,一开始确认了)。如果到最后还没找到,就会在全局作用域中隐式的创建一个全局变量。而全局变量的滥用会造成什么样的结果,我们在上一章节中var的缺陷有进行分析
    2. 所以这道题就可以简单的解析了出来,在运行foo函数的时候,n会从foo函数的AO找到全局作用域的window(GO)中,最终在GO中找到了n,把200这个值给赋进去。所以答案是200
  • 但这个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
    • 就结果的反馈来说,这内容替换的过程,在我们的眼里已经没有丝毫秘密可言了
    • 如果能够做到这个程度,我想这道题就完全过关了
  • 我们需要掌握的是这个变化的规律,如果只记住表面的话,将会顾此失彼。这里其实就可以出三种不同结果的面试题,而当内容再复杂一点,能出的题目是无穷的,所以我们需要掌握其中的变化规律,而非背题。因为不管题目怎么变,所考验的内容都是一样的,以不变应万变

面试题1运行结果

图5-1 面试题1运行结果

1.2. 面试题2

function foo(){
    console.log(m)
    var m = "小余"
    console.log(m);
}
var m = "coderwhy"
foo()
//结果如下
//undefined
//小余
  • 还是刚才的步骤,先确认变量所处的作用域
    1. 变量只有m一个,但这跟上一题可就不一样了。我们的m变量,所处的作用域不同。一个位于foo作用域,一个位于全局作用域。我们在多层的嵌套作用域定义了同名的标识符,而作用域查找会在找到第一个匹配的标识符时停止
    2. 而作用域是会形成隔离的,形成"遮蔽效应",内部标识符遮蔽了外部标识符,在此基础上,我们来进行一下变量提升
var m
function foo(){
    var m
    console.log(m)
    m = "小余"
    console.log(m);
}
m = "coderwhy"
foo()
  • 在这里,请注意,我们的变量查找顺序,是先从当前作用域范围开始找,没有找到才会沿着作用域链往上找

    • 这个变量的查找是LHS和RHS引用都会遵循这个原则,变量名会沿着作用域链往上找,而赋值也会往作用域链找到所对应的变量进行赋值。

    • 前者可以实现在函数内引用变量,能引用到函数外的变量。后者可以实现函数外声明变量,函数内对其进行赋值。直到在往上的过程找到目标为止

    1. foo执行体:

      声明的m变量会被放入foo函数的AO对象当中,而在AO对象的m变量,有一个undefined到"小余"的逐渐替换过程。我们两次打印时机刚好卡在了这里,这最终会造成了两次打印结果的完全不同

      第一次去拿的时候,JS引擎刚把m变量放上去,但还没进行赋值。因为赋值操作是不会被提升的,我们第一次打印代码在赋值操作前面,所以此时拿到的是默认放上去的undefined

      第二次去拿的时候,m的赋值操作已经完成,此时拿到的内容就会是"小余"了

    2. 全局执行:

      在这一段的代码中,最外层的声明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

  1. foo1函数的执行结果,如果自身作用域内没有找到n,就会沿着父级作用域寻找,然后foo1是在foo2函数内调用的,父级作用域并不取决于在哪调用,而取决于我们函数体处于哪里,foo1的作用域是跟foo2的作用域平级的,他们的父级作用域都是最外层的全局作用域。
  2. 然后foo2内部首先自己创建出来一个AO对象,在AO对象里创建一个执行上下文,里面先对编译阶段的{n:undefined}进行赋值200,然后通过console.log进行了打印,接着调用了foo1()函数,这foo1()函数答案为一百,在上一步中我们已经进行分析了
  3. 接着就是调用了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

  1. 首先最外层,一个GO对象(Global Object):{a:undefined,foo:0xa00},foo的0xa00是内存地址,然后a被赋值为100
  2. 然后到foo函数部分,生成AO对象,AO对象里面是执行上下文,首先a的内容肯定是先为undefined,接着就return了,后面的var a = 100都还没生效foo函数就结束了,在编辑器中会给出提示:检测到无法访问的代码。但是还是请注意,这个执行上下文中还是出现了a这个变量,虽然完全没有用上,但是他意味着我们的执行上下文中还是出现了a这个变量,阻止了我们向父级作用域继续寻找的道路,所以我们访问不到全局作用域的100
  3. 最后就只能返回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其实是指垃圾回收器
  • 但是这里又出现了另外一个很关键的问题:GC怎么知道哪些对象是不再使用的呢? 这里就要用到GC的实现以及对应的算法

2.1. 内存的分配方式

  • 在前面章节中,我们简要的讲解了一下,在各种语言之中,都是如何操作内存的。有手动有自动

    • 而内存的分配方式,我们也提到了堆和栈的两种概念,但到底都有哪些内容填入堆,哪些内容填入栈中。我们都还不够了解,所以在这里,我们进一步的总结

    1. JavaScript对于基本数据类型内存的分配会在执行时,直接在栈空间进行分配
    2. 对于复杂数据类型内存的分配会在堆内存中开辟一块空间,并且将这块空间的指针返回值变量引用。我们一般也称呼这个为引用类型
    3. 栈空间存放的是地址,真正的对象实例存放在堆空间中
  • 如果大家对于全局对象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)。算法的实现过程如下

    工作原理:这种方法分为“标记”“清除”两个阶段

    1. 设置一个根对象(root object),垃圾回收器会定期从这个根开始,找所有从根开始有引用到的对象
    2. 对于每一个找到的对象,标记为可达(mark),表示该对象正在使用中
    3. 对于所有没有被标记为可达的对象,即不可达对象,就认为是不可用的对象,需要被回收清除
    4. 回收不可达对象所占用的内存空间,并将其加入空闲内存池中,以备将来重新分配使用
  • 标记清除算法可以很好地解决循环引用的问题,因为它只关注可达性,能有效处理循环引用的情况,只要对象不可达就可以被清除

    • 可达性这个名词主要在于可达两个字,这可不是可达鸭,而是指可以到达的含义
    • 但标记和清除过程中,需要暂停程序执行(称为停顿时间),而且还需要遍历整个对象图,可能会影响程序性能,比如在需要快速响应的应用场景中
    • 此外,标记清除算法还会造成内存碎片的问题,因为回收的内存空间不一定是连续的,导致大块的内存无法被分配使用

标记清除算法

图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 引擎会根据对象的生命周期和大小将它们分配到不同的内存块中,以优化内存的使用效率

V8引擎的内存图

图5-5 V8引擎的内存图

后续预告

  • JS中作用域链和内存管理,我们通过这两章节的学习,已经能够非常熟练了
    • 但内存中的奥秘远还没结束,在前面章节的画图中,有说明图片中的内容并不是全部,而是先单独绘制其中的一部分,以免内容太密,不好找重点
    • 在现有的基础上,我们是时候去研究闭包了,但JS中的闭包理念是比较难以理解的,而且该理念和函数是有着较为紧密的关系的
    • 所以,我们下一章节会先学习函数的概念,JS中的函数到底是怎么样的,并抛出一些常见的高阶函数进行学习使用,比如filter过滤器、map映射、forEech迭代、find查找、reduce累加
  • 高阶函数和普通函数的区别在哪里呢?又该如何使用呢?应用程度到底怎么样呢?
    • 前两点我们放在下一章节进行详细讲解
    • 应用程度(其中一方面):在React这一前端框架中,对于高阶函数的使用非常高频,几乎到处都是高阶函数的使用。后续想要学习React18或者19版本的同学,就需要好好学习,这对JS的要求较高。只要JS基础好,React从学习感官来说没准比Vue还简单
  • 后续JavaScript高级知识技术会持续更新,如果喜欢我们的文章,欢迎关注、点赞、转发、评论,大家的支持是我们最大的动力