【七】垃圾回收机制

前言

本篇章讲述关于垃圾回收机制、内存泄漏以及堆栈溢出的相关知识,并了解如何通过工具定位排查内存泄漏情况,实现性能优化。

面试回答

1.垃圾回收机制:垃圾回收机制就是周期性地找出不再继续使用的变量,释放其内存。那么通常相关的问题就是内存泄漏问题,原因就是被占用的内存由于程序原因无法释放,造成浪费,如果内存泄漏过多的话,会使程序无法申请到内存,出现程序缓慢甚至崩溃的情况,那么通常的解决办法就是把这些隐患消灭在开发阶段,比如避免意外的全局变量、及时关闭定时器、释放DOM引用、释放监听事件以及减少闭包的使用。

知识点

1.垃圾回收机制

垃圾回收机制即周期性地找出不再继续使用的变量,释放其内存。判断变量是否不再使用的条件是通过标签判断,标识无用变量的策略有以下的2种方式。

1.标记清除

绝大多数浏览器采用的垃圾收集机制均是通过标记清除的方式,它主要分为两个阶段,标记阶段和清除阶段,判断标准是看这个对象是否可抵达

  • 标记阶段:垃圾收集器会从根对象(Window对象)出发,扫描所有可以触及的对象,这就是所谓的可抵达

  • 清除阶段:在扫描的同时,根对象无法触及(不可抵达)的对象,就是被认为不被需要的对象,就会被当成垃圾清除。

    ini 复制代码
    function changeName(){
      var obj1={};
      var obj2={};
      
      obj1.target=obj2;
      obj2.target=obj1;
      obj1.age=15;
      console.log(obj1.target);
      console.log(obj2.target);
    }
    ​
    changeName();

    在函数执行完毕之后,函数的声明周期结束,那么现在,从 Window对象 出发, obj1obj2 都会被垃圾收集器标记为不可抵达(因为方法已经执行过,扫描就到达不了方法内部,自然也就扫描不到obj对象),这样子的情况下,互相引用的情况也会迎刃而解。

2.引用计数

引用计数法,就是变量引用的次数。你可以认为它就是对当前变量所引用次数的描述。

css 复制代码
var obj={name:'jack'}

当给obj赋值的同时,其实就创建了一个指向该变量的引用,引用计数为1,在引用计数法的机制下,内存中的每一个值都会对应一个引用计数。 而当我们给obj赋值为null时,这个变量就变成了一块没用的内存,那么此时,obj的引用计数将会变成 0,所有引用计数为0的变量都将会被垃圾收集器所回收,然后obj所占用的内存空间将会被释放。

2.内存泄漏

内存泄漏(Memory Leak)是指程序中已动态分配的堆内存由于某种原因程序未释放或无法释放,造成系统内存的浪费,内存泄露过多的话,就会导致后面的程序申请不到内存,因此内存泄露会导致内部内存溢出,导致程序运行速度减慢甚至系统崩溃等严重后果。

1.意外的全局变量

在JavaScript中并未严格定义对未声明变量的处理方式,即使在局部函数作用域中依旧能够定义全局变量,这种意外的全局变量可能会存储大量数据,且由于其是能够通过全局对象例如window能够访问到的,所以进行内存回收时不认为其是需要回收的内存而一直存在,只有在窗口关闭或者刷新页面时才能够被释放,造成意外的内存泄漏。

javascript 复制代码
//未声明变量在全局环境创建,通过全局对象访问
function test() {
    a ='1111'
    console.log('test=====',window.a)
}
test()  
解决方式
  • 使用严格模式("use strict"),使用let const 来定义变量,严格模式下定义未声明变量在会抛出错误;

  • 减少创建全局变量,如果必须使用全局变量存储大量数据,确保使用完以后把他设置为null或者重新定义。

    javascript 复制代码
    //必须要用的情况下,手动释放全局变量的内存
    window.a = null
    delete window.a

2.被遗忘的定时器和回调函数

当不需要setInterval或者setTimeout时,定时器没有被clear,定时器的回调函数以及内部依赖的变量都不能被回收,造成内存泄漏。

xml 复制代码
<button>开启定时器</button>
<script>
    function fn1() {
        let largeObj = new Array(100000)
        setInterval(() => {
            let myObj = largeObj
        }, 1000)
    }
    document.querySelector('button').addEventListener('click', function () {
        fn1()
    })
