10k

设计模式之美-课程笔记35-享元模式

享元模式

如何利用享元模式优化文本编辑器内存占用

原理和实现

  1. 享元:被共享的单元。

  2. 享元模式的意图是复用对象,节省内存,前提是享元对象是不可变对象。

  3. 当一个系统中存在大量重复对象的时候,如果这些重复的对象是不可变对象,我们可以利用享元模式将对象设计成享元,内存中只保留一份实例,供多处代码引用。(减少对象数量,节省内存)。

  4. 不可变对象指的是:一单通构造函数初始化完成之后,他的状态(对象的成员变量或者属性)就不会再被修改了。所以不可变对象不能暴露setter等修改内部状态的方法。

  5. 来个例子:

    假设我们在开发一个棋牌游戏(比如象棋)。一个游戏厅中有成千上万个“房间”,每个房间对应一个棋局。棋局要保存每个棋子的数据,比如:棋子类型(将、相、士、炮等)、棋子颜色(红方、黑方)、棋子在棋局中的位置。利用这些数据,我们就能显示一个完整的棋盘给玩家。具体的代码如下所示。其中,ChessPiece 类表示棋子,ChessBoard 类表示一个棋局,里面保存了象棋中 30 个棋子的信息。

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

  public ChessPiece(int id, String text, Color color, int positionX, int positionY) {
    this.id = id;
    this.text = text;
    this.color = color;
    this.positionX = positionX;
    this.positionY = positionX;
  }

  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) {
    //...省略...
  }
}
  1. 游戏大厅有许许多多的对局,如果对于每个棋局上每个棋子都创建新的对象,那将是非常庞大的对象数量。这里可以利用享元模式,将棋子的信息抽离出来设计为独立的类,作为享元供多个棋盘使用。棋盘只记录棋子的位置信息即可。
// 享元类
public class ChessPieceUnit {
  private int id;
  private String text;
  private Color color;

  public ChessPieceUnit(int id, String text, Color color) {
    this.id = id;
    this.text = text;
    this.color = color;
  }

  public static enum Color {
    RED, BLACK
  }

  // ...省略其他属性和getter方法...
}

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;

  public ChessPiece(ChessPieceUnit unit, int positionX, int positionY) {
    this.chessPieceUnit = unit;
    this.positionX = positionX;
    this.positionY = positionY;
  }
  // 省略getter、setter方法
}

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

  public ChessBoard() {
    init();
  }

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

  public void move(int chessPieceId, int toPositionX, int toPositionY) {
    //...省略...
  }
}
  1. 在上述代码中,我们利用工厂类来缓存ChessPieceUnit心选,通过工厂类获取到的就是享元。(对比使用享元模式之前,一万个棋局,每个棋局都是30个棋子那就要创建30万个对象,而使用享元模式之后,只需要30个对象即可)。
  2. 代码结果: 通过工厂模式,在工厂类中,通过一个Map来缓存已经创建过的享元对象,以达到复用目的。

享元模式在文本编辑器中的应用

  1. 我们要在内存中表示一个文本文件,只需要记录文字和格式两部分信息就可以了,其中,格式又包括文字的字体、大小、颜色等信息。
  2. 文本编辑器可以给每个文字设置不同的格式,我们需要将每个文字当成一个对象来看待并且记录他的格式:
public class Character {//文字
  private char c;

  private Font font;
  private int size;
  private int colorRGB;

  public Character(char c, Font font, int size, int colorRGB) {
    this.c = c;
    this.font = font;
    this.size = size;
    this.colorRGB = colorRGB;
  }
}

public class Editor {
  private List<Character> chars = new ArrayList<>();

  public void appendCharacter(char c, Font font, int size, int colorRGB) {
    Character character = new Character(c, font, size, colorRGB);
    chars.add(character);
  }
}
  1. 同样的问题,如果是许多文字那这个实现将会创建许多对象。这里也可以用到享元。
  2. 在一个文本文件中,用到的字体格式不会太多,毕竟不大可能有人把每个文字都设置成不同的格式。所以对于字体格式我们可以将他设计成享元,让不同的文字使用。
public class CharacterStyle {
  private Font font;
  private int size;
  private int colorRGB;

  public CharacterStyle(Font font, int size, int colorRGB) {
    this.font = font;
    this.size = size;
    this.colorRGB = colorRGB;
  }

  @Override
  public boolean equals(Object o) {
    CharacterStyle otherStyle = (CharacterStyle) o;
    return font.equals(otherStyle.font)
            && size == otherStyle.size
            && colorRGB == otherStyle.colorRGB;
  }
}

public class CharacterStyleFactory {
  private static final List<CharacterStyle> styles = new ArrayList<>();

  public static CharacterStyle getStyle(Font font, int size, int colorRGB) {
    CharacterStyle newStyle = new CharacterStyle(font, size, colorRGB);
    for (CharacterStyle style : styles) {
      if (style.equals(newStyle)) {
        return style;
      }
    }
    styles.add(newStyle);
    return newStyle;
  }
}

