闭包 和垃圾回收机制

1,115 阅读16分钟

闭包

闭包-- 内层函数引用外层函数的变量的集合即有权访问另一个函数作用域中变量的函数。在 JavaScript 中,闭包常用于封装变量、模拟私有方法和实现函数级别的模块化等方面。
闭包=内层函数 +外层变量


闭包形成的条件

  1. 首先要有内层函数(嵌套关系)
  2. 内层函数使用了外层函数的变量

当一个嵌套函数引用了其外部函数(父函数)的变量时,该内部函数和它引用的变量组成了一个闭包。由于闭包中的变量不会被垃圾回收机制回收,所以我们可以使用闭包来维护这些变量的状态,以便在以后的函数调用中持久化这些值。

例如:

 function outer(){
            var x= 100
            function bar(){
                console.log(x)// 输出 100
            }
             bar()
        }
        outer()

在这个示例中,outer 函数创建了一个内部函数 bar,并调用这个函数。由于 bar 函数引用了其外部函数 outer 中定义的变量 x,所以它形成了一个闭包。当 bar 被调用时,它会访问和输出变量 x 的值,因此输出 100。

需要注意的是,由于闭包中的变量不会被垃圾回收机制回收,滥用闭包可能会导致内存泄漏和性能问题。因此,在使用闭包时应该遵循最小化闭包范围的原则,只将必要的变量封装在闭包中。

闭包的作用-优点

1. 外层的函数可以访问内层的变量

 function outer(){
            let x=100
            function bar(){
            console.log(x) //输出 100
            }
            return bar
        }
        //外部可以访问到内部的变量
         let fn=outer()
         fn()
===> 2.闭包的简单写法
function outer(){
    let x = 100 
    return function (){
        console.log(x)
    }
}
const fn = outer()
fn ()

在这个示例中,outer 函数创建了一个内部函数 bar,并返回了这个函数。由于 bar 函数引用了其外部函数 outer 中定义的变量 x,所以它形成了一个闭包。通过outer函数,可以访问到内部的bar,

执行 let=outer()时,


        let fn=outer()
        // let fn=function bar(){
          // console.log(x)
        // }
         //我们外部调用fn,相当于执行上面注释的函数,函数内部log-x, 找的是outer里面最开始定义的x
         // 因为一开始他们就形成了闭包,一种捆绑关系。
         fn()

2.实现数据的私有化,避免全局污染

在 JavaScript 中,可以使用闭包来实现私有化数据。具体而言,就是通过将变量封装在闭包中,从而使其无法被外部直接访问或修改。

例如:

function outer() {
  var count = 0;
  return {
    increment: function() {
      count++;
    },
    decrement: function() {
      count--;
    },
    getCount: function() {
      return count;
    }
  };
}

var fn = outer();
fn.increment();//借助increment()进行自加
console.log(fn.getCount());//输出 1
fn.increment();
console.log(fn.getCount());//输出2

fn.decrement()
console.log(fn.getCount())// 输出 1

在这个示例中,outer 函数返回一个对象,其中包含了三个方法:incrementdecrementgetCount。它们都被定义在一个匿名函数的作用域内,并且都共享了 count 变量。由于这些方法被封装在闭包中,所以外部无法直接访问和修改 count 变量的值。只能通过调用 incrementdecrement 方法来间接地增加和减少 count 的值,以及通过调用 getCount 方法来获取 count 的值。

使用闭包来实现私有化数据可以有效地保护代码的安全性和可维护性,同时也可以使代码更简洁和易读。但需要注意,在使用闭包时应该遵循最小化闭包范围的原则,避免闭包过多地保存函数内部的大量状态,从而导致内存泄漏或性能问题。

3.实现高阶函数

使用闭包可以轻松实现高阶函数。下面介绍两种常见的高阶函数实现方式:

  1. 函数柯里化(Currying):指将一个需要多个参数的函数转变为一系列只需要单个参数的函数,并且能够返回新函数的技术。

例如,我们可以使用闭包实现一个简单的柯里化函数:

 function curry(func) {
  return function curried(...args) {
    if (args.length >= func.length) {
      return func.apply(this, args);
    } else {
      return function(...args2) {
        return curried.apply(this, args.concat(args2));
      }
    }
  };
};
//使用柯里化函数转换一个接受三个参数的函数
function add(x, y, z) {
  return x + y + z;
}
//调用柯里化函数生成新函数,传入参数
var curriedAdd = curry(add);
console.log(curriedAdd(1)(2)(3)); // 输出6

