1. 程式人生 > 其它 >享元模式(上):如何利用享元模式優化文字編輯器的記憶體佔用?

享元模式(上):如何利用享元模式優化文字編輯器的記憶體佔用?

上一節課中,我們講了組合模式。組合模式並不常用,主要用在資料能表示成樹形結構、能通過樹的遍歷演算法來解決的場景中。今天,我們再來學習一個不那麼常用的模式,享元模式(Flyweight Design Pattern)。這也是我們要學習的最後一個結構型模式。

享元模式原理與實現

所謂“享元”,顧名思義就是被共享的單元。享元模式的意圖是複用物件,節省記憶體,前提是享元物件是不可變物件

具體來講,當一個系統中存在大量重複物件的時候,如果這些重複的物件是不可變物件,我們就可以利用享元模式將物件設計成享元,在記憶體中只保留一份例項,供多處程式碼引用。這樣可以減少記憶體中物件的數量,起到節省記憶體的目的。實際上,不僅僅相同物件可以設計成享元,對於相似物件,我們也可以將這些物件中相同的部分(欄位)提取出來,設計成享元,讓這些大量相似物件引用這些享元。

具體來講,當一個系統中存在大量重複物件的時候,如果這些重複的物件是不可變物件,我們就可以利用享元模式將物件設計成享元,在記憶體中只保留一份例項,供多處程式碼引用。這樣可以減少記憶體中物件的數量,起到節省記憶體的目的。實際上,不僅僅相同物件可以設計成享元,對於相似物件,我們也可以將這些物件中相同的部分(欄位)提取出來,設計成享元,讓這些大量相似物件引用這些享元。

接下來,我們通過一個簡單的例子解釋一下享元模式。

假設我們在開發一個棋牌遊戲(比如象棋)。一個遊戲廳中有成千上萬個“房間”,每個房間對應一個棋局。棋局要儲存每個棋子的資料,比如:棋子型別(將、相、士、炮等)、棋子顏色(紅方、黑方)、棋子在棋局中的位置。利用這些資料,我們就能顯示一個完整的棋盤給玩家。具體的程式碼如下所示。其中,ChessPiece 類表示棋子,ChessBoard 類表示一個棋局,裡面儲存了象棋中 30 個棋子的資訊。

public class ChessPiece {//棋子
  private int id;
  private String text;
  private Color color;
  private int positionX;
  private int positionY;

  public ChessPiece(int id, String text, Color color, int positionX, int positionY) {
    this.id = id;
    this.text = text;
    this.color = color;
    this.positionX = positionX;
    this.positionY = positionX;
  }

  public static enum Color {
    RED, BLACK
  }

  // ...省略其他屬性和getter/setter方法...
}

public class ChessBoard {//棋局
  private Map<Integer, ChessPiece> chessPieces = new HashMap<>();

  public ChessBoard() {
    init();
  }

  private void init() {
    chessPieces.put(1, new ChessPiece(1, "車", ChessPiece.Color.BLACK, 0, 0));
    chessPieces.put(2, new ChessPiece(2,"馬", ChessPiece.Color.BLACK, 0, 1));
    //...省略擺放其他棋子的程式碼...
  }

  public void move(int chessPieceId, int toPositionX, int toPositionY) {
    //...省略...
  }
}

這個時候,享元模式就可以派上用場了。像剛剛的實現方式,在記憶體中會有大量的相似物件。這些相似物件的 id、text、color 都是相同的,唯獨 positionX、positionY 不同。實際上,我們可以將棋子的 id、text、color 屬性拆分出來,設計成獨立的類,並且作為享元供多個棋盤複用。這樣,棋盤只需要記錄每個棋子的位置資訊就可以了。具體的程式碼實現如下所示:


// 享元類
public class ChessPieceUnit {
  private int id;
  private String text;
  private Color color;

  public ChessPieceUnit(int id, String text, Color color) {
    this.id = id;
    this.text = text;
    this.color = color;
  }

  public static enum Color {
    RED, BLACK
  }

  // ...省略其他屬性和getter方法...
}

public class ChessPieceUnitFactory {
  private static final Map<Integer, ChessPieceUnit> pieces = new HashMap<>();

  static {
    pieces.put(1, new ChessPieceUnit(1, "車", ChessPieceUnit.Color.BLACK));
    pieces.put(2, new ChessPieceUnit(2,"馬", ChessPieceUnit.Color.BLACK));
    //...省略擺放其他棋子的程式碼...
  }

  public static ChessPieceUnit getChessPiece(int chessPieceId) {
    return pieces.get(chessPieceId);
  }
}

public class ChessPiece {
  private ChessPieceUnit chessPieceUnit;
  private int positionX;
  private int positionY;

  public ChessPiece(ChessPieceUnit unit, int positionX, int positionY) {
    this.chessPieceUnit = unit;
    this.positionX = positionX;
    this.positionY = positionY;
  }
  // 省略getter、setter方法
}

public class ChessBoard {
  private Map<Integer, ChessPiece> chessPieces = new HashMap<>();

  public ChessBoard() {
    init();
  }

  private void init() {
    chessPieces.put(1, new ChessPiece(
            ChessPieceUnitFactory.getChessPiece(1), 0,0));
    chessPieces.put(1, new ChessPiece(
            ChessPieceUnitFactory.getChessPiece(2), 1,0));
    //...省略擺放其他棋子的程式碼...
  }

