设计模式预计阅读 18 分钟6956 字2866 次浏览

设计模式修炼之路 ⑫ 享元模式——共享细粒度对象

共享细粒度对象、内部状态与外部状态的切分、Integer 缓存与字符串常量池

目录

上一篇外观模式给它留了句实在话:前面几位省的都是"代码的复杂度",它省的是"货真价实的内存"。今天正主登场:享元模式(Flyweight Pattern),GoF 23 人名单正牌选手、结构型阵营只剩它和代理压轴。Flyweight 是个自造词——fly(飞)+ weight(重量),合起来"轻如飞絮":同样的对象要造一万份?留一份共享,其余全指过来。它回答的问题朴素到心酸:对象太多,内存不够用了,怎么办?

一、什么是享元模式?

先看定义:

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

翻译成人话:当系统里出现海量小对象、而它们大部分内容彼此相同时,把"相同的部分"抽出来做成有限几个共享对象,"不同的部分"在调用时再传进去。一万个对象瘦身成 N 个对象加一万份参数,内存从"万级"降到"常数级"。

生活类比是共享单车停车场——不对,单车会被骑走,更准确的类比是景区地图上的印章:盖一百个"到此一游",不存在一百枚印章,只有一枚,盖一百次;每次位置不同(你手按哪),印章本身不变。印章是共享的享元,位置是随用随传的参数。

它有四个角色:

  • Flyweight(享元接口):本篇里 GoPiece 的公开方法 place(x, y);
  • ConcreteFlyweight(具体享元):真正被共享的对象,只装内部状态,本篇里是黑棋、白棋两个实例;
  • FlyweightFactory(享元工厂):按内部状态缓存享元,保证"同一状态全 JVM 一份",本篇里是 PieceFactory;
  • UnsharedConcreteFlyweight(非共享享元):不参与共享的享元,本篇用不上,知道有这一角色即可。

而全模式的灵魂,是它对状态的这刀切分:

  • 内部状态(Intrinsic):不随环境变化、可以共享的部分——围棋里棋子的颜色和纹理。黑棋的纹理和另一颗黑棋的纹理一模一样,没理由存两份;
  • 外部状态(Extrinsic):随环境变化、无法共享的部分——围棋里棋子的坐标。(3,3) 的黑棋和 (15,15) 的黑棋,差异全在坐标上。

切完之后享元就成型了:内部状态装进对象、全 JVM 一份;外部状态从对象里赶出去、每次调用当参数传入。上一篇外观是"把流程从客户端收进门面",这一篇是"把变化从对象里赶出去"——两位干的是同一门手艺:把"多"变成"一"。

二、享元模式的优缺点

优点

  1. 内存立竿见影:对象数量从"实例数"降到"内部状态组合数"。一盘围棋 300 颗子,棋子对象只需要 2 个——省的不是 30%,是 99%;
  2. 创建与 GC 压力同步下降:对象不重复造了,年轻代的分配速率和垃圾回收频率跟着降——高并发场景里这往往比省内存本身还值钱;
  3. 结构稳定:享元不可变,天生线程安全,并发共享零加锁。

缺点

  1. 设计成本前置:哪些字段能共享、哪些必须外置,得先想清楚——切错一刀(把可变字段留在享元里),多个"逻辑调用方"就会通过同一对象互相踩脚;
  2. API 变啰嗦:外部状态从字段变成参数,原来 piece.place() 一个字都不用传,现在 piece.place(x, y)——每个调用点都得多背一份"本该对象自己记的事";
  3. 生命周期常驻:工厂的缓存通常静态常驻,内部状态种类一旦膨胀(比如键设计不当导致缓存无限增长),享元池自己反而变成内存泄漏点。

