概述
在软件系统的演进历程中,内存占用与性能优化始终是开发者面临的核心挑战之一。当业务场景需要同时创建数以万计的细粒度对象时(例如文本编辑器中的每个字符、围棋棋盘上的每一枚棋子、文件系统中的每一个图标),传统“按需新建”的策略将迅速耗尽堆内存,并因频繁的垃圾回收导致系统响应迟缓。享元模式(Flyweight Pattern)正是为破解此困境而生——它运用共享技术有效地支持大量细粒度对象的复用,通过精细剥离对象的内部状态(可共享的、不随环境改变的固有属性)与外部状态(依赖上下文、不可共享的动态属性),使得相同内部状态的对象在内存中仅存一份,从而大幅度压缩内存开销并提升吞吐性能。
本文旨在提供一份专家级的技术深度解读。我们将从模式定义与经典UML结构入手,通过渐进式代码演进(从暴力的对象创建到享元池共享,再延伸至复合享元与缓存策略)建立扎实的认知模型。随后,文章将深入剖析JDK(如Integer.valueOf缓存)、Spring(Bean单例池与三级缓存)、MyBatis(反射元数据缓存)等主流框架中享元思想的源码级应用,并专设章节探讨分布式环境下基于Redis的跨服务享元池设计。此外,我们将通过五个典型场景的独立可运行Demo(文本编辑器、围棋棋子、连接池、图标管理、权限字典)配合详尽的Mermaid图表进行实战解剖,最后以十道专家面试题收尾,助您彻底掌握这一经典模式及其现代变体。全文逾万言,力求成为您技术进阶路上的可靠参考。
一、模式定义与结构
1.1 GoF标准定义
享元模式(Flyweight Pattern):运用共享技术有效地支持大量细粒度对象的复用。享元对象通过共享内部状态来减少内存占用,并将外部状态作为参数传入方法以控制其行为。
1.2 UML类图
classDiagram
class Flyweight {
<<interface>>
+operation(extrinsicState: Object) void
}
class ConcreteFlyweight {
-intrinsicState: Object
+ConcreteFlyweight(intrinsicState: Object)
+operation(extrinsicState: Object) void
}
class UnsharedConcreteFlyweight {
-allState: Object
+UnsharedConcreteFlyweight(allState: Object)
+operation(extrinsicState: Object) void
}
class FlyweightFactory {
-flyweights: Map
+getFlyweight(key: String): Flyweight
}
class Client {
-flyweightFactory: FlyweightFactory
+main(args: String[]) void
}
Flyweight <|.. ConcreteFlyweight
Flyweight <|.. UnsharedConcreteFlyweight
Client --> FlyweightFactory
Client --> Flyweight
FlyweightFactory --> Flyweight
FlyweightFactory ..> ConcreteFlyweight : creates
1.3 内部状态与外部状态的核心区分
享元模式之所以能够实现对象复用,关键在于对对象状态属性的精准切分:
-
内部状态(Intrinsic State):存储在享元对象内部,不会随环境的改变而改变,因此可以在多个客户端之间安全共享。例如,一个表示字母
'A'的字符享元对象,其内部状态即为字符的字形编码'A'。无论该字符出现在文档的哪个位置、使用何种字体颜色,其字形本质恒定不变。内部状态在享元对象创建时注入,并应在对象的整个生命周期中保持不可变(Immutable),以天然保证线程安全。 -
外部状态(Extrinsic State):随环境变化而改变,不可共享。客户端在使用享元对象时必须通过方法参数将外部状态传入。例如,字符
'A'在屏幕上的显示位置(x, y)、字体大小、颜色等,均属于外部状态。它们由客户端维护,享元对象仅在使用时临时读取,绝不持有其引用。
享元工厂(FlyweightFactory)维护一个享元池(通常为HashMap或ConcurrentHashMap),以内部状态的标识符(key)为索引。当客户端请求某个内部状态的享元时,工厂先检查池中是否存在对应实例:若存在则直接返回,实现复用;若不存在则创建新实例、存入池中再返回。通过这种集中式的对象生命周期管理,享元模式确保了全局范围内相同内部状态的对象唯一性。
1.4 角色职责详解
对照上述UML类图,各角色的职责如下:
-
Flyweight(抽象享元):声明一个公共接口
operation(extrinsicState),该方法接收外部状态作为参数。所有享元对象必须实现此接口,以保证客户端能以统一方式使用共享与非共享对象。 -
ConcreteFlyweight(具体享元):实现
Flyweight接口,并持有内部状态(通常通过构造器注入并声明为final)。在operation方法中,它会结合自身的内部状态与传入的外部状态完成业务逻辑。 -
UnsharedConcreteFlyweight(非共享具体享元):同样实现
Flyweight接口,但其对象实例不被共享(例如某些复合结构,或者因业务需求必须独立存在的对象)。它的存在完善了享元模式的层次结构,使得客户端无需关心获取的对象究竟是共享的还是独立的。 -
FlyweightFactory(享元工厂):核心管理角色。内部维护一个享元池
Map<String, Flyweight>,并提供getFlyweight(key)工厂方法。该方法的逻辑是:若池中存在对应key的享元则返回之,否则创建新享元、存入池、再返回。工厂本身通常设计为单例。 -
Client(客户端):负责维护外部状态,并通过享元工厂获取享元对象。客户端调用享元的
operation方法时,需将当前上下文的外部状态作为参数传递进去。
二、代码演进与实现
2.1 不使用模式的原始代码:海量对象的创建之殇
设想一个文本渲染场景:我们需要在屏幕上绘制一篇文章,包含100万个字符。如果不假思索地为每个字符单独创建一个对象,会发生什么?
/**
* 原始实现:为每个字符独立创建对象
* 缺点:内存爆炸、GC频繁、性能低下
*/
class RawCharacter {
private char c; // 内部状态
private String font; // 外部状态
private int size; // 外部状态
private int x, y; // 外部状态
public RawCharacter(char c, String font, int size, int x, int y) {
this.c = c;
this.font = font;
this.size = size;
this.x = x;
this.y = y;
}
public void display() {
System.out.printf("显示字符 %c,字体:%s,大小:%d,位置:( %d, %d )%n",
c, font, size, x, y);
}
}
// 客户端模拟
public class RawDemo {
public static void main(String[] args) {
// 模拟一篇10万字符的文章(实际上百万字符更明显)
String text = "Hello World! Java Expert Flyweight Pattern ... ";
String font = "宋体";
int size = 12;
// 为每个字符创建一个独立对象
for (int i = 0; i < text.length() * 10000; i++) {
char ch = text.charAt(i % text.length());
RawCharacter rc = new RawCharacter(ch, font, size, i % 100, i / 100);
rc.display();
}
// 内存中同时存在数十万个RawCharacter实例,即使它们有大量重复的字符(如'e','l')
}
}
问题分析:对于10万字符,假设仅有52个字母(大小写),其余均为重复。原始方法创建了10万个对象,而其中绝大多数内部状态(字符值)是重复的。这导致:
- 内存占用:每个对象头(Mark Word、Klass Pointer)及字段占用数十字节,总内存可能高达数十MB。
- GC压力:大量短生命周期对象(如果频繁创建和丢弃)会导致年轻代频繁Minor GC,甚至触发Full GC,影响系统响应。
- 性能瓶颈:对象创建本身也是耗时操作(分配内存、初始化)。
2.2 经典享元模式重构
定义Flyweight接口
/**
* 抽象享元:声明接收外部状态的方法
*/
interface CharacterFlyweight {
// 外部状态通过参数传入:字体、大小、坐标
void display(String font, int size, int x, int y);
}
实现ConcreteFlyweight类
/**
* 具体享元:持有内部状态(字符值),且该状态不可变
*/
class ConcreteCharacter implements CharacterFlyweight {
private final char intrinsicChar; // 内部状态,构造后不可变
public ConcreteCharacter(char intrinsicChar) {
this.intrinsicChar = intrinsicChar;
}
@Override
public void display(String font, int size, int x, int y) {
// 享元对象结合内部状态与外部状态完成操作
System.out.printf("绘制字符 [%c] 在 ( %d, %d ),字体:%s,大小:%d%n",
intrinsicChar, x, y, font, size);
}
}
定义FlyweightFactory类
import java.util.HashMap;
import java.util.Map;
/**
* 享元工厂:管理享元池,确保每个内部状态只对应一个享元实例
*/
class CharacterFlyweightFactory {
// 享元池:key为字符,value为对应的享元对象
private final Map<Character, CharacterFlyweight> flyweightPool = new HashMap<>();
/**
* 获取享元对象,若池中不存在则创建并缓存
*/
public CharacterFlyweight getFlyweight(char c) {
CharacterFlyweight flyweight = flyweightPool.get(c);
if (flyweight == null) {
flyweight = new ConcreteCharacter(c);
flyweightPool.put(c, flyweight);
System.out.println("创建新的享元对象:字符 " + c);
} else {
System.out.println("复用已有享元对象:字符 " + c);
}
return flyweight;
}
public int getPoolSize() {
return flyweightPool.size();
}
}
客户端调用
/**
* 客户端:维护外部状态,通过享元工厂获取共享对象
*/
public class FlyweightDemo {
public static void main(String[] args) {
CharacterFlyweightFactory factory = new CharacterFlyweightFactory();
String document = "Hello World! Java Expert Flyweight Pattern";
String font = "微软雅黑";
int size = 14;
// 模拟大量字符渲染
for (int i = 0; i < document.length(); i++) {
char c = document.charAt(i);
// 通过工厂获取享元(共享内部状态)
CharacterFlyweight flyweight = factory.getFlyweight(c);
// 传入外部状态进行显示
flyweight.display(font, size, i * 10, 20);
}
System.out.println("享元池中实际对象数量:" + factory.getPoolSize());
System.out.println("文档字符总数:" + document.length());
// 输出:享元池对象数量远小于文档字符数(因为字符重复)
}
}
效果对比:重构后,对于10万个字符的渲染任务,享元池中最多只会有52个(若只考虑字母)或数百个(含标点)具体享元对象。内存占用骤降至可忽略级别,对象创建开销几乎为零,系统性能得到质变提升。
2.3 享元模式的进阶特性
a. 复合享元(Composite Flyweight)
当一个对象由多个基本享元组合而成,且该组合本身也需要被共享时,可以引入复合享元。例如,一个文本行对象可能包含一串字符享元,我们可以将整行的字符序列作为复合享元缓存。
import java.util.ArrayList;
import java.util.List;
/**
* 复合享元:由多个基本享元组成,但对外表现为一个享元
*/
class CompositeCharacterFlyweight implements CharacterFlyweight {
private final List<CharacterFlyweight> children = new ArrayList<>();
public void add(CharacterFlyweight flyweight) {
children.add(flyweight);
}
@Override
public void display(String font, int size, int x, int y) {
int offsetX = x;
for (CharacterFlyweight child : children) {
child.display(font, size, offsetX, y);
offsetX += 10; // 假设每个字符宽度为10
}
}
}
// 在工厂中增加复合享元的获取方法
b. 享元池的缓存策略:LRU与弱引用
经典HashMap缓存可能导致内存泄漏(若key无限增加)。在长期运行的服务中,应采用WeakReference或SoftReference配合LinkedHashMap实现LRU淘汰策略。
import java.lang.ref.WeakReference;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 带LRU和弱引用的享元工厂(示例)
*/
class LruFlyweightFactory {
private final Map<Character, WeakReference<CharacterFlyweight>> cache;
public LruFlyweightFactory(final int maxSize) {
// LinkedHashMap的accessOrder为true表示按访问顺序排序,实现LRU
cache = new LinkedHashMap<Character, WeakReference<CharacterFlyweight>>(16, 0.75f, true) {
@Override
protected boolean removeEldestEntry(Map.Entry<Character, WeakReference<CharacterFlyweight>> eldest) {
return size() > maxSize;
}
};
}
public CharacterFlyweight getFlyweight(char c) {
WeakReference<CharacterFlyweight> ref = cache.get(c);
CharacterFlyweight fw = (ref != null) ? ref.get() : null;
if (fw == null) {
fw = new ConcreteCharacter(c);
cache.put(c, new WeakReference<>(fw));
}
return fw;
}
}
c. 与单例模式的对比
| 对比维度 | 享元模式 | 单例模式 |
|---|---|---|
| 实例数量 | 多个,按内部状态区分 | 全局仅一个 |
| 管理方式 | 享元工厂维护一个对象池 | 类自身控制唯一实例 |
| 设计意图 | 共享大量细粒度对象以节约内存 | 确保全局唯一访问点并控制资源 |
| 常见配合 | 享元工厂通常实现为单例 | 不涉及池化概念 |
2.4 享元模式对象获取时序图
sequenceDiagram
participant Client as 客户端
participant Factory as 享元工厂
participant Pool as 享元池(Map)
participant Concrete as 具体享元
Client->>Factory: getFlyweight(key)
Factory->>Pool: 检查池中是否存在key
alt 池中存在
Pool-->>Factory: 返回已有享元对象
else 池中不存在
Factory->>Concrete: 创建新享元(内部状态)
Concrete-->>Factory: 返回新实例
Factory->>Pool: 将新实例存入池中(key)
end
Factory-->>Client: 返回享元对象
Client->>Concrete: operation(外部状态)
Concrete->>Concrete: 结合内部状态执行逻辑
文字说明:上图清晰地展示了享元模式的核心交互流程。客户端首先调用工厂的getFlyweight方法并传入代表内部状态的键(如字符'A')。工厂立即查询内部的享元池(通常是一个Map结构)。若命中(即该内部状态的对象之前已被创建),则直接返回缓存的对象,省略了对象创建开销;若未命中,工厂负责实例化一个全新的具体享元对象,将其注册到池中,然后返回给客户端。随后,客户端调用享元对象的业务方法(如display),并将当前上下文的外部状态(如坐标、字体)作为参数传递。这种设计将“不变的”内部状态交由享元对象自我持有,而“易变的”外部状态则由客户端在每次调用时动态提供,从而在保持对象可共享性的同时,不失灵活性。
三、源码级应用分析
3.1 JDK中的享元模式典范
1. java.lang.Integer.valueOf(int)
这是JDK中最著名的享元模式实现。Integer内部维护了一个静态内部类IntegerCache,默认缓存-128到127之间的Integer实例。
// JDK源码片段(简化)
public static Integer valueOf(int i) {
if (i >= IntegerCache.low && i <= IntegerCache.high)
return IntegerCache.cache[i + (-IntegerCache.low)];
return new Integer(i);
}
当调用Integer.valueOf(100)时,直接返回缓存池中的同一个对象,避免了重复创建。缓存上界可通过JVM参数-XX:AutoBoxCacheMax=<size>调整。
2. java.lang.Boolean.valueOf(boolean)
public static final Boolean TRUE = new Boolean(true);
public static final Boolean FALSE = new Boolean(false);
public static Boolean valueOf(boolean b) {
return (b ? TRUE : FALSE);
}
布尔值只有两种可能,因此直接返回两个静态常量,完美体现了享元思想。
3. Byte/Short/Long/Character的valueOf缓存
Byte、Short、Long同样缓存了-128~127范围的对象;Character缓存了0~127的ASCII字符。
4. java.math.BigDecimal的静态常量
public static final BigDecimal ZERO = new BigDecimal(BigInteger.ZERO, 0, 0, 1);
public static final BigDecimal ONE = new BigDecimal(BigInteger.ONE, 1, 0, 1);
public static final BigDecimal TEN = new BigDecimal(BigInteger.TEN, 10, 0, 2);
这些常用的BigDecimal对象被预先创建并共享,减少重复实例化开销。
5. ThreadPoolExecutor中的Worker线程复用
虽然线程池通常被归类为对象池模式,但其“复用有限数量的线程处理大量任务”的思想与享元模式一脉相承。Worker线程作为内部状态(线程能力)的载体被反复使用,而任务(Runnable)作为外部状态每次被传入。
3.2 Spring框架中的享元实践
1. Bean作用域singleton
Spring IoC容器默认将Bean定义为单例(singleton)作用域。当容器启动时,会创建Bean的实例并放入缓存(singletonObjects Map)中。后续每次getBean请求都返回同一个实例,这正是享元模式在框架层面的宏观体现。
2. AbstractAutowireCapableBeanFactory的三级缓存
Spring解决循环依赖时使用的三级缓存(singletonObjects、earlySingletonObjects、singletonFactories)本质上是一种享元池的变体:通过缓存提前暴露的对象引用来实现复用和依赖解耦。
3. StringValueResolver与占位符解析缓存
Spring在处理${...}占位符时,会将解析后的值缓存起来,避免每次都对同一表达式进行环境变量查询和解析。
3.3 MyBatis中的享元应用
1. Configuration中的MappedStatement缓存
MyBatis在解析XML映射文件后,会将每个SQL语句封装为MappedStatement对象,并存入Configuration的mappedStatements(一个StrictMap)中。应用程序执行SQL时,直接从该Map中根据ID获取对应的MappedStatement。由于MappedStatement包含了SQL、参数映射、结果映射等不可变元数据,非常适合作为享元共享。
2. ReflectorFactory中的Reflector缓存
MyBatis通过反射操作JavaBean,Reflector类封装了类的getter/setter、字段等元信息。ReflectorFactory内部维护了一个ConcurrentHashMap<Class<?>, Reflector>,确保每个类只被解析一次,后续复用缓存的Reflector。这极大地提升了ORM映射的性能。
// MyBatis ReflectorFactory 源码简化
public class DefaultReflectorFactory implements ReflectorFactory {
private final Map<Class<?>, Reflector> reflectorMap = new ConcurrentHashMap<>();
@Override
public Reflector findForClass(Class<?> type) {
return reflectorMap.computeIfAbsent(type, Reflector::new);
}
}
3. TypeHandlerRegistry
类型处理器在MyBatis启动时注册,全局共享。每个类型处理器实例处理特定的Java类型与JDBC类型的转换,属于典型享元。
3.4 其他框架中的享元思想
- 数据库连接池(HikariCP / Druid):连接对象是重量级资源,连接池作为享元工厂管理一组可复用的物理连接。客户端借用连接(外部状态为SQL语句)并最终归还,避免了频繁创建/销毁连接的开销。
- JVM字符串常量池:
String.intern()方法可将字符串放入常量池,确保相同内容的字符串在堆内存中只有一份拷贝。
四、分布式环境下的享元模式
当系统从单体演进为分布式微服务,享元思想并未过时,而是演化为跨节点的资源池化与本地缓存相结合的新形态。
4.1 分布式对象池化
基于Redis的集中式对象池允许不同服务节点共享同一批大对象(如全局配置模板、静态数据字典)。例如,一个营销活动模板包含复杂的HTML结构和规则引擎脚本,体积较大且不常变更。若每个服务节点各自加载,会造成内存浪费和启动缓慢。通过将模板序列化后存入Redis Hash,各节点按需从Redis拉取并缓存在本地内存(如Caffeine),形成二级缓存架构。
4.2 分布式ID生成器的分段缓存
美团Leaf、Twitter Snowflake等分布式ID生成方案中,ID生成服务会预先从数据库(或ZooKeeper)获取一个号段(如1~1000),然后缓存在本地内存中。本地生成ID时直接使用该号段,用完再取下一段。这种“批量化获取+本地复用”的模式,本质上是将ID号段视为享元的内部状态,而具体生成的ID作为外部状态返回。它极大地降低了与远程服务的交互频率。
4.3 微服务配置的本地缓存
Apollo或Nacos客户端会将从配置中心拉取的配置内容缓存在本地内存(或文件),并开启定时轮询或长轮询监听变更。应用读取配置时直接命中本地缓存,无需每次调用远程API。配置项(内部状态)被所有业务线程共享,不同线程带来的不同调用场景即为外部状态。
4.4 CDN边缘节点缓存
CDN将源站的静态资源(图片、CSS、JS)分发并缓存到全球各地的边缘节点。用户请求资源时,由距离最近的边缘节点直接返回缓存副本,而无需每次都回源站获取。这种“空间换时间”的策略与享元模式通过共享对象减少创建开销的理念高度一致。边缘节点缓存的资源副本即可视为共享的“享元对象”。
4.5 分布式Session共享
在集群环境中,用户的Session信息被序列化后存入Redis等集中存储。任何一台服务节点处理请求时,都从Redis读取Session并反序列化为对象进行操作。尽管不同请求可能由不同节点处理,但它们操作的是同一份Session“享元”。
4.6 基于Redis的分布式享元池示例
下面展示一个结合Redis与Caffeine的二级缓存享元工厂。
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import redis.clients.jedis.Jedis;
import java.io.*;
import java.util.concurrent.TimeUnit;
/**
* 分布式享元工厂:Redis一级缓存 + Caffeine二级缓存
*/
public class DistributedFlyweightFactory {
private final Jedis jedis;
private final Cache<String, Object> localCache;
public DistributedFlyweightFactory() {
this.jedis = new Jedis("localhost", 6379);
this.localCache = Caffeine.newBuilder()
.expireAfterWrite(10, TimeUnit.MINUTES)
.maximumSize(10000)
.build();
}
/**
* 获取享元对象(以序列化形式存储在Redis)
*/
public Object getFlyweight(String key) {
// 1. 先查本地缓存
Object obj = localCache.getIfPresent(key);
if (obj != null) {
return obj;
}
// 2. 查Redis
byte[] data = jedis.get(key.getBytes());
if (data != null) {
obj = deserialize(data);
localCache.put(key, obj);
return obj;
}
// 3. 创建新对象(模拟耗时操作,例如加载大型配置)
obj = createHeavyObject(key);
// 4. 存入Redis并设置过期时间
jedis.setex(key.getBytes(), 3600, serialize(obj));
localCache.put(key, obj);
return obj;
}
private Object createHeavyObject(String key) {
// 实际可能从数据库或文件加载
return new HeavyConfigObject(key);
}
private byte[] serialize(Object obj) {
try (ByteArrayOutputStream bos = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(bos)) {
oos.writeObject(obj);
return bos.toByteArray();
} catch (IOException e) {
throw new RuntimeException(e);
}
}
private Object deserialize(byte[] data) {
try (ByteArrayInputStream bis = new ByteArrayInputStream(data);
ObjectInputStream ois = new ObjectInputStream(bis)) {
return ois.readObject();
} catch (IOException | ClassNotFoundException e) {
throw new RuntimeException(e);
}
}
static class HeavyConfigObject implements Serializable {
private String id;
// 省略大量字段
public HeavyConfigObject(String id) { this.id = id; }
}
}
4.7 分布式享元池架构图
flowchart TD
A["客户端请求享元 key"] --> B{"本地缓存 Caffeine 命中?"}
B -->|"是"| C["返回本地缓存对象"]
B -->|"否"| D{"Redis 缓存命中?"}
D -->|"是"| E["反序列化对象"]
E --> F["存入本地缓存"]
F --> G["返回对象"]
D -->|"否"| H["创建新享元对象 (加载配置/查询DB)"]
H --> I["序列化并写入 Redis"]
I --> J["存入本地缓存"]
J --> K["返回新对象"]
文字说明:该流程图展示了分布式享元池的典型工作流程。客户端以某个键(如配置ID)向享元工厂请求对象。请求首先抵达本地缓存层(Caffeine),这一层位于服务进程内部,访问延迟极低。若本地未命中,请求将穿透至分布式缓存层(Redis),该层为集群内所有服务节点提供共享的数据视图。若Redis命中,则将二进制数据反序列化为对象,并回填至本地缓存以减少后续请求的穿透;若Redis也未能命中,意味着该享元对象从未被任何节点创建过,此时工厂将执行重量级的创建逻辑(如读取数据库、加载文件),生成对象后,需将其序列化并写入Redis以供其他节点后续使用,同时存入本地缓存。这种二级缓存架构完美地平衡了数据一致性、内存占用与访问性能,是享元模式在分布式环境下的高级演进形态。
五、对比辨析
5.1 享元模式 vs 单例模式
- 享元模式:管理一类对象的多个实例,每个实例对应一种内部状态。例如,字符享元工厂管理52个字母实例。享元工厂本身常设计为单例,但享元对象是多实例的。
- 单例模式:严格控制某一个类在整个JVM中仅存在一个实例。例如,Spring中的
Environment对象。 - 总结:享元模式是“有限多例”,单例模式是“唯一单例”。
5.2 享元模式 vs 原型模式
- 享元模式:关注复用已有对象,避免重复创建。
- 原型模式:关注通过克隆已有对象来创建新对象,避免重新执行复杂的初始化流程。
- 结合场景:享元池中可以存储原型对象。当需要获取某个内部状态的变体时,先从享元池获取模板对象,再调用其
clone方法生成一个可修改的副本。例如,Word中的字符格式刷:先获取共享的字符格式享元,再克隆出一份用于局部编辑。
5.3 享元模式 vs 对象池模式
- 享元模式:对象被设计为无状态或只读,可同时被多个客户端并发使用。对象池不关心状态,只负责分配。
- 对象池模式:对象是有状态的,被客户端独占使用,使用完毕后必须归还(如数据库连接)。连接池中的连接对象不能同时被两个线程使用(除非是线程安全的包装)。
- 关联:享元工厂通常是一个简单的Map,而对象池需要管理空闲/忙碌状态、超时、验证等复杂逻辑。二者常被统称为池化技术。
5.4 享元模式 vs 缓存模式
- 享元模式:是一种结构型设计模式,强调通过分离内部/外部状态来实现细粒度对象共享,核心是对象复用。
- 缓存模式:是一种通用的性能优化策略,通过存储计算结果或数据副本来加速访问,核心是空间换时间。
- 关系:享元工厂内部的
Map本质上就是一种缓存。但享元模式的范围更窄、意图更明确。
5.5 内部状态 vs 外部状态与线程安全
- 内部状态:必须是**不可变(Immutable)**的,或者至少是线程安全的。因为同一个享元实例会被多个线程并发访问。例如,
ConcreteCharacter中的char字段是final的。 - 外部状态:由客户端持有,且每次调用时作为方法参数传入。享元对象不应缓存外部状态的引用,以免引发线程安全问题。
- 结论:遵循“内部状态不可变、外部状态参数化”的原则,享元对象天生是线程安全的,无需加锁。
六、适用场景分析(重点强化)
场景一:文本编辑器字符格式化
1. 独立可运行Demo
// 享元接口
interface Glyph {
void draw(String font, int size, String color, int x, int y);
}
// 具体字符享元
class CharacterGlyph implements Glyph {
private final char symbol; // 内部状态
public CharacterGlyph(char symbol) {
this.symbol = symbol;
}
@Override
public void draw(String font, int size, String color, int x, int y) {
System.out.printf("绘制字符 [%c] 字体:%s 大小:%d 颜色:%s 坐标:(%d,%d)%n",
symbol, font, size, color, x, y);
}
}
// 享元工厂
class GlyphFactory {
private final Map<Character, Glyph> pool = new HashMap<>();
public Glyph getGlyph(char c) {
return pool.computeIfAbsent(c, CharacterGlyph::new);
}
}
// 客户端
public class TextEditorDemo {
public static void main(String[] args) {
GlyphFactory factory = new GlyphFactory();
String text = "Flyweight Pattern in Action";
String font = "Arial";
int size = 12;
String color = "#000000";
for (int i = 0; i < text.length(); i++) {
char c = text.charAt(i);
Glyph g = factory.getGlyph(c);
g.draw(font, size, color, i * 8, 20);
}
System.out.println("享元池大小: " + factory.getPoolSize());
}
}
2. Mermaid类图
classDiagram
class Glyph {
<<interface>>
+draw(font, size, color, x, y)
}
class CharacterGlyph {
-char symbol
+draw(font, size, color, x, y)
}
class GlyphFactory {
-Map pool
+getGlyph(char) Glyph
}
class TextEditorDemo {
+main(String[])
}
Glyph <|.. CharacterGlyph
TextEditorDemo --> GlyphFactory
TextEditorDemo --> Glyph
GlyphFactory --> CharacterGlyph
3. 文字说明
在文本编辑器中,一篇文章可能包含数万个字符,但字符的种类却十分有限(字母、数字、常用标点)。上述Demo严格分离了内部状态(字符的字形符号symbol)与外部状态(字体、大小、颜色、坐标)。GlyphFactory确保每个唯一字符在内存中只有一个CharacterGlyph实例。当编辑器渲染文本时,它反复从工厂获取相同的享元对象,仅通过draw方法的参数传递不同的显示属性。这种设计将原本O(N)的内存复杂度(N为字符总数)降低为O(M)(M为字符集大小),极大提升了大规模文档的编辑与渲染性能。
场景二:围棋棋子共享
1. 独立可运行Demo
// 棋子享元接口
interface GoPiece {
void place(int x, int y);
}
// 具体棋子:内部状态为颜色
class ConcreteGoPiece implements GoPiece {
private final String color; // 内部状态
public ConcreteGoPiece(String color) {
this.color = color;
}
@Override
public void place(int x, int y) {
System.out.printf("在 (%d,%d) 落下一枚%s棋%n", x, y, color);
}
}
// 棋子工厂
class GoPieceFactory {
private static final Map<String, GoPiece> pieces = new HashMap<>();
static {
pieces.put("black", new ConcreteGoPiece("黑"));
pieces.put("white", new ConcreteGoPiece("白"));
}
public static GoPiece getPiece(String color) {
return pieces.get(color.toLowerCase());
}
}
// 游戏引擎
public class GoGameDemo {
public static void main(String[] args) {
// 模拟落子
GoPiece black = GoPieceFactory.getPiece("black");
GoPiece white = GoPieceFactory.getPiece("white");
black.place(3, 3);
white.place(15, 16);
black.place(4, 4);
// 无论下多少步棋,内存中永远只有两个棋子对象
}
}
2. Mermaid时序图
sequenceDiagram
participant Game as 游戏引擎
participant Factory as 棋子工厂
participant Piece as 共享棋子对象
Game->>Factory: getPiece("black")
Factory-->>Game: 返回黑棋享元实例
Game->>Piece: place(3, 3)
Note over Piece: 输出:在(3,3)落下一枚黑棋
Game->>Factory: getPiece("black")
Factory-->>Game: 返回相同的黑棋享元实例
Game->>Piece: place(4, 4)
3. 文字说明
围棋棋盘有361个交叉点,一局游戏可能多达两百余手。若采用传统方式,每下一子就新建一个Stone对象,则一局游戏会产生上百个对象,同时在线对局增多时内存压力巨大。上述享元实现中,棋子工厂在类加载时即预创建黑白两个享元对象,全局共享。时序图清晰地表明:无论游戏引擎请求多少次“黑棋”,工厂返回的始终是同一个实例。外部状态(落子坐标)通过place方法传递,而代表颜色的内部状态“黑”或“白”固化在享元内部。这种设计使内存占用与棋局复杂度、对局数量解耦,是游戏开发中处理海量静态元素的经典范式。
场景三:数据库连接池
1. 独立可运行Demo
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;
// 模拟连接对象
class Connection {
private final int id;
public Connection(int id) { this.id = id; }
public void execute(String sql) {
System.out.printf("连接[%d] 执行SQL: %s%n", id, sql);
}
}
// 连接池(享元工厂变体)
class ConnectionPool {
private final BlockingQueue<Connection> idlePool = new LinkedBlockingQueue<>();
private final int maxSize;
public ConnectionPool(int maxSize) {
this.maxSize = maxSize;
for (int i = 0; i < maxSize; i++) {
idlePool.offer(new Connection(i + 1));
}
}
public Connection borrow() throws InterruptedException {
return idlePool.take(); // 若无空闲则阻塞等待
}
public void release(Connection conn) {
idlePool.offer(conn);
}
}
// 客户端
public class ConnectionPoolDemo {
public static void main(String[] args) throws InterruptedException {
ConnectionPool pool = new ConnectionPool(3);
// 模拟并发请求
for (int i = 0; i < 5; i++) {
new Thread(() -> {
try {
Connection conn = pool.borrow();
conn.execute("SELECT * FROM users");
Thread.sleep(1000); // 模拟业务处理
pool.release(conn);
} catch (InterruptedException e) {
e.printStackTrace();
}
}).start();
}
}
}
2. Mermaid流程图
flowchart TD
A[客户端请求连接] --> B{空闲池是否有连接?}
B -- 有空闲 --> C[从池中取出连接]
C --> D[标记连接为忙碌/移出空闲队列]
D --> E[返回连接给客户端]
E --> F[客户端执行SQL]
F --> G[客户端归还连接]
G --> H[连接放回空闲池]
B -- 无空闲且未达最大数 --> I[创建新连接]
I --> D
B -- 无空闲且已达最大数 --> J[阻塞等待其他客户端归还]
J --> B
3. 文字说明
数据库连接池是享元思想在重量级资源管理中的最佳实践之一。连接对象(Connection)创建成本极高(涉及网络握手、认证等),且底层资源有限,必须复用。流程图展示了典型的连接池工作流:客户端调用borrow请求连接,若空闲池(通常用BlockingQueue实现)中存在可用连接,则直接取出返回;若池已空但未达到最大连接数上限,允许创建新连接;若已达上限,线程必须阻塞等待其他客户端归还。归还时,连接被重新放回空闲池供后续复用。此处的“连接”相当于享元对象(但其状态会改变,故更贴近对象池模式),而“SQL语句”则是外部状态。通过池化,系统以固定数量的连接支撑了海量数据库操作。
场景四:图标资源管理
1. 独立可运行Demo
// 图标享元接口
interface Icon {
void draw(int x, int y, int size);
}
// 具体图标(内部状态:图片数据模拟)
class FileIcon implements Icon {
private final byte[] imageData; // 模拟大图片数据
public FileIcon(String type) {
this.imageData = new byte[1024 * 1024]; // 1MB 模拟图片
System.out.println("加载图标资源: " + type);
}
@Override
public void draw(int x, int y, int size) {
System.out.printf("绘制%s图标,位置(%d,%d),尺寸%dx%d%n",
"文件", x, y, size, size);
}
}
class FolderIcon implements Icon {
private final byte[] imageData;
public FolderIcon() {
this.imageData = new byte[1024 * 1024];
System.out.println("加载文件夹图标资源");
}
@Override
public void draw(int x, int y, int size) {
System.out.printf("绘制文件夹图标,位置(%d,%d),尺寸%dx%d%n", x, y, size, size);
}
}
// 图标工厂
class IconFactory {
private static final Map<String, Icon> icons = new HashMap<>();
public static Icon getIcon(String type) {
return icons.computeIfAbsent(type, k -> {
if ("file".equals(k)) return new FileIcon("file");
if ("folder".equals(k)) return new FolderIcon();
throw new IllegalArgumentException("未知图标类型");
});
}
}
// 文件管理器
public class FileManagerDemo {
public static void main(String[] args) {
// 渲染大量文件列表
for (int i = 0; i < 1000; i++) {
Icon icon = IconFactory.getIcon("file");
icon.draw(20 + i*5, 50, 16);
}
for (int i = 0; i < 500; i++) {
Icon icon = IconFactory.getIcon("folder");
icon.draw(20 + i*8, 150, 24);
}
// 尽管渲染了1500个图标,但图片资源只加载了2次
}
}
2. Mermaid类图
classDiagram
class Icon {
<<interface>>
+draw(x, y, size)
}
class FileIcon {
-byte[] imageData
+draw(x, y, size)
}
class FolderIcon {
-byte[] imageData
+draw(x, y, size)
}
class IconFactory {
-Map icons
+getIcon(type) Icon
}
Icon <|.. FileIcon
Icon <|.. FolderIcon
IconFactory --> Icon
3. 文字说明
在图形化文件管理器(如Windows资源管理器或Mac Finder)中,界面可能同时展示数千个文件与文件夹条目。每个条目都需要渲染对应的图标。图标图片通常较大(含多分辨率),若为每个条目单独加载一份图片数据,内存会瞬间爆满。该Demo中,IconFactory作为享元工厂,确保每种图标类型(文件、文件夹)在内存中仅加载一次。FileIcon和FolderIcon的内部状态是imageData字节数组(模拟),而外部状态(绘制位置、尺寸)由draw方法参数传入。当渲染1500个图标时,实际仅执行了两次重量级的资源加载操作,内存占用与界面元素数量脱钩,保障了GUI应用的流畅度。
场景五:权限数据字典缓存
1. 独立可运行Demo
// 权限享元
class Permission {
private final int id;
private final String name;
private final String description; // 内部状态,不可变
public Permission(int id, String name, String description) {
this.id = id;
this.name = name;
this.description = description;
}
// 校验外部状态(用户ID)
public boolean checkAccess(int userId) {
// 模拟权限校验逻辑(实际可能查询用户-权限关联表)
System.out.printf("校验用户 %d 对权限 [%s] 的访问%n", userId, name);
return userId % 2 == 0; // 简单模拟:偶数ID用户有权限
}
@Override
public String toString() {
return "Permission{id=" + id + ", name='" + name + "'}";
}
}
// 权限享元工厂
class PermissionFactory {
private static final Map<Integer, Permission> cache = new HashMap<>();
static {
// 模拟从数据库加载全量权限定义
cache.put(1, new Permission(1, "user:add", "创建用户"));
cache.put(2, new Permission(2, "user:delete", "删除用户"));
cache.put(3, new Permission(3, "article:publish", "发布文章"));
}
public static Permission getPermission(int id) {
return cache.get(id);
}
}
// 业务系统
public class AuthDemo {
public static void main(String[] args) {
// 用户1001请求访问权限1
Permission perm = PermissionFactory.getPermission(1);
boolean allowed = perm.checkAccess(1001);
System.out.println("授权结果: " + allowed);
// 用户1002请求访问同一权限
Permission perm2 = PermissionFactory.getPermission(1);
System.out.println(perm == perm2); // true,共享同一个实例
perm2.checkAccess(1002);
}
}
2. Mermaid流程图
flowchart TD
A["收到权限校验请求 userId, permId"] --> B["调用 PermissionFactory.getPermission"]
B --> C["从缓存Map中获取Permission享元"]
C --> D["调用 Permission.checkAccess(userId)"]
D --> E{"内部状态+用户ID 进行校验"}
E -->|"通过"| F["返回 true"]
E -->|"拒绝"| G["返回 false"]
3. 文字说明
企业级应用中,权限数据(权限ID、名称、描述)是典型的静态元数据,系统启动后极少变更。上述Demo将权限定义为享元,其内部状态(ID、名称、描述)在PermissionFactory初始化时一次性加载自数据库并缓存。当用户请求访问某个功能时,系统从工厂获取共享的权限对象(避免了重复的数据库查询及对象创建),然后调用checkAccess方法并传入外部状态——用户ID,以执行具体的权限判定逻辑。流程图直观展示了这一过程:请求首先命中缓存获取轻量级享元,随后将动态的用户上下文传入进行校验。这种设计将稳定的元数据与易变的业务数据解耦,大幅提升了权限校验的吞吐量,是享元模式在后台系统中的典型应用。
七、面试题精选与专家级解答
1. 什么是享元模式的内部状态和外部状态?为什么要这样区分?
答:
- 内部状态:存储在享元对象内部的、不随环境变化的属性。它可以被安全地共享。例如,字符
'A'的字形编码。 - 外部状态:依赖于具体上下文,不可共享的属性。例如,字符显示的位置坐标、颜色。
- 区分原因:只有将不可变部分(内部状态)提取出来共享,才能大幅减少对象实例数量;而将可变部分(外部状态)剥离并由客户端传递,既保证了享元对象的纯粹性与线程安全性,又维持了业务逻辑的灵活性。
2. JDK中Integer.valueOf(int)是如何应用享元模式的?缓存范围是多少?能否修改?
答:
Integer.valueOf(int)内部维护了一个静态内部类IntegerCache,在类加载时预创建了-128至127范围内的Integer对象数组。- 当调用
valueOf时,若参数在此范围内,直接返回缓存数组中的对象;否则new一个新对象。 - 默认上界为
127,可通过JVM参数-XX:AutoBoxCacheMax=<size>调整,但不能低于127。
3. 享元模式和单例模式有什么本质区别?享元工厂可以设计为单例吗?
答:
- 本质区别:享元模式旨在管理一类对象的多个共享实例(每个内部状态对应一个实例);单例模式严格控制一个类仅有一个实例。
- 工厂设计:享元工厂通常应该设计为单例,因为工厂本身不需要多个实例,且享元池需要在全局范围内统一管理。工厂的单例化与享元对象的多例化并不矛盾。
4. 享元模式如何保证线程安全?内部状态和外部状态分别如何处理并发问题?
答:
- 内部状态:必须设计为不可变对象(Immutable),即所有字段用
final修饰,且不提供修改方法。由于不可变,多线程并发读取无需任何同步措施,天然线程安全。 - 外部状态:通过方法参数传入,存储在栈上(每个线程私有)或由客户端以线程封闭方式管理。享元对象内部不持有外部状态引用,因此不存在并发修改的风险。
- 享元池(Map):若工厂在运行时动态添加享元(非预加载),需使用
ConcurrentHashMap以保证池操作的线程安全。
5. 数据库连接池(如HikariCP)是否属于享元模式?为什么?
答:
- 严格来说,连接池更贴近对象池模式(Object Pool Pattern),但思想同源。
- 相似点:都通过复用现有对象来减少创建/销毁开销。
- 不同点:享元模式的对象通常是无状态(只读)且可同时被多个客户端并发使用;而连接池中的连接对象有状态(如事务状态),同一时刻只能由一个线程独占使用,用完必须归还。因此连接池管理的不是享元模式的严格定义,但广义上属于池化技术的应用。
6. 享元模式与原型模式能否结合使用?请给出结合场景和代码示例。
答:可以结合。享元池中存储的是原型对象,客户端获取享元后,如果需要一份可修改的副本,就调用clone()方法。
interface CloneableGlyph extends Cloneable {
void draw(String font);
CloneableGlyph clone();
}
// 享元工厂返回缓存的原型对象
CloneableGlyph prototype = factory.getGlyph('A');
CloneableGlyph custom = prototype.clone(); // 克隆后可以修改外部属性并独立使用
场景:Word文档中,用户选择一段带有复杂格式的文本作为“模板”,后续可以克隆该格式应用到其他文本,同时允许微调。
7. JVM字符串常量池(String.intern)体现的是享元模式吗?它的实现机制是怎样的?
答:
- 是。字符串常量池是享元模式的经典实现。内部状态是字符序列(
char[]),外部状态是字符串对象在代码中的引用位置。 - 实现机制:
- JDK 6及之前:常量池在永久代(PermGen),
intern()方法将字符串实例复制到永久代并返回引用。 - JDK 7及以后:常量池移至堆内存。
intern()方法会检查堆中是否已有相同内容的字符串:若有则直接返回其引用;若没有则将该字符串对象的引用记录在常量池中(不再复制)。这避免了重复对象,节约了大量堆内存。
- JDK 6及之前:常量池在永久代(PermGen),
8. 在分布式环境下,如何实现一个跨服务的共享享元池?需要考虑哪些问题?
答:
- 实现方式:通常采用集中式缓存(如Redis) 作为一级共享存储,各服务节点内部使用本地缓存(如Caffeine) 作为二级缓存,构成二级缓存架构。
- 需考虑问题:
- 一致性:享元对象若更新,需通知所有节点刷新本地缓存(可通过Redis Pub/Sub或配置中心推送)。
- 序列化开销:享元对象需序列化存入Redis,需评估序列化性能(建议使用Protobuf、Kryo等高效方案)。
- 内存控制:本地缓存应设置合理的过期策略和最大容量,防止内存溢出。
- 穿透与雪崩:采用互斥锁或布隆过滤器防止缓存穿透,设置随机过期时间防止雪崩。
9. 享元模式可能带来哪些缺点?什么情况下不适合使用享元模式?
答:
- 缺点:
- 逻辑复杂化:需要分离内部/外部状态,增加了代码理解与维护成本。
- 线程安全要求高:内部状态必须设计为不可变,对设计能力有一定要求。
- 外部状态管理繁琐:客户端需要负责维护和传递外部状态。
- 不适合场景:
- 内部状态频繁变化:如果所谓的“内部状态”经常改变,享元模式退化为对象拷贝,失去意义。
- 对象数量少且轻量:过度设计导致代码臃肿,性能提升微乎其微。
- 外部状态极其复杂:如果外部状态过多,导致方法参数列表过长,应考虑是否设计不合理。
10. MyBatis中ReflectorFactory对反射元数据的缓存是否属于享元模式的应用?请结合源码分析。
答:属于。Reflector对象封装了一个Java类的元信息(getter/setter/字段列表),这些信息在类加载后是恒定不变的(内部状态)。DefaultReflectorFactory内部维护了一个ConcurrentHashMap<Class<?>, Reflector>作为享元池。
源码分析:
public class DefaultReflectorFactory implements ReflectorFactory {
private final Map<Class<?>, Reflector> reflectorMap = new ConcurrentHashMap<>();
@Override
public Reflector findForClass(Class<?> type) {
// 享元模式核心:池中存在则返回,不存在则创建并缓存
return reflectorMap.computeIfAbsent(type, Reflector::new);
}
}
当MyBatis需要操作某个JavaBean时,通过findForClass获取Reflector。由于类的元数据是稳定的,全局共享同一个Reflector实例避免了重复的反射解析开销(Class.getMethods()等操作耗时),这正是享元模式“共享不可变内部状态”思想的体现。
八、结语
从JDK中Integer的区区缓存,到MyBatis中Reflector的元数据复用,再到分布式环境下依托Redis的二级缓存设计,享元模式以其“分离不变与可变”的优雅哲学,贯穿了软件架构的各个层面。掌握享元模式,不仅是掌握一种编码技巧,更是习得一种用空间换时间、以共享降开销的系统优化思维。希望本文逾万字的深度剖析,能助您在面对海量细粒度对象的场景时,游刃有余地祭出这把“内存手术刀”,构建出高性能、低延迟的卓越系统。