微信号:uxForever

介绍:与您分享前端技术,程序猿的工作、生活,每周一早上推送一篇前端技术文章,不定时与您分享程序猿的另类生活.from 阿里巴巴信息平台前端团队.

Javascript 内存管理系列(上)——垃圾回收机制

2016-12-06 07:58 昊祯

每个需要消耗内存的程序都需要某种机制来预约和释放内存空间。内存管理提供了一种动态分配内存区块的机制,它会动态创建一个内存区块来存储一个数据单元,而当应用不再需要该数据单元时又可以释放该内存区块,该机制保证了处于内存区块中的数据单元的可复用性

内存管理既可以人工维护,也可以是自动的,自动的内存管理往往会涉及垃圾回收

幸运的是,Javascript 自带垃圾回收机制,也就是说 Javascript 是一个具有自动内存管理的语言,我们不需要手动去管理内存分配/释放

垃圾回收的概念

垃圾回收(Garbage Collector, 简称 GC)是一种自动管理内存单元的机制。GC 的主要职责是回收不再被使用的内存单元。1959 年首次在 LISP 语言中由 John McCarthy 发明

  • 垃圾回收之前

下图展现了内存中的样子,有一些对象之间互相引用,而有一些对象与其它对象之间不存在任何引用关系,这些不存在引用关系的对象将会被 GC 回收

  • 垃圾回收之后

一旦 GC 运行之后,这些不存在引用关系的对象被删除,内存也被释放

垃圾回收的好处

  • 避免因野指针引起的 bugs
  • 不会重复释放已经释放的内存单元
  • 避免内存泄露

当然,垃圾回收机制不是万能解药(如果垃圾回收机制可以帮我们解决所有问题,也就没有必要写这篇文章啦~),垃圾回收机制不是内存管理的银弹,下面我们来看下开发者还需要关注的注意点:

  • 性能:为了辨识哪些内存单元可以被释放,GC 需要消耗计算能力
  • 不可预测性:现代的 GC 实现都尝试去避免 stop-the-world

在开始学习 GC 之前你应该知道一个词:stop-the-world。不管选择哪种 GC 算法,stop-the-world 都是不可避免的。Stop-the-world 意味着从应用中停下来并进入到 GC 执行过程中去。一旦 Stop-the-world 发生,除了 GC 所需的线程外,其他线程都将停止工作,中断了的线程直到 GC 任务结束才继续它们的任务。GC 调优通常就是为了改善 stop-the-world 的时间

垃圾回收原理

先来介绍下 Javascript 中内存的基础知识

堆栈

我们知道 Javascript 中的变量分为基本类型和引用类型两种,基本类型就是保存在栈内存中的数据单元,而引用类型指的是那些保存在堆内存中的对象

引用类型,值大小不固定,栈内存中存放地址指向堆内存中的对象,是按引用访问的。如下图所示:栈内存中存放的只是该对象的访问地址,在堆内存中为这个值分配空间。由于这种值的大小不固定,因此不能把它们保存到栈内存中。但内存地址大小的固定的,因此可以将内存地址保存在栈内存中。这样,当查询引用类型的变量时, 先从栈中读取内存地址,然后再通过地址找到堆中的值。对于这种,我们把它叫做按引用访问

栈中保存了基本类型的变量、以及一些引用对象/函数的指针

下面例子中的 ab 变量都会被存储到栈内存中

堆内存中存储的是一些引用类型的对象

以下例子中创建的 Car 就是一个存储在堆内存中的对象

Javascript 中,当一个方法执行时,这个方法都会创建一个自己的内存栈,在这个方法中定义的变量会逐个放入到这个内存栈中,随着这个方法的执行,这个方法的内存栈会被回收

Javascript 创建一个对象的时候,会在堆内存中为该对象分配一个存储区块,堆内存中的对象不会随着方法的结束而销毁,即使方法结束后,这个对象还可能被另外一个引用变量所引用,这种情况下该对象不会被销毁,只有当一个对象没有任何引用变量引用的时候,才会被销毁

垃圾回收机制的两种策略

对于函数内部的局部变量来说,函数运行结束之后,这些局部变量就没有存在的必要了,在这种情况下可以比较容易判断是否需要进行垃圾回收;但是并非所有情况都这么好判断,垃圾回收器必须跟踪一个变量,对于没有用的变量进行打标,以备将来回收器占用的内存。

用于标识变量是否还有用的策略可能会因现实而异,主要分为以下两种策略:

  • 标记清除(mark-and-sweep)

当变量进入环境(例如,在函数中声明一个变量)时,就将这个变量标记为“进入环境”。从逻辑上讲,永远不能释放进入环境的变量所占的内存,因为只要执行流进入相应的环境,就可能用到它们。而当变量离开环境时,则将其标记为“离开环境”

垃圾回收器在运行的时候会给存储在内存中的所有变量都加上标记(当然,可以使用任何标记方式)。然后,它会去掉环境中变量以及被环境中的变量引用的变量标记。而在此之后再被加上标记的变量将被视为准备删除的变量,原因是环境中的变量已经无法访问到这些变量了。最后,垃圾收集器完成内存清除工作,销毁那些带标记的值并回收它们所占用的内存空间

算法原理

顾名思义,标记-清除算法分为两个阶段,标记(mark)和清除(sweep)

在标记阶段,垃圾回收器从应用根对象开始进行遍历,对从应用根对象可以访问到的对象都打上一个标识,一般是在对象的 header 中,将其记录为可达对象

而在清除阶段,垃圾回收器对堆内存(heap memory)从头到尾进行线性的遍历,如果发现某个对象没有标记为可达对象-通过读取对象的 header 信息,则就将其回收