三、享元模式的应用场景

  • 包装类缓存(JDK):Integer、Long、Short、Byte、Character、Boolean 的 valueOf 全走缓存——Integer 缓存 -128 到 127,Boolean 就 TRUE/FALSE 两个。这是 JDK 官方实装的最大规模享元池,第五节专门解剖。
  • 字符串常量池(JVM):"hello" 字面量全 JVM 存一份,两个类里写同一个字符串,指向的是同一块内存——你从第一天写 Java 起就在用享元。
  • 枚举:JVM 保证每个枚举常量全 JVM 只实例化一次,天然享元——所以枚举天生适合当"种类"用(单例模式那篇聊过它还是最硬的单例写法)。
  • POI 的 CellStyle:用 Apache POI 写过 Excel 的都撞过那堵墙——每个单元格 new 一个 CellStyle,写到几万行报"样式表超限",官方文档明晃晃写着"请复用样式对象"。CellStyle 就是被 Excel 格式限定了数量的享元。
  • 棋牌类游戏:一副斗地主 54 张牌,服务端管一万桌,"黑桃 A"这个对象需要一万份吗?一份就够,哪桌出了它、压在谁的出牌序列里,全是外部状态。
  • 地图 POI / GUI 图标:十万个餐馆图标,样式按品类就几十种——图标是享元,经纬度是外部状态。

两个高频误认,先纠个偏:

  1. 数据库连接池、线程池不是享元,是对象池(Object Pool)。分水岭在"共享方式":享元是同一个对象被并发共享、且不可变(黑白棋被全盘同时引用);对象池是同一个对象被轮流独占、且可变(连接借走用完还回来,池里同一时刻只有一个线程在用它)。一个像公共雕塑,一个像共享充电宝。
  2. 单例也不是享元。单例是"整个类只要一个实例",享元是"每种内部状态一个实例"——享元池里通常躺着好几个对象(黑白两颗棋),池子本身就是个"按状态分组的批量单例"。

什么时候该用:对象数量大(万级起步)、且大部分内部状态重复、且重复部分是大头(内存大头在纹理不在坐标)——三条全中再上享元;只有一万个十几字节的 POJO?GC 表示毫无压力,别折腾。

四、享元模式的实现方式

示例代码在 design-pattern 项目的 flyweight 包里:

design-pattern/src/main/java
└── flyweight               // 享元模式
    ├── BadPiece.java       // 反面教材:每颗棋子自带颜色+纹理+坐标
    ├── GoPiece.java        // 享元:只装内部状态(颜色、纹理),坐标外部传入
    ├── PieceFactory.java   // 享元工厂:按颜色缓存,全 JVM 一份
    └── FlyweightTest.java  // 测试入口

场景:围棋。棋子有颜色(黑/白)、每颗棋子渲染要一份 1MB 的纹理、还要落在某个坐标上。先看"一颗棋子一个对象"的下场,再看享元怎么救。

1. 反面教材:每颗棋子一份纹理

最直觉的建模:落一子 new 一颗棋子,颜色、纹理、坐标全塞对象里:

// 反面教材:每颗棋子一个对象,颜色、纹理、坐标全塞在对象里
public class BadPiece {
    private final String color;
    // 模拟渲染纹理:真正的内存大户(实际项目里可能是图片、字形、网格模型)
    private final int[] texture = new int[256 * 1024]; // 1MB
    private final int x;
    private final int y;

    public BadPiece(String color, int x, int y) {
        this.color = color;
        this.x = x;
        this.y = y;
    }

    public void place() {
        System.out.println("[BadPiece] " + color + "棋落在 (" + x + ", " + y + "),自带一份 1MB 纹理");
    }
}

模拟下十手棋:

List<BadPiece> board = new ArrayList<>();
for (int i = 0; i < 10; i++) {
    BadPiece piece = new BadPiece(i % 2 == 0 ? "黑" : "白", i, i * 2);
    board.add(piece);
    piece.place();
}
System.out.println("10 手棋 = " + board.size() + " 个对象 = 10 份 1MB 纹理");
System.out.println("一盘 300 手的棋就是 300 个对象、300MB 纹理——而所有黑棋的纹理,本来就一模一样");

运行结果:

===== 反面教材:每颗棋子自带一份纹理 =====
[BadPiece] 黑棋落在 (0, 0),自带一份 1MB 纹理
[BadPiece] 白棋落在 (1, 2),自带一份 1MB 纹理
[BadPiece] 黑棋落在 (2, 4),自带一份 1MB 纹理
[BadPiece] 白棋落在 (3, 6),自带一份 1MB 纹理
[BadPiece] 黑棋落在 (4, 8),自带一份 1MB 纹理
[BadPiece] 白棋落在 (5, 10),自带一份 1MB 纹理
[BadPiece] 黑棋落在 (6, 12),自带一份 1MB 纹理
[BadPiece] 白棋落在 (7, 14),自带一份 1MB 纹理
[BadPiece] 黑棋落在 (8, 16),自带一份 1MB 纹理
[BadPiece] 白棋落在 (9, 18),自带一份 1MB 纹理
10 手棋 = 10 个对象 = 10 份 1MB 纹理
一盘 300 手的棋就是 300 个对象、300MB 纹理——而所有黑棋的纹理,本来就一模一样

能跑,但账没法看:

  1. 重复度触目惊心:十个对象里其实只有两种内容——黑棋、白棋。颜色加纹理只有 2 种组合,却造了 10 个对象、10 份纹理;一盘棋 300 颗子,300MB 纹理里 298MB 是纯重复;
  2. 真正的差异被淹没了:十颗棋子彼此唯一不同的是坐标——几个字节的信息,却拖着 1MB 的车身;
  3. 对象数随对局线性膨胀:每落一子堆一个对象,一场多桌并发的棋牌室,内存曲线直接跟着在线人数起飞。

病根只有一个:"不变的 1MB"和"多变的几个字节"被塞进了同一个对象,重复的部分跟着实例数一起翻倍。

2. 享元版:一份纹理,全盘共享

先动刀切状态——纹理和颜色留下(内部状态),坐标赶出去(外部状态):

// 享元:颜色和纹理是内部状态(不变、全盘共享),坐标是外部状态(用时传入)
public class GoPiece {
    private final String color;
    // 纹理只需两份:黑一份、白一份,全盘所有同色棋子共享这同一份
    private final int[] texture = new int[256 * 1024]; // 1MB

    public GoPiece(String color) {
        this.color = color;
    }

    // 坐标 x、y 是外部状态:每次落子由调用方传入,享元对象自己不保存
    public void place(int x, int y) {
        System.out.println("[GoPiece] " + color + "棋落在 (" + x + ", " + y + "),纹理共享全盘同一份");
    }
}

注意两个细节:字段全是 final——享元被全盘共享,必须不可变,哪个调用方手贱改了纹理,全盘棋子跟着变脸;place 的参数 x、y——每颗棋子落哪由调用方告诉它,对象自己"失忆"了。

再配工厂,保证"同一颜色全 JVM 一份":

// 享元工厂:按内部状态(颜色)缓存享元,保证每种颜色全 JVM 只有一份
public class PieceFactory {
    private static final Map<String, GoPiece> CACHE = new HashMap<>();

    public static GoPiece get(String color) {
        GoPiece piece = CACHE.get(color);
        if (piece == null) {
            piece = new GoPiece(color);
            CACHE.put(color, piece);
            System.out.println("[工厂] 第一次要" + color + "棋,创建并缓存(此后全盘复用)");
        }
        return piece;
    }
}

同样的四手棋:

// 黑白各只有一份,坐标作为外部状态在调用时传入
PieceFactory.get("黑").place(3, 3);
PieceFactory.get("白").place(15, 15);
PieceFactory.get("黑").place(16, 3);
PieceFactory.get("白").place(2, 2);

GoPiece black1 = PieceFactory.get("黑");
GoPiece black2 = PieceFactory.get("黑");
System.out.println("两次取到的黑棋是同一个对象吗?" + (black1 == black2));
System.out.println("全盘 300 手也一样:对象数永远 2 个,纹理永远 2MB");

运行结果:

===== 正解:享元模式 =====
[工厂] 第一次要黑棋,创建并缓存(此后全盘复用)
[GoPiece] 黑棋落在 (3, 3),纹理共享全盘同一份
[工厂] 第一次要白棋,创建并缓存(此后全盘复用)
[GoPiece] 白棋落在 (15, 15),纹理共享全盘同一份
[GoPiece] 黑棋落在 (16, 3),纹理共享全盘同一份
[GoPiece] 白棋落在 (2, 2),纹理共享全盘同一份
两次取到的黑棋是同一个对象吗?true
全盘 300 手也一样:对象数永远 2 个,纹理永远 2MB

