设计模式实战之享元模式(Flyweight Pattern)

简介: 本文详细介绍了享元模式(Flyweight Pattern)的原理、实现及应用场景。享元模式通过复用不可变对象节省内存,适用于系统中存在大量相似对象的场景,如象棋游戏中的棋子或文本编辑器中的字符格式。文章以具体案例(象棋、文本编辑器、Shape等)解析了如何通过工厂模式和Map缓存实现享元,并分析了Java中Integer与String类对享元模式的应用。此外,还对比了享元模式与单例、缓存、对象池的区别,强调其核心目的是节省空间而非时间。最后提醒,享元模式可能对垃圾回收不友好,需谨慎评估是否真正能优化内存使用。

本文已收录在Github关注我,紧跟本系列专栏文章,咱们下篇再续!

  • 🚀 魔都架构师 | 全网30W技术追随者
  • 🔧 大厂分布式系统/数据中台实战专家
  • 🏆 主导交易系统百万级流量调优 & 车联网平台架构
  • 🧠 AIGC应用开发先行者 | 区块链落地实践者
  • 🌍 以技术驱动创新,我们的征途是改变世界!
  • 👉 实战干货:编程严选网

1 简介

结构型模式。“享元”,被共享的单元,即通过复用对象而节省内存,注意前提是享元对象是不可变对象。

用于减少创建对象的数量,以减少内存占用和提高性能。尝试复用现有同类对象,若未找到匹配对象,则创建新对象。

意图

运用共享技术有效地支持大量细粒度的对象。

主要解决

在有大量对象时,有可能会造成内存溢出,我们把其中共同的部分抽象出来,如果有相同的业务请求,直接返回在内存中已有的对象,避免重新创建。

2 优点

大大减少对象的创建,降低系统的内存,使效率提高。

3 缺点

提高系统复杂度,需分离出外部状态、内部状态,且外部状态具有固有化的性质,不应随内部状态变化而变化,否则会造成系统混乱。

4 适用场景

  • 系统中有大量对象
  • 这些对象消耗大量内存
  • 这些对象的状态大部分可外部化
  • 这些对象可以按照内蕴状态分为很多组,当把外蕴对象从对象中剔除出来时,每一组对象都可以用一个对象来代替
  • 系统不依赖于这些对象身份,这些对象是不可分辨的
  • 系统有大量相似对象
  • 需要缓冲池的场景

这些类必须有一个工厂对象加以控制。

如何解决

用唯一标识码判断,如果在内存中有,则返回这个唯一标识码所标识的对象。

关键代码

用 HashMap 存储这些对象。

应用实例

  • String,若有则返回,无则创建一个字符串保存在字符串缓存池
  • 数据库的数据池

5 原理

当一个系统中存在大量重复对象,若这些重复对象是【不可变】对象,就能用该模式将对象设计成享元,在内存中只保留一份实例供引用。减少了内存中对象数量,最终节省内存。

不仅是相同对象,相似对象也能提取对象中的相同部分(字段)设计成享元。

“不可变对象”

一旦构造器初始化完成后,其状态(对象的成员变量或属性)就不会再被修改。所以,不可变对象不能暴露任何set()等修改内部状态的方法。之所以要求享元是不可变对象,是因为它会被多处代码共享使用,避免一处代码修改享元,影响到其他使用它的代码。

实现

主要通过工厂模式,在工厂类中,通过一个Map或List缓存已创建好的享元对象,以复用。

6 案例

6.1 象棋

一个游戏厅中有成千上万个“房间”,每个房间对应一个棋局。棋局要保存每个棋子的数据,比如:棋子类型(将、相、士、炮等)、棋子颜色(红方、黑方)、棋子在棋局中的位置。利用这些数据,我们就能显示一个完整的棋盘给玩家。具体的代码如下所示:

  • ChessPiece类表示棋子
  • ChessBoard类表示一个棋局,里面保存了象棋中30个棋子的信息
/**
 * 棋子
 *
 * @author JavaEdge
 * @date 2022/5/28
 */
@AllArgsConstructor
@Getter
@Setter
public class ChessPiece {
    private int id;
    private String text;
    private Color color;
    private int positionX;
    private int positionY;
    public static enum Color {
        RED, BLACK
    }
}
/**
 * 棋局
 */
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) {
        //...省略...
    }
}