从上图我们可以看到,在 Mark 阶段,从根对象 1 可以访问到 B 对象,从 B 对象又可以访问到 E 对象,所以 B,E 对象都是可达的。同理,F,G,J,K 也都是可达对象。到了 Sweep 阶段,所有非可达对象都会被垃圾回收器回收。同时,垃圾回收器在进行标记和清除阶段时会将整个应用程序暂停,等待标记清除结束后才会恢复应用程序的运行,这也是 Stop-The-World 这个单词的来历

大多数浏览器的 Javascript 实现使用的都是标记清除式的垃圾回收策略,只不过垃圾回收器的时间间隔互不相同

  • 引用计数(reference counting)

引用计数的含义是跟踪记录每个值被引用的次数。当声明一个变量并将引用类型的值赋给该变量时,则这个值的引用次数就是 1。如果同一个值又被赋给另一个变量,则该值的引用次数加 1。相反,如果包含对这个值引用的变量又取得另外一个值,则这个值的引用次数减 1,当这个值的引用次数变成 0 时,则说明没有办法访问这个值了,因此就可以将其中占用的内存空间回收回来。这样当垃圾回收器下次再运行时,它就会释放那些引用次数为 0 的值所占用的内存

引用计数存在一个严重的问题:循环引用。循环引用指的是对象 A 中包含一个指向对象 B 的引用,而对象 B 中也包含一个指向对象 A 的引用

在这个例子中,objectAobjectB 通过各自的属性相互引用,也就是说,这两个对象的引用次数都是 2。在采用标记清除策略的实现中,由于函数执行之后,这两个对象都离开了作用域。因此这两种相互引用不是个问题。但在采用引用计数策略的实现中,在函数执行完毕后,objectAobjectB 还将继续存在,因此他们的引用次数永远不会是 0。假如这个函数被重复调用,就会导致大量的内存得不到回收(函数在调用过程会重新为变量分配内存空间)

垃圾回收在 IE 浏览器中存在的问题

浏览器对象中除了原生的 Javascript 对象之外,还有一些宿主对象提供的对象,比如:BOM 对象和 DOM 对象,BOMDOM 中的对象就是使用 C++ 以 COMComponent Object Model,组件对象模型)对象的形式实现的,而 COM 对象的垃圾回收机制采用的就是引用计数策略。因此,尽管 IEJavascript 引擎是使用标记清除策略来实现的,而 Javascript 访问的 COM 对象依然是基于引用计数策略的

我们来看一个例子:

我们在一个原生的 Javascript 对象与 DOM 对象之间建立了一个循环引用的关系,这样就算我们删除了这个 DOM 元素,由于还存在对其的引用,这个 DOM 对象在引用计算策略中的引用次数永远都不可能变为 0,也就无法被垃圾回收机制标记为可删除

垃圾回收的性能问题

垃圾回收是个代价非常高的进程,因为它会中断程序的执行,从而影响程序的性能。垃圾回收器都是周期性运行的,而且如果为变量分配的内存数量很客观,那么回收工作量也是相当大的

引起内存泄露问题常见使用方式

  • 不小心声明的全局变量

在 Javascript 函数中,如果忘记使用 var 声明一个变量,这个变量将会提升为全局变量:

这段代码等同于:

我们知道在函数结束之后,函数内部的变量将会被回收,但是如果是用全局变量的方式声明一个变量,这个全局变量在函数结束之后还可以被根对象所访问,所以该变量无法被回收

  • 计时器 & 回调

先来看一个例子:

计时器回调闭包中引用了一个外部变量 someResource,由于计时器的存在,这个闭包函数不会被垃圾回收,同时它引用的 someResource 变量也无法被回收

另外由于早期 IE 的实现的机制无法对循环引用的变量进行垃圾回收,在事件回调中最好显式的方式移除事件监听:

jQuery 之类的 DOM 操作类库也有在移除 DOM 结点的时候显式移除事件监听

  • DOM 引用问题

有些时候我们会频繁的操作 DOM 元素,那么使用一个变量来保存对该 DOM 元素的引用,这样可以减少 DOM 查询:

即使执行 removeButton 函数删除 button 元素,由于在 elements 对象中还存在对该 DOM 结点的引用,所以该 DOM 结点无法进行垃圾回收

另外一个更加复杂点的场景,使用一个变量来保存对 table 元素中的一个单元格 td 的引用,然后删除该 table 元素,由于还存在对其子结点 td 元素的引用,大家可能会认为仅仅是该 td 元素无法被垃圾回收,实际上整个 table 元素都无法被垃圾回收,因为子结点会保持对其父结点的引用

  • 闭包

场景:匿名函数中存在对父作用域对象的引用

计时器每隔 1 秒钟执行 replaceThing 函数,在 unused 函数中保存了对 originalThing 对象的引使得其无法被垃圾回收,而下面的 theThing 又通过字面量的方式创建了一个大数据对象(同时,theThingoriginalThing 引用),使得每执行一次 replaceThing 函数,都会创建一个大的内存区块(确切说应该是在堆内存中)来保存这个大数据对象

参考文章:


长按二维码,关注猿猿相抱

转载请注明出处和作者

 
猿猿相抱 更多文章 基于Nodejs的第三方认证 2016第11届D2前端技术论坛开放报名 2016第11届D2前端技术论坛开放报名 第十一届 D2 前端技术论坛将于2016年12月17日在杭州举办 从一个bug认识JSON
猜您喜欢 PHP多线程的实现方法详解 jquery官方性能优化建议 如何恢复Linux中的误删文件 Zabbix 3.0安装指南 线性回归与R语言