• JavaScript理论篇2之内存机制


    JavaScript内存空间分为栈,堆,池,队列。其中栈存放变量,基本类型数据与指向复杂类型数据的引用指针;堆存放复杂类型数据;池又称为常量池,用于存放常量;而队列在任务队列也会使用。

    1 基本类型和引用类型(栈和堆)
    1】堆内存和栈内存
    JS引擎中对变量的存储主要有两种位置:堆内存和栈内存
    栈内存:主要用于存储各种基本类型的变量
    堆内存:主要负责像对象Object这种变量类型的存储(或者说存储引用类型)
    栈内存中的变量一般都是已知大小或者有范围上限的,算作一种简单存储。而堆内存存储的对象类型数据对于大小这方面,一般都是未知的,这也是为什么null作为一个object类型的变量却存储在栈内存中的原因

    栈内存线性有序存储,容量小,系统分配效率高。而堆内存首先要在堆内存新分配存储区域,之后又要把指针存储到栈内存中,效率相对就要低一些了。
    2】JavaScript数据类型
    a、值类型(基本类型):
    字符串(String)、数字(Number)、布尔(Boolean)、对空(Null)、未定义(Undefined)、Symbol 和 ES10的BigInt。
    基本变量的值一般都是存在栈内存中。
    b、引用数据类型(对象类型):
    对象(Object)、数组(Array)、函数(Function)、ES6新增的Set、Map、WeakSet、WeakMap
    对象类型的变量的值存储在堆内存中,堆内存专门存放大小为止,不可预测的数据。

    2 浅拷贝和深拷贝
    1】先看小例子,自己回味
    (浅拷贝的对象属性,可以相互影响)

    2】浅拷贝和深拷贝
    Javascript 中的对象只是对内存地址的引用。
    基本类型的数据存放在栈内存中,复制的时候是值传递。
    引用类型的数据存放在堆内存中,栈内存中只存放具体的地址值,把object1赋值给object2的时候是把object1的地址值赋值给了object2,这个时候两个对象同时指向堆内存中的同一数据。
    深拷贝在于引用类型的时候,浅拷贝只复制地址值,实际上还是指向同一堆内存中的数据,深拷贝则是重新创建了一个相同的数据,二者指向的堆内存的地址值是不同的。这个时候修改赋值前的变量数据不会影响赋值后的变量。

    3】实现
    浅拷贝:
    a、直接用=拷贝
    b、使用 Object.assign({},srcObj),前提是所拷贝对象的属性是属于引用类型。
    深拷贝:
    a、使用 Object.assign,前提是所拷贝对象的属性是属于基本类型。
    b、利用 JSON 对象中的 parse 和 stringify
    c、利用递归来实现每一层都重新创建对象并赋值
    var obj1 = { //对象
    name: “cat”,
    show:function(){
    console.log(this.name);
    }
    };
    function deepClone(obj) { //参数为源对象
    let objClone = Array.isArray(obj) ? [] : {}; //先判断源对象是普通对象还是数组
    if(obj && typeof obj === “object”) {
    for(key in obj) { //for…in遍历属性
    if(obj.hasOwnProperty(key)) { //判断ojb子元素是否为对象,如果是,递归复制
    if(obj[key] && typeof obj[key] === “object”) {
    objClone[key] = deepClone(obj[key]);
    } else { //如果不是对象,简单复制
    objClone[key] = obj[key];
    }
    }
    }
    }
    return objClone;
    }
    var obj3=deepClone(obj1);
    obj3.show(); //输出cat

    3 常量和常量池
    (略)

    4 执行环境和作用域
    1)JS 执行上下文(函数调用栈)
    1】定义
    当代码运行时,会产生一个对应的执行环境,在这个环境中,所有变量会被事先提出来(变量提升),有的直接赋值,有的为默认值 undefined,代码从上往下开始执行,就叫做执行上下文
    2】运行环境(注意:没有块级环境)
    1)全局环境:代码首先进入的环境
    2)函数环境:函数被调用时执行的环境
    3)eval函数
    3】特点
    1)单线程,在主进程上运行
    2)同步执行,从上往下按顺序执行
    3)全局上下文只有一个,浏览器关闭时会被弹出栈
    4)函数的执行上下文没有数目限制
    5)函数每被调用一次,都会产生一个新的执行上下文环境
    4】过程
    全局执行环境:
      全局执行环境是最外围的一个执行环境,在web浏览器中,我们可以认为他是window对象,因此所有的全局变量和函数都是作为window对象的属性和方法创建的。代码载入浏览器时,全局环境被创建,关闭网页或者关闭浏览时全局环境被销毁。
    函数执行环境:
      每个函数都有自己的执行环境,当执行流进入一个函数时,函数的环境就被推入一个环境栈中,当函数执行完毕后,栈将其环境弹出,把控制权返回给之前的执行环境。

    5】生命周期

    6】this
    上下文,主要是关键字this的值,这个是由函数运行时决定的,简单来说就是谁调用此函数,this就指向谁
    7】上下文与作用域的区别
    a、作用域只是用于划分在这个作用域里面定义的变量和函数的有效范围,是个静态观念;而执行上下文环境是代码的一系列执行过程,是个动态观念
    b、作用域是在函数创建时就确定的;作用域中变量的值是在代码执行过程中确定的

    2)词法作用域和动态作用域
    作用域有两种工作模型:词法作用域和动态作用域

     词法作用域也就是在词法阶段定义的作用域,也就是说词法作用域在代码书写时就已经确定了。 
     js中其实只有词法作用域,并没有动态作用域,this的执行机制让作用域表现的像动态作用域,this的绑定是在代码执行的时候确定的。
    
    • 1
    • 2

    example1: 理解词法作用域
    记住js中只有词法作用域没有真正的动态作用域,作用域是在代码书写时确定的
    var value = 1;
    function foo() {
    console.log(value);
    }
    function bar() {
    var value = 2;
    foo(); //注意,这里只是调用
    }

    bar();
    //1  
    输出是1,函数在哪里调用没有关系,变量的位置在编译的词法分析阶段就确定了。
    当调用foo时,会对value进行一次RHS查询,在当前函数作用域中没有查找到会查找到最外层的作用域,也就是全局作用域定义的value。

    3)作用域和作用域链
    1】自由变量
    凡是跨了自己的作用域的变量都叫自由变量
    比如:在全局中定义了一个变量a,然后在函数中使用了这个a,这个a就可以称之为自由变量
    自由变量,也就是变量在父级作用域里面的一个查找过程
    2】作用域
    A、定义
    作用域就是变量与函数的可访问范围,即作用域控制着变量与函数的可访问性和生命周期(生命周期见垃圾回收机制)
    B、分类
    a、全局作用域:
    任何地方都能访问到的对象或变量拥有全局作用域
    全局变量在页面关闭后销毁
    b、局部作用域(函数作用域):
    只能带函数fn内部使用,在fn外部使用就会报错,这就是局部作用域的特性,外部无法访问
    局部变量在函数执行完后销毁
    注意:
    在创建这个函数的时候,这个函数的作用域就已经决定了,而是不是在调用的时候
    比如:
    var aa = 22;
    function a(){
    console.log(aa);
    }
    function b(fn){
    var aa = 11;
    fn();
    }
    b(a); //22

    c、块级作用域:
    块级作用域用于解决:1、变量提升导致内层的变量覆盖外层的变量;2、用于计数的循环变量泄露为全局变量
    3】作用域链
    Javascript语言特有的"链式作用域"结构(chain scope),子对象会一级一级地向上寻找所有父级作用域的变量。所以,父对象的所有变量,对子对象都是可见的,反之则不成立。
    a、全局作用域和局部作用域中变量的访问权限,其实是由作用域链决定的,它可以保证对执行环境有权访问的所有变量和函数的有序访问
    b、每当代码进入一个新的执行环境,都会创建一个用于搜索变量和函数的作用域链,这个作用域链里面的东西即是这个作用域的可访问的数据对象的集合
    通俗来说,作用域链就是:从当前作用域开始一层一层向上寻找某个变量,直到找到全局作用域还是没找到,就宣布放弃。这种一层一层的关系,就形成了作用域链。

    5、this的几种指向
    1】全局作用域或者普通函数中 this 指向全局对象 window

    2】方法调用中谁调用 this 指向谁

    3】在构造函数或者构造函数原型对象中 this 指向构造函数的实例(包括class的constructor)

    4】箭头函数中指向外层作用域的 this
    这里箭头函数外层是obj,所以this指向obj
    this值继承自外围作用域,谁定义的,this指向谁

    5】call、apply、bind的作用是改变函数运行时this的指向,此时this指向作为参数传入的对象
    (详情见下)
    6】在Vue的生命周期钩子函数里面,this指向Vue实例

    6、call,apply,bind的用法以及区别
    1】用法
    call、apply、bind的作用是改变函数运行时this的指向,此时this指向作为参数传入的对象
    2】区别
    相同点:都是调用一个对象的一个方法,用另一个对象替换当前对象
    传参:
    call和bind:第一个参数是this指向的对象,第二到第n个参数都是用逗号分隔
    apply:第一个参数是this指向的对象,剩余的参数全部放到一个数组里面
    call 和 apply不同点:传参格式不同
    bind 与 call apply 不同点:bind 返回的是一个新的函数,必须再一次调用它才会被执行
    call(obj,args1,args2)
    apply(obj,[args1,args2])
    bind(obj,args1,args2)() // bind 的传参和call一样
    3】call
    可以利用call()判断数据类型(见上第9)
    4】注意
    call,apply,bind不能改变箭头函数的this指向,因为箭头函数的this是继承外部的this
    5】案例

    7、闭包
    1】定义
    简单讲,闭包就是指有权访问另一个函数作用域中的变量的函数,并让其变量保持在内存中。
    一般页面关闭后,全局变量会被销毁;函数执行之后,其函数作用域的变量会被销毁;但是,如果外部的变量和闭包存在引用关系,则该函数的私有变量不会因为函数的执行完毕而销毁,而是一直保持在内存中,不会被js垃圾回收器所回收。

    2】作用
    a、够访问其他函数的作用域中的变量
    b、让这些私有变量的值始终保持在内存中,不会因为调用后被自动清除。

    3】常见的结构(function内嵌一个带return的function)

    闭包有时候不一定带return的,比如:
    var a;
    function b(){
    var i=0;
    a=function(){ alert(i);}
    };
    b(); //结果是什么?执行完就生成了一个全局变量a引用的闭包

    4】闭包的优点(应用场景)
    a、可以读取函数内部的变量
    b、可以作为计数器,不会污染全局变量
    c、匿名自执行函数防止变量提升
    es6没出来之前,用var定义变量存在变量提升问题,闭包可以解决这个问题

    d、setTimeout计数
    第一种写法,会先执行完for循环,再执行setTimeout。原因是:setTimeout定义的操作在函数调用栈清空之后才会执行,所以for循环的 i 值,已经先一步变成了6
    第二和第三种写法,借助闭包的特性,每次循环时,将 i 值保存在一个闭包中,当setTimeout中定义的操作执行时,则访问对应闭包保存的 i 值即可。函数中闭包判定的准则,即执行时是否在内部定义的函数中访问了上层作用域的变量。因此我们需要包裹一层自执行函数为闭包的形成提供条件

    5】使用闭包的注意点(闭包的缺点)
    由于闭包会使得函数中的变量都被保存在内存中,内存消耗很大,所以不能滥用闭包,否则会造成网页的性能问题,在IE中可能导致内存泄露。解决方法是,在退出函数之前,将不使用的局部变量全部删除或者置为null
    在基于引用计数策略的垃圾回收机制中,如果两个对象之间形成了循环引用,那么这两个对象都无法被回收,但循环引用造成的内存泄漏在本质上也不是闭包造成的。如果要解决循环引用带来的内存泄漏问题,我们只需要在循环引用中的变量设为null即可

    8、内存泄露
    内存泄漏:应用程序不再需要的内存,由于某种原因(对象的循环引用),内存没有返回到操作系统或可用内存池中。
    1】意外的全局变量引起的内存泄漏

    原因:全局变量,不会被回收
    解决:使用严格模式避免
    2】闭包引起的内存泄漏

    原因:闭包可以维持函数内局部变量,使其得不到释放
    解决:将事件处理函数定义在外部,解除闭包,或者在定义事件处理函数的外部函数中,删除对dom的引用
    3】没有清理的DOM元素引用
    原因:虽然别的地方删除了,但是对象中还存在对dom的引用
    解决:手动删除
    4】被遗忘的定时器或者回调
    原因:定时器比如setInterval
    解决:手动删除定时器
    5】子元素存在引用引起的内存泄漏
    原因:div中的ul li 得到这个div,会间接引用某个得到的li,那么此时因为div间接引用li,即使li被清空,也还是在内存中,并且只要li不被删除,他的父元素都不会被删除
    解决:手动删除清空
    6】程序中存在死循环
    7】滥用Set、Map,建议改用weakSet 和 weakMap
    排查方法:
    chrome浏览器,F12切换到控制台。
    a、Performance
    可以用chrome的Performance录制一段时间内页面性能变化,以此进行对比。
    需要切换到Performance面板,点击Record,然后在页面上正常操作一段时间,最后停止录制即可。

    如果录制结束后,看到内存的下限在不断升高的话,你就要注意了 —— 这里有可能发生了内存泄漏。
    b、Memory
    然后,进一步结合 Memory 来进行判断。
    从Memory的主界面开始,点击左上角的圆点就可以记录下当前的堆内存快照(heap snapshot)了。
    在你的页面上执行可能发生内存泄漏的操作,再记录一个堆内存快照。
    将两个堆内存快照进行对比,找出差异,顺着差异再跳转到源代码所在行。

    9、垃圾回收机制
    简单说,就是js的垃圾回收器找出那些不再继续使用的变量(没被引用),然后释放其占用的内存,js垃圾回收器会按照固定的时间间隔周期性地执行这一操作。
    1】什么是垃圾?
    比如:var a = 200,则在栈内存里面开辟了key为a,value为200的内存区块。当a = 300时,此时的200没有被引用,则形成垃圾,被js引擎回收。
    一般来说没有被引用的对象就是垃圾,就是要被清除, 有个例外如果几个对象引用形成一个环,互相引用,但根访问不到它们,这几个对象也是垃圾,也要被清除。

    2】清除垃圾的必要性
    由于字符串、对象和数组没有固定大小,所有当他们的大小已知时,才能对他们进行动态的存储分配。JavaScript程序每次创建字符串、数组或对象时,解释器都必须分配内存来存储那个实体。只要像这样动态地分配了内存,最终都要释放这些内存以便他们能够被再用,否则,JavaScript的解释器将会消耗完系统中所有可用的内存,造成系统崩溃。

    3】如何清除垃圾?
    存于栈内存的变量(指针除外),一般用完就立刻回收,简单且高效率;而堆内存的变量回收就比较复杂,一般有下面两种方式:
    1)标记-清除
    工作原理:是当变量进入环境时,将这个变量标记为“进入环境”;当变量离开环境时,则将其标记为“离开环境”。标记“离开环境”的就回收内存
    工作流程:

    1. 垃圾回收器,在运行的时候会给存储在内存中的所有变量都加上标记。
    2. 去掉环境中的变量以及被环境中的变量引用的变量的标记。
    3. 再被加上标记的会被视为准备删除的变量。
    4. 垃圾回收器完成内存清除工作,销毁那些带标记的值并回收他们所占用的内存空间。

    2)引用计数
    工作原理:跟踪记录每个变量被引用的次数,次数为0就将其回收。
    工作流程:

    1. 声明了一个变量并将一个引用类型的值赋值给这个变量,这个引用类型值的引用次数就是1
    2. 同一个值又被赋值给另一个变量,这个引用类型值的引用次数加1
    3. 当包含这个引用类型值的变量又被赋值成另一个值了,那么这个引用类型值的引用次数减1
    4. 当引用次数变成0时,说明没办法访问这个值了
    5. 当垃圾收集器下一次运行时,它就会释放引用次数是0的值所占的内存

    4】垃圾回收时栈和堆的区别
    栈内存线性有序存储,容量小,系统分配效率高,回收效率也高。而堆内存首先要在堆内存新分配存储区域,之后又要把指针存储到栈内存中,效率相对就要低一些了。
    垃圾回收方面,栈内存变量基本上用完就回收了,而堆内存中的变量因为存在很多不确定的引用,只有当所有调用的变量全部销毁之后才能回收。

    10、任务队列和事件驱动
    1】定义
    javascript从诞生之日起就是一门 单线程的 非阻塞的 脚本语言,单线程意味着,javascript代码在执行的任何时候,都只有一个主线程来处理所有的任务,非阻塞靠的就是 event loop(事件循环)
    2】分类
    event loop最主要是分三部分:主线程、宏队列(macrotask)、微队列(microtask)
    3】执行顺序
    1、遇到宏任务(macrotask)放到宏队列(macrotask)
    2、遇到微任务(microtask)放到微队列(microtask)
    3、先执行主线程(也叫外层宏,优先级高于内层宏)
    4、主线程作为第一个宏任务执行完毕
    5、执行微队列里所有的微任务,微任务执行完毕
    6、执行一次宏队列中的一个任务,执行完毕
    7、执行微队列,执行完毕
    8、浏览器渲染
    9、综上所述,继续依次循环

    11、JS中的宏任务和微任务
    1】介绍
    宏任务和微任务,表示JS异步任务的两种分类
    2】JS异步任务执行过程
    JS 引擎会将所有任务按照类别分到这两个队列中,首先在 宏任务 的队列中取出第1个任务,执行完毕后取出 微任务队列中的所有微任务来进行顺序执行(哪怕微任务嵌套微任务,也会全部执行完毕);之后再取下一个 宏任务,周而复始,直至两个队列的任务都取完。

    3】宏任务和微任务有哪些
    外层宏:
    最外层的同步执行代码,Promise构造函数也是
    宏任务(内层宏):

    微任务:

    注意:
    外层宏(主线程)优先级大于内层宏
    外部console.log 和 new Promise的主体,都可以称之为外层宏
    setTimeout/setInterval内部的叫内层宏

    总结:
    同步代码执行顺序优先级高于异步代码执行顺序优先级;
    process.nextTick()>Promise.then().catch().finally()
    setTimeout>setImmediate
    注:
    setTimeout回调函数执行结果在前的概率更大些,这是因为他们采用的观察者不同,setTimeout采用的是类似IO观察者,setImmediate采用的是check观察者。
    三种观察者的优先级顺序是:idle观察者>>io观察者>check观察者。
    4】setImmediate
    这个方法IE10以上的IE浏览器或者nodejs才支持,该方法用来把一些需要长时间运行的操作,放在一个回调函数里,在浏览器完成后面的其他语句后,就立刻执行这个回调函数。比如:

    该方法跟setTimeout(0)的效果基本是一样的。

    12、定时器为什么是不精确的,如何解决?
    在JS事件循环机制中,异步事件 setInterval 到时后会把回调函数放入消息队列中,主线程的任务执行完毕后依次执行消息队列的任务,由于消息队列中存在大量任务,其他任务执行时间就会造成定时器回调函数的延迟,如果不处理则会一直叠加延迟。
    解决方案:
    1】动态计算时差
    核心:将每次要定时执行的时间 - 因执行其他异步任务的时间,缩短固定的定时执行时间
    根据整体最开始时间计算当前时间(回调函数执行时间)与定时器开始时间的误差,用期望时差减误差作为下一次任务的时间间隔
    注:时差过大(超过期望时差)时,由于无法时间回流,只能按没有间隔(0)处理,减轻影响

    2】使用 web Worker
    H5新属性
    将定时函数作为独立线程执行:new Worker

  • 相关阅读:
    摘要-签名-PKI-访问控制-DOS-欺骗技术
    设计模式-策略模式
    uni-upgrade-center 升级中心详细流程
    @Bean注解详解
    Clickhouse MAP类型
    LeetCode790多米诺和托米诺平铺
    JavaSwing项目合集专栏订阅须知
    Kotlin学习快速入门(7)——扩展的妙用
    java:BeanProperSupport实现复杂类型对象的成员访问
    C#界面里的winform AutoValidate和CausesValidation属性
  • 原文地址:https://blog.csdn.net/qq_37546835/article/details/125476803