JavaScript的内存管理和内存泄露问题以及闭包

191 阅读9分钟

内存是什么?

在讨论JavaScript中的内存管理之前,我们先来看一下内存的概况以及它是如何工作的 在硬件层面上,内存包含大量的触发器。每一个触发器包含一些晶体管并能够存储一位数据。单独的触发器可通过唯一标识符寻址, 我们对它进行读写操作。因此,从概念上讲,我们可以把整个计算机内存看作是我们可以读写的一个大的位组。内存中存放着很多东西

  1. 程序使用的所有变量和其他数据
  2. 程序中的代码,包括操作系统的代码。

在现代化的高级语言中编译器和操作系统一起为您处理了大部分的内存管理,但是我们还是需要来看一下底层到底发生了什么。

当你编译代码时,编译器可以检查原始数据类型,并提前计算它们需要多少内存。然后所需的内存被分配给栈空间中的程序。随着函数被调用,后续分配的内存被添加到现有的内存之上。当函数调用结束的时候,在栈空间中分配的内存将以后进先出(LIFO)的方式被释放

假设我们有如下代码片段:

int n; // 4 bytes

int x[4]; // array of 4 elements, each 4 bytes

double m; // 8 bytes

在编译期间,编译器可以立即计算到代码所需内存空间的大小:

4 + 4 × 4 + 8 = 28 bytes

并向操作系统申请28bytes的内存空间分配给需要存储的变量。

在上面的例子中,编译器知道存储这些变量具体需要多大的内存空间。

但是遗憾的是,编译器并不总是知道需要分配多少内存空间,当我们不知道编译时变量需要多少内存时,事情变得不那么简单。

string str = readInput(); // reads input from the user

这里,在编译时,编译器不知道str变量需要多少内存,因为它是由用户提供的输入决定的。 因此,它不能为堆栈上的变量分配空间。但是,我们的程序需要在运行时明确地向操作系统请求正确的内存量。这个内存是从堆空间分配的。

以下是静态和动态内存分配之间的区别: image.png

认识内存管理

不管什么样的编程语言,在代码的执行过程中都是需要给它分配内存的,不同的是某些编程语言需要我们自己手动的管理内存,某些编程语言可以自动帮助我们管理内存:

不管以什么样的方式来管理内存,内存的管理都会有如下的生命周期: 第一步:分配申请你需要的内存(申请)

第二步:使用分配的内存(存放或读取一些东西,比如对象等)

第三步:不需要使用时,对其进行释放

不同的编程语言对于第一步和第三步会有不同的实现:

手动管理内存:比如C、C++,包括早期的OC,都是需要手动来管理内存的申请和释放的(调用malloc和free函数)

自动管理内存:比如Java、JavaScript、Python、Swift、Dart等,它们有自动帮助我们管理内存

JS的内存管理

作为一门高级语言,JavaScript嵌入了垃圾回收器(Garbage Collection),来帮助程序员自动管理内存。这也为程序员编写代码降低了心智负担。而垃圾回收器(GC)最困难的任务是确定分配过的内存空间什么时不再需要,从而释放这些内存空间。

不幸的是,这个垃圾回收的过程是一个近似值,因为预估是否需要某些内存的问题通常是不可判定的(无法通过算法解决)。

大多数垃圾回收器通过收集不能再访问的内存来工作,例如,所有指向它的变量都超出了作用域。然而,这是可以收集的一组内存空间的近似值,因为在某种情况下内存位置可能仍然有一个指向它的变量,但它将不会被再次访问。

JavaScript会在定义变量时为我们分配内存。 对于基本数据类型,会在代码执行时,直接在栈空间进行分配 对于复杂数据类型内存的分配,会在堆内存中开辟一块空间,并且将这块空间的地址返回给栈空间的值变量作为引用

image.png

JS的垃圾回收

因为内存的大小是有限的,所以当内存不再需要的时候,我们需要对其进行释放,在手动管理内存的语言中,我们需要通过一些方式自己来释放不再需要的内存,比如调用c语言通过free函数释放内存。而JavaScript是自动管理内存的,它有自己的垃圾回收机制。现在的问题是GC怎么知道哪些对象是不再使用的呢?

常见的GC算法 – 引用计数

引用计数:

当一个对象有一个引用指向它时,那么这个对象的引用就+1,当一个对象的引用为0时,这个对象就可以被销毁掉

这个算法有一个很大的弊端就是会产生循环引用问题:

image.png 如上图,这2个对象是存在相互引用的,即使外部没有其他变量引用着它们,在计算引用的时候仍然会是1个,这导致了这2个对象无法被垃圾回收器清除。