2. 函数组合(Compose):指将多个函数结合起来形成一个新的函数的过程。

例如,我们可以使用闭包实现一个简单的函数组合函数:

//1.两简单的函数
function  fnOne(a) {
    return a++
}
function fnTwo(a){
    return a+2
}
//2.组合函数
function Compose(x,y) {
    return function(a){
        return x(y(a))
    } 
}
//3.调用组合函数, 传参
const  fnNew=Compose(fnOne,fnTwo)
console.log(fnNew(3))//输出 5
 

通过使用这些高阶函数和闭包,我们可以编写出更加简洁、灵活和易于扩展的代码。

闭包的作用-缺点-内存泄露

闭包会延长变量的生命周期:如果一个函数返回一个闭包,那么该闭包可以访问函数作用域中的变量,即使该函数已经执行完毕并被销毁掉,这些变量的值仍然可以被保留下来,延长了它们的生命周期。===>但同时也造成了内存泄露
解决----用完之后,手动释放---在不再需要使用闭包函数时,手动将其设置为null,以便其引用的变量可以被垃圾回收机制及时清除。

什么是内存泄露

内存泄漏也称作"存储渗漏",程序中已经不再需要使用的内存没有被及时释放,导致这些内存无法被再次使用,最终可能会耗尽计算机的可用内存。常见的内存泄露情况包括:

1. 没有正确释放动态分配的内存。


在程序中使用动态分配的内存时,如果不及时释放已经使用完毕的内存,它们就会一直留存在内存中,导致内存泄漏。

例如,使用malloc()函数分配内存时,需要使用free()函数来释放这些内存;使用new操作符动态创建对象时,需要使用delete操作符来释放这些对象所占用的内存。

解决这个问题的方法是,仔细管理程序中的内存分配和释放,在不再需要使用某段内存时,及时将其释放。可以使用智能指针、RAII等技术来帮助自动化管理内存的生命周期,从而避免内存泄漏的发生。
动态分配
程序中的动态分配指的是在程序运行时,根据需要动态地分配内存空间。与静态分配相对应,静态分配是在程序编译阶段就确定了内存空间的大小和位置。

动态分配通常使用特定的函数或操作符来实现,例如C语言中的malloc()、calloc()函数,C++中的new操作符等。通过这些函数和操作符,程序可以在运行时根据需要分配所需大小的内存,并返回一个指向该内存块的指针。

2. 循环引用导致的内存泄露

循环引用指的是两个或多个对象之间相互引用了对方。当存在循环引用时,这些对象在一段时间内都不会被垃圾回收机制回收,从而导致内存泄漏。

在 JavaScript 中,循环引用常常出现在对象之间的相互引用或者事件监听等场景中。例如:

function fn(){
      let oA={}//空对象,存的是一个引用地址
      let oB={}
       oA.c=oB //赋值的是oB的引用地址,oB被oA引用
       oB.d=oA//赋值的是oA的引用地址,oA被oB引用
        }
        fn()
    // 调用完fn这个函数之后, oA,oB两个变量会被回收掉,
// 但是堆内存空间中间的两个对象相互引用,计数永远不为0.
// 这个对象会一直占用内存空间,造成内存泄漏
       

image.png

在这个示例中,oAoB 相互引用了对方,形成了循环引用。由于这些对象没有被任何其他变量引用,所以它们将无法被垃圾回收机制回收,从而导致内存泄漏。

避免循环引用造成的内存泄漏可以使用一些方法,例如手动解除引用、使用 WeakMap 等。如果需要相互引用的对象可以通过弱引用来维护,那么它们就可以在不再被其他对象引用时自动被垃圾回收机制回收。例如:

function fn(){
      let oA={}
      let oB={}
       oA.c= new WeakRef(oB)
       oB.d=new WeakRef(oA);
        }
        fn()

在这个示例中,使用了 WeakRef 对象来将 oAoB 进行了相互引用。由于 WeakRef 对象只维护了对象的弱引用,因此不会造成循环引用和内存泄漏的问题。
WeakRef对象
是Python中的一个内置类,用于实现弱引用功能。弱引用是一种特殊的引用方式,它不会增加所指向对象的引用计数,因此被引用的对象仍然可以被垃圾回收机制清除。

