JavaScript-设计模式系列-享元模式与资源池

35次阅读

共计 8531 个字符,预计需要花费 22 分钟才能阅读完成。

享元模式(Flyweight Pattern)运用共享技术来有效地支持大量细粒度对象的复用,以减少创建的对象的数量。

享元模式的主要思想是 共享细粒度对象,也就是说如果系统中存在多个相同的对象,那么只需共享一份就可以了,不必每个都去实例化每一个对象,这样来精简内存资源,提升性能和效率。

Fly 意为苍蝇,Flyweight 指轻蝇量级,指代对象粒度很小。

注意: 本文用到 ES6 的语法 let/const、Class、Promise 等,如果还没接触过可以点击链接稍加学习 ~

1. 你曾见过的享元模式

我们去驾考的时候,如果给每个考试的人都准备一辆车,那考场就挤爆了,考点都堆不下考试车,因此驾考现场一般会有几辆车给要考试的人依次使用。如果考生人数少,就分别少准备几个自动档和手动档的驾考车,考生多的话就多准备几辆。如果考手动档的考生比较多,就多准备几辆手动档的驾考车。

我们去考四六级的时候(为什么这么多考试?????),如果给每个考生都准备一个考场,怕是没那么多考场也没有这么多监考老师,因此现实中的大多数情况都是几十个考生共用一个考场。四级考试和六级考试一般同时进行,如果考生考的是四级,那么就安排四级考场,听四级的听力和试卷,六级同理。

生活中类似的场景还有很多,比如咖啡厅的咖啡口味,餐厅的菜品种类,拳击比赛的重量级等等。

在类似场景中,这些例子有以下特点:

  1. 目标对象具有一些共同的状态,比如驾考考生考的是自动档还是手动档,四六级考生考的是四级还是六级;
  2. 这些共同的状态所对应的对象,可以被共享出来;

2. 实例的代码实现

首先假设考生的 ID 为奇数则考的是手动档,为偶数则考的是自动档。如果给所有考生都 new 一个驾考车,那么这个系统中就会创建了和考生数量一致的驾考车对象:

var candidateNum = 10   // 考生数量
var examCarNum = 0      // 驾考车的数量

/* 驾考车构造函数 */
function ExamCar(carType) {
    examCarNum++
    this.carId = examCarNum
    this.carType = carType ? '手动档' : '自动档'
}

ExamCar.prototype.examine = function(candidateId) {console.log('考生 -' + candidateId + '在' + this.carType + '驾考车 -' + this.carId + '上考试')
}

for (var candidateId = 1; candidateId <= candidateNum; candidateId++) {var examCar = new ExamCar(candidateId % 2)
    examCar.examine(candidateId)
}

console.log('驾考车总数 -' + examCarNum)
// 输出: 驾考车总数 - 10

如果考生很多,那么系统中就会存在更多个驾考车对象实例,假如驾考车对象比较复杂,那么这些新建的驾考车实例就会占用大量内存。这时我们将同种类型的驾考车实例进行合并,手动档和自动档档驾考车分别引用同一个实例,就可以节约大量内存:

var candidateNum = 10   // 考生数量
var examCarNum = 0      // 驾考车的数量

/* 驾考车构造函数 */
function ExamCar(carType) {
    examCarNum++
    this.carId = examCarNum
    this.carType = carType ? '手动档' : '自动档'
}

ExamCar.prototype.examine = function(candidateId) {console.log('考生 -' + candidateId + '在' + this.carType + '驾考车 -' + this.carId + '上考试')
}

var manualExamCar = new ExamCar(true)
var autoExamCar = new ExamCar(false)

for (var candidateId = 1; candidateId <= candidateNum; candidateId++) {
    var examCar = candidateId % 2 ? manualExamCar : autoExamCar
    examCar.examine(candidateId)
}

console.log('驾考车总数 -' + examCarNum)
// 输出: 驾考车总数 - 2

可以看到我们使用 2 个驾考车实例就实现了刚刚 10 个驾考车实例实现的功能。这是仅有 10 个考生的情况,如果有几百上千考生,这时我们节约的内存就比较可观了,这就是享元模式要达到的目的。

3. 享元模式改进

如果你阅读了之前文章关于继承部分的讲解,那么你实际上已经接触到享元模式的思想了。相比于构造函数窃取,在原型链继承和组合继承中,子类通过原型 prototype 来复用父类的方法和属性,如果子类实例每次都创建新的方法与属性,那么在子类实例很多的情况下,内存中就存在有很多重复的方法和属性,即使这些方法和属性完全一样,因此这部分内存完全可以通过复用来优化,这也是享元模式的思想。

传统的享元模式是将目标对象的状态区分为 内部状态 外部状态,内部状态相同的对象可以被共享出来指向同一个内部状态。正如之前举的驾考和四六级考试的例子中,自动档还是手动档、四级还是六级,就属于驾考考生、四六级考生中的内部状态,对应的驾考车、四六级考场就是可以被共享的对象。而考生的年龄、姓名、籍贯等就属于外部状态,一般没有被共享出来的价值。

主要的原理可以参看下面的示意图:

享元模式的主要思想是细粒度对象的共享和复用,因此对之前的驾考例子,我们可以继续改进一下:

  1. 如果某考生正在使用一辆驾考车,那么这辆驾考车的状态就是被占用,其他考生只能选择剩下未被占用状态的驾考车;
  2. 如果某考生对驾考车的使用完毕,那么将驾考车开回考点,驾考车的状态改为未被占用,供给其他考生使用;
  3. 如果所有驾考车都被占用,那么其他考生只能等待正在使用驾考车的考生使用完毕,直到有驾考车的状态变为未被占用;
  4. 组织单位可以根据考生数量多准备几辆驾考车,比如手动档考生比较多,那么手动档驾考车就应该比自动档驾考车多准备几辆;

我们可以简单实现一下,为了方便起见,这里就直接使用 ES6 的语法。

首先创建 3 个手动档驾考车,然后注册 10 个考生参与考试,一开始肯定有 3 个考生同时上车,然后在某个考生考完之后其他考生接着后面考。为了实现这个过程,这里使用了 Promise,考试的考生在 0 到 2 秒后的随机时间考试完毕归还驾考车,其他考生在前面考生考完之后接着进行考试:

let examCarNum = 0                  // 驾考车总数

/* 驾考车对象 */
class ExamCar {constructor(carType) {
        examCarNum++
        this.carId = examCarNum
        this.carType = carType ? '手动档' : '自动档'
        this.usingState = false    // 是否正在使用
    }
    