常见的GC算法 – 标记清除

标记清除

这个算法是设置一个根对象(root object),垃圾回收器会定期从这个根开始,找所有从根开始有引用到的对象,对于那些没有引用到的对象,就认为是不可用的对象;这个算法可以很好的解决循环引用的问题。

尽管垃圾收集器很方便,但他们也有自己的一套权衡策略。其中之一是不确定性。你不能确定一个垃圾收集器何时会执行收集。这意味着在某些情况下,程序其实需要使用更多的内存。

内存泄漏

内存泄漏是之前为应用程序分配使用,但当这些内存不再需要的时候,未返回到操作系统或可用内存池的内存片段。

JavaScript 常见的四种内存泄漏

1.意外创建全局变量

在JavaScript中,如果在函数中使用一个未声明过的变量,则这个变量会变成全局变量,存放在全局对象(GO)中。

function foo(arg){
      bar = "some text";
}

等同于:

function foo(arg) {
    window.bar = "some text";
}

如果这个bar变量在后续的程序运行中将不再被使用,但是它是存放在GO中的,这导致这个变量在程序的运行过程中都无法被释放,这就造成了内存泄露。

2.未被释放的定时器

var someResource = getData(); 
setInterval(function() { 
    var node = document.getElementById('Node'); 
    if(node) { 
        node.innerHTML = JSON.stringify(someResource)); 
    } 
  }, 1000);

如果没有清除定时器,那么 someResource 就不会被释放,这就造成了内存泄露,如果刚好它又占用了较大内存,就会引发性能问题。

3. 被移除的DOM的引用

image.png

4.闭包

JS中闭包的定义

这里先来看一下闭包的定义,分成两个:在计算机科学中和在JavaScript中。

在计算机科学中对闭包的定义(维基百科):

闭包(英语:Closure),又称词法闭包(Lexical Closure)或函数闭包(function closures)是在支持 头等函数 的编程语言中,实现词法绑定的一种技术

闭包在实现上是一个结构体,它存储了一个函数和一个关联的环境(相当于一个符号查找表)

闭包跟函数最大的区别在于,当捕捉闭包的时候,它的自由变量会在捕捉时被确定,这样即使脱离了捕捉时的上下文,它也能照常运行;

闭包的概念出现于60年代,最早实现闭包的程序是 Scheme,那么我们就可以理解为什么JavaScript中有闭包:

因为JavaScript中有大量的设计是来源于Scheme的;

我们再来看一下MDN对JavaScript闭包的解释:

一个函数和对其周围状态(lexical environment,词法环境)的引用捆绑在一起(或者说函数被引用包围),这样的组合就是闭包(closure)

也就是说,闭包让你可以在一个内层函数中访问到其外层函数的作用域

在 JavaScript 中,每当创建一个函数,闭包就会在函数创建的同时被创建出来

那么我的理解和总结:

一个普通的函数function,如果它可以访问外层作用域的自由变量,那么这个函数就是一个闭包;

从广义的角度来说:JavaScript中的函数都是闭包;

从狭义的角度来说:JavaScript中一个函数,如果访问了外层作用域的变量,那么它是一个闭包;

我们来回顾一下JavaScript中函数的执行过程: 1.解析阶段在堆内存中创建GO对象(假设这个对象的内存地址是0x100),在这个对象上保存着String、window、console、全局变量,而且全局变量的初始值都是undefined。解析阶段,当遇到函数的时候会在堆中创建相应的函数对象,函数对象保存着函数的执行体和父级作用域(如果是全局函数,则函数的父级作用域是GO:0x100),并且把该函数对象的地址保存在GO中

2.创建执行上下文栈ECStack(需要有一个VO,这个VO指向GO)

3.VO指向GO之后,往GO添加全局变量,此时全局变量的值都是undefined

4.执行到函数的时候,创建函数执行上下文FEC,在函数执行上下文中存着一个VO,这个VO指向一个AO,此时在堆内存中创建函数的AO对象,在真正执行前把函数用到的变量放入AO中,在函数执行的过程对AO中的变量进行赋值

image.png

为什么闭包有内存泄漏?

如上图:在闭包中把内部bar函数返回给了GO中的全局变量fn,因为GO对象是不会被销毁的,而GO对象中的fn引用了bar函数对象,导致bar函数对象一直不会被销毁,由于bar函数对象中又保存了自己的父级作用域(parentScope)也就是foo函数的AO对象,所以又导致了foo函数的AO对象不会被销毁,即使代码执行完了,内存状态还是依旧保持如此,因此产生了内存泄漏

解决方法:fn=null