《JavaScript高级程序设计》(二) ---变量,作用域和内存

86 阅读9分钟

写在前面: 查漏补缺系列旨在跟随《JavaScript高级程序设计》一书对JS的内容进行复盘,向心流状态前进!

变量,作用域和内存

原始值和引用值

原始值: Undefined,String,NUmber,Boolean,Null,Symbol,保存在栈内存中.按值访问,变量存储的是值

引用值: Object,保存在堆内存中,按引用访问,变量存储的是对象在堆内存的地址,

复制

原始值: 复制是深拷贝,会在栈内存中重新开辟一个空间

引用值:复制是浅拷贝,会在栈内存中重新开辟空间来存放指针,指向的还是堆内存中原来的变量

作为参数

ECMAscript中所有函数的参数都是按值传递,如果是原始值那么就跟原始值的复制一样,如果是引用值就和引用值的复制一样

执行上下文和作用域

  • 执行上下文用于确定变量什么时候释放内存,执行上下文有三种: 全局执行上下文,函数执行上下文和块级上下执行上下文,它决定了变量和函数可以访问哪些数据,每个上下文都有一个变量对象,这个对象包含了这个上下文中定义的所有变量和函数,全局执行上下文是最外层上下文,在浏览器中window对象就是全局上下文,因此所有通过var定义的变量和函数都会成为window上的属性

    function showContext(){
        cosnt a = 1;
        const b = 2
    }
    上述函数的上下文中的变量对象大概是这样: 
    变量对象: {
        a:1,
        b:2,
    }
    ​
    
  • 执行上下文栈: 当代码执行的时候由上下文栈控制代码执行流:程序由上至下,执行上下文依次入栈,执行完毕后出栈并销毁,直到最后全局执行上下文出栈,程序执行完毕

  • 存储变量,查找变量的规则就是作用域,作用域链用来搜索变量和函数,作用域链: 上下文中的代码执行的时候会根据变量对象创建作用域链,他决定着各级上下文中代码在访问变量和函数时的顺序,其会沿着作用域链逐级搜索标识符,搜索过程始终从当前作用域开始逐级向外查找,若没找到通常会报错.作用域链是单项向静态的,只跟代码定义的地方有关

    var a =1;
    function fn1(){
        const b = 2
        function fn2(){
            const b = 2
            const c = 3
        }
        fn2()
    }
    fn1()
    上述作用域链可以这样理解: 
    {
        window变量对象: {
            a:1
            fn1变量对象: {
                b: 2,
                fn2变量对象: {
                    b:2
                    c:3
                }
            }
        }
    }
    变量查找是单向的只能由内向外查找
    

垃圾回收

在js中开发者不需要跟踪内存,由js来负责代码执行时的内内存管理,通过自动内存管理实现内存的分配和闲置资源所占内存的回收.基本思路: 确定哪个变量不会再使用,然后释放它占用的内存,周期性的执行上述步骤.

有两个关键点: 哪些变量可以回收?什么时候进行回收?

哪些变量可以回收

最常用的回收策略是标记清理

引用计数算法

这是最初的垃圾收集算法,此算法将对象是否不再需要简化定义为对象有没有其他对象引用到它,每一个值都记录它的引用次数,A对象被B变量引用,那么A对象的计数器就+1,如果A对象又被C对象引用,那么A对象的计数器就是2,同样如果C变量的值被其他对象覆盖,相应的A对象的计数器就-1,依次类推,当一个对象的计数器是0时,在下次垃圾回收周期就会被释放掉

这种算法思路简单但存在两个很大的问题:

  • 计数器消耗过大: 每个对象都需要一个计数器,而且也不知道计数器的引用上限是多大,导致计数器需要占很大的内存
  • 循环引用: 这是最致命的,当两个对象相互引用的时候,这两个对象的计数器永远不会是0,就导致这两个对象永远无法回收

这种算法已经被淘汰了

标记清理算法

这种方式是目前最常用的方式,这种算法把对象是否不再需要简化定义为对象是否可以获得,其主要思路是:

  1. 垃圾回收程序运行时,会将所有变量打上标记,
  2. 从根对象(包括window,document但不限于)对上下文中的变量,以及变量引用的变量的标记去掉,表示不能回收,
  3. 清理所有带标记的,然后将所有的变量重新打上标记,
  4. 等待下一轮的回收