</script>

这段代码是在点击按钮后执行fn1函数,fn1函数内创建了一个很大的数组对象largeObj,同时创建了一个setInterval定时器,定时器的回调函数只是简单的引用了一下变量largeObj,但是从 Chrome devTools 查看还是能看出内存泄漏。原因其实就是因为setInterval的回调函数内对变量largeObj有一个引用关系,而定时器一直未被清除,所以变量largeObj的内存也自然不会被释放。

验证方式:打开控制台,点击Performacne,点击圆点,每个一段时间点击按钮,如果存在内存泄漏的情况会出现这种阶梯状

正常的则是单个,如下图。

解决方式

设置关闭条件,使用clearInterval、clearTimeout释放

3.分离的DOM节点

假设你手动移除了某个dom节点,本应释放该dom节点所占用的内存,但却因为疏忽导致某处代码仍对该被移除节点有引用,最终导致该节点所占内存无法被释放,例如这种情况:

xml 复制代码
<div id="root">
    <div class="child">我是子元素</div>
    <button>移除</button>
</div>
<script>
    let btn = document.querySelector('button')
    let child = document.querySelector('.child')
    let root = document.querySelector('#root')
    
    btn.addEventListener('click', function() {
        root.removeChild(child)
    })
</script>

该代码所做的操作就是点击按钮后移除.child的节点,虽然点击后,该节点确实从dom被移除了,但全局变量child仍对该节点有引用,所以导致该节点的内存一直无法被释放。

解决方式

改动很简单,就是将对.child节点的引用移动到了click事件的回调函数中,那么当移除节点并退出回调函数的执行上文后就会自动清除对该节点的引用,那么自然就不会存在内存泄漏的情况了。

xml 复制代码
    <div id="root">
        <div class="child">我是子元素</div>
        <button>移除</button>
    </div>
    <script>
        let btn = document.querySelector('button')
        btn.addEventListener('click', function () {
            let child = document.querySelector('.child')
            let root = document.querySelector('#root')
            root.removeChild(child)
        })
    </script>

4.闭包使用不当

下面的例子中,在test函数执行上下文后,该上下文中的变量a本应被当作垃圾数据给回收掉,但因test函数最终将变量a返回并赋值给全局变量res,其产生了对变量a的引用,所以变量a被标记为活动变量并一直占用着相应的内存,假设变量res后续用不到,这就算是一种闭包使用不当的例子。

xml 复制代码
<button onclick="myClick()">ddd</button>
<script>
    function test() {
        let a = [1,2,3] 
        return a
    }
    var res = []
    function myClick() {
        res.push(test())
    }
</script>
解决方式
csharp 复制代码
//让不在需要的函数或者变量等于 null
test = null

3.堆栈溢出

堆栈溢出 :每次执行JavaScript代码时,都会分配一定尺寸的栈空间(Windows系统中为1M),每次方法调用时都会在栈里储存一定信息(如参数、局部变量、返回值等等),这些信息再少也会占用一定空间,如果存在较多的此类空间,就会超过线程的栈空间了。堆栈溢出很可能由无限递归产生,但也可能仅仅是过多的堆栈层级。 说白了就是就是不顾堆栈中分配的局部数据块大小,向该数据块写入了过多的数据,导致数据越界,结果覆盖了别的数据。如下示例,便会报错,这是由于过多的函数调用,导致调用堆栈无法容纳这些调用的返回地址,一般在递归中产生。

dart 复制代码
//例子,test在ES6环境不会报错,因为有尾调用优化。
function test(num){
    if(num===0) return true
    if(num===1) return false
    return test(Math.abs(num-2))
}
console.log(test(1000000000000))
​
// 阶乘,若用递归实现,层级不能过深,比如10可以、100000不可以
const factorial = n => n <= 1 ? 1 : n * factorial(n - 1)
factorial(100000)
​
// 斐波那契数列也一样
const fibonacci = n => n <= 1 ? 1 : fibonacci(n - 1) + fibonacci(n - 2)
fibonacci(100000)
​
//都会产生Uncaught RangeError: Maximum call stack size exceeded错误。

解决方式:

1.递归改为循环

优化原理:所有运算均在一个执行上下文中执行,不用生成额外的上下文。