    /* 在本车上考试 */
    examine(candidateId) {
        return new Promise((resolve => {
            this.usingState = true
            console.log(` 考生 - ${ candidateId} 开始在 ${this.carType}驾考车 - ${this.carId} 上考试 `)
            setTimeout(() => {
                this.usingState = false
                console.log(`%c 考生 - ${ candidateId} 在 ${this.carType}驾考车 - ${this.carId} 上考试完毕 `, 'color:#f40')
                resolve()                       // 0~2 秒后考试完毕}, Math.random() * 2000)
        }))
    }
}

/* 手动档汽车对象池 */
ManualExamCarPool = {_pool: [],                  // 驾考车对象池
    _candidateQueue: [],        // 考生队列
    
    /* 注册考生 ID 列表 */
    registCandidates(candidateList) {candidateList.forEach(candidateId => this.registCandidate(candidateId))
    },
    
    /* 注册手动档考生 */
    registCandidate(candidateId) {const examCar = this.getManualExamCar()    // 找一个未被占用的手动档驾考车
        if (examCar) {examCar.examine(candidateId)           // 开始考试,考完了让队列中的下一个考生开始考试
              .then(() => {const nextCandidateId = this._candidateQueue.length && this._candidateQueue.shift()
                  nextCandidateId && this.registCandidate(nextCandidateId)
              })
        } else this._candidateQueue.push(candidateId)
    },
    
    /* 注册手动档车 */
    initManualExamCar(manualExamCarNum) {for (let i = 1; i <= manualExamCarNum; i++) {this._pool.push(new ExamCar(true))
        }
    },
    
    /* 获取状态为未被占用的手动档车 */
    getManualExamCar() {return this._pool.find(car => !car.usingState)
    }
}

ManualExamCarPool.initManualExamCar(3)          // 一共有 3 个驾考车
ManualExamCarPool.registCandidates([1, 2, 3, 4, 5, 6, 7, 8, 9, 10])  // 10 个考生来考试

在浏览器中运行下试试:

可以看到一个驾考的过程被模拟出来了,这里只简单实现了手动档,自动档驾考场景同理,就不进行实现了。上面的实现还可以进一步优化,比如考生多的时候自动新建驾考车,考生少的时候逐渐减少驾考车,但又不能无限新建驾考车对象,这些情况读者可以自行发挥~

如果可以将目标对象的内部状态和外部状态区分的比较明显,就可以将内部状态一致的对象很方便地共享出来,但是对 JavaScript 来说,我们并不一定要严格区分内部状态和外部状态才能进行资源共享,比如资源池模式。

4. 资源池

上面这种改进的模式一般叫做 资源池(Resource Pool),或者叫对象池(Object Pool),可以当作是享元模式的升级版,实现不一样,但是目的相同。资源池一般维护一个装载对象的池子,封装有获取、释放资源的方法,当需要对象的时候直接从资源池中获取,使用完毕之后释放资源等待下次被获取。

在上面的例子中,驾考车相当于有限资源,考生作为访问者根据资源的使用情况从资源池中获取资源,如果资源池中的资源都正在被占用,要么资源池创建新的资源,要么访问者等待占用的资源被释放。

资源池在后端应用相当广泛,比如缓冲池、连接池、线程池、字符常量池等场景,前端使用场景不多,但是也有使用,比如有些频繁的 DOM 创建销毁操作,就可以引入对象池来节约一些 DOM 创建损耗。

下面介绍资源池的几种主要应用。

4.1 线程池

以 Node.js 中的线程池为例,Node.js 的 JavaScript 引擎是执行在单线程中的,启动的时候会新建 4 个线程放到线程池中,当遇到一些异步 I/O 操作(比如文件异步读写、DNS 查询等)或者一些 CPU 密集的操作(Crypto、Zlib 模块等)的时候,会在线程池中拿出一个线程去执行。如果有需要,线程池会按需创建新的线程。

线程池在整个 Node.js 事件循环中的位置可以参照下图:

上面这个图就是 Node.js 的事件循环(Event Loop)机制,简单解读一下(扩展视野,不一定需要懂):

  1. 所有任务都在主线程上执行,形成执行栈(Execution Context Stack);
  2. 主线程之外维护一个任务队列(Task Queue),接到请求时将请求作为一个任务放入这个队列中,然后继续接收其他请求;
  3. 一旦执行栈中的任务执行完毕,主线程空闲时,主线程读取任务队列中的任务,检查队列中是否有要处理的事件,这时要分两种情况:如果是非 I/O 任务,就亲自处理,并通过回调函数返回到上层调用;如果是 I/O 任务,将传入的参数和回调函数封装成请求对象,并将这个请求对象推入线程池等待执行,主线程则读取下一个任务队列的任务,以此类推处理完任务队列中的任务;
  4. 线程池当线程可用时,取出请求对象执行 I/O 操作,任务完成以后归还线程,并把这个完成的事件放到任务队列的尾部,等待事件循环,当主线程再次循环到该事件时,就直接处理并返回给上层调用;

感兴趣的同学可以阅读《深入浅出 Nodejs》或 Node.js 依赖的底层库 Libuv 官方文档 来了解更多。

4.2 缓存

根据二八原则,80% 的请求其实访问的是 20% 的资源,我们可以将频繁访问的资源缓存起来,如果用户访问被缓存起来的资源就直接返回缓存的版本,这就是 Web 开发中经常遇到的 缓存

缓存服务器就是缓存的最常见应用之一,也是复用资源的一种常用手段。缓存服务器的示意图如下:

缓存服务器位于访问者与业务服务器之间,对业务服务器来说,减轻了压力,减小了负载,提高了数据查询的性能。对用户来说,提升了网页打开速度,优化了体验。

缓存技术用的非常多,不仅仅用在缓存服务器上,浏览器本地也有缓存,查询的 DNS 也有缓存,包括我们的电脑 CPU 上,也有缓存硬件。

4.3 连接池

我们知道对数据库进行操作需要先创建一个数据库连接对象,然后通过创建好的数据库连接来对数据库进行 CRUD(增删改查)操作。如果访问量不大,对数据库的 CRUD 操作就不多,每次访问都创建连接并在使用完销毁连接就没什么,但是如果访问量比较多,并发的要求比较高时,频繁创建和销毁连接就比较消耗资源了。

这时,可以不销毁连接,一直使用已创建的连接,就可以避免频繁创建销毁连接的损耗了。但是有个问题,一个连接同一时间只能做一件事,某使用者(一般是线程)正在使用时,其他使用者就不可以使用了,所以如果只创建一个不关闭的连接显然不符合要求,我们需要创建多个不关闭的连接。

这就是 连接池 的来源,创建多个数据库连接,当有调用的时候直接在创建好的连接中拿出来使用,使用完毕之后将连接放回去供其他调用者使用。

我们以 Node.js 中 mysql 模块的连接池应用为例,看看后端一般是如何使用数据库连接池的。在 Node.js 中使用 mysql 创建单个连接,一般这样使用:

var mysql = require('mysql')

var connection = mysql.createConnection({     // 创建数据库连接
    host: 'localhost',
    user: 'root',         // 用户名
    password: '123456',   // 密码
    database: 'db',       // 指定数据库
    port: '3306'          // 端口号
})

// 连接回调,在回调中增删改查
connection.connect(...)

// 关闭连接
connection.end(...)

在 Node.js 中使用 mysql 模块的连接池创建连接:

var mysql = require('mysql')

var pool = mysql.createPool({     // 创建数据库连接池
    host: 'localhost',
    user: 'root',         // 用户名
    password: '123456',   // 密码
    database: 'db',       // 制定数据库
    port: '3306'          // 端口号
})

// 从连接池中获取一个连接,进行增删改查
pool.getConnection(function(err, connection) {
    // ... 数据库操作
    connection.release()  // 将连接释放回连接池中})

// 关闭连接池
pool.end()

一般连接池在初始化的时候,都会自动打开 n 个连接,称为 连接预热 。如果这 n 个连接都被使用了,再从连接池中请求新的连接时,会动态地隐式创建额外连接,即 自动扩容 。如果扩容后的连接池一段时间后有不少连接没有被调用,则 自动缩容 ,适当释放空闲连接,增加连接池中连接的使用效率。在连接失效的时候,自动 抛弃无效连接 。在系统关闭的时候,自动 释放所有连接。为了维持连接池的有效运转和避免连接池无限扩容,还会给连接池设置最大最小连接数。

这些都是连接池的功能,可以看到连接池一般可以根据当前使用情况自动地进行缩容和扩容,来进行连接池资源的最优化,和连接池连接的复用效率最大化。这些连接池的功能点,看着是不是和之前驾考例子的优化过程有点似曾相识呢~

在实际项目中,除了数据库连接池外,还有 HTTP 连接池。使用 HTTP 连接池管理长连接可以复用 HTTP 连接,省去创建 TCP 连接的 3 次握手和关闭 TCP 连接的 4 次挥手的步骤,降低请求响应的时间。

连接池某种程度也算是一种缓冲池,只不过这种缓冲池是专门用来管理连接的。

4.4 字符常量池

很多语言的引擎为了减少字符串对象的重复创建,会在内存中维护有一个特殊的内存,这个内存就叫 字符常量池。当创建新的字符串时,引擎会对这个字符串进行检查,与字符常量池中已有的字符串进行比对,如果存在有相同内容的字符串,就直接将引用返回,否则在字符常量池中创建新的字符常量,并返回引用。

类似于 Java、C# 这些语言,都有字符常量池的机制。JavaScript 有多个引擎,以 Chrome 的 V8 引擎为例,V8 在把 JavaScript 编译成字节码过程中就引入了字符常量池这个优化手段,这就是为什么很多 JavaScript 的书籍都提到了 JavaScript 中的字符串具有不可变性,因为如果内存中的字符串可变,一个引用操作改变了字符串的值,那么其他同样的字符串也会受到影响。

V8 引擎中的字符常量池存在一个变量 string_table_ 中,这个变量保存有所有的字符串 All strings are copied here, one after another,地址位于 v8/src/ast/ast-value-factory.h,核心方法是 LookupOrInsert,这个方法给每一个字符串计算出 hash 值,并从 table 中搜索,没有则插入,感兴趣的同学可以自行阅读。

可以引用《JavaScript 高级程序设计》中的话解释一下:

ECMAScript 中的字符串是不可变的,也就是说,字符串一旦创建,它们的值就不能改变。要改变某个变量保存的字符串,首先要销毁原来的字符串,然后再用另一个包含新值的字符串填充该变量。

字符常量池也是复用资源的一种手段,只不过这种手段通常用在编译器的运行过程中,通常开发(搬砖)过程用不到,了解即可。

5. 享元模式的优缺点

享元模式的优点:

  1. 由于 减少了系统中的对象数量,提高了程序运行效率和性能,精简了内存占用,加快运行速度;
  2. 外部状态相对独立,不会影响到内部状态,所以享元对象能够在不同的环境被共享;

享元模式的缺点:

  1. 引入了共享对象,使对象结构变得复杂;
  2. 共享对象的创建、销毁等需要维护,带来额外的复杂度(如果需要把共享对象维护起来的话);

6. 享元模式的适用场景

  1. 如果一个程序中大量使用了相同或相似对象,那么可以考虑引入享元模式;
  2. 如果使用了大量相同或相似对象,并造成了比较大的内存开销;
  3. 对象的大多数状态可以被转变为外部状态;
  4. 剥离出对象的外部状态后,可以使用相对较少的共享对象取代大量对象;

在一些程序中,如果引入享元模式对系统的性能和内存的占用影响不大时,比如目标对象不多,或者场景比较简单,则不需要引入,以免适得其反。

7. 其他相关模式

享元模式和单例模式、工厂模式、组合模式、策略模式、状态模式等等经常会一起使用。

7.1 享元模式和工厂模式、单例模式

在区分出不同种类的外部状态后,创建新对象时需要选择不同种类的共享对象,这时就可以使用工厂模式来提供共享对象,在共享对象的维护上,经常会采用单例模式来提供单实例的共享对象。

7.2 享元模式和组合模式

在使用工厂模式来提供共享对象时,比如某些时候共享对象中的某些状态就是对象不需要的,可以引入组合模式来提升自定义共享对象的自由度,对共享对象的组成部分进一步归类、分层,来实现更复杂的多层次对象结构,当然系统也会更难维护。

7.3 享元模式和策略模式

策略模式中的策略属于一系列功能单一、细粒度的细粒度对象,可以作为目标对象来考虑引入享元模式进行优化,但是前提是这些策略是会被频繁使用的,如果不经常使用,就没有必要了。


参考文章:

  1. Understanding NodeJS Event Loop
  2. What you should know to really understand the Node.js Event Loop

本文出自我的专栏 <JavaScript 设计模式精讲 > 中的一篇,感兴趣的同学可以点击链接看看更多文章~

PS:欢迎大家关注我的公众号【前端下午茶】,一起加油吧~

另外可以加入「前端下午茶交流群」微信群,长按识别下面二维码即可加我好友,备注 加群,我拉你入群~

正文完
 0