【软考设计模式】享元模式共享技术支撑海量细粒度对象与代码填空精讲系列定位软考软件设计师 / 系统架构设计师 — 结构型模式专题第 5 讲考察分值上午题 1-2 分下午题常作为代码填空或类图识别出现难度等级⭐⭐⭐⭐☆内部状态与外部状态的区分是核心也是高频失分点一、考纲定位与模式定义1.1 考纲要求享元模式在软考中属于结构型模式的中高难度内容。考察形式包括上午选择题判断场景描述所属模式识别内部状态Intrinsic State与外部状态Extrinsic State的区分判断享元模式与单例模式、原型模式的区别判断 FlyweightFactory 的缓存/池化逻辑下午设计题补全 FlyweightFactory 的getFlyweight()方法从池中获取或创建享元对象补全 ConcreteFlyweight 的operation()方法使用外部状态参数识别类图中享元工厂与具体享元的关系1.2 模式定义享元模式运用共享技术有效地支持大量细粒度的对象。核心意图当系统中存在大量相似对象如文档中的字符、棋盘上的棋子、地图上的图标这些对象的大部分状态是相同的只有少量状态不同。如果为每个对象都创建独立实例内存开销巨大。享元模式将对象的状态拆分为内部状态Intrinsic State可共享存储在享元对象内部和外部状态Extrinsic State不可共享由客户端传入让多个对象共享同一个享元实例从而大幅减少对象数量。通俗理解你在 Word 里打了一万字每个字都是一个对象。如果每个字都创建一个独立对象内存里就有一万个对象。但实际上字 只有几千个常用汉字内部状态字符本身而每个字在文档中的位置、字体大小、颜色外部状态是不同的。享元模式的思路是内存里只保存一份 中 字对象一万个 中 字都共享这同一个对象只是在使用时传入各自的位置和颜色外部状态。二、UML 类图与角色划分┌─────────────────────────────────────────────────────────┐ │ Client │ │ main() │ │ ├── Flyweight f factory.getFlyweight(A); │ │ └── f.operation(extrinsicState); // 传入外部状态 │ └───────────────────────┬─────────────────────────────────┘ │ uses ▼ ┌─────────────────────────────────────────────────────────┐ │ FlyweightFactory │ │ - flyweights: MapString, Flyweight │ │ getFlyweight(key): Flyweight { │ │ if (!flyweights.containsKey(key)) { │ │ flyweights.put(key, new ConcreteFlyweight(key)); │ │ } │ │ return flyweights.get(key); │ │ } │ └───────────────────────┬─────────────────────────────────┘ │ manages ▼ ┌─────────────────────────────────────────────────────────┐ │ interface │ │ Flyweight │ │ operation(extrinsicState) │ └───────────────────────┬─────────────────────────────────┘ ┌────────────┴────────────┐ ▼ ▼ ┌──────────────────────┐ ┌──────────────────────┐ │ ConcreteFlyweight │ │ UnsharedConcrete │ │ (共享的享元对象) │ │ Flyweight │ │ - intrinsicState │ │ (非共享享元对象) │ │ operation(ext) { │ │ operation(ext) { │ │ // 使用内部状态 │ │ // 不共享独立实例 │ │ // 使用外部状态 │ │ // 所有状态独立 │ │ } │ │ } │ └──────────────────────┘ └──────────────────────┘角色职责软考填空关键词Flyweight抽象享元声明接口通过operation()方法接收外部状态并执行操作interfaceoperation(extrinsicState)ConcreteFlyweight具体享元实现 Flyweight 接口存储内部状态intrinsic接收并使用外部状态extrinsicintrinsicState字段 构造器初始化UnsharedConcreteFlyweight非共享享元不需要共享的享元子类所有状态都是外部状态或独立状态不通过工厂缓存每次new创建FlyweightFactory享元工厂创建并管理享元对象维护一个享元池Map确保相同内部状态的对象只创建一次MapString, FlyweightgetFlyweight(key)Client客户端通过工厂获取享元对象传入外部状态调用operation()factory.getFlyweight(key)flyweight.operation(extrinsicState)类图识别要点软考类图中享元模式的核心特征是存在FlyweightFactory类内部维护一个Map享元池通过getFlyweight(key)方法获取享元对象。ConcreteFlyweight 通常只有一个或少数几个实例被共享。如果类图里有工厂 Map 共享对象 → 享元。三、场景一字符渲染GoF 经典例子内部状态与外部状态分离业务背景文本编辑器中每个字符都是一个对象。字符的字符编码如 A、中是内部状态可共享字符的位置坐标 (x, y)和颜色是外部状态每个字符不同由客户端传入。使用享元模式内存中只保存有限数量的字符对象而不是每个出现的字符都创建一个对象。说明这是 GoF 原书中享元模式的经典例子也是软考下午题最爱考的 内部状态 vs 外部状态 场景。3.1 代码实现import java.util.HashMap; import java.util.Map; // Flyweight字符享元接口 public interface CharFlyweight { void render(int x, int y, String color); } // ConcreteFlyweight具体字符享元共享对象 public class ConcreteChar implements CharFlyweight { // 内部状态字符编码可共享存储在享元对象内部 private char charCode; public ConcreteChar(char charCode) { this.charCode charCode; } Override public void render(int x, int y, String color) { System.out.println(渲染字符 charCode 在位置( x , y )颜色: color); } } // FlyweightFactory字符享元工厂 public class CharFactory { private MapCharacter, CharFlyweight pool new HashMap(); public CharFlyweight getChar(char charCode) { CharFlyweight flyweight pool.get(charCode); if (flyweight null) { flyweight new ConcreteChar(charCode); pool.put(charCode, flyweight); System.out.println([工厂] 创建新享元: charCode ); } else { System.out.println([工厂] 复用享元: charCode ); } return flyweight; } public int getPoolSize() { return pool.size(); } } // Client客户端 public class Client { public static void main(String[] args) { CharFactory factory new CharFactory(); String text Hello; int[][] positions {{0,0}, {10,0}, {20,0}, {30,0}, {40,0}}; for (int i 0; i text.length(); i) { char c text.charAt(i); CharFlyweight flyweight factory.getChar(c); flyweight.render(positions[i][0], positions[i][1], 黑色); } System.out.println(\n文档共 text.length() 个字符内存中实际创建 factory.getPoolSize() 个享元对象); } }关键体会内部状态charCode存储在ConcreteChar内部由工厂通过Map缓存共享。相同的字符只创建一次。外部状态x, y, color不存储在享元对象中由客户端在使用时传入。每个字符的位置和颜色可以不同。文档有 5 个字符但内存中只有 4 个享元对象l 被共享节省了 20% 的内存。如果文档有 10 万字节省的内存就非常可观了。四、场景二围棋棋子内部状态颜色外部状态坐标业务背景围棋棋盘上有 361 个交叉点每局棋可能有数百个棋子。棋子的颜色黑/白是内部状态只有两种可共享棋子的位置坐标 (x, y)是外部状态每个棋子不同。使用享元模式内存中只需保存 2 个棋子对象黑棋、白棋而不是数百个。说明这是软考上午题中常考的 棋子共享 场景也是理解享元模式最直观的例子。4.1 代码实现import java.util.HashMap; import java.util.Map; // Flyweight棋子享元接口 public interface ChessPiece { void place(int x, int y); } // ConcreteFlyweight具体棋子享元共享对象 public class ConcreteChessPiece implements ChessPiece { private String color; public ConcreteChessPiece(String color) { this.color color; } Override public void place(int x, int y) { System.out.println(在 ( x , y ) 放置 color 棋); } } // FlyweightFactory棋子享元工厂 public class ChessPieceFactory { private MapString, ChessPiece pool new HashMap(); public ChessPiece getPiece(String color) { ChessPiece piece pool.get(color); if (piece null) { piece new ConcreteChessPiece(color); pool.put(color, piece); System.out.println([工厂] 创建新棋子: color); } else { System.out.println([工厂] 复用棋子: color); } return piece; } public int getPieceCount() { return pool.size(); } } // Client客户端模拟一局棋 public class Client { public static void main(String[] args) { ChessPieceFactory factory new ChessPieceFactory(); ChessPiece black factory.getPiece(黑); black.place(3, 3); ChessPiece white factory.getPiece(白); white.place(4, 4); ChessPiece black2 factory.getPiece(黑); black2.place(5, 5); ChessPiece white2 factory.getPiece(白); white2.place(6, 6); System.out.println(\n本局共 4 个棋子内存中实际创建 factory.getPieceCount() 个享元对象); System.out.println(black black2 ? (black black2)); System.out.println(white white2 ? (white white2)); } }关键体会一局棋可能有 200 多个棋子但内存中只有2 个享元对象黑棋和白棋。每个棋子的位置是外部状态由客户端在调用place(x, y)时传入。如果不用享元模式200 个棋子需要 200 个对象用了享元模式只需 2 个对象。内存节省99%。软考高频考点围棋/五子棋是享元模式的经典例子。内部状态 颜色黑/白外部状态 坐标 (x, y)。五、场景三数据库连接池享元模式的工程化应用业务背景系统需要频繁访问数据库创建数据库连接是昂贵的操作需要 TCP 握手、认证等。使用享元模式维护一个连接池池中每个连接是共享的享元对象。连接的配置信息URL、用户名、密码是内部状态连接的当前使用者和使用状态空闲/占用是外部状态。说明这是享元模式在实际工程中的典型应用也是软考上午题中 连接池/线程池使用的设计模式 的标准答案。5.1 代码实现import java.util.ArrayList; import java.util.List; // Flyweight连接享元接口 public interface Connection { void execute(String sql, String user); void close(); } // ConcreteFlyweight数据库连接共享对象 public class DatabaseConnection implements Connection { private String url; private String dbUser; private String dbPassword; private int connectionId; public DatabaseConnection(int id, String url, String dbUser, String dbPassword) { this.connectionId id; this.url url; this.dbUser dbUser; this.dbPassword dbPassword; System.out.println([连接池] 创建新连接 # id); } Override public void execute(String sql, String user) { System.out.println([连接 # connectionId ] 用户 user 执行: sql); } Override public void close() { System.out.println([连接 # connectionId ] 归还到连接池); } } // FlyweightFactory连接池享元工厂 public class ConnectionPool { private ListConnection available new ArrayList(); private ListConnection inUse new ArrayList(); private String url; private String dbUser; private String dbPassword; private int maxSize; private int createdCount 0; public ConnectionPool(String url, String dbUser, String dbPassword, int maxSize) { this.url url; this.dbUser dbUser; this.dbPassword dbPassword; this.maxSize maxSize; } public synchronized Connection getConnection() { if (!available.isEmpty()) { Connection conn available.remove(0); inUse.add(conn); System.out.println([连接池] 复用现有连接空闲: available.size() , 占用: inUse.size()); return conn; } if (createdCount maxSize) { createdCount; Connection conn new DatabaseConnection(createdCount, url, dbUser, dbPassword); inUse.add(conn); System.out.println([连接池] 创建新连接总数: createdCount); return conn; } throw new RuntimeException(连接池已满无法获取连接); } public synchronized void release(Connection conn) { inUse.remove(conn); available.add(conn); System.out.println([连接池] 连接归还空闲: available.size() , 占用: inUse.size()); } public int getCreatedCount() { return createdCount; } } // Client public class Client { public static void main(String[] args) { ConnectionPool pool new ConnectionPool( jdbc:mysql://localhost:3306/test, root, 123456, 3 ); Connection conn1 pool.getConnection(); conn1.execute(SELECT * FROM users, 用户A); Connection conn2 pool.getConnection(); conn2.execute(UPDATE orders SET status 1, 用户B); conn1.close(); pool.release(conn1); Connection conn3 pool.getConnection(); conn3.execute(INSERT INTO logs VALUES (...), 用户C); System.out.println(\n连接池实际创建连接数: pool.getCreatedCount()); } }关键体会连接池是享元模式的工程化变体。连接的配置信息URL、用户名、密码是内部状态所有连接共享相同的配置。连接的当前使用者user参数和使用状态空闲/占用是外部状态由客户端和工厂共同管理。连接池通过限制享元对象的总数maxSize避免资源耗尽。这与纯享元模式略有不同纯享元模式不限制总数只限制不同内部状态的数量但核心思想一致共享对象减少创建。六、三种场景对比与演进思路维度场景一字符渲染场景二围棋棋子场景三数据库连接池内部状态字符编码charCode棋子颜色黑/白连接配置URL、用户名、密码外部状态位置 (x, y)、颜色坐标 (x, y)当前使用者、SQL 语句享元对象数量字符种类数如 100 个汉字2黑棋、白棋池大小如 10 个连接实际对象数量文档字符数如 10 万字棋局棋子数如 200 个并发请求数如 1000 个节省比例约 99%10 万字 / 100 种字符约 99%200 个 / 2 种颜色约 99%1000 请求 / 10 连接工厂管理Map 缓存无上限Map 缓存无上限List 池化有上限maxSize软考考法下午大题区分内外状态上午选择题识别棋子共享上午选择题识别连接池模式七、内部状态 vs 外部状态核心对比对比项内部状态Intrinsic State外部状态Extrinsic State存储位置存储在ConcreteFlyweight内部不存储在享元对象中由Client传入是否共享✅可共享相同的内部状态对应同一个享元对象❌不可共享每个使用场景不同变化频率不变化创建时确定每次调用都可能变化典型例子字符编码、棋子颜色、连接配置字符位置、棋子坐标、当前使用者软考考点通过工厂缓存确保相同内部状态只创建一个对象通过operation()参数传入使用时临时确定软考记忆口诀内部状态是 身份证固定不变可共享外部状态是 当前位置随时变化不可共享。八、软考高频考点与易混淆辨析8.1 高频考点考点内容模式分类结构型模式GoF 23 正式成员核心角色Flyweight抽象享元、ConcreteFlyweight具体享元、FlyweightFactory享元工厂、Client客户端核心思想将对象状态拆分为内部状态可共享和外部状态不可共享通过共享减少对象数量内部状态存储在享元对象内部由工厂通过 Map 缓存共享外部状态由客户端在使用时通过参数传入享元工厂维护享元池MapgetFlyweight(key)方法确保相同 key 只创建一个对象与单例区别单例整个系统只有一个实例享元相同内部状态的对象共享一个实例不同内部状态的对象各自独立与原型区别原型通过clone()复制现有对象享元通过工厂共享已有对象不创建新对象适用场景① 大量相似对象 ② 对象的大部分状态可外部化 ③ 需要缓存/池化如连接池、线程池8.2 易混淆辨析享元 vs 单例 vs 原型对比项享元模式单例模式原型模式对象数量有限个每种内部状态一个只有一个任意多个每次 clone 一个新对象创建方式工厂管理从池中获取静态实例或 DCLclone()复制现有对象状态特点内部状态共享外部状态传入无内外状态之分复制原型当前的所有状态目的减少对象数量节省内存确保全局唯一基于现有对象快速创建新对象典型应用字符渲染、棋子、连接池配置管理器、日志管理器对象复制、模板克隆软考判断工厂 Map 共享对象getInstance() 私有构造器clone()Cloneable关键区分单例是 只有一个享元是 相同内部状态的共享一个原型是 复制一个。九、真题风格模拟与代码填空模拟题 1上午选择题以下关于享元模式的叙述中正确的是。A. 享元模式属于创建型模式主要用于创建大量细粒度的对象B. 在享元模式中对象的所有状态都可以被共享客户端无需传入任何额外信息C. 享元模式通过将对象状态分为内部状态和外部状态运用共享技术减少对象数量D. 享元模式与单例模式没有本质区别都是确保系统中只有一个对象实例答案C解析A 错误享元属于结构型模式不是创建型。它的目的不是创建对象而是共享对象。B 错误享元模式中只有内部状态可共享外部状态不可共享必须由客户端传入。C 正确这是享元模式的核心定义。D 错误两者有本质区别。单例是全局只有一个实例享元是相同内部状态的对象共享一个实例不同内部状态可以有多个实例。模拟题 2下午代码填空 — 补全享元工厂某系统使用享元模式实现图标渲染。图标类型type是内部状态位置坐标x, y是外部状态。请补全1~4。import java.util.HashMap; import java.util.Map; // Flyweight interface Icon { void draw(int x, int y); } // ConcreteFlyweight class ConcreteIcon implements Icon { private String type; public ConcreteIcon(String type) { this.type type; } public void draw(int x, int y) { System.out.println(绘制 type 图标在 ( x , y )); } } // FlyweightFactory class IconFactory { private MapString, Icon pool new HashMap(); public Icon getIcon(String type) { Icon icon (1)______; if (icon null) { icon (2)______; pool.put(type, icon); } return (3)______; } public int getIconCount() { return (4)______; } } public class Client { public static void main(String[] args) { IconFactory factory new IconFactory(); Icon home1 factory.getIcon(home); home1.draw(10, 10); Icon home2 factory.getIcon(home); home2.draw(20, 20); System.out.println(内存中图标对象数: factory.getIconCount()); } }答案(1)pool.get(type)(2)new ConcreteIcon(type)(3)icon(4)pool.size()阅卷要点(1) 必须是pool.get(type)从享元池中获取已有对象。如果写new ConcreteIcon(type)会零分因为这样就失去了共享的意义。(2) 必须是new ConcreteIcon(type)池中不存在时创建新对象。(3) 必须是icon返回获取到的享元对象可能是池中复用的也可能是新创建的。(4) 必须是pool.size()返回池中享元对象的数量。如果写pool.length()会零分Map 没有 length 方法。模拟题 3下午代码填空 — 区分内部状态与外部状态某系统使用享元模式实现树形对象的渲染。树的种类松树、柳树是内部状态树的位置坐标x, y和年龄是外部状态。请补全1~3。import java.util.HashMap; import java.util.Map; // Flyweight interface Tree { void display(int x, int y, int age); } // ConcreteFlyweight class ConcreteTree implements Tree { // (1)______树的种类可共享 private String type; public ConcreteTree(String type) { this.type type; } public void display(int x, int y, int age) { System.out.println(type 树在 ( x , y )树龄 age 年); } } // FlyweightFactory class TreeFactory { private MapString, Tree pool new HashMap(); public Tree getTree(String type) { Tree tree pool.get(type); if (tree null) { tree new ConcreteTree(type); pool.put(type, tree); } return tree; } } public class Client { public static void main(String[] args) { TreeFactory factory new TreeFactory(); for (int i 0; i 1000; i) { Tree tree factory.getTree(松树); // (2)______传入外部状态 tree.display(i * 10, i * 5, (3)______); } } }答案(1)内部状态或intrinsicState/可共享状态(2)tree.display(i * 10, i * 5, i % 100 1)或任何使用外部状态的调用(3)i % 100 1或任何表示年龄的外部状态值阅卷要点(1) 必须明确写出 内部状态或同义词。如果写 外部状态 会零分。(2) 需要体现客户端传入外部状态调用享元对象的方法。tree.display(...)中必须包含位置坐标和年龄参数。(3) 是外部状态的具体值可以是任意表达式只要体现 由客户端传入、不存储在享元对象中 即可。十、常见陷阱与注意事项陷阱 1误认为享元是创建型模式享元属于结构型模式因为它解决的是对象之间的结构关系共享 vs 独立而不是对象的创建问题。虽然涉及工厂创建对象但核心目的是共享而非创建。软考上午题如果问以下属于创建型模式的是选项里出现享元不能选。陷阱 2将所有状态都放入内部状态这是软考最高频的失分点。享元模式的核心是状态分离内部状态可共享存储在享元对象内部由工厂管理。外部状态不可共享由客户端传入。如果将所有状态都放入内部状态那就失去了共享的意义每个对象都独一无二。如果将所有状态都作为外部状态那享元对象就变成了无状态对象虽然可以共享但意义不大。关键是找到可共享的状态和不可共享的状态的分界线。陷阱 3与单例模式混淆享元模式单例模式相同内部状态的对象共享一个实例整个系统只有一个实例可以有多个享元对象不同内部状态只能有一个对象通过工厂按 key 获取通过getInstance()获取如果类图里有 Map 缓存多个对象 → 享元。如果只有一个静态实例 → 单例。陷阱 4与原型模式混淆享元模式原型模式从工厂获取已有的共享对象通过clone()复制现有对象不创建新对象复用创建新对象复制目的是减少对象数量目的是保留状态快速创建陷阱 5享元工厂没有缓存机制享元工厂的核心是享元池Map 或 List。如果工厂每次调用getFlyweight()都new一个新对象那就不是享元模式只是一个普通工厂。软考代码填空里工厂里必须有Map或List作为缓存池且getFlyweight()方法里必须有 判断是否存在 → 不存在则创建 → 放入池中 → 返回 的逻辑。陷阱 6外部状态存储在享元对象中如果外部状态如坐标、颜色、使用者被存储在 ConcreteFlyweight 的字段中那就违背了享元模式的设计。外部状态必须是无状态的每次通过方法参数传入。如果 ConcreteFlyweight 里有private int x, y;这样的字段且这些字段是外部状态那就是错误的。陷阱 7连接池/线程池与享元的关系连接池和线程池是享元模式的工程化应用但不是纯享元模式。纯享元模式中享元对象的数量等于内部状态种类的数量。而连接池限制了对象的总数maxSize与内部状态种类无关。软考中如果问 连接池使用了哪种设计模式答案是享元模式或享元模式的变体。十一、总结要点内容定义运用共享技术有效地支持大量细粒度的对象分类结构型模式GoF 23 正式成员核心角色Flyweight抽象享元、ConcreteFlyweight具体享元、FlyweightFactory享元工厂、Client客户端核心思想将对象状态分为内部状态可共享和外部状态不可共享通过共享减少对象数量内部状态存储在享元对象内部由工厂通过 Map 缓存共享外部状态由客户端在使用时通过参数传入享元工厂维护享元池MapgetFlyweight(key)确保相同 key 只创建一个对象与单例区别单例全局只有一个实例享元相同内部状态共享一个实例可以有多个与原型区别原型复制现有对象享元复用已有对象适用场景大量相似对象、对象大部分状态可外部化、连接池/线程池软考重点区分内部状态与外部状态享元工厂的缓存逻辑与单例/原型的辨析答题技巧看到 大量相似对象、共享、内部状态/外部状态、连接池 → 享元看到工厂里有 Map 缓存 → 确认享元系列预告下一篇将讲代理模式—— 当需要控制对对象的访问时如延迟加载、权限检查、远程调用如何通过代理对象作为中间层在不改变目标对象的前提下实现访问控制。咱们下回见。