内存碎片问题

使用标记清理算法,最大的弊端就是会产生内存碎片,即内存不再连续,内存上一段有值,一段没值,且每一段空闲碎片的大小不一.这就导致下次存储变量时需要遍历找到大小合适的内存碎片进行存储,有三种算法来找到这合适的空闲内存碎片:

  • First-fit: 找到大小合适的碎片立即返回
  • Best-fit: 遍历所有内存碎片,返回大于等于所需空间的最小内存
  • Worst-fit: 遍历所有内存碎片,找到最大的,切成两部分: 所需空间剩余空间,并将这两部分返回

这三种算法中,考虑到分配速度和效率第一种算法是最明智的选择

标记整理算法

是对标记清理算法的优化,主要解决内存碎片的问题,其查找过程和标记清理算法一致,不同的是在清理的时候,标记整理算法会先将需要的对象统一移动到相连的内存地址,然后将不需要的对象清理掉.这样就不会产生内存碎片

什么时候进行回收

现在的垃圾回收程序会基于javascript运行时环境的探测来决定如何运行,探测机制因引擎而异,但基本上都是根据已分配的对象大小和数量来判断的,例如早期的IE会有一个阈值: 分配了256个变量,4096个对象,64KB字符串等,一旦达到其中的某个条件就会运行垃圾回收程序,不过这个问题在于运行有多个全局变量的脚本时.,这些全局变量会频繁运行,导致性能下降,针对这点后来IE7做了优化,垃圾回收程序被调优为动态分配阈值,如果垃圾回收程序回收的内存不到分配的15%,这些阈值就会翻倍,若超过85%,这些阈值会重置为默认值

如何做好内存管理

将内存保持在较小的占用量,可以让页面的性能更好,优化内存最好的手段就是保证执行代码时只保存必要的数据,

  1. 解除引用: 如果数据不在需要,将其置为null,从而释放其引用,让其能在下次GC时回收,特别是全局对象

  2. 通过const,let: 不仅可以改善代码风格,当块级作用域比函数作用域更早中止的情况下,能让变量更早的进行回收

  3. 隐藏类: 针对V8引擎,V8引擎在将解释后的代码编译为实际的机器码时会利用隐藏类,V8会将创建的对象和隐藏类关联起来,以追踪他们的属性特征,如果对代码的性能非常注重,那么尽量少的创建隐藏类有助于提升性能,

    function A(){
        this.title = "test"
    }
    const a1 = new A()
    const a2 = newA()
    

    V8的后台会进行配置,让两个类的实例共享相同的隐藏类,因为这两个实例基本一样,然而有两种情况会让其对应两个隐藏类: 先创建后补充,delete操作

    • 避免动态式的属性赋值,创建时最好一次性声明所有属性

      function A(){
          this.title = "test"
      }
      const a1 = new A()
      const a2 = newA()
      a2.author = '灭霸'
      动态给a2赋值新属性之后,a1,a2就不共享一个隐藏类了,而是分别对应不同的隐藏类
      
    • 避免delete操作,赋值为null替换delete操作

      function A(){
          this.title = "test"
      }
      const a1 = new A()
      const a2 = newA()
      delete a2.title
      同样分别对应不同的隐藏类
      
  4. 内存泄漏

    导致内存泄漏大部分是不合理的引用导致的,引用一直存在导致变量永远无法回收,

    • 意外声明: 没用关键字声明变量直接使用
    • 定时器: 循环定时器内部一直引用着外部变量
    • 闭包
  5. 静态分配和对象池

    这是很极端的优化手段,很难有使用场景,它的核心思想是减少对象频繁创建和销毁,从而减少垃圾回收机制的频繁运行

V8的优化

大多数浏览器都是基于标记清理算法实现垃圾回收的,同时也对这个算法进行了优化,下面是v8引擎的优化方式

分代式垃圾回收

上述的垃圾回收机制,对于内存中的对象一视同仁,对于存活时间长,占用内存大的对象和存活时间短,占用内存小的对象采用同一频率进行清理

那么可以优化的点就来了,对于存活时间长的对象和存活时间短的对象采用不同的回收频率,根据对象的存活收起V8将堆内存分成两个区域: 新生代和老生代,对不同的区域使用不同的回收优化机制.推荐这位老哥的文章讲述的非常详细: GC