模式动机
自从几年前 “共享单车” 横空出世,”共享经济“ 一词也被推广得热火朝天,它也逐渐渗透到我们日常生活中的各个角落。从 “共享单车” 到 “共享房车”,我们看到的不仅是市场需求的转变,更是人们对这一理念的推崇。
归根到底,人们推崇共享理念无非是因为 “共享” 极大的减少了不必要的资源消耗,这会使得生活负担不再如往常沉重。
“共享” 同样也适用于软件系统的开发。在面向对象程序设计过程中,有时会面临创建大量相同或相似对象实例的问题,但是创建过多的对象会耗费掉很多的系统资源,它会成为阻碍系统性能提升的瓶颈。联想上面所提及的 “共享理念”,我们可以将对象中的相同部分提取出来用于共享,可以大幅度节省系统资源,何乐而不为呢?
🚀以上的设计理念在 “软件体系结构与设计模式” 中有一个专有名词,称作「享元模式」。像数据库的连接池、程序的线程池等都是享元模式的典型应用。
🌔为了能让你更好理解稍后的 UML 类图绘制,我在此举一个更为详细的例子帮助你理解享元模式是如何运作的。
假设你闲暇之余开发了一款游戏,并在编译游戏后将其发送给朋友测试,尽管在你的电脑上能流畅地运行,但你的朋友运行几分钟后却崩溃了,调试后发现是内存容量不足所造成的,他的电脑性能远比不上你的,因此很快就出现了问题。
游戏介绍:玩家们在地图上移动并相互射击,大量的子弹会在整个地图上穿行。
一个小小的游戏为什么能造成内存容量不足的问题?经排查后发现,内存不足跟粒子系统有关,每个粒子(一颗子弹)都由完整数据的独立对象构成,当游戏鏖战到激烈的画面时,系统却无法在剩余内存在载入新建的粒子,从而导致系统崩溃。
但仔细观察粒子 Particle 类,你会注意到其颜色 color 与精灵图 sprite 这两个成员变量所耗费的内存要比其他变量多得多。😨最糟糕的是:对于所有粒子对象而言,这两个成员变量所存储的数据几乎完全一样(也就是所有子弹的颜色和精灵图一样)。只有另一些状态,如坐标 coords、移动矢量 vector 和速度 speed 不一样,因为这些成员变量会不断变化,它代表粒子存在期间不断变化的情景。
⭐粒子对象中那些持续不变的数据(颜色 & 精灵图)通常称为内部状态,它们只能读取但不能被修改其数值;而那些可以被其他对象 “从外部” 改变的数据则称为外部状态,在粒子对象中体现为坐标、移动矢量和速度。
享元模式墙裂建议别在享元对象中存储外部状态(享元对象只保存内部状态),而是将外部状态传递给依赖于它的特殊方法作为参数,以便于在不同情境下进行重用/共享。这样会使得内部状态的变体会少很多,所耗费的系统资源也会如是减少。
我们将一个仅存储内部状态的对象称为享元对象。
回到这款游戏中,我们只需要从粒子类中抽取出外部状态封装到 MovingParticle 类中,使得封装了所有内部状态的 Particle 类作为 MovingParticle 的成员变量。这样一来系统中所有的粒子只需要用到相应数量且内存占用少的 MovingParticle 对象和一个内存占用稍多的 Particle 对象即可保证游戏正常流畅地持续运行(消耗内存最多的成员变量已经被移动到唯一的享元对象中),因为所需内存已经从优化前的 21GB 变成了 32MB,这下再也不用担心因内存不足导致的系统崩溃啦!
⭐说了这么多,你应该对享元模式有个大概印象了,再提两点:
- 享元与不可变性:由于享元对象能在不同情景中使用,必须确保其状态无法被修改,享元对象的状态只能由构造函数的参数进行一次性初始化。
- 享元工厂:为了更方便访问各种享元,你可以创建一个工厂方法来管理已有享元对象的缓存池(像线程池、数据库连接池这类)。工厂方法从客户端处接收目标享元对象的内部状态作为参数,如果它能在缓存池中找到所需享元,则将其返回给客户端;否则新建一个享元,并将其添加到缓存池中。
定义
享元模式又可以理解为缓存模式、轻量级模式,它是一种对象结构型模式。
享元模式摒弃了保存每个对象所有数据的方式,运用共享技术有效支持大量细粒度对象的复用,共享多个对象所共有的相同状态,让你能够在有限的内存容量中载入更多对象。
享元模式本质就是缓存共享对象,降低内存消耗。它只是针对大量类似对象造成内存消耗的一种优化,不用将其过度神化。
UML 类图
模式结构
享元模式包含如下角色:
FlyweightFactory:享元工厂类会对已有享元的缓存池进行管理。有了工厂后,客户端Client就无需直接创建享元,它们只需调用工厂并向其传递目标享元的一些内在状态 (intrinsicState) 即可。工厂会根据参数在之前已创建的享元中进行查找,如果找到满足条件的享元就将其返回;如果没有找到就根据参数新建享元。Flyweight:享元类包含原始对象中部分能在多个对象中共享的内部状态。同一享元对象可在许多不同原始对象 (Context) 中使用。享元中存储的状态被称为 “内部状态 (intrinsicState)”,传递给享元方法的状态被称为 “外在状态 (extrinsicState)”。⭐通常情况下,原始对象的行为 (operation()) 会保留在享元类中,因此调用享元方法必须提供部分外在状态 (extrinsicState) 作为参数;但你也可将行为移动到原始类中,然后将连入的享元作为单纯的数据对象。Context:原始类包含原始对象中各不相同的外在状态,与享元对象组合在一起就能表示原始对象的全部状态,其extrinsicState字段是可变的,通常取决于客户端。
⭐其中,intrinsicState 表示可共享的内部状态;extrinsicState 表示不可以共享的外部状态,它以参数的形式注入享元相关方法中。
较为具体的享元模式
较为普遍的享元模式
⭐UnsharedConcreteFlyweight 更多是为了结构的完整性而出现的(它并非所有的字段都是可共享的,allState 同时包含了共享与非共享字段),但我们其实可以不用太注意它的存在,而应该聚焦于可共享的对象。
更多实例
虽然网络设备可以共享,但是分配给每一个终端计算机的端口 Port 是不同的,因此多台计算机虽然可以共享同一个网络设备,但必须使用不同的端口。我们可以将端口从网络设备中抽取出来作为外部状态,需要时再进行设置。
共享网络设备(有外部状态)
示例代码
Flyweight.java
public class Flyweight {
private String intrinsicState;
public Flyweight(String intrinsicState) {
this.intrinsicState = intrinsicState;
}
public void operation(String extrinsicState) {
// TODO: here using 'extrinsicState'; you can use it in "Context" too.
}
}
FlyweightFactory.java
public class FlyweightFactory {
private static Map<String, Flyweight> flyweights;
static {
flyweights = new ConcurrentHashMap<String, Flyweight>();
}
public static Flyweight getFlyweight(String intrinsicState) {
if (flyweights.containsKey(intrinsicState)) {
// Exist
System.out.println("Get by cache");
return flyweights.get(intrinsicState);
} else {
// No exist
System.out.println("Create new one");
Flyweight flyweight = new Flyweight(intrinsicState);
flyweights.put(intrinsicState, flyweight);
return flyweight;
}
}
}
Context.java
public class Context {
private String extrinsicState;
private Flyweight flyweight;
public Context(String extrinsicState, String intrinsicState) {
this.extrinsicState = extrinsicState;
this.flyweight = FlyweightFactory.getFlyweight(intrinsicState);
}
public void operation() {
flyweight.operation(extrinsicState);
}
@Override
public String toString() {
return "Context{" +
"extrinsicState='" + extrinsicState + '\'' +
", flyweight=" + flyweight +
'}';
}
}
Client.java
public class Client {
public static void main(String[] args) {
Context context = new Context("ex1", "in");
System.out.println(context);
Context context1 = new Context("ex2", "in");
System.out.println(context1);
}
}
优缺点
✔享元模式使得相同对象或相似对象在内存中只保存一份,可以极大减少内存中对象的数量,降低了系统中细粒度对象给内存带来的压力。
✔享元模式的外部状态相对独立,而且不会影响其内部状态,从而使得享元模式可以在不同的情境中被共享。
❌为了使对象可以共享,需要将一些不能共享的状态外部化,这将会增加程序的复杂性。
❌读取外部状态需要耗费时间,这就意味着享元会牺牲执行速度来换取内存。
适用场景
在以下情况推荐使用享元模式:
享元模式只有一个目的:将内存消耗最小化。
(1)仅在程序必须支持大量相同/相似对象且没有足够的内存容量时使用享元模式。
「享元模式」落地
编辑器软件
享元模式在编辑器软件中大量使用,如在一个文档中出现多次相同的图片,则只需要创建一个图片对象,通过在应用程序中设置该图片出现的位置,可以实现该图片在不同地方多次重复显示。
String 中的享元模式
Java 中将 String 类定义为 final 类型,JVM 中字符串一般保存在字符串常量池中,Java 会确保一个字符串在常量池中只有一个拷贝。
public class StringMain {
public static void main(String[] args) {
// String 中的享元模式: 字符串常量池
String s1 = "Hello World";
String s2 = "Hello " + "World";
String s3 = "Hello " + new String("World");
String s4 = new String("Hello World");
String s5 = s4.intern();
System.out.println(s1 == s2); // true: 指向字符串常量池同一个字面量
System.out.println(s1 == s3); // false: new String("World") 存在于堆中, 相加后的结果 s3 也在堆中, 而 s1 在字符串常量池中, 所以不相等
System.out.println(s1 == s4); // false: 同上, s1 在字符串常量池中, s3 在堆中
System.out.println(s3 == s4); // false: 尽管都在堆中, 但仍是同一个类的不同对象
System.out.println(s1 == s5); // true: intern() 方法能使一个位于堆中的字符串在运行期间动态地加入到字符串常量池中
}
}
String 类由 final 修饰,以字面量形式创建 String 变量,JVM 会在编译期间就把该字面量 "Hello World" 放到字符串常量池中,在 Java 程序启动的时候就已经加载到内存中了。
该字符串常量池的特点就是有且仅有一份相同的字面量,如果所需字面量在池中存在,JVM 会返回该字面量的引用,如果没有则字符串常量池创建这个字面量并返回它的引用。
Integer 中的享元模式
先来看一段匪夷所思的代码:
public class IntegerMain {
public static void main(String[] args) {
// Integer 中的享元模式
Integer i1 = 1;
Integer i2 = 1;
System.out.println(i1 == i2); // true
Integer i3 = 128;
Integer i4 = 128;
System.out.println(i3 == i4); // false
}
}
🙄为什么第一个为 true,而第二个为 false?
反编译后发现 Integer i3 = 128; 实际变成了 Integer i3 = Integer.valueOf(128);、Integer i4 = 128; 变成了 Integer i4 = Integer.valueOf(128);。从中可以得知 valueOf() 方法有所蹊跷,我们接着来查看下 Integer 源码中 valueOf() 方法的实现!
Integer中享元模式部分的关键源码!
可以看到 Integer 事先创建并缓存 -128 ~ 127 之间数字的 Integer 对象,当调用 valueOf() 时,如果参数在 -128 ~ 127 之间,则直接从缓存中返回,否则创建一个新的 Integer 对象。
⭐更多:除 String 和 Integer 之外,还有 Boolean、Byte、Character、Short、Long、BigDecimal.. 感兴趣的可以自己翻翻源码,学习一下其中值得借鉴的思想。
模式扩展
解锁享元模式新类图
(1)享元模式墙裂建议别在享元对象中存储外部状态(享元对象只保存内部状态),而是将外部状态传递给依赖于它的特殊方法作为参数。该图和 “更多实例” 部分中的类图结构一致,也是一种较为普遍的享元模式类图,其 ExtrinsicState 作为外部状态直接传递给 Flyweight 的 operation() 作为参数。
(2)从更宏观的角度来看(也就是从客户端的角度出发),也可以将外部状态抽取到一个情景类 (Context) 中。这幅图就和上文 “较为具体的享元模式” 的类图结构如出一辙。
单纯享元模式
在单纯享元模式中,所有的享元对象都是可以共享的,即所有抽象享元类的子类都可以共享,不存在非共享具体享元类 UnsharedConcreteFlyweight,更接近我们平时所使用的享元模式。
享元模式 & 组合模式:复合享元模式
将一些「享元模式」使用「组合模式」加以组合,可以形成复合享元模式,这样的复合享元对象本身不能共享,但是它们可以分解成单纯享元模式,而后者可以共享。
享元模式 vs 单例模式
如果原始对象 Context 中所有状态皆为内部状态,那么「享元模式」和「单例模式」就类似了,但这两个模式有两个根本性的不同:
- 单例类只会有一个单例实体;但是享元类可以有多个实体,各实体的内在状态也可以不同。
- 单例对象可以是可变的,享元对象是不可变的。
😆如果你能理解这两句话,那么恭喜你,你已经能很好地掌握享元模式与单例模式啦!
最后
👆上一篇:「设计模式」🏰外观模式(Facade)
👇下一篇:持续更文中,敬请期待...
❤️ 好的代码无需解释,关注「手撕设计模式」专栏,跟我一起学习设计模式,你的代码也能像诗一样优雅!
❤️ / END / 如果本文对你有帮助,点个「赞」支持下吧,你的支持就是我最大的动力!