ini 复制代码
function newTest(num){
    if(num===0) return true
    if(num===1) return false
    let result 
    while(Math.abs(num)-2>=0){
        num = Math.abs(num)-2
        if(num===0){
            result = true
        }
        if(num===1) {
            result =false
        }
    }
    return result
}
console.log(newTest(100))
2.使用闭包
scss 复制代码
function newTest(num){
    if(num===0) return true
    if(num===1) return false
    return function(){
        return newTest(Math.abs(num)-2)
    }
}
console.log(newTest(4)()()) //true
console.log(newTest(6)()()())//true
//由上可见,需要判断return的是不是function,如果是,需要继续执行,调整一下如下:
​
function reCall(func,arg){
    var value = func(arg)
    while(typeof value === 'function'){
        value = value();
    }
    return value 
}
​
console.log(reCall(newTest,10000)) //true
​

每次都返回一个匿名函数,再去调用上面的方法,形成一个闭包,匿名函数完后执行相关的参数和局部变量将会释放,不会额外增加堆栈大小,保证每次都是新的。

3.使用setTimeout()来解决
javascript 复制代码
function newTest(num) {
    if(Math.abs(num)-2>=0){
        setTimeout(function() {
          newTest(Math.abs(num-2))
        }, 0)
    }else{
        console.log(num===0?true:false)
    }
};
console.log(newTest(1000000000000))
​
堆栈溢出之所以会被消除,是因为事件循环操纵了递归,而不是调用堆栈(也就是执行栈)。思路就是不把递归里的函数放到调用栈里,比如通过setTimeout(宏任务)丢到任务队列中然后按照事件循环来控制,但是这样的就会有作用域及this指针的问题,需要修改一些业务逻辑,而且调用有一个最小的时间间隔,又是异步的,即时性也不好。
4.通过promise来处理
javascript 复制代码
function newTest(num){
    if(Math.abs(num)-2>=0){
        Promise.resolve().then(() => {
            newTest(Math.abs(num)-2)
        })  
    }else{
        console.log(num===0?true:false)
    }
}
console.log(newTest(10))
​
//使用promise把递归放到微任务里执行,原理与setTimeout一致,只不过一个是靠宏任务(setTimeout),一个是靠微任务(Promise),通过时间循环来解决递归里的调用栈问题。
//这个还能接受,微任务即时性挺好,原理
5.尾调用优化

ECMAScript 6 规范新增了一项内存管理优化机制,让 JavaScript 引擎在满足条件时可以重用栈帧。尾调用优化的条件就是确定外部栈帧真的没必要存在了

  1. 代码在严格模式下执行
  2. 外部函数的返回值是对尾调用函数的调用
  3. 尾调用函数返回后不需要执行额外的逻辑
  4. 尾调用函数不是引用外部函数作用域中自由变量的闭包
bash 复制代码
function test(num){
    if(num===0) return true
    if(num===1) return false
    return test(Math.abs(num-2))
}
console.log(test(1000000000000))

最后

走过路过,不要错过,点赞、收藏、评论三连~

相关推荐
也无晴也无风雨42 分钟前
深入剖析输入URL按下回车,浏览器做了什么
前端·后端·计算机网络
Martin -Tang1 小时前
Vue 3 中,ref 和 reactive的区别
前端·javascript·vue.js
FakeOccupational3 小时前
nodejs 020: React语法规则 props和state
前端·javascript·react.js
放逐者-保持本心,方可放逐3 小时前
react 组件应用
开发语言·前端·javascript·react.js·前端框架
曹天骄4 小时前
next中服务端组件共享接口数据
前端·javascript·react.js
阮少年、4 小时前
java后台生成模拟聊天截图并返回给前端
java·开发语言·前端
郝晨妤6 小时前
鸿蒙ArkTS和TS有什么区别?
前端·javascript·typescript·鸿蒙
AvatarGiser6 小时前
《ElementPlus 与 ElementUI 差异集合》Icon 图标 More 差异说明
前端·vue.js·elementui
喝旺仔la6 小时前
vue的样式知识点
前端·javascript·vue.js
别忘了微笑_cuicui6 小时前
elementUI中2个日期组件实现开始时间、结束时间(禁用日期面板、控制开始时间不能超过结束时间的时分秒)实现方案
前端·javascript·elementui