设计模式系列文章:https://zhum.in/blog/category/notes/design-pattern
Demo code:https://github.com/njZhuMin/BlogSampleCode
享元模式
面向对象可以很好地解决一些灵活性或可扩展性问题,但在很多情况下需要在系统中增加类和 对象的个数。当对象数量太多时,将导致运行代价过高,带来性能下降等问题。享元模式正是为解决这类问题而诞生的。
享元模式 (Flyweight Pattern) 又称为轻量级模式,是对象池的一种实现。例如通过引入线程池,可以避免不停的创建和销毁多个链接对象,减小了性能的损耗。享元模式的宗旨是共享细粒度对象,将多个对同类对象的访问集中管理,不必为每个访问者都创建一个单独的对象,以此来降低内存的消耗。
享元模式把一个对象的状态分成内部状态和外部状态,内部状态即是不变的,外部状态是变化的。通过共享不变的部分,达到减少对象数量并节约内存的目的。
享元模式有三个参与角色:
- 抽象享元角色 (Flyweight):享元对象抽象基类或者接口,同时定义出对象的外部状态和内部状态的接口或实现;
- 具体享元角色 (ConcreteFlyweight):实现抽象角色定义的业务。该角色的内部状态处理应该与环境无关,不能出现会有一个操作改变内部状态,同时修改了外部状态;
- 享元工厂 (FlyweightFactory):负责管理享元对象池和创建享元对象。
享元模式的内部状态和外部状态
享元模式的定义为我们提出了两个要求:细粒度和共享对象。
因为要求细粒度对象,所以不可避免地会使对象数量多且性质相近,此时我们就将这些对象的信息分为两个部分:内部状态和外部状态。
内部状态指对象共享出来的信息,存储在享元对象内部并且不会随环境的改变而改变;外部状态指对象得以依赖的一个标记,是随环境改变而改变的、不可共享的状态。
应用场景
生活中的一些 “共享” 场景:
- 图书馆借书
- 共享单车
- 共享车位
以共享单车举例,其内部状态有:是否损坏,颜色,款式,编码;外部状态:是否解锁,是否已被借出。
当系统中,多个访问者都需要访问相同的信息时,可以把这些信息(固定的状态属性)封装到一个对象中,然后对该对象进行缓存,这样,就可以将同一个对象提供给多个访问者,避免大量的重复创建同一个对象,消耗大量内存空间。
享元模式可以看作是工厂模式的一个改进机制。享元模式同样要求创建一个或一组对象,并且通过工厂方法生成对象。只不过在享元模式中,为工厂方法增加了缓存这一功能。
享元模式主要用于以下场景:
- 常应用于系统底层的开发,以便解决系统的性能问题。
- 系统中需要提供大量相似对象、需要缓冲池的场景。
这里看一个简易的数据库连接池的实现:
public class SimpleConnectionPool {
private static Vector<Connection> pool;
private static final int MAX_SIZE = 100;
private final String url = "jdbc:mysql://localhost:3306/test";
private final String username = "root";
private final String password = "root";
private final String driverClassName = "com.mysql.jdbc.Driver";
public SimpleConnectionPool() {
pool = new Vector<Connection>(MAX_SIZE);
try {
Class.forName(driverClassName);
for (int i = 0; i < MAX_SIZE; i++) {
Connection conn = DriverManager.getConnection(url, username, password);
pool.add(conn);
}
} catch (Exception e) {
e.printStackTrace();
}
}
public synchronized Connection getConnection() {
if (pool.size() > 0) {
Connection conn = pool.get(0);
pool.remove(conn);
return conn;
}
return null;
}
public synchronized void release(Connection conn) {
pool.add(conn);
}
}
对于外部使用者来说,其可见的连接对象的状态操作是创建和释放,可用和不可用。但是从连接池内部的角度来看,这些连接对象的内部状态(如用户名、密码、连接 URL 等信息)是不变的,只是对应从缓存中取出和放回并标记其可用状态(外部状态)。这就是享元模式封装对象从而实现复用的核心思想。
而对于大量连接对象创建这个耗时的行为,我们也可以在系统启动时就完成,在查询时节省宝贵的初始化时间。
JDK 中的享元模式
Java 中将 String 类定义为 final 类型,是不可改变的。 JVM 中字符串一般保存在字符串常量池中,java 会确保一个字符串在常量池中只有一个拷贝。这个字符串常量池在 JDK 6.0 以前是位于永久代中的常量池,而在 JDK 7.0 中,JVM 将其从永久代移除,放置于堆内存中。
看测试代码:
public class StringTest {
public static void main(String[] args) {
String s1 = "hello"; // 创建 "hello" 常量,s1 -> &"hello"(#0)
String s2 = "hello"; // "hello" 常量已存在,s2 -> &"hello"(#0)
System.out.println(s1 == s2); // true
String s3 = "he" + "llo"; // 编译期推断出常量已存在,s3 -> &"hello"(#0)
System.out.println(s1 == s3); // true
// 编译时创建常量 "he",运行时在堆上创建 "llo" 对象
// 相加结果得到新的 "hello" 对象保存在堆上,和常量池中的 "hello" 不是同一个
String s4 = "he" + new String("llo"); // s4 -> &"hello"(#1)
System.out.println(s1 == s4); // false
String s5 = new String("hello"); // 运行时在堆上创建 "hello" 对象
System.out.println(s1 == s5); // false,s5 -> &"hello"(#2)
System.out.println(s4 == s5); // false
String s6 = s5.intern(); // 取 s5 的常量地址,即常量池中的 &"hello"(#0)
System.out.println(s1 == s6); // true
String s7 = "h"; // 创建常量 "h"
String s8 = "ello"; // 创建常量 "ello"
// 编译期只认识常量,由于是变量运算,等到运行时才执行结果
String s9 = s7 + s8; // 运行时创建 "hello" 对象,s9 -> &"hello"(#3)
System.out.println(s1 == s9); // false
}
}
再举个大家都非常熟悉的例子,Integer 对象也用到了享元模式,其中暗藏玄机:
public class IntegerTest {
public static void main(String[] args) {
Integer a1 = Integer.valueOf(127);
Integer a2 = 100;
System.out.println(a1 == a2); // true
Integer a3 = Integer.valueOf(128);
Integer a4 = 500;
System.out.println(a3 == a4); // false
}
}
为什么同样的 valueOf
方法行为却不一样呢,这是因为 Integer 底层使用了享元模式缓存了他认为我们最常用的对象,范围是-128 至 127,上限可以通过 java.lang.Integer.IntegerCache.high
参数设置:
public final class Integer extends Number implements Comparable<Integer> {
public static Integer valueOf(int i) {
if (i >= -128 && i <= Integer.IntegerCache.high) {
return Integer.IntegerCache.cache[i + 128];
}
return new Integer(i);
}
}
同样的 Long 类型也应用了类似的缓存机制,只不过范围是固定的:
public static Long valueOf(long l) {
int offset = true;
if (l >= -128L && l <= 127L) {
return Long.LongCache.cache[(int)l + 128];
}
return new Long(l);
}
Apache Commons Pool 中的享元模式
对象池化的基本思路是:将用过的对象保存起来,等下一次需要这种对象的时候,再取出来重复使用,从而在一定程度上减少频繁创建对象所造成的开销。用于充当保存对象的 “容器” 对象,被称为 “对象池”(Object Pool,或简称 Pool) 。
Apache Commons Pool 实现了对象池的功能,定义了对象的生成、销毁、激活、钝化等操作及其状态转换,并提供几个默认的对象池实现。
其中有几个重要的对象:
- PooledObject(池对象):用于封装对象(如线程、数据库连接、 TCP 连接),将其包裹成可被池管理的对象。
- PooledObjectFactory(池对象工厂):定义了操作 PooledObject 实例生命周期的一些方法,PooledObjectFactory 必须实现线程安全。
- ObjectPool(对象池):ObjectPool 负责管理 PooledObject,如借出对象,返回对象,校验对象,有多少激活对象,有多少空闲对象。
private final Map<IdentityWrapper<T>, PooledObject<T>> allObjects;
小结
享元模式的优点:减少对象的创建,降低内存中对象的数量,降低系统的内存占用,提高效率。
享元模式的缺点:关注对象的内部、外部状态,关注线程安全问题。
享元模式与代理模式:
- 两种模式都持有对象的引用。
- 静态代理一般是一对一的,目的是增强功能;享元模式是一对多的,目的是统一控制对象的访问资源。
享元模式与单例模式:
享元模式一般配合工厂模式来使用,而这个享元工厂类一般实现为到单例。否则缓存对象存在多个地方,很难控制。
组合模式
组合模式 (Composite Pattern) 也称为整体-部分 (Part-Whole) 模式,它的宗旨是通过将单个对象(叶子节点)和组合对象(树枝节点)用相同的接口进行表示,使得客户对单个对象和组合对象的使用具有一致性。
组合模式一般用来描述整体与部分的关系,它将对象组织到树形结构中以表示 “部分-整体” 的层次结构,最顶层的节点称为根节点,根节点下面可以包含树枝节点和叶子节点,树枝节点下面又可以包含树枝节点和叶子节点。
组合模式包含三个角色:
- 抽象根节点 (Component):定义系统各层次对象的共有方法和属性,可以预先定义一些默认行为和属性;
- 树枝节点 (Composite):定义树枝节点的行为,存储子节点,组合树枝节点和叶子节点形成一个树形结构;
- 叶子节点 (Leaf):叶子节点对象,其下再无分支,是系统层次遍历的最小单位。
组合模式在代码具体实现上,有两种不同的方式,分别是透明组合模式和安全组合模式。
应用场景
当子系统与其内各个对象层次呈现树形结构时,可以使用组合模式让子系统内各个对象层次的行为操作具备一致性。
组合模式主要应用场景:
- 希望客户端可以忽略组合对象与单个对象的差异
- 对象层次具备整体和部分,呈树形结构。
在我们生活中的组合模式也非常常见,比如树形菜单,操作系统目录结构,公司组织架构等。
透明组合模式
透明组合模式是把所有公共方法都定义在 Component 中,这样做的好处是客户端无需分辨叶子节点 (Leaf) 和树枝节点 (Composite),它们具备完全一致的接口。
看一个透明实现的 Directory 类的案例,把所有可能用到的方法都定义到这个最顶层的抽象类中,但是不写任何逻辑处理的代码。
public abstract class Directory {
protected String name;
public Directory(String name) {
this.name = name;
}
public void add(Directory dir) {
throw new UnsupportedOperationException("add not supported");
}
public void remove(Directory dir) {
throw new UnsupportedOperationException("remove not supported");
}
public void ls() {
throw new UnsupportedOperationException("ls not supported");
}
public void tree() {
throw new UnsupportedOperationException("tree not supported");
}
}
这里有些同学会感到疑惑,为什么不用抽象方法?因为定义了抽象方法,其子类就必须实现,这样便体现不出各子类的细微差异。因此,子类继承此抽象类后,只需要重写有差异的方法来覆盖父类的对应方法即可。
public class File extends Directory {
public File(String name) {
super(name);
}
@Override
public void tree() {
System.out.println(this.name);
}
}
public class Folder extends Directory {
private List<Directory> dirs;
private Integer dirLevel;
public Folder(String name, Integer dirLevel) {
super(name);
this.dirLevel = dirLevel;
this.dirs = new ArrayList<Directory>();
}
@Override
public void add(Directory dir) {
this.dirs.add(dir);
}
@Override
public void remove(Directory dir) {
this.dirs.remove(dir);
}
@Override
public void ls() {
for (Directory dir : this.dirs) {
System.out.println(dir.name + (dir instanceof Folder ? "/" : ""));
}
}
@Override
public void tree() {
System.out.println(this.name + "/");
for (Directory dir : this.dirs) {
if (this.dirLevel != null) {
for (int i = 0; i < this.dirLevel; i ++) {
System.out.print(" ");
}
for (int i = 0; i < this.dirLevel; i ++) {
if (i == 0) {
System.out.print("+");
}
System.out.print("-");
}
}
dir.tree();
}
}
}
public class Test {
public static void main(String[] args) {
System.out.println("[Transparent Composite]");
Folder home = new Folder("Home", 1);
File a = new File("a.txt");
File b = new File("b.doc");
home.add(a);
home.add(b);
Folder apps = new Folder("Apps", 2);
File chrome = new File("Chrome.exe");
File firefox = new File("Firefox.exe");
apps.add(chrome);
apps.add(firefox);
File word = new File("Word.exe");
File excel = new File("Excel.exe");
File ppt = new File("Ppt.exe");
Folder office = new Folder("Office", 3);
office.add(word);
office.add(excel);
office.add(ppt);
apps.add(office);
home.add(apps);
System.out.println("[List home folder]");
home.ls();
System.out.println("[Tree home folder]");
home.tree();
// Transparent implement
// Not safe: caller can call unsupported method defined in Interface
chrome.ls();
// Exception in thread "main" java.lang.UnsupportedOperationException: ls not supported
}
}
透明组合模式的缺点是,叶子节点 (Leaf) 会继承得到一些它所不需要的方法,这与接口隔离的设计原则相违背。
安全组合模式
安全组合模式是只规定系统各个层次的最基础的一致行为,而把组合(树节点)本身的方法放到实现子类中。
还是举文件系统的例子,文件系统有两大层次:文件夹,文件。其中,文件夹能容纳其他层次,为树枝节点;文件为最小单位,为叶子节点。
由于目录系统层次较少,且树枝节点(文件夹)结构相对稳定,而文件可以有很多类型,所以选择使用安全组合模式来实现目录系统,可以避免为叶子类型(文件)引入冗余方法。
public abstract class Directory {
protected String name;
public Directory(String name) {
this.name = name;
}
public abstract void tree();
}
public class File extends Directory {
public File(String name) {
super(name);
}
@Override
public void tree() {
System.out.println(this.name);
}
}
public class Folder extends Directory {
private List<Directory> dirs;
private Integer dirLevel;
public Folder(String name, Integer dirLevel) {
super(name);
this.dirLevel = dirLevel;
this.dirs = new ArrayList<Directory>();
}
public void add(Directory dir) {
this.dirs.add(dir);
}
public void remove(Directory dir) {
this.dirs.remove(dir);
}
public void ls() {
for (Directory dir : this.dirs) {
System.out.println(dir.name + (dir instanceof Folder ? "/" : ""));
}
}
@Override
public void tree() {
System.out.println(this.name + "/");
for (Directory dir : this.dirs) {
if (this.dirLevel != null) {
for (int i = 0; i < this.dirLevel; i ++) {
System.out.print(" ");
}
for (int i = 0; i < this.dirLevel; i ++) {
if (i == 0) {
System.out.print("+");
}
System.out.print("-");
}
}
dir.tree();
}
}
}
public class Test {
public static void main(String[] args) {
System.out.println("[Safe Composite]");
Folder home = new Folder("Home", 1);
File a = new File("a.txt");
File b = new File("b.doc");
home.add(a);
home.add(b);
Folder apps = new Folder("Apps", 2);
File chrome = new File("Chrome.exe");
File firefox = new File("Firefox.exe");
apps.add(chrome);
apps.add(firefox);
File word = new File("Word.exe");
File excel = new File("Excel.exe");
File ppt = new File("Ppt.exe");
Folder office = new Folder("Office", 3);
office.add(word);
office.add(excel);
office.add(ppt);
apps.add(office);
home.add(apps);
System.out.println("[List home folder]");
home.ls();
System.out.println("[Tree home folder]");
home.tree();
// Safe implement
// Safe: caller cannot call unsupported method as is defined in subclass
// chrome.ls();
}
}
我们可以看到,由于子节点自身的实现并不在接口中公开保留,File 对象就无法调用到 Folder 对象才有的 ls
方法,从而在调用端保证了安全性。
安全组合模式的好处:
- 接口定义职责清晰,符合设计模式单一职责原则和接口隔离原则;
缺点:
- 客户需要区分树枝节点 (Composite) 和叶子节点 (Leaf),这样才能正确处理各个层次的操作;
- 客户端无法依赖抽象 (Component),违背了设计模式依赖倒置原则。
组合模式在源码中的应用
HashMap
组合模式在源码中应用也是非常广泛的。首先我们来看一个非常熟悉的 HashMap.putAll()
方法:
public class HashMap<K, V> extends AbstractMap<K, V> implements Map<K, V>, Cloneable, Serializable {
public void putAll(Map<? extends K, ? extends V> m) {
this.putMapEntries(m, true);
}
final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) {
int s = m.size();
if (s > 0) {
// ...
Iterator var8 = m.entrySet().iterator();
while(var8.hasNext()) {
Entry<? extends K, ? extends V> e = (Entry)var8.next();
K key = e.getKey();
V value = e.getValue();
this.putVal(hash(key), key, value, false, evict);
}
}
}
}
我们看到 putAll() 方法传入的是 Map 对象,Map 就是一个抽象构件(同时这个构件中只支持键值对的存储格式),而 HashMap 是一个中间构件。 HashMap 中的 Node 节点就是叶子节点。
HashMap 中的存储方式是一个静态内部类的数组 Node<K,V>[] tab
:
static class Node<K, V> implements Entry<K, V> {
final int hash;
final K key;
V value;
HashMap.Node<K, V> next;
Node(int hash, K key, V value, HashMap.Node<K, V> next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
}
ArrayList
同理,我们常用的 ArrayList 对象中的 addAll()
方法,其参数也是 ArrayList 的父类 Collection:
public class ArrayList<E> extends AbstractList<E> implements List<E>, RandomAccess, Cloneable, Serializable {
public boolean addAll(Collection<? extends E> c) {
Object[] a = c.toArray();
++this.modCount;
int numNew = a.length;
if (numNew == 0) {
return false;
} else {
Object[] elementData;
int s;
if (numNew > (elementData = this.elementData).length - (s = this.size)) {
elementData = this.grow(s + numNew);
}
System.arraycopy(a, 0, elementData, s, numNew);
this.size = s + numNew;
return true;
}
}
}
MyBatis
再举一个 MyBatis 中非常经典的案例,MyBatis 解析各种 Mapping 文件中的 SQL,设计了一个非常关键的类叫做 SqlNode
。 XML 中的每一个 Node 都会解析为一个 SqlNode 对象,最后把所有的 SqlNode 拼装到一起,从而生成完整的 SQL 语句。
它的顶层设计非常简单:
public interface SqlNode {
boolean apply(DynamicContext context);
}
apply()
方法会根据传入的参数 context,解析该 SqlNode 所记录的 SQL 片段,并调用 DynamicContext.appendSql()
方法拼接 SQL 片段。当 SQL 节点下的所有 SqlNode 完成解析后,可以通过 DynamicContext.getSql()
获取一条完成的 SQL 语句。
组合模式小结
既然组合模式分为两种实现,那么不同的实现方式会更适合某些特定场景,即具体情况具体分析。
- 透明组合模式:适用于系统中绝大多数层次具备相同的公共行为时;代价是为剩下少数层次节点引入不需要的方法。
- 安全组合模式:适用于系统中各个层次差异性行为较多或者树节点层次相对稳定(健壮)时。
下面我们再来总结一下组合模式的优缺点。
优点:
- 清楚地定义分层次的复杂对象,表示对象的全部或部分层次
- 让客户端忽略系统层次的差异,方便对整个层次结构进行控制
- 简化客户端代码,符合开闭原则
缺点:
- 限制类型时会较为复杂
- 使设计变得更加抽象