「手摸手設計模式系列」 享元模式與資源池

享元模式 (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 享元模式和策略模式

策略模式中的策略屬於一系列功能單一、細粒度的細粒度對象,可以作為目標對象來考慮引入享元模式進行優化,但是前提是這些策略是會被頻繁使用的,如果不經常使用,就沒有必要了。


分享到:


相關文章: