• JavaScript变量存储机制


    本文分两个部分讨论变量存储模式

    • 局部/ 全局/ 闭包变量的存储机制: 在这部分我们讨论什么样的变量有资格存储在栈中
    • 不同类型变量的存储机制: 在这部分我们讨论存储在栈中的元素究竟存的是字面量还是引用

    先说结论: 万物都存在堆中, 有的变量会在栈上存储引用地址

    堆与栈

    • 堆是一个很大的内存存储空间, 你可以在里面存储任何类型数据. 操作系统不会自动回收. 在栈中存储不了的数据比如对象就会被存储在堆中, 在栈中呢是保留了对象在堆中的地址, 也就是对象的引用.
    • 栈是内存中一块用于存储局部变量和函数参数的线性结构, 遵循着先进后出的原则. 数据只能顺序的入栈, 顺序的出栈. 内存中栈区的数据, 在函数调用结束后, 就会自动的出栈, 不需要程序进行操作, 操作系统会自动回收

    于是出现了一个问题, 在闭包出现时, 函数是如何访问到闭包所在的已经销毁的栈中的变量的呢?

    局部/全局/闭包变量的存储机制

    • 局部变量: 最简单的, 局部变量存储在作用域所在的栈空间中, 例如

      function demo() {
        let a = 1;
        let b = '213';
        let c = [213];
        let d = new Object();
      }
      
      console.dir(demo);
      
      // ƒ demo()
      //   arguments: null
      //   caller: null
      //   length: 0
      //   name: "demo"
      //   prototype:
      //     constructor: ƒ demo()
      //     [[Prototype]]: Object
      //   [[FunctionLocation]]: demo.html:53
      //   [[Prototype]]: ƒ ()
      //   [[Scopes]]: Scopes[1]
      //     0: Global {0: Window, window: Window, self: Window, document: document,
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6
      • 7
      • 8
      • 9
      • 10
      • 11
      • 12
      • 13
      • 14
      • 15
      • 16
      • 17
      • 18
      • 19
      • 20
      • 21

      在上面我们找不到定义的变量, 在DevTools的内存-堆分析中也找不到他们

    • 全局变量

      • 使用var声明的全局变量
        使用var声明全局变量其实仅仅是为global对象添加了一条属性, 全局变量会被默认添加到函数作用域链的最底端, 也就是[[Scopes]]中的最后一个

        var aaa = 1;          // 随便var一个变量
        // 等同于 window.aaa = 1;
        console.dir(()=>{})   // 随便打印一个函数看看他的作用域
        
        // anonymous()
        //   length: 0
        //   name: ""
        //   arguments: (…)
        //   caller: (…)
        //   [[FunctionLocation]]: VM167:1
        //   [[Prototype]]: ƒ ()
        //   [[Scopes]]: Scopes[1]    <- 看到函数的作用域
        //     0: Global              <- 只有global(window)作用域
        //       aaa: 1               <- 看到window上的aaa
        //       alert: ƒ alert()
        //       atob: ƒ atob()
        //       blur: ƒ blur()
        //       btoa: ƒ btoa()
        
        • 1
        • 2
        • 3
        • 4
        • 5
        • 6
        • 7
        • 8
        • 9
        • 10
        • 11
        • 12
        • 13
        • 14
        • 15
        • 16
        • 17
        • 18
      • 使用let/const声明全局变量不会修改window对象, 而是将变量的声明放在了一个特殊的对象Script

        let t1 = 1;
        const t2 = 2;
        console.dir(()=>{})
        
        // anonymous()
        //   length: 0
        //   name: ""
        //   arguments: (…)
        //   caller: (…)
        //   [[FunctionLocation]]: VM99:1
        //   [[Prototype]]: ƒ ()
        //   [[Scopes]]: Scopes[2]      <- 查看作用域
        //     0: Script {t1: 1, t2: 2}   <- 看到这些数据被存储到了Script对象中
        //     1: Global {window: Window, self: Window, document: document,...}
        
        • 1
        • 2
        • 3
        • 4
        • 5
        • 6
        • 7
        • 8
        • 9
        • 10
        • 11
        • 12
        • 13
        • 14
    • 闭包中的变量: 闭包中的变量会在子函数调用的时候存储为一个对象(存储在堆中), 并在[[Scopes]]Closure(闭包)中体现

      function testCatch1 () {
        let a1 = 1;
        var a2 = 'a';
        const a3 = true;
        let a4 = {a: 1};
        return function () {
            console.log(a1, a2, a3, a4)
        }
      }
      
      function testCatch2 () {
      let a1 = 1;
      var a2 = 'a';
      const a3 = true;
      let a4 = {a: 1};
      return function () {
          console.log(a1, a2, a3, a4)
      }
      }
      
      console.dir(testCatch1())
      
      // ƒ anonymous()
      //   arguments: null
      //   caller: null
      //   length: 0
      //   name: ""
      //   prototype: {constructor: ƒ}
      //   [[FunctionLocation]]: VM469:6
      //   [[Prototype]]: ƒ ()
      //   [[Scopes]]: Scopes[2]
      //     0: Closure (testCatch1) {a1: 1, a2: 'a', a3: true, a4: {…}}  <- 可以看到是按照对象存储在堆中的 
      //     1: Global {window: Window, self: Window, document: document, name: '',...} <- global在作用域最后
      
      console.dir(testCatch2())
      
      // ƒ anonymous()
      //   arguments: null
      //   caller: null
      //   length: 0
      //   name: ""
      //   prototype: {constructor: ƒ}
      //   [[FunctionLocation]]: VM469:16
      //   [[Prototype]]: ƒ ()
      //   [[Scopes]]: Scopes[2]
      //     0: Closure (testCatch2) {a1: 1, a2: 'a', a3: true, a4: {…}} <- 可以看到是按照对象存储在堆中的, 但是闭包名不同
      //     1: Global {window: Window, self: Window, document: document, name: '',}
      
      console.dir(testCatch1().a4 === testCatch1().a4)
      
      // true <- 连引用对象都是相同的
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6
      • 7
      • 8
      • 9
      • 10
      • 11
      • 12
      • 13
      • 14
      • 15
      • 16
      • 17
      • 18
      • 19
      • 20
      • 21
      • 22
      • 23
      • 24
      • 25
      • 26
      • 27
      • 28
      • 29
      • 30
      • 31
      • 32
      • 33
      • 34
      • 35
      • 36
      • 37
      • 38
      • 39
      • 40
      • 41
      • 42
      • 43
      • 44
      • 45
      • 46
      • 47
      • 48
      • 49
      • 50
      • 51

    小结: 除了局部变量, 其他变量都在堆中

    那么, 栈中变量是如何存储的呢? 是如开头所说基本数据类型存字面值, 对象存引用地址吗?

    不同类型变量的存储机制

    • 对于对象类型的数据, 毫无疑问: 栈中存储的是对象在堆中的地址
    • 对于基础数据类型呢? 他存储的是字面量吗? 首先我们清楚基础类型包括
      • Number & String & Boolean
      • Null & Undefined
      • Symbol & BigInt

    String类型

    • 首先创建两个包含string的对象

      const BasicVarGen = function () {
          this.s1 = 'IAmString'
          this.s2 = 'IAmString'
      }
      
      let a = new BasicVarGen()
      let b = new BasicVarGen()
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6
      • 7

      切换到DevTools-内存-堆快照-BasicVarGen

      BasicVarGen×2	
      
        BasicVarGen@47647
          __proto__::Object@52873
          map::system / Map@52877
          s1::"IAmString"@16065🗖
          s2::"IAmString"@16065🗖
          
        BasicVarGen@47649
          __proto__::Object@52873
          map::system / Map@52877
          s1::"IAmString"@16065🗖
          s2::"IAmString"@16065🗖
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6
      • 7
      • 8
      • 9
      • 10
      • 11
      • 12
      • 13

      可以看到a, b两个对象的虚拟地址不同, 但是s1与s2指向的虚拟地址都是@16065, 四个变量存储的都是引用地址

    • 继续实验, 尝试在新建对象后增加变量, 修改变量值

      const BasicVarGen = function () {
        this.s1 = 'IAmstring'
        this.s2 = 'IAmstring'
      }
      
      let a = new BasicVarGen()
      let b = new BasicVarGen()
      debugger
      a.s0 = 'different string'
      a.s2 = 'IAmstring1'
      b.s1 = 'IAmstring'
      b.s2 = 'IAm' + typeof '111'
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6
      • 7
      • 8
      • 9
      • 10
      • 11
      • 12

      分别记录debugger前后内存, 比较

      BasicVarGen×2	
      
        BasicVarGen@64695
          map::system / Map@67337
          __proto__::Object@67331
      +   s0::"different string"@16533🗖    <- 新增的string有自己的地址
          s1::"IAmstring"@64749🗖
      -   s2::"IAmstring"@64749🗖
      +   s2::"IAmstring1"@64745🗖    <- string内容变化会导致地址变化
      
        BasicVarGen@64697
          map::system / Map@67337
          __proto__::Object@67331
          s1::"IAmstring"@64749🗖    <- 赋相同值不会导致地址变化
      -   s2::"IAmstring"@64749🗖    
      +   s2::"IAmstring"@74797🗖    <- 虽然字面量相同, 但是存储地址不同
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6
      • 7
      • 8
      • 9
      • 10
      • 11
      • 12
      • 13
      • 14
      • 15
      • 16

    结论

    当我们声明一个字符串时:

    • v8内部有一个名为stringTable的hashmap缓存了所有字符串, 在V8阅读我们的代码, 转换抽象语法树时, 每遇到一个字符串, 会根据其特征换算为一个hash值, 插入到hashmap中. 在之后如果遇到了hash值一致的字符串, 会优先从里面取出来进行比对, 一致的话就不会生成新字符串类.
    • 缓存字符串时, 根据字符串不同采取不同hash方式.
    • 字符串拼接时如果以传统方式(如SeqString)存储, 拼接操作的时间复杂度为O(n), 采用绳索结构(也就是 ConsString 所采用的数据结构)可以减少拼接所花费的时间, 但是不被hash为同一值.

    Number

    数字在V8中分为smiheapNumber.

    • smi直接存进内存, 范围为: − 2 31 ∼ 2 31 − 1 -2^{31}\sim 2^{31}-1 2312311的整数

    • heapNumber类似字符串, 范围为: 所有非smi的数字, 最低位用来表示是否为指针, 最低位为1则是一个指针

      const o = {
        x: 42,  // Smi
        y: 4.2, // HeapNumber
      };
      
      • 1
      • 2
      • 3
      • 4

      o.x中的42会被当成Smi直接存储在对象本身, 而o.y中的4.2需要额外开辟一个内存实体存放, 并将o.y的对象指针指向该内存实体.

    如果是32位操作系统, 用32位表示smi可以理解, 可是64位操作系统中, 为什么smi范围也是 − 2 31 ∼ 2 31 − 1 -2^{31}\sim 2^{31}-1 2312311? ECMAScript 标准约定number数字需要被当成64位双精度浮点数处理, 但事实上, 一直使用64位去存储任何数字实际是非常低效的(空间低效, 计算时间低效 smi大量使用位运算), 所以JavaScript引擎并不总会使用64位去存储数字, 引擎在内部可以采用其他内存表示方式(如32位), 只要保证数字外部所有能被监测到的特性对齐64位的表现就行.

    套用之前的实验

    const BasicVarGen = function () {
      this.smi1 = 1
      this.smi2 = 2
      this.heapNumber1 = 1.1
      this.heapNumber2 = 2.1
    }
    
    let foo = new BasicVarGen()
    let bar = new BasicVarGen()
    
    debugger
    
    bar.heapNumber1 ++
    bar.sim1 ++
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    BasicVarGen×2	
    
      BasicVarGen@5713
        map::system / Map@59709
        __proto__::Object@59707
    -   heapNumber1::heap number@59687
    +   heapNumber1::heap number@59701
    -   heapNumber2::heap number@59691
    +   heapNumber2::heap number@59703
    -   heapNumber3::heap number@59697
    +   heapNumber3::heap number@59705
    -   smi1::smi number@30901🗖
    +   sim1::heap number@64357
    +   smi1::smi number@64483🗖
    -   smi2::smi number@30901🗖
    +   smi2::smi number@64483🗖
    -   smi3::smi number@34275🗖
    +   smi3::smi number@64505🗖
    
      BasicVarGen@5715
        map::system / Map@59709
        __proto__::Object@59707
    -   heapNumber1::heap number@59701
    +   heapNumber1::heap number@59687
    -   heapNumber2::heap number@59703
    +   heapNumber2::heap number@59691
    -   heapNumber3::heap number@59705
    +   heapNumber3::heap number@59697
    -   smi1::smi number@30901🗖
    +   smi1::smi number@64483🗖
    -   smi2::smi number@30901🗖
    +   smi2::smi number@64483🗖
    -   smi3::smi number@34275🗖
    +   smi3::smi number@64505🗖
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34

    可以看到在变量修改时, 所有变量都变了

    结论: smi存储在栈, 其他存储在堆

    OddBall 类型与 Boolean & Undefined & null

    OddBall是V8中的一个数据类型, Oddball继承于HeapObject, 而HeapObject继承于Object

    简单的看下Boolean & Undefined & null

    const BasicVarGen = function () {
      this.a = true;
      this.b = false;
      this.c = undefined;
      this.d = null;
    }
    
    let foo = new BasicVarGen()
    let bar = new BasicVarGen()
    
    debugger
    
    foo.a = false;
    foo.b = !(1 <= 0);
    foo.c = null;
    foo.d = undefined;
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    BasicVarGen×2	
      BasicVarGen@5713
        map::system / Map@59603
        __proto__::Object@59601
    -   d::system / Oddball@69🗖
    +   d::system / Oddball@65🗖
    -   c::system / Oddball@65🗖
    +   c::system / Oddball@69🗖
    -   b::system / Oddball@73
    +   b::system / Oddball@71
    -   a::system / Oddball@71🗖
    +   a::system / Oddball@73🗖
      BasicVarGen@5715
        map::system / Map@59603
        __proto__::Object@59601
        d::system / Oddball@69🗖
        c::system / Oddball@65🗖
        b::system / Oddball@73
        a::system / Oddball@71🗖
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19

    看到了OddBall类型, 发现同一值的地址也是相同的. 在赋值时, 也是就地复用. (而且这些拓展自oddBall的基本类型, 其地址是固定的, 也就是说, 在V8跑起来的第一时间, 不管我们有没有声明这些基本类型, 他们都已经被创建完毕了. 而我们声明对象时, 赋的是他们的引用. 这也可以解释为什么我们说基本类型是赋到栈中: 在V8中, 存放在@73的值, 永远是空字符串, 那么v8就可以等效把这些地址视为值本身. )

    小结

    • 字符串: 存在堆里, 栈中为引用地址, 如果存在相同字符串, 则引用地址相同.
    • 数字: 小整数存在栈中, 其他类型存在堆中.
    • 其他类型: 引擎初始化时分配唯一地址, 栈中的变量存的是唯一的引用.

    参考资料

  • 相关阅读:
    微信聚合聊天,自动回复
    JVM进阶
    资本-劳动力错配指数计算、金融错配指标两大维度指标(内附代码)
    Spring Data JPA审计
    项目部署——后端Springboot+前端VUE3
    了解Prop的使用
    KVM虚拟化介绍和安装使用方法
    【C#】使用ffmpeg image2pipe 将图片保存为mp4视频
    Vue实现打印功能(vue-print-nb)
    李沐:用随机梯度下降来优化人生!
  • 原文地址:https://blog.csdn.net/Liukairui/article/details/125496267