为记录每个房间当前的棋局情况,要给每个房间都创建一个ChessBoard棋局对象。因为游戏大厅中有成千上万房间,保存这么多棋局对象就会消耗大量内存。咋节省内存?

就得用上享元模式。在内存中有大量相似对象。这些相似对象的id、text、color都一样,仅positionX、positionY不同。将棋子的id、text、color属性拆出设计成独立类,并作为享元供多个棋盘复用。棋盘只需记录每个棋子的位置信息:

/**
 * 享元类
 */
public class ChessPieceUnit {
    private int id;
    private String text;
    private Color color;
    public static enum Color {
        RED, BLACK
    }
}
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);
    }
}
@AllArgsConstructor
@Data
public class NewChessPiece {
    private ChessPieceUnit chessPieceUnit;
    private int positionX;
    private int positionY;
}
/**
 * 棋局
 */
public class NewChessBoard {
    private Map<Integer, NewChessPiece> chessPieces = new HashMap<>();
    public NewChessBoard() {
        init();
    }
    private void init() {
        chessPieces.put(1, new NewChessPiece(
                ChessPieceUnitFactory.getChessPiece(1), 0, 0));
        chessPieces.put(1, new NewChessPiece(
                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缓存已创建过的享元对象,达到复用。

6.2 文本编辑器

若文本编辑器只实现文字编辑功能,不包含图片、表格编辑。简化后的文本编辑器,要在内存表示一个文本文件,只需记录文字、格式两部分信息。格式又包括字体、大小、颜色。

一般按文本类型(标题、正文……)设置文字格式,标题是一种格式,正文是另一种。但理论上可给文本文件中的每个文字都设置不同格式。为实现如此灵活格式设置,且代码实现又不复杂,把每个文字都当作一个独立对象,并在其中包含它的格式信息:

/**
 * 文字
 */
@AllArgsConstructor
@Data
public class Character {
    private char c;
    private Font font;
    private int size;
    private int 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对象,咋节省内存?

一个文本文件用到的字体格式不多,毕竟不可能有人把每个文字都置不同格式。所以,字体格式可设计成享元,让不同文字共享:

public class CharacterStyle {
  
  private Font font;
  
  private int size;
  
  private int colorRGB;
  @Override
  public boolean equals(Object o) {
    CharacterStyle otherStyle = (CharacterStyle) o;
    return font.equals(otherStyle.font)
            && size == otherStyle.size
            && colorRGB == otherStyle.colorRGB;
  }
}
public class CharacterStyleFactory {
  private static final List<CharacterStyle> styles = new ArrayList<>();
  public static CharacterStyle getStyle(Font font, int size, int colorRGB) {
    CharacterStyle newStyle = new CharacterStyle(font, size, colorRGB);
    for (CharacterStyle style : styles) {
      if (style.equals(newStyle)) {
        return style;
      }
    }
    styles.add(newStyle);
    return newStyle;
  }
}
public class Character {
  
  private char c;
  
  private CharacterStyle style;
}
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, CharacterStyleFactory.getStyle(font, size, colorRGB));
    chars.add(character);
  }
}

6.3 Shape

无论何时接收到请求,都会创建一个特定颜色的圆。

它将向 ShapeFactory 传递信息(red / green / blue/ black / white),以便获取它所需对象的颜色。

步骤 1:创建一个接口。

public interface Shape {
   void draw();
}

步骤 2:创建实现接口的实体类。

public class Circle implements Shape {
    private String color;
    private int x;
    private int y;
    private int radius;
    public Circle(String color) {
        this.color = color;
    }
    @Override
    public void draw() {
        System.out.println("Circle: Draw() [Color : " + color
                + ", x : " + x + ", y :" + y + ", radius :" + radius);
    }
}

步骤 3:创建一个工厂,生成基于给定信息的实体类的对象。

public class ShapeFactory {
    private static final HashMap<String, Shape> circleMap = new HashMap<>();
    public static Shape getCircle(String color) {
        Circle circle = (Circle) circleMap.get(color);
        if (circle == null) {
            circle = new Circle(color);
            circleMap.put(color, circle);
            System.out.println("Creating circle of color : " + color);
        }
        return circle;
    }
}

步骤 4:使用该工厂,通过传递颜色信息来获取实体类的对象。

public class FlyweightPatternDemo {
    private static final String colors[] =
            {"Red", "Green", "Blue", "White", "Black"};
    public static void main(String[] args) {
        for (int i = 0; i < 20; ++i) {
            Circle circle =
                    (Circle) ShapeFactory.getCircle(getRandomColor());
            circle.setX(getRandomX());
            circle.setY(getRandomY());
            circle.setRadius(100);
            circle.draw();
        }
    }
    private static String getRandomColor() {
        return colors[(int) (Math.random() * colors.length)];
    }
    private static int getRandomX() {
        return (int) (Math.random() * 100);
    }
    private static int getRandomY() {
        return (int) (Math.random() * 100);
    }
}

步骤 5:执行程序,输出结果。

6.4 Integer

Integer i1 = 56;
Integer i2 = 56;
Integer i3 = 129;
Integer i4 = 129;
System.out.println(i1 == i2);
System.out.println(i3 == i4);

Java为基本数据类型提供了对应包装器:

基本数据类型 对应的包装器类型
int Integer
long Long
float Float
double Double
boolean Boolean
short Short
byte Byte
char Character
Integer i = 56; //自动装箱
int j = i; //自动拆箱

数值56是基本数据类型int,当赋值给包装器类型(Integer)变量的时候,触发自动装箱操作,创建一个Integer类型的对象,并且赋值给变量i。底层相当于执行:

// 底层执行了:Integer i = Integer.valueOf(59);
Integer i = 59;

反过来,当把包装器类型的变量i,赋值给基本数据类型变量j的时候,触发自动拆箱操作,将i中的数据取出,赋值给j。其底层相当于执行了下面这条语句:

// 底层执行了:int j = i.intValue();
int j = i;

Java对象在内存的存储

User a = new User(123, 23); // id=123, age=23

内存存储结构图:a存储的值是User对象的内存地址,即a指向User对象

通过“==”判定相等时,实际上是在判断两个局部变量存储的地址是否相同,即判断两个局部变量是否指向相同对象。

Integer i1 = 56;
Integer i2 = 56;
Integer i3 = 129;
Integer i4 = 129;
System.out.println(i1 == i2);
System.out.println(i3 == i4);

前4行赋值语句都会触发自动装箱操作,即创建Integer对象并赋值给i1、i2、i3、i4变量。i1、i2尽管存储数值相同56,但指向不同Integer对象,所以通过==来判定是否相同的时候,会返回false。同理,i3==i4判定语句也会返回false。

不过,上面的分析还是不对,答案并非是两个false,而是一个true,一个false。因为Integer用了享元模式复用对象,才导致这样的运行差异。通过自动装箱,即调用valueOf()创建Integer对象时,如果要创建的Integer对象的值在-128到127之间,会从IntegerCache类中直接返回,否则才调用new方法创建:

public static Integer valueOf(int i) {
    if (i >= IntegerCache.low && i <= IntegerCache.high)
        return IntegerCache.cache[i + (-IntegerCache.low)];
    return new Integer(i);
}

实际上,这里的IntegerCache相当于,我们上一节课中讲的生成享元对象的工厂类,只不过名字不叫xxxFactory而已。我们来看它的具体代码实现。这个类是Integer的内部类,你也可以自行查看JDK源码。

/**
 * Cache to support the object identity semantics of autoboxing for values between
 * -128 and 127 (inclusive) as required by JLS.
 *
 * The cache is initialized on first usage.  The size of the cache
 * may be controlled by the {@code -XX:AutoBoxCacheMax=<size>} option.
 * During VM initialization, java.lang.Integer.IntegerCache.high property
 * may be set and saved in the private system properties in the
 * sun.misc.VM class.
 */
private static class IntegerCache {
    static final int low = -128;
    static final int high;
    static final Integer cache[];
    static {
        // high value may be configured by property
        int h = 127;
        String integerCacheHighPropValue =
            sun.misc.VM.getSavedProperty("java.lang.Integer.IntegerCache.high");
        if (integerCacheHighPropValue != null) {
            try {
                int i = parseInt(integerCacheHighPropValue);
                i = Math.max(i, 127);
                // Maximum array size is Integer.MAX_VALUE
                h = Math.min(i, Integer.MAX_VALUE - (-low) -1);
            } catch( NumberFormatException nfe) {
                // If the property cannot be parsed into an int, ignore it.
            }
        }
        high = h;
        cache = new Integer[(high - low) + 1];
        int j = low;
        for(int k = 0; k < cache.length; k++)
            cache[k] = new Integer(j++);
        // range [-128, 127] must be interned (JLS7 5.1.7)
        assert IntegerCache.high >= 127;
    }
    private IntegerCache() {}
}

Q:为啥IntegerCache只缓存-128到127之间整型值?

IntegerCache类被加载时,缓存的享元对象会被集中一次性创建好。整型值太多,不可能IntegerCache类预先创建好所有,既占太多内存,也使加载IntegerCache类时间过长。只能选择缓存对大部分应用来说最常用整型值,即一个字节大小(-128到127之间数据)。

JDK也提供方法可自定义缓存最大值,两种方式:

如果你通过分析应用的JVM内存占用情况,发现-128到255之间的数据占用的内存比较多,可将缓存最大值从127调到255,但JDK没有提供设置最小值方法。

# 方法一
-Djava.lang.Integer.IntegerCache.high=255
# 方法二
-XX:AutoBoxCacheMax=255

因为56处于-128和127之间,i1和i2会指向相同的享元对象,所以i1==i2返回true。而129大于127,并不会被缓存,每次都会创建一个全新的对象,也就是说,i3和i4指向不同的Integer对象,所以i3==i4返回false。

实际上,除了Integer类型之外,其他包装器类型,比如Long、Short、Byte等,也都利用了享元模式来缓存-128到127之间的数据。比如,Long类型对应的LongCache享元工厂类及valueOf()函数代码如下所示:

private static class LongCache {
    private LongCache(){}
    static final Long cache[] = new Long[-(-128) + 127 + 1];
    static {
        for(int i = 0; i < cache.length; i++)
            cache[i] = new Long(i - 128);
    }
}
public static Long valueOf(long l) {
    final int offset = 128;
    if (l >= -128 && l <= 127) { // will cache
        return LongCache.cache[(int)l + offset];
    }
    return new Long(l);
}

平时开发对下面这样三种创建整型对象的方式,优先用后两种:

// 第一种创建方式不会用到IntegerCache
Integer a = new Integer(123);
// 后两种创建方法可用IntegerCache缓存,返回共享对象
Integer a = 123;
Integer a = Integer.valueOf(123);

极端案例:

若程序需创建1万个 -128~127 之间的Integer对象:

  • 使用第一种创建方式,需分配1万个Integer对象的内存空间
  • 使用后两种创建方式,最多只需分配256个Integer对象的内存空间

6.5 String

String s1 = "JavaEdge";
String s2 = "JavaEdge";
String s3 = new String("JavaEdge");
// true
System.out.println(s1 == s2);
// false
System.out.println(s1 == s3);

跟Integer设计相似,String利用享元模式复用相同字符串常量(即“JavaEdge”)。JVM会专门开辟一块存储区来存储字符串常量,即“字符串常量池”,对应内存存储结构示意图:

不同点:

  • Integer类要共享对象,是在类加载时,一次性全部创建好
  • 字符串,没法预知要共享哪些字符串常量,所以无法事先创建,只能在某字符串常量第一次被用到时,存储到常量池,再用到时,直接引用常量池中已存在的

7 竞品

7.1 V.S 单例

  • 单例模式,一个类只能创建一个对象
  • 享元模式,一个类可创建多个对象,每个对象被多处代码引用共享。类似单例的变体:多例。

还是要看设计意图,即要解决啥问题:

  • 享元模式是为对象复用,节省内存
  • 单例模式是为限制对象个数

7.2 V.S 缓存

享元模式得实现,通过工厂类“缓存”已创建好的对象。“缓存”实际上是“存储”,跟平时说的“数据库缓存”、“CPU缓存”、“MemCache缓存”是两回事。平时所讲的缓存,主要为提高访问效率,而非复用。

7.3  V.S 对象池

C++内存管理由程序员负责。为避免频繁地进行对象创建和释放导致内存碎片,可以预先申请一片连续的内存空间,即对象池。每次创建对象时,我们从对象池中直接取出一个空闲对象来使用,对象使用完成之后,再放回到对象池中以供后续复用,而非直接释放掉。

虽然对象池、连接池、线程池、享元模式都是为复用,但对象池、连接池、线程池等池化技术中的“复用”和享元模式中的“复用”是不同概念:

  • 池化技术中的“复用”可以理解为“重复使用”,主要目的是节省时间(比如从数据库池中取一个连接,不需要重新创建)。在任意时刻,每一个对象、连接、线程,并不会被多处使用,而是被一个使用者独占,当使用完成之后,放回到池中,再由其他使用者重复利用
  • 享元模式中的“复用”可以理解为“共享使用”,在整个生命周期中,都是被所有使用者共享的,主要目的是节省空间

X 总结

  • 单例模式是为保证对象全局唯一
  • 享元模式是为实现对象复用,节省内存。缓存是为提高访问效率,而非复用
  • 池化技术中的“复用”理解为“重复使用”,主要为节省时间

Integer的-128到127之间整型对象会被事先创建好,缓存在IntegerCache类。当使用自动装箱或valueOf()创建这个数值区间的整型对象时,会复用IntegerCache类事先创建好的对象。IntegerCache类就是享元工厂类,事先创建好的整型对象就是享元对象。

String类,JVM开辟一块存储区(字符串常量池)存储字符串常量,类似Integer的IntegerCache。但并非事先创建好需要共享的对象,而是在程序运行期间,根据需要创建和缓存字符串常量

享元模式对GC不友好。因为享元工厂类一直保存对享元对象的引用,导致享元对象在无任何代码使用时,也不会被GC。因此,某些情况下,若对象生命周期很短,也不会被密集使用,利用享元模式反倒浪费更多内存。务必验证享元模式真的能大大节省内存吗。

目录
相关文章
|
6月前
|
设计模式 消息中间件 监控
并发设计模式实战系列(5):生产者/消费者
🌟 ​大家好,我是摘星!​ 🌟今天为大家带来的是并发设计模式实战系列,第五章,废话不多说直接开始~
223 1
|
6月前
|
设计模式 监控 Java
并发设计模式实战系列(8):Active Object
🌟 大家好,我是摘星!🌟今天为大家带来的是并发设计模式实战系列,第8章,废话不多说直接开始~
131 0
并发设计模式实战系列(8):Active Object
|
2月前
|
设计模式 人工智能 算法
基于多设计模式的状态扭转设计:策略模式与责任链模式的实战应用
接下来,我会结合实战案例,聊聊如何用「策略模式 + 责任链模式」构建灵活可扩展的状态引擎,让抽奖系统的状态管理从「混乱战场」变成「有序流水线」。
|
6月前
|
设计模式 缓存 安全
【高薪程序员必看】万字长文拆解Java并发编程!(8):设计模式-享元模式设计指南
🌟 ​大家好,我是摘星!​ 🌟今天为大家带来的是并发编程中的经典对象复用设计模式-享元模式,废话不多说让我们直接开始。
164 0
|
6月前
|
设计模式 负载均衡 监控
并发设计模式实战系列(2):领导者/追随者模式
🌟 ​大家好,我是摘星!​ 🌟今天为大家带来的是并发设计模式实战系列,第二章领导者/追随者(Leader/Followers)模式,废话不多说直接开始~
202 0
|
6月前
|
设计模式 监控 Java
并发设计模式实战系列(1):半同步/半异步模式
🌟 ​大家好,我是摘星!​ 🌟今天为大家带来的是并发设计模式实战系列,第一章半同步/半异步(Half-Sync/Half-Async)模式,废话不多说直接开始~
190 0
|
6月前
|
设计模式 运维 监控
并发设计模式实战系列(4):线程池
需要建立持续的性能剖析(Profiling)和调优机制。通过以上十二个维度的系统化扩展,构建了一个从。设置合理队列容量/拒绝策略。动态扩容/优化任务处理速度。检查线程栈定位热点代码。调整最大用户进程数限制。CPU占用率100%
442 0
|
6月前
|
设计模式 消息中间件 监控
并发设计模式实战系列(3):工作队列
🌟 ​大家好,我是摘星!​ 🌟今天为大家带来的是并发设计模式实战系列,第三章,废话不多说直接开始~
183 0
|
6月前
|
设计模式 存储 安全
并发设计模式实战系列(7):Thread Local Storage (TLS)
🌟 大家好,我是摘星! 🌟今天为大家带来的是并发设计模式实战系列,第七章Thread Local Storage (TLS),废话不多说直接开始~
226 0
|
6月前
|
消息中间件 设计模式 监控
并发设计模式实战系列(9):消息传递(Message Passing)
🌟 大家好,我是摘星!🌟今天为大家带来的是并发设计模式实战系列,第九章,废话不多说直接开始~
126 0

热门文章

最新文章