对照反面教材逐条验收:

  • 对象数与落子数彻底脱钩:反面教材 300 手 300 个对象,正解 300 手还是 2 个——内存从 O(手数) 降到 O(颜色数),这正是定义里"支持大量细粒度对象"的实现方式:大量对象被"一份享元 + 每次调用的外部状态"撑起来了;
  • black1 == black2 为 true——工厂真的只发同一份对象,连"=="都验明正身;
  • 谁该记住坐标?棋盘。真实实现里落子记录是棋盘上的一个 List<Placement>(每条只存颜色引用 + 坐标),棋盘管外部状态、享元管内部状态,职责各归其位;
  • 纹理这种重资源,从"随对象生"变成"随类别生"——加载一次,全盘受益,连加载耗时都省了。

五、Integer 缓存与字符串常量池:JDK 里的享元

理论讲完,去看这套机制在 JDK 里的样子——先看一段让无数人怀疑人生的代码:

Integer a = 127;
Integer b = 127;
Integer c = 128;
Integer d = 128;
System.out.println(a == b);   // true
System.out.println(c == d);   // false

同一个写法,127 相等、128 不等,玄机全在 Integer.valueOf 的源码里:

public static Integer valueOf(int i) {
    if (i >= IntegerCache.low && i <= IntegerCache.high)
        return IntegerCache.cache[i + (-IntegerCache.low)];  // 命中缓存,返回共享对象
    return new Integer(i);                                    // 超范围,每次真 new
}

IntegerCache 是个静态内部类,类加载时就把 -128 到 127 这 256 个 Integer 提前 new 好、放进数组——一个提前造好的享元池,PieceFactory 需要时才创建,IntegerCache 是"先造好等你来取",思路一脉相承。Integer a = 127 的自动装箱编译成 Integer.valueOf(127),命中缓存,a 和 b 拿到的是同一个对象,所以 == 为 true;128 超出范围,c 和 d 各自 new,== 自然 false。

这就是各大编码规范反复强调"包装类比较必须用 equals"的技术根源——享元缓存的存在,让 == 的结果跟数值范围挂钩,这种语义陷阱只能靠规范绕开。顺带两个冷知识:缓存上限可以用 -XX:AutoBoxCacheMax=<size> 调大(下限 -128 动不了);Long、Short、Byte、Character 也有各自的缓存,只是范围写死、不能调。

字符串那边是另一副景象。String s = "hello" 的字面量进字符串常量池,两个类里写同一个字面量,指向池里同一份:

String s1 = "享元";
String s2 = "享元";
System.out.println(s1 == s2);          // true:常量池里就一份
System.out.println(s1 == new String("享元")); // false:new 出来的不走池
System.out.println(s1 == new String("享元").intern()); // true:intern() 把它送回池里对齐

和 IntegerCache 比,常量池是"运行时也能进进出出的享元池"——intern() 就是手动登记处:池里有就返回池里那份,没有就登记当前这份。字符串不可变(String 全字段 final),恰好满足享元"共享必须不可变"的铁律——JDK 这两套享元,一套提前造、一套随时进,把"对象复用"做进了语言的地基。

写在最后

回顾这一篇:享元模式把对象劈成两半——内部状态(不变、共享)留在对象里,外部状态(多变)赶出去做参数,配一个工厂保证"同一状态全 JVM 一份",海量对象瘦身成有限几个;反面教材"一颗棋子一份纹理"300MB 的账单,享元两行字抹平;包装类缓存、字符串常量池、枚举、POI 的 CellStyle、棋牌服务端的牌对象,全是它的地盘;享元 vs 对象池的分水岭是"并发共享的不可变对象"还是"轮流独占的可变对象"——连接池线程池都不算享元。

下一篇 ⑬ 代理模式,结构型最后一位压轴出场。它的 UML 和装饰器几乎一个模子:同样实现同一个接口、同样手里攥着一个同类对象——区别全在意图上:装饰器回答"怎么给它加功能",代理回答"怎么替它管住访问"。懒加载、权限校验、远程调用,还有 JDK 动态代理、CGLIB、Spring AOP 这整座大厦的地基,全是这一篇的内容。

花

花开空白

开发者 · 西安

相关文章

评论(0)

还没有评论,来抢沙发吧

发表评论