在Python中,如果一个对象只被弱引用所引用,那么这个对象就可以被垃圾回收机制删除。WeakRef对象提供了创建和管理弱引用的接口,可以通过它来创建弱引用对象,并获取该对象所引用的原始对象,或者判断原始对象是否已经被销毁。

例如,可以使用WeakRef对象来实现定时器等功能,当某个对象需要在一段时间后被自动销毁时,可以创建一个指向该对象的弱引用,并设置一个定时器,在定时器到期后检查该对象是否还存在,如果不存在,则手动释放相关资源。WeakRef对象是Python中的一个内置类,用于实现弱引用功能。弱引用是一种特殊的引用方式,它不会增加所指向对象的引用计数,因此被引用的对象仍然可以被垃圾回收机制清除。

3. 全局变量或静态变量过多导致的内存泄露。

全局变量以及静态变量是保存在内存中的,因此过多的全局变量或静态变量会占用大量的系统资源,从而导致内存泄露和系统性能下降。

在 JavaScript 中,由于全局变量和静态变量都具有全局范围,因此容易被滥用。
全局变量,例如:

var x = 1;
var a=2
let b=3
function foo() {
  var y = 2;
  console.log(x + y);
}
foo();

在这个示例中,变量 x,a,b 是全局变量,它会一直存在于内存中,直到页面被关闭。尽管它只占用了很少的内存空间,但如果代码中存在大量的全局变量,那么就会导致内存占用过多。


静态变量
静态变量是指不依赖对特定实例或者对象而存在的变量,通常称为类变量或全局变量,在面向对象编程语言中,静态变量属于类或构造函数本身,而非类或实例对象的属性,因此其值在所有实例或对象之间共享。
在JavaScript中,由于语言的动态性和灵活性,虽没有内置的静态变量机制,但是可以通过模拟实现静态变量,例如:

  1. 构造函数的外部定义:可以在函数的外部定义一个变量,并将其赋值给函数属性。这样,在多次调用该函数时,函数属性的值会保持不变,从而实现了静态变量的目的。
 function Object(name, age) {
        this.name = name;
        this.age = age;
      }
      //静态变量
      Object.eyes = 2;
      Object.a='w'
      console.log(Object.eyes)//输出 2
      console.log(Object.a)//输出 w 

      //实例变量
      const o=new Object("熊二",8)
      o.x=1
      o.y=3
      console.log(o)//输出 {name: '熊二', age: 8, x: 1, y: 3}
  1. 使用闭包:可以使用闭包封装一个私有变量,并将其暴露给公共接口,这样,在多次调用该函数时,私有变量的状态会被保留,从而实现了静态变量的功能。
function MyClass() {
  var count = 0;
  this.increment = function() {
    count++;
    console.log(count);
  };
}

var obj1 = new MyClass();
obj1.increment(); // 输出1

var obj2 = new MyClass();
obj2.increment(); // 输出1

为了避免过多的全局变量和静态变量导致的内存泄露,可以采取一些措施,例如:

  1. 使用模块化:通过将代码分解为多个模块,并使用闭包来限制每个模块中的变量范围,从而减少全局变量和静态变量的数量。
  2. 显式地声明变量:在函数中使用 varlet 或 const 关键字来声明变量,避免意外地创建全局变量。
  3. 及时释放无用变量:删除不再需要的变量和对象引用,帮助垃圾回收机制及时释放内存空间。
  4. 使用工具进行监测:使用工具来检测代码中的内存泄露问题,并及时进行修复和优化。例如 Chrome DevTools 中的 Memory 标签页可以用于分析 JavaScript 应用的内存使用情况。

4. 创建的定时器未被清理

解决:
定时器执行完,立马清除
在JavaScript中使用clearTimeout()和clearInterval()方法来清除定时器,示例代码如下:

//=======要清除定时器,首先要先声明
        //延迟定时器
        let timer=setTimeout(() => {
                alert('1000')
            }, 1000);
       
       clearTimeout(timer)
 //间隔定时器
       let timer1=setInterval(() => {
        console.log('啦啦啦')
       }, 500);
    clearInterval(timer1)

5. 闭包引用的变量没有及时被释放

当闭包函数中引用了外部函数的变量,并且这些变量的生命周期比闭包函数长时;又没有及时释放这些变量,即使在这个变量被传递到其他作用域下时也同样有效,==>造成内存泄露

  1. 使用null,手动释放引用的变量,如下所示:
let a=1
let b=2
let fn= function (){
    return a+b
}
console.log(fn()) //输出3

//手动用null释放内存
fn=null
  1. 使用一个中介函数,将闭包存储在其中,然后在不使用时手动调用该函数来清除引用变量
function outer() {
   let a = 2
   let b = 3

   let bar = (function(){
       //访问a,b
   })();

   return function() {
      // 在不再使用时,调用该函数以清除引用变量
      bar = null;
   };
}

// 首先创建闭包,并在现在和将来的某个时间清除引用变量
let clearFn = outer();
  1. 将闭包从全局命名空间中移除,释放其引用变量
   let a = 2
   let b = 3
  window.bar = (function(){
       //访问a,b
   })();

    // 在不再使用时,将 window.bar 从全局命名空间中移除以释放其引用变量
    delete window.bar; 

这些方式可以确保在不需要使用闭包时及时清除它们的引用变量,避免因闭包而引发内存泄漏的问题。

解决内存泄露的方法总结:

  1. 显式地释放动态分配的内存。
  2. 避免循环引用。
  3. 减少全局变量或静态变量的使用。
  4. 定时器及时清除
  5. 使用垃圾回收机制来自动管理内存。

垃圾回收机制

垃圾回收机制(Garbage Collection)GC,是一种自动内存管理机制;用于自动检测并释放程序中无用的内存空间,从而避免程序因为内存占用过多而导致崩溃或性能下降。

1. 引用计数法-基本淘汰

IE采用的引用计数法,通过查看一个对象是否有指向它的引用来判定,内存是否还在使用;
算法

  1. 跟踪记录每一个值被引用的次数
  2. 根据引用的次数,每次累加1
  3. 减少一次引用没救自减1
  4. 引用次数为0时,释放内存

缺点-内存泄露

当存在循环引用时,即两个或多个对象之间相互引用并且它们的引用计数都不为零时,这些对象就不能被垃圾回收器正确地处理和释放,从而导致内存泄漏。为了避免这个问题,通常需要使用更高级别的垃圾回收算法,例如标记-清除(Mark and Sweep)或复制(Copying)算法。

2.标记清除法

  1. 标记阶段: 标记内存为活动对象和非活动对象

  2. 清除阶段:回收非活动对象的内存,也就是销毁清除非活动对象

缺点-内存碎片化

标记清除算法的一个主要缺陷是会导致内存碎片化。当已使用的内存块和未使用的内存块交替出现时,标记清除算法可能会将大量小的自由块分配给较大的对象,从而导致内存碎片化。这使得分配大型对象变得更加困难,并且可能导致操作系统无法为应用程序分配所需的连续内存空间。为了避免这个问题,可以考虑使用其他垃圾回收算法,例如复制算法或标记-压缩算法。
解决--标记整理算法

3. 复制(Copying)算法

该算法将内存空间分为两块,每次只使用其中一块。当这一块空间用完后,将剩余的对象复制到另一块空间,再将原来的空间全部清除。

缺点-是需要额外的内存空间。

4. 分代回收法(Generational GC)

将内存分成多个代,按照对象的生命周期将其分配在不同的代,并设定不同的垃圾回收策略。一般来说,新产生的对象放在较年轻的代中,而经常活跃的对象则放在年长的代中。这种方法更高效并减少了对全局内存的频繁扫描。

缺点 --

  1. 需要维护多个代的数据结构:分代算法需要维护多个代的数据结构,包括每个代的对象集合、指针等。这会增加内存使用量,同时也会增加垃圾回收器的复杂度和开销。
  2. 可能会导致对象晋升次数过多:分代算法中,新创建的对象通常会被放入第一代,如果经过多次回收仍然存活下来,就会晋升到更高的代。但是如果对象在低代中存活的时间很短,那么它可能会被频繁地晋升和回收,导致垃圾回收效率降低。
  3. 无法有效处理跨代引用的情况:当一个对象引用了其他代的对象时,分代算法需要对被引用的对象进行特殊处理,才能确保正确回收。这会增加算法的复杂度和开销。
  4. 对象分配不均衡可能导致性能下降:分代算法会将新创建的对象放入第一代,如果对象分配不均,第一代的对象数量很多,可能会导致垃圾回收时间增加,影响程序的性能。