public class Character {
  private char c;
  private CharacterStyle style;

  public Character(char c, CharacterStyle style) {
    this.c = c;
    this.style = style;
  }
}

public class Editor {
  private List<Character> chars = new ArrayList<>();

  public void appendCharacter(char c, Font font, int size, int colorRGB) {
    Character character = new Character(c, CharacterStyleFactory.getStyle(font, size, colorRGB));
    chars.add(character);
  }
}

享元模式vs单例、缓存、对象池

区别设计模式看的是设计意图和应用场景

  1. 单例中一个类只能创建一个对象,享元中一个类可创建多个对象,并在多处被共享(有点像多例)。单例是为了限制对象的个数,享元是为了对象复用,节省内存。
  2. 缓存是为了提高访问效率而非复用。
  3. 对象池: 为了避免频繁地进行对象创建和释放导致内存碎片,我们可以预先申请一片连续的内存空间,也就是这里说的对象池。每次创建对象时,我们从对象池中直接取出一个空闲对象来使用,对象使用完成之后,再放回到对象池中以供后续复用,而非直接释放掉。
    1. 池化技术的复用是重复使用,还是为了节省时间(连接数据库)。一个时刻这个线程、连接、对象都是被单独的使用者占用。
    2. 享元模式的复用是共享使用。多个使用者同一时间可以使用同一个对象。

剖析享元模式在Java Integer、String中的应用

享元模式在Java Integer中的应用

Integer i1 = 56;
Integer i2 = 56;
Integer i3 = 129;
Integer i4 = 129;
System.out.println(i1 == i2); // true;
System.out.println(i3 == i4); // false;
  1. 要判断上面代码的输出结果,需要知道三个知识点:
    1. 如何判断Java对象是否相等(”==“)
    2. 自动装箱和自动拆箱
    3. 享元模式
  2. Java为基本数据类型提供对应的包装器类型,所谓自动装箱就是将基本数据类型转换为包装器类型。反之就是自动拆箱。
Integer i = 56; //自动装箱
int j = i; //自动拆箱
  1. 数值56是基本数据类型int,当赋值给包装器类型Integer的时候,出发装箱操作,创建一个Integer类型的对象,并赋值给变量i。其底层相当于执行了下面:
Integer i = 59
// 底层执行了:
Integer i = Integer.valueOf(59);
  1. 反之当包装器类型变量i,复制给基本变量类型j的时候,触发自动拆箱,将i中的数据提取出赋值给j
int j = i; 
// 底层执行了:
int j = i.intValue();
  1. 而Java对象在内存中存储中存储方式为:

    img

  2. 当用双等号==判断两个对象相等时,判断的是他们的地址是否指向同一个位置。所以开头的代码你可能会判断都是false。但事实上第一个判断并不是。

  3. Integer用到享元模式来复用对象。当我们自动装箱创建integer对象的时候,如果值在-128~127之间,会从IntegerCache类中直接返回。否则才调用new方法创建。
public static Integer valueOf(int i) {
    if (i >= IntegerCache.low && i <= IntegerCache.high)
        return IntegerCache.cache[i + (-IntegerCache.low)];
    return new Integer(i);
}

IntegerCache源码:

/**
 * 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
 * sun.misc.VM class.
 */
private static class IntegerCache {
    static final int low = -128;
    static final int high;
    static final Integer cache[];

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

        cache = new Integer[(high - low) + 1];
        int j = low;
        for(int k = 0; k < cache.length; k++)
            cache[k] = new Integer(j++);

        // range [-128, 127] must be interned (JLS7 5.1.7)
        assert IntegerCache.high >= 127;
    }

    private IntegerCache() {}
}
  1. 为什么只有-127~128ze这个范围呢? 因为整数太多了,不可能吧所有数字做成这样,只选择常用的整数,也就是一个字节的大小。
  2. JDK还允许通过property配置最大值。
  3. 另外,Long、Short、Byte也都有类似的方法利用享元模式缓存-128~127之间的数据。
  4. 平时使用可以优先使用后两种创建方式以利用享元模式:
Integer a = new Integer(123);
Integer a = 123;
Integer a = Integer.valueOf(123);

享元模式在Java String中的应用

String s1 = "小争哥";
String s2 = "小争哥";
String s3 = new String("小争哥");

System.out.println(s1 == s2);
System.out.println(s1 == s3);
  1. 和Integer一样的思路,String类会利用享元模式复用相同的字符串常量。JVM会专门开辟一款存储区来存储字符串常量,叫做:字符串常量池。

    img

  2. 和Integer不同的是,String没办法事先知道哪些字符串要被创建和使用,所以在第一次使用到的时候还是先创建并加到常量池,后续使用就使用常量池中的对象即可。

  3. 字符串常量池是一个固定大小的HashTable,哈希表,默认值大小长度是1009。如果放进String Pool的String非常多,就会造成Hash冲突严重,从而导致链表会很长。
Thoughts? Leave a comment