跳至主要內容

享元模式

AruNi_Lu设计模式设计模式与范式约 3558 字大约 12 分钟

本文内容

前言

上一篇文章讲的组合模式,主要应用在数据能表示成树形结构,所以不太常用。

而本篇文章要讲的 享元模式,也不太常用,因为它的使用场景也比较特殊。不过在 Java 中,你经常使用的 String、Integer 都使用到了享元模式

1. 什么是享元模式

享元,顾名思义就是 被共享的单元,它的意图是 复用对象,节省内存。对象要复用,前提肯定是 不可变对象

不可变对象” 指的是 对象一旦通过构造函数初始化后,它的状态(对象的成员变量或属性)就不会再被修改了

具体来说,当系统中存在 大量重复不可变 的对象时,就可以使用 享元模式 将对象设计成享元,在内存中只保存一份实例,供多处代码引用。这样便可以减少内存中的对象数量,起到节省内存的目的。

对于 相似的对象,其实也可以 将对象中相同的部分(字段)提取出来,设计成享元,让大量相似的对象引用这些享元。

2. 如何实现享元模式

下面用一个象棋游戏来看看享元模式的具体代码实现。

在象棋游戏中,一个游戏厅有成千上万个 “房间”,每个房间对应一个棋局。棋局中要保存每个棋子的数据,如:棋子类型(将、相、士等)、棋子颜色(红、黑)、棋子的位置(x, y)。

利用上面这些数据,就可以将一个完整的棋盘显示出来,具体代码如下:

public class ChessPiece {	//棋子
  private int id;
  private String text;
  private Color color;
  private int positionX;
  private int positionY;

  public static enum Color {
    RED, BLACK
  }

  // ...省略其他属性和构造器、getter/setter 方法...
}

public class ChessBoard {	//棋局
  private Map<Integer, ChessPiece> chessPieces = new HashMap<>();

  public ChessBoard() {
    init();
  }

  private void init() {
    chessPieces.put(1, new ChessPiece(1, "車", ChessPiece.Color.BLACK, 0, 0));
    chessPieces.put(2, new ChessPiece(2, "馬", ChessPiece.Color.BLACK, 0, 1));
    //...省略摆放其他棋子的代码...
  }

  public void move(int chessPieceId, int toPositionX, int toPositionY) {
    //...省略...
  }
}

为了记录每个房间的棋局情况,需要给每个房间都创建一个 ChessBoard 棋局对象,所以当房间比较多的时候,就会 消耗大量的内存

这时候就可以使用享元模式了,因为其实这些棋局对象是相似的,这些对象中很多属性都是相同的,比如 id、text、color,唯独 positionX、positionY 不同。所以可以把相同的属性拆分出来,设计成独立的类,作为享元供多个期盼复用。

具体代码实现如下:

// 享元类,创建对象后便不能改变对象,所以不提供 setter 方法
public class ChessPieceUnit {
  private int id;
  private String text;
  private Color color;

  public static enum Color {
    RED, BLACK
  }

  // ...省略其他属性和构造器、getter方法...
}

// 提供一个ChessPieceUnit 工厂类,用来获取享元
public class ChessPieceUnitFactory {
  private static final Map<Integer, ChessPieceUnit> pieces = new HashMap<>();

  static {
    pieces.put(1, new ChessPieceUnit(1, "車", ChessPieceUnit.Color.BLACK));
    pieces.put(2, new ChessPieceUnit(2, "馬", ChessPieceUnit.Color.BLACK));
    //...省略摆放其他棋子的代码...
  }

  public static ChessPieceUnit getChessPiece(int chessPieceId) {
    return pieces.get(chessPieceId);
  }
}

public class ChessPiece {
  private ChessPieceUnit chessPieceUnit;
  private int positionX;
  private int positionY;

  // 省略构造器、getter、setter方法
}

public class ChessBoard {
  private Map<Integer, ChessPiece> chessPieces = new HashMap<>();

  public ChessBoard() {
    init();
  }

  private void init() {
    // 通过 ChessPieceUnitFactory 来获取享元
    chessPieces.put(1, new ChessPiece(
            ChessPieceUnitFactory.getChessPiece(1), 0,0));
    chessPieces.put(2, new ChessPiece(
            ChessPieceUnitFactory.getChessPiece(2), 1,0));
    //...省略摆放其他棋子的代码...
  }

  public void move(int chessPieceId, int toPositionX, int toPositionY) {
    //...省略...
  }
}

我们使用 工厂类来缓存 ChessPieceUnit 信息(id、text、color),通过工厂类获取到的 ChessPieceUnit 就是享元

因为在一个棋盘中,只有 30 个不同的棋子,所以所有的 ChessBoard 对象都可以共享这 30 个 ChessPieceUnit 对象。在使用享元模式之前,记录 1 万个棋局,需要创建 30 万(30×1 万)个棋子的 ChessPieceUnit 对象。利用享元模式,只需要创建 30 个享元对象供所有棋局共享使用,大大节省了内存

