结构型设计模式-享元模式

2 阅读34分钟

概述

在软件系统的演进历程中,内存占用与性能优化始终是开发者面临的核心挑战之一。当业务场景需要同时创建数以万计的细粒度对象时(例如文本编辑器中的每个字符、围棋棋盘上的每一枚棋子、文件系统中的每一个图标),传统“按需新建”的策略将迅速耗尽堆内存,并因频繁的垃圾回收导致系统响应迟缓。享元模式(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)维护一个享元池(通常为HashMapConcurrentHashMap),以内部状态的标识符(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无限增加)。在长期运行的服务中,应采用WeakReferenceSoftReference配合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,默认缓存-128127之间的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/CharactervalueOf缓存

ByteShortLong同样缓存了-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解决循环依赖时使用的三级缓存(singletonObjectsearlySingletonObjectssingletonFactories)本质上是一种享元池的变体:通过缓存提前暴露的对象引用来实现复用和依赖解耦。

3. StringValueResolver与占位符解析缓存

Spring在处理${...}占位符时,会将解析后的值缓存起来,避免每次都对同一表达式进行环境变量查询和解析。

3.3 MyBatis中的享元应用

1. Configuration中的MappedStatement缓存

MyBatis在解析XML映射文件后,会将每个SQL语句封装为MappedStatement对象,并存入ConfigurationmappedStatements(一个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作为享元工厂,确保每种图标类型(文件、文件夹)在内存中仅加载一次。FileIconFolderIcon的内部状态是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,在类加载时预创建了-128127范围内的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()方法会检查堆中是否已有相同内容的字符串:若有则直接返回其引用;若没有则将该字符串对象的引用记录在常量池中(不再复制)。这避免了重复对象,节约了大量堆内存。

8. 在分布式环境下,如何实现一个跨服务的共享享元池?需要考虑哪些问题?

  • 实现方式:通常采用集中式缓存(如Redis) 作为一级共享存储,各服务节点内部使用本地缓存(如Caffeine) 作为二级缓存,构成二级缓存架构。
  • 需考虑问题
    1. 一致性:享元对象若更新,需通知所有节点刷新本地缓存(可通过Redis Pub/Sub或配置中心推送)。
    2. 序列化开销:享元对象需序列化存入Redis,需评估序列化性能(建议使用Protobuf、Kryo等高效方案)。
    3. 内存控制:本地缓存应设置合理的过期策略和最大容量,防止内存溢出。
    4. 穿透与雪崩:采用互斥锁或布隆过滤器防止缓存穿透,设置随机过期时间防止雪崩。

9. 享元模式可能带来哪些缺点?什么情况下不适合使用享元模式?

  • 缺点
    1. 逻辑复杂化:需要分离内部/外部状态,增加了代码理解与维护成本。
    2. 线程安全要求高:内部状态必须设计为不可变,对设计能力有一定要求。
    3. 外部状态管理繁琐:客户端需要负责维护和传递外部状态。
  • 不适合场景
    1. 内部状态频繁变化:如果所谓的“内部状态”经常改变,享元模式退化为对象拷贝,失去意义。
    2. 对象数量少且轻量:过度设计导致代码臃肿,性能提升微乎其微。
    3. 外部状态极其复杂:如果外部状态过多,导致方法参数列表过长,应考虑是否设计不合理。

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的二级缓存设计,享元模式以其“分离不变与可变”的优雅哲学,贯穿了软件架构的各个层面。掌握享元模式,不仅是掌握一种编码技巧,更是习得一种用空间换时间、以共享降开销的系统优化思维。希望本文逾万字的深度剖析,能助您在面对海量细粒度对象的场景时,游刃有余地祭出这把“内存手术刀”,构建出高性能、低延迟的卓越系统。