享元模式
如何利用享元模式优化文本编辑器内存占用
原理和实现
-
享元:被共享的单元。
-
享元模式的意图是复用对象,节省内存,前提是享元对象是不可变对象。
-
当一个系统中存在大量重复对象的时候,如果这些重复的对象是不可变对象,我们可以利用享元模式将对象设计成享元,内存中只保留一份实例,供多处代码引用。(减少对象数量,节省内存)。
-
不可变对象指的是:一单通构造函数初始化完成之后,他的状态(对象的成员变量或者属性)就不会再被修改了。所以不可变对象不能暴露setter等修改内部状态的方法。
-
来个例子:
假设我们在开发一个棋牌游戏(比如象棋)。一个游戏厅中有成千上万个“房间”,每个房间对应一个棋局。棋局要保存每个棋子的数据,比如:棋子类型(将、相、士、炮等)、棋子颜色(红方、黑方)、棋子在棋局中的位置。利用这些数据,我们就能显示一个完整的棋盘给玩家。具体的代码如下所示。其中,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) { //...省略... } }
- 游戏大厅有许许多多的对局,如果对于每个棋局上每个棋子都创建新的对象,那将是非常庞大的对象数量。这里可以利用享元模式,将棋子的信息抽离出来设计为独立的类,作为享元供多个棋盘使用。棋盘只记录棋子的位置信息即可。
// 享元类 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) { //...省略... } }
- 在上述代码中,我们利用工厂类来缓存ChessPieceUnit心选,通过工厂类获取到的就是享元。(对比使用享元模式之前,一万个棋局,每个棋局都是30个棋子那就要创建30万个对象,而使用享元模式之后,只需要30个对象即可)。
- 代码结果: 通过工厂模式,在工厂类中,通过一个Map来缓存已经创建过的享元对象,以达到复用目的。
享元模式在文本编辑器中的应用
- 我们要在内存中表示一个文本文件,只需要记录文字和格式两部分信息就可以了,其中,格式又包括文字的字体、大小、颜色等信息。
- 文本编辑器可以给每个文字设置不同的格式,我们需要将每个文字当成一个对象来看待并且记录他的格式:
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); } }
- 同样的问题,如果是许多文字那这个实现将会创建许多对象。这里也可以用到享元。
- 在一个文本文件中,用到的字体格式不会太多,毕竟不大可能有人把每个文字都设置成不同的格式。所以对于字体格式我们可以将他设计成享元,让不同的文字使用。
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单例、缓存、对象池
区别设计模式看的是设计意图和应用场景。
- 单例中一个类只能创建一个对象,享元中一个类可创建多个对象,并在多处被共享(有点像多例)。单例是为了限制对象的个数,享元是为了对象复用,节省内存。
- 缓存是为了提高访问效率而非复用。
- 对象池: 为了避免频繁地进行对象创建和释放导致内存碎片,我们可以预先申请一片连续的内存空间,也就是这里说的对象池。每次创建对象时,我们从对象池中直接取出一个空闲对象来使用,对象使用完成之后,再放回到对象池中以供后续复用,而非直接释放掉。
- 池化技术的复用是重复使用,还是为了节省时间(连接数据库)。一个时刻这个线程、连接、对象都是被单独的使用者占用。
- 享元模式的复用是共享使用。多个使用者同一时间可以使用同一个对象。
剖析享元模式在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;
- 要判断上面代码的输出结果,需要知道三个知识点:
- 如何判断Java对象是否相等(”==“)
- 自动装箱和自动拆箱
- 享元模式
- Java为基本数据类型提供对应的包装器类型,所谓自动装箱就是将基本数据类型转换为包装器类型。反之就是自动拆箱。
Integer i = 56; //自动装箱 int j = i; //自动拆箱
- 数值56是基本数据类型int,当赋值给包装器类型Integer的时候,出发装箱操作,创建一个Integer类型的对象,并赋值给变量i。其底层相当于执行了下面:
Integer i = 59; // 底层执行了: Integer i = Integer.valueOf(59);
- 反之当包装器类型变量i,复制给基本变量类型j的时候,触发自动拆箱,将i中的数据提取出赋值给j
int j = i; // 底层执行了: int j = i.intValue();
-
而Java对象在内存中存储中存储方式为:
-
当用双等号==判断两个对象相等时,判断的是他们的地址是否指向同一个位置。所以开头的代码你可能会判断都是false。但事实上第一个判断并不是。
- 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() {} }
- 为什么只有-127~128ze这个范围呢? 因为整数太多了,不可能吧所有数字做成这样,只选择常用的整数,也就是一个字节的大小。
- JDK还允许通过property配置最大值。
- 另外,Long、Short、Byte也都有类似的方法利用享元模式缓存-128~127之间的数据。
- 平时使用可以优先使用后两种创建方式以利用享元模式:
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);
-
和Integer一样的思路,String类会利用享元模式复用相同的字符串常量。JVM会专门开辟一款存储区来存储字符串常量,叫做:字符串常量池。
-
和Integer不同的是,String没办法事先知道哪些字符串要被创建和使用,所以在第一次使用到的时候还是先创建并加到常量池,后续使用就使用常量池中的对象即可。
- 字符串常量池是一个固定大小的HashTable,哈希表,默认值大小长度是1009。如果放进String Pool的String非常多,就会造成Hash冲突严重,从而导致链表会很长。