所以,享元模式的代码实现主要是:通过工厂模式,在工厂类中通过一个 Map 来缓存已经创建过的享元,来达到复用的目的,从而节省了内存空间

3. 享元模式 vs 单例、缓存、池化技术

好像享元、单例、缓存、对象池,都有点对象复用或共享的意思,那么它们有啥区别呢?

享元模式和单例的区别

单例模式中,一个类只能创建一个对象,而享元模式中,一个类可以创建多个对象,每个对象被多处代码引用共享

可以发现,享元模式有点类似与单例的变体,多例。不过它们的 设计意图是完全不同的

  • 享元模式是为了对象复用,节省内存
  • 多例模式是为了限制对象的个数

享元模式和缓存的区别

享元模式 中,我们一般通过工厂类来 “缓存” 已经创建好的对象,这里的 “缓存” 其实是 “存储” 的意思。

而我们 平时说的缓存,例如CPU 缓存、Redis 缓存等,主要是为了 提高访问速度,而非复用

享元模式和池化技术的区别

池化技术常见的有连接池、线程池等,也是为了复用,那它们和享元模式有什么区别呢?

虽然都是复用,但是它们的 复用其实是不同的概念

  • 池化技术 的复用,指的是 重复使用,主要目的是 节省时间(比如线程池节省了线程创建和销毁的时间)。在任意时刻,每个连接(连接池)或线程(线程池)并不会被多处使用,而是被一个使用者独占,使用完后放回池中,再由其他使用者重复利用
  • 享元模式 中的复用,指的是 共享使用在享元的整个声明周期中,都是被所有使用者共享的,主要目的是节省空间

4. 享元模式在 Java 中的应用

4.1 享元模式在 Integer 中的应用

先给你抛一道面试题:

Integer i1 = 56;
Integer i2 = 56;
Integer i3 = 129;
Integer i4 = 129;
System.out.println(i1 == i2);
System.out.println(i3 == i4);

我们知道,Java 中 == 号是比较对象的地址值是否相等,所以答案是 false、false 吗?显然没有这么简单。

这就涉及到 Integer 的缓存机制 了,其实 在创建 Integer 对象时,如果对象的值在 -128 到 127 之间,则会先从 IntegerCache 类中获取对象,否则才会新创建对象

所以其实上面的答案是 true,false。

而这就是一种 享元模式,这里的 IntegerCache 就相当于前面讲的享元对象的工厂类,只是名字不叫 xxxFactory 而已。IntegerCache 是 Integer 的静态内部类,源码如下:

    /**
     * Cache to support the object identity semantics of autoboxing for values between
     * -128 and 127 (inclusive) as required by JLS.
     *
     * The cache is initialized on first usage.  The size of the cache
     * may be controlled by the {@code -XX:AutoBoxCacheMax=<size>} option.
     * During VM initialization, java.lang.Integer.IntegerCache.high property
     * may be set and saved in the private system properties in the
     * jdk.internal.misc.VM class.
     */
    private static class IntegerCache {
        static final int low = -128;
        static final int high;
        static final Integer[] cache;
        static Integer[] archivedCache;

        static {
            // high value may be configured by property
            int h = 127;
            String integerCacheHighPropValue =
                VM.getSavedProperty("java.lang.Integer.IntegerCache.high");
            if (integerCacheHighPropValue != null) {
                try {
                    h = Math.max(parseInt(integerCacheHighPropValue), 127);
                    // Maximum array size is Integer.MAX_VALUE
                    h = Math.min(h, Integer.MAX_VALUE - (-low) -1);
                } catch( NumberFormatException nfe) {
                    // If the property cannot be parsed into an int, ignore it.
                }
            }
            high = h;

            // Load IntegerCache.archivedCache from archive, if possible
            CDS.initializeFromArchive(IntegerCache.class);
            int size = (high - low) + 1;

            // Use the archived cache if it exists and is large enough
            if (archivedCache == null || size > archivedCache.length) {
                Integer[] c = new Integer[size];
                int j = low;
                for(int i = 0; i < c.length; i++) {
                    c[i] = new Integer(j++);
                }
                archivedCache = c;
            }
            cache = archivedCache;
            // range [-128, 127] must be interned (JLS7 5.1.7)
            assert IntegerCache.high >= 127;
        }

        private IntegerCache() {}
    }

通过查看源码可知,当使用到 IntegerCache 时,就会在类加载时将缓存的享元对象一次性创建好。这也是为什么只提供 -128 到 127 之间的值,如果范围太大,那么就需要创建很多的享元对象,占用了太多的内存,也使得该类的加载时间过长。所以 JDK 只选择缓存了最常用的,也就是一个字节的大小(-128 到 127 之间的数据)。

JDK 也提供了自定义调节缓存的最大值,通过 -Djava.lang.Integer.IntegerCache.high=255-XX:AutoBoxCacheMax=255 参数调节,不过没有提供设置最小值的方法。

当你掌握了 IntegerCache 后,面试官又抛给你一道题:

Integer n1 = 56;
Integer n2 = 56;
Integer n3 = new Integer(56);
Integer n4 = new Integer(56);
System.out.println(n1 == n2);
System.out.println(n3 == n4);

如果你觉得答案是 true,true 的话,那面试官可能就直接叫你回去等通知了。

我们回过头去看看 IntegerCache 类的注释:

    /**
     * Cache to support the object identity semantics of autoboxing for values between
     * -128 and 127 (inclusive) as required by JLS.
     *
     * ......
     */

可以发现,这个缓存只对范围在 -128 到 127 之间,且通过自动装箱 autoboxing 的对象创建时,才会起作用

关于 自动拆装箱,其实就是 Java 提供的一种语法糖:

  • 当直接将基本数据类型变量赋值给包装类型变量时,就会发生自动装箱,实际上就是调用 Integer.valueOf() 方法;
  • 当直接将包装类型变量赋值给基本数据类型变量时,就会发生自动拆箱,实际上就是调用 包装类型变量.intValue() 方法。

所以在上面的面试题中,n1 和 n2 都是自动装箱,所以 n1、n2 直接复用了 IntegerCache 中的对象(56 的对象值在类加载的时候就已经添加到缓存了)。而 n3 和 n4 直接通过 new 来创建对象,没有使用自动装箱,自然就使用不到 IntegerCache 了。

Integer 装箱的源码如下:

    public static Integer valueOf(int i) {
        // 如果值在范围内,则从 IntegerCache 中获取
        if (i >= IntegerCache.low && i <= IntegerCache.high)
            return IntegerCache.cache[i + (-IntegerCache.low)];
        return new Integer(i);
    }

所以,我们在开发中使用 Integer 时,最好都使用自动装箱(或者自己显示调用 Integer.valueOf())来创建对象,这样可以大大节省内存

在 JDK 9 中已经将包装类型的构造器设置为 Deprecated 了,彻底弃用了。

image-20230418220348470

实际上,除了 Integer 之外,其他的包装类型比如 Long、Short、Byte 等,也都利用享元模式来缓存 -128 到 127 之间的数据,实现方式都和 Integer 类似。

4.2 享元模式在 String 中的应用

同样,先给你抛一道面试题:

String s1 = "AruNi";
String s2 = "AruNi";
String s3 = new String("AruNi");
System.out.println(s1 == s2);
System.out.println(s1 == s3);

答案是 true,false。和 Integer 类的设计思路类似,String 利用享元模式来复用相同的字符串常量(代码中的 "AruNi"),JVM 会专门开辟一块存储区来存储字符串常量,也就是 常量池

不过,String 类的享元模式和 Integer 类还是有点 区别

  • Integer 中的享元,是在类加载的时候就一次性创建好
  • String 常量池中的享元,是在字符串常量第一次被使用时,才放到常量池中,之后就可以复用这个字符串常量了

原因也很好理解,因为对于字符串来说,没法事先知道要共享哪些字符串常量,所以只能在被使用时才添加了。

关于字符串常量池,其实还有很多内容,涉及到 JVM,这里就不过多讲解了,可以看我的另一篇文章:String 类open in new window

5. 总结

享元,顾名思义就是 被共享的单元,它的意图是 复用对象,节省内存。对象要复用,前提肯定是 不可变对象

享元模式的代码实现主要是通过工厂模式,在工厂类中通过一个 Map(或 List,看具体场景)来缓存已经创建好的享元对象,以达到复用的目的。

其实很多设计模式的代码实现都大差不差,但 设计意图是截然不同的,主要是看要解决什么问题

这里再简单的概括下 享元模式和单例、缓存、池化技术的区别

  • 享元模式是为了对象复用,节省内存,而且享元对象是多个使用者共享的;
  • 单例模式是为了保证对象的全局唯一,就算是多例,也是要限制对象的个数的;
  • 缓存是为了提高访问速度,而非复用;
  • 池化技术中的复用,是为了节省时间(不为对象创建和销毁花费太多的时间),而且池化技术中的线程/连接是被使用者独占的。

最后讲到了享元模式在 Java Integer、String 中的应用:

  • Integer 的缓存机制,其实是 在创建 Integer 对象时,如果对象的值在 -128 到 127 之间,则会先从 IntegerCache 类中获取对象,否则才会新创建对象

    需要注意的是,只有在使用自动装箱的时候,才会使用缓存。而且 IntegerCache 在使用到时,会在类加载的时候就初始化好缓存。

  • String 的缓存机制主要是通过字符串常量池来提供的,当使用到字符串时,会先尝试从字符串常量池中获取,获取不到才会创建对象,然后将对象放入字符串常量池中。

其实,IntegerCache 也可以像字符串常量池那样,在使用到时才创建然后放入缓存中,一个简单的思路是使用 WeakHashMap。

使用弱引用是为了让外部没有变量引用对象时,可以让这些对象被 GC 回收掉,而不至于浪费内存。

上次编辑于: