享元模式(Flyweight Pattern)是GoF二十三种设计模式中比较低调但非常实用的一种。它的名字来源于拳击术语“轻量级”,寓意是用轻量的方式承载大量对象。在C#开发中,当你的程序需要同时存在几十万个相似对象时,比如一个文档编辑器里的每一个字符、一款游戏地图上的每一棵树、一个图形软件中的每一个像素点,如果每个对象都独立分配内存,程序很快就会被内存压力拖垮。享元模式通过共享对象的内部状态来解决这个问题,让成千上万个逻辑上的对象实际上只占用极少的物理内存。本文将从原理到代码实现,完整讲解C#中享元模式的落地方法。

享元模式的核心原理:内部状态与外部状态的分离
理解享元模式的关键在于理解两个概念:内部状态(Intrinsic State)和外部状态(Extrinsic State)。内部状态是对象中不变的部分,存储在享元对象内部并且可以被安全地共享,比如一棵树对象的树种、纹理、几何模型,这些数据对所有同类树来说都是一样的。外部状态则是随环境变化的部分,不能共享,比如每棵树在地图上的坐标位置,必须由客户端在使用时传入。
这种分离带来一个重要约束:享元对象必须是不可变的,或者说至少其内部状态不可变。如果某个客户端修改了共享对象的内部状态,所有引用这个对象的其他客户端都会受到影响,这会导致难以排查的bug。在C#中,通常将内部状态声明为readonly字段,通过构造函数注入,从语言层面保证不可变性。
享元模式的标准结构包含四个角色:抽象享元接口(定义业务方法)、具体享元类(实现接口并保存内部状态)、享元工厂(负责创建和缓存享元对象)、以及客户端。其中享元工厂是整个模式的枢纽,它维护一个缓存字典,客户端请求对象时先查缓存,命中则直接返回,未命中才创建新实例。
C#实现享元模式的完整代码示例
下面通过一个森林渲染的例子演示完整实现。假设游戏地图上有五十万棵树,树的种类只有十几种,那么树的模型、纹理就是典型的可共享内部状态,而坐标是外部状态。
首先定义抽象享元接口和具体享元类:
// 抽象享元接口,extrinsicState即外部状态,由调用方传入
public interface ITree
{
void Display(int x, int y);
}
// 具体享元类,内部状态全部readonly,保证不可变
public class TreeType : ITree
{
private readonly string _name;
private readonly string _color;
private readonly string _texture;
public TreeType(string name, string color, string texture)
{
_name = name;
_color = color;
_texture = texture;
}
// 外部状态x、y不存储在对象内部,由方法参数传入
public void Display(int x, int y)
{
Console.WriteLine($"渲染树种:{_name},颜色:{_color},位置:({x}, {y})");
}
}
然后是核心的享元工厂,它使用Dictionary缓存已创建的享元对象。注意这里用ConcurrentDictionary替代普通字典,可以保证多线程环境下的线程安全:
public static class TreeFactory
{
// 共享缓存池,key是内部状态的唯一标识
private static readonly ConcurrentDictionary<string, TreeType> _cache =
new ConcurrentDictionary<string, TreeType>();
public static TreeType GetTreeType(string name, string color, string texture)
{
string key = $"{name}_{color}_{texture}";
// GetOrAdd保证不存在时才创建,存在时直接复用
return _cache.GetOrAdd(key, _ => new TreeType(name, color, texture));
}
public static int GetCacheCount() => _cache.Count;
}
最后看客户端的使用方式。客户端维护一个树的位置列表,但每棵树只持有对共享享元对象的引用:
public class Tree
{
private readonly int _x;
private readonly int _y;
private readonly TreeType _type; // 引用共享对象,不复制数据
public Tree(int x, int y, TreeType type)
{
_x = x;
_y = y;
_type = type;
}
public void Display() => _type.Display(_x, _y);
}
class Program
{
static void Main()
{
var forest = new List<Tree>();
var random = new Random();
string[] types = { "松树", "杨树", "桦树" };
for (int i = 0; i < 500000; i++)
{
string name = types[random.Next(types.Length)];
// 五十万棵树,实际TreeType对象最多只有三个
var type = TreeFactory.GetTreeType(name, "绿色", "标准纹理");
forest.Add(new Tree(random.Next(1000), random.Next(1000), type));
}
Console.WriteLine($"树的数量:{forest.Count}");
Console.WriteLine($"实际树种对象数:{TreeFactory.GetCacheCount()}");
}
}
运行这段代码会发现,五十万个Tree对象虽然存在,但真正携带完整树种数据的TreeType对象只有三个。每个Tree只保存两个坐标整数加一个引用,内存占用从理论上的数百MB降到几十MB,这就是享元模式的威力。
享元模式在.NET中的隐性应用
其实.NET运行时本身就大量使用了享元思想。最典型的例子是字符串驻留机制:C#中相同的字符串字面量在编译后会被驻留到一张全局表中,两处代码写下"hello"实际引用的是同一个字符串对象。可以用string.ReferenceEquals验证这一点,也可以调用string.Intern方法手动将动态生成的字符串放入驻留池。
另一个例子是Encoding.ASCII、Encoding.UTF8这类静态属性,它们返回的都是预创建好的单例对象,反复调用不会产生新实例。Boolean.True与Boolean.False字段同理,CLR为布尔值只维护两个装箱对象。还有小整数的缓存机制,虽然装箱值类型不完全等同享元,但设计思想是相通的。
这些框架层面的设计说明一个事实:享元模式特别适合那些“对象数量巨大、种类有限、状态可拆分”的场景。反过来说,如果对象的种类和数量差不多,或者外部状态比内部状态还多,享元模式反而会增加系统复杂度却省不下多少内存。
享元模式的注意事项与适用场景判断
使用享元模式时最容易踩的坑是线程安全。如果享元工厂用普通的Dictionary,多线程并发创建时可能抛出异常或产生重复对象,务必使用ConcurrentDictionary或加锁。其次是内部状态的不可变性必须严格保证,一旦某个地方修改了共享对象,所有使用者都会被波及,这种bug在大型系统里极难定位。可以考虑把内部状态的属性改成只有get访问器。
还需要权衡外部状态的传递成本。享元模式把外部状态从对象中剥离,意味着客户端每次调用操作时都要传入这些状态。如果外部状态很多、调用频繁,方法签名会变得臃肿,此时可以定义一个上下文类统一封装外部状态并传递给享元方法,代码会更整洁。
关于适用场景,可以参照以下几个判断条件:一是对象数量级达到万以上且对内存敏感;二是对象的大部分状态可以外部化;三是对象创建成本高或者对象身份不重要(客户端不依赖对象引用相等性做判断)。典型场景包括棋类游戏的棋子、文字处理软件的字符样式、地图瓦片、数据库连接池中的连接配置等。如果场景不满足这些条件,直接用普通对象加缓存可能更简单直接,不必为了模式而模式。
总结一下,享元模式在C#中的实现要点有三:用readonly字段固化内部状态、用线程安全字典构建享元工厂、由客户端负责维护和传递外部状态。掌握这三个要点,配合对业务场景的准确判断,就能在遇到海量对象问题时给出优雅的解决方案。