  public void move(int chessPieceId, int toPositionX, int toPositionY) {
    //...省略...
  }
}

在上面的程式碼實現中,我們利用工廠類來快取 ChessPieceUnit 資訊(也就是 id、text、color)。通過工廠類獲取到的 ChessPieceUnit 就是享元。所有的 ChessBoard 物件共享這 30 個 ChessPieceUnit 物件(因為象棋中只有 30 個棋子)。在使用享元模式之前,記錄 1 萬個棋局,我們要建立 30 萬(30*1 萬)個棋子的 ChessPieceUnit 物件。利用享元模式,我們只需要建立 30 個享元物件供所有棋局共享使用即可,大大節省了記憶體。

那享元模式的原理講完了,我們來總結一下它的程式碼結構。實際上,它的程式碼實現非常簡單,主要是通過工廠模式,在工廠類中,通過一個 Map 來快取已經建立過的享元物件,來達到複用的目的。

享元模式在文字編輯器中的應用

弄懂了享元模式的原理和實現之後,我們再來看另外一個例子,也就是文章標題中給出的:如何利用享元模式來優化文字編輯器的記憶體佔用?

儘管在實際的文件編寫中,我們一般都是按照文字型別(標題、正文……)來設定文字的格式,標題是一種格式,正文是另一種格式等等。但是,從理論上講,我們可以給文字檔案中的每個文字都設定不同的格式。為了實現如此靈活的格式設定,並且程式碼實現又不過於太複雜,我們把每個文字都當作一個獨立的物件來看待,並且在其中包含它的格式資訊。具體的程式碼示例如下所示:


public class Character {//文字
  private char c;

  private Font font;
  private int size;
  private int colorRGB;

  public Character(char c, Font font, int size, int colorRGB) {
    this.c = c;
    this.font = font;
    this.size = size;
    this.colorRGB = colorRGB;
  }
}

public class Editor {
  private List<Character> chars = new ArrayList<>();

  public void appendCharacter(char c, Font font, int size, int colorRGB) {
    Character character = new Character(c, font, size, colorRGB);
    chars.add(character);
  }
}

在文字編輯器中,我們每敲一個文字,都會呼叫 Editor 類中的 appendCharacter() 方法,建立一個新的 Character 物件,儲存到 chars 陣列中。如果一個文字檔案中,有上萬、十幾萬、幾十萬的文字,那我們就要在記憶體中儲存這麼多 Character 物件。那有沒有辦法可以節省一點記憶體呢?

實際上,在一個文字檔案中,用到的字型格式不會太多,畢竟不大可能有人把每個文字都設定成不同的格式。所以,對於字型格式,我們可以將它設計成享元,讓不同的文字共享使用。按照這個設計思路,我們對上面的程式碼進行重構。重構後的程式碼如下所示:

享元模式 vs 單例、快取、物件池

在上面的講解中,我們多次提到“共享”“快取”“複用”這些字眼,那它跟單例、快取、物件池這些概念有什麼區別呢?我們來簡單對比一下。

我們先來看享元模式跟單例的區別。

在單例模式中,一個類只能建立一個物件,而在享元模式中,一個類可以建立多個物件,每個物件被多處程式碼引用共享。實際上,享元模式有點類似於之前講到的單例的變體:多例

我們前面也多次提到,區別兩種設計模式,不能光看程式碼實現,而是要看設計意圖,也就是要解決的問題。儘管從程式碼實現上來看,享元模式和多例有很多相似之處,但從設計意圖上來看,它們是完全不同的。應用享元模式是為了物件複用,節省記憶體,而應用多例模式是為了限制物件的個數。

我們再來看享元模式跟快取的區別。

在享元模式的實現中,我們通過工廠類來“快取”已經建立好的物件。這裡的“快取”實際上是“儲存”的意思,跟我們平時所說的“資料庫快取”“CPU 快取”“MemCache 快取”是兩回事。我們平時所講的快取,主要是為了提高訪問效率,而非複用。

最後我們來看享元模式跟物件池的區別

物件池、連線池(比如資料庫連線池)、執行緒池等也是為了複用,那它們跟享元模式有什麼區別呢?

你可能對連線池、執行緒池比較熟悉,對物件池比較陌生,所以,這裡我簡單解釋一下物件池。像 C++ 這樣的程式語言,記憶體的管理是由程式設計師負責的。

為了避免頻繁地進行物件建立和釋放導致記憶體碎片,我們可以預先申請一片連續的記憶體空間,也就是這裡說的物件池。每次建立物件時,我們從物件池中直接取出一個空閒物件來使用,物件使用完成之後,再放回到物件池中以供後續複用,而非直接釋放掉

雖然物件池、連線池、執行緒池、享元模式都是為了複用,但是,如果我們再細緻地摳一摳“複用”這個字眼的話,物件池、連線池、執行緒池等池化技術中的“複用”和享元模式中的“複用”實際上是不同的概念。

池化技術中的“複用”可以理解為“重複使用”,主要目的是節省時間(比如從資料庫池中取一個連線,不需要重新建立)。在任意時刻,每一個物件、連線、執行緒,並不會被多處使用,而是被一個使用者獨佔,當使用完成之後,2021-06-21 14:58:58 星期一放回到池中,再由其他使用者重複利用。享元模式中的“複用”可以理解為“共享使用”,在整個生命週期中,都是被所有使用者共享的,主要目的是節省空間。共享使用,在整個生命週期中,都是被所有使用者共享的,主要目的是節省空間。