本文已收录在Github,关注我,紧跟本系列专栏文章,咱们下篇再续!
- 🚀 魔都架构师 | 全网30W技术追随者
- 🔧 大厂分布式系统/数据中台实战专家
- 🏆 主导交易系统百万级流量调优 & 车联网平台架构
- 🧠 AIGC应用开发先行者 | 区块链落地实践者
- 🌍 以技术驱动创新,我们的征途是改变世界!
- 👉 实战干货:编程严选网
1 简介
结构型模式。“享元”,被共享的单元,即通过复用对象而节省内存,注意前提是享元对象是不可变对象。
用于减少创建对象的数量,以减少内存占用和提高性能。尝试复用现有同类对象,若未找到匹配对象,则创建新对象。
意图
运用共享技术有效地支持大量细粒度的对象。
主要解决
在有大量对象时,有可能会造成内存溢出,我们把其中共同的部分抽象出来,如果有相同的业务请求,直接返回在内存中已有的对象,避免重新创建。
2 优点
大大减少对象的创建,降低系统的内存,使效率提高。
3 缺点
提高系统复杂度,需分离出外部状态、内部状态,且外部状态具有固有化的性质,不应随内部状态变化而变化,否则会造成系统混乱。
4 适用场景
- 系统中有大量对象
- 这些对象消耗大量内存
- 这些对象的状态大部分可外部化
- 这些对象可以按照内蕴状态分为很多组,当把外蕴对象从对象中剔除出来时,每一组对象都可以用一个对象来代替
- 系统不依赖于这些对象身份,这些对象是不可分辨的
- 系统有大量相似对象
- 需要缓冲池的场景
这些类必须有一个工厂对象加以控制。
如何解决
用唯一标识码判断,如果在内存中有,则返回这个唯一标识码所标识的对象。
关键代码
用 HashMap 存储这些对象。
应用实例
- String,若有则返回,无则创建一个字符串保存在字符串缓存池
- 数据库的数据池
5 原理
当一个系统中存在大量重复对象,若这些重复对象是【不可变】对象,就能用该模式将对象设计成享元,在内存中只保留一份实例供引用。减少了内存中对象数量,最终节省内存。
不仅是相同对象,相似对象也能提取对象中的相同部分(字段)设计成享元。
“不可变对象”
一旦构造器初始化完成后,其状态(对象的成员变量或属性)就不会再被修改。所以,不可变对象不能暴露任何set()等修改内部状态的方法。之所以要求享元是不可变对象,是因为它会被多处代码共享使用,避免一处代码修改享元,影响到其他使用它的代码。
实现
主要通过工厂模式,在工厂类中,通过一个Map或List缓存已创建好的享元对象,以复用。
6 案例
6.1 象棋
一个游戏厅中有成千上万个“房间”,每个房间对应一个棋局。棋局要保存每个棋子的数据,比如:棋子类型(将、相、士、炮等)、棋子颜色(红方、黑方)、棋子在棋局中的位置。利用这些数据,我们就能显示一个完整的棋盘给玩家。具体的代码如下所示:
- ChessPiece类表示棋子
- ChessBoard类表示一个棋局,里面保存了象棋中30个棋子的信息
/** * 棋子 * * @author JavaEdge * @date 2022/5/28 */ @AllArgsConstructor @Getter @Setter public class ChessPiece { private int id; private String text; private Color color; private int positionX; private int positionY; public static enum Color { RED, BLACK } }
/** * 棋局 */ 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不同。将棋子的id、text、color属性拆出设计成独立类,并作为享元供多个棋盘复用。棋盘只需记录每个棋子的位置信息:
/** * 享元类 */ public class ChessPieceUnit { private int id; private String text; private Color color; public static enum Color { RED, BLACK } }
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); } }
@AllArgsConstructor @Data public class NewChessPiece { private ChessPieceUnit chessPieceUnit; private int positionX; private int positionY; }
/** * 棋局 */ public class NewChessBoard { private Map<Integer, NewChessPiece> chessPieces = new HashMap<>(); public NewChessBoard() { init(); } private void init() { chessPieces.put(1, new NewChessPiece( ChessPieceUnitFactory.getChessPiece(1), 0, 0)); chessPieces.put(1, new NewChessPiece( ChessPieceUnitFactory.getChessPiece(2), 1, 0)); //...摆放其他棋子 } public void move(int chessPieceId, int toPositionX, int toPositionY) { //... } }
利用工厂类缓存ChessPieceUnit信息(id、text、color)。通过工厂类获取到的ChessPieceUnit就是享元。所有ChessBoard对象共享这30个ChessPieceUnit对象(因为象棋中只有30个棋子)。在使用享元模式之前,记录1万个棋局,我们要创建30万(30*1万)个棋子的ChessPieceUnit对象。利用享元模式,只需创建30个享元对象供所有棋局共享使用即可,大大节省内存。
主要通过工厂模式,在工厂类中,通过Map缓存已创建过的享元对象,达到复用。
6.2 文本编辑器
若文本编辑器只实现文字编辑功能,不包含图片、表格编辑。简化后的文本编辑器,要在内存表示一个文本文件,只需记录文字、格式两部分信息。格式又包括字体、大小、颜色。
一般按文本类型(标题、正文……)设置文字格式,标题是一种格式,正文是另一种。但理论上可给文本文件中的每个文字都设置不同格式。为实现如此灵活格式设置,且代码实现又不复杂,把每个文字都当作一个独立对象,并在其中包含它的格式信息:
/** * 文字 */ @AllArgsConstructor @Data public class Character { private char c; private Font font; private int size; private int 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); } }
文本编辑器中,每敲一个字,就调Editor#appendCharacter(),创建一个新Character对象,保存到chars数组。若一个文本文件中,有上万、十几万、几十万的文字,就得在内存存储大量Character对象,咋节省内存?
一个文本文件用到的字体格式不多,毕竟不可能有人把每个文字都置不同格式。所以,字体格式可设计成享元,让不同文字共享:
public class CharacterStyle { private Font font; private int size; private int 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 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); } }
6.3 Shape
无论何时接收到请求,都会创建一个特定颜色的圆。
它将向 ShapeFactory 传递信息(red / green / blue/ black / white),以便获取它所需对象的颜色。
步骤 1:创建一个接口。
public interface Shape { void draw(); }
步骤 2:创建实现接口的实体类。
public class Circle implements Shape { private String color; private int x; private int y; private int radius; public Circle(String color) { this.color = color; } @Override public void draw() { System.out.println("Circle: Draw() [Color : " + color + ", x : " + x + ", y :" + y + ", radius :" + radius); } }
步骤 3:创建一个工厂,生成基于给定信息的实体类的对象。
public class ShapeFactory { private static final HashMap<String, Shape> circleMap = new HashMap<>(); public static Shape getCircle(String color) { Circle circle = (Circle) circleMap.get(color); if (circle == null) { circle = new Circle(color); circleMap.put(color, circle); System.out.println("Creating circle of color : " + color); } return circle; } }
步骤 4:使用该工厂,通过传递颜色信息来获取实体类的对象。
public class FlyweightPatternDemo { private static final String colors[] = {"Red", "Green", "Blue", "White", "Black"}; public static void main(String[] args) { for (int i = 0; i < 20; ++i) { Circle circle = (Circle) ShapeFactory.getCircle(getRandomColor()); circle.setX(getRandomX()); circle.setY(getRandomY()); circle.setRadius(100); circle.draw(); } } private static String getRandomColor() { return colors[(int) (Math.random() * colors.length)]; } private static int getRandomX() { return (int) (Math.random() * 100); } private static int getRandomY() { return (int) (Math.random() * 100); } }
步骤 5:执行程序,输出结果。
6.4 Integer
Integer i1 = 56; Integer i2 = 56; Integer i3 = 129; Integer i4 = 129; System.out.println(i1 == i2); System.out.println(i3 == i4);
Java为基本数据类型提供了对应包装器:
| 基本数据类型 | 对应的包装器类型 |
| int | Integer |
| long | Long |
| float | Float |
| double | Double |
| boolean | Boolean |
| short | Short |
| byte | Byte |
| char | Character |
Integer i = 56; //自动装箱 int j = i; //自动拆箱
数值56是基本数据类型int,当赋值给包装器类型(Integer)变量的时候,触发自动装箱操作,创建一个Integer类型的对象,并且赋值给变量i。底层相当于执行:
// 底层执行了:Integer i = Integer.valueOf(59); Integer i = 59;
反过来,当把包装器类型的变量i,赋值给基本数据类型变量j的时候,触发自动拆箱操作,将i中的数据取出,赋值给j。其底层相当于执行了下面这条语句:
// 底层执行了:int j = i.intValue(); int j = i;
Java对象在内存的存储
User a = new User(123, 23); // id=123, age=23
内存存储结构图:a存储的值是User对象的内存地址,即a指向User对象
通过“==”判定相等时,实际上是在判断两个局部变量存储的地址是否相同,即判断两个局部变量是否指向相同对象。
Integer i1 = 56; Integer i2 = 56; Integer i3 = 129; Integer i4 = 129; System.out.println(i1 == i2); System.out.println(i3 == i4);
前4行赋值语句都会触发自动装箱操作,即创建Integer对象并赋值给i1、i2、i3、i4变量。i1、i2尽管存储数值相同56,但指向不同Integer对象,所以通过==来判定是否相同的时候,会返回false。同理,i3==i4判定语句也会返回false。
不过,上面的分析还是不对,答案并非是两个false,而是一个true,一个false。因为Integer用了享元模式复用对象,才导致这样的运行差异。通过自动装箱,即调用valueOf()创建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相当于,我们上一节课中讲的生成享元对象的工厂类,只不过名字不叫xxxFactory而已。我们来看它的具体代码实现。这个类是Integer的内部类,你也可以自行查看JDK源码。
/** * 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() {} }
Q:为啥IntegerCache只缓存-128到127之间整型值?
IntegerCache类被加载时,缓存的享元对象会被集中一次性创建好。整型值太多,不可能IntegerCache类预先创建好所有,既占太多内存,也使加载IntegerCache类时间过长。只能选择缓存对大部分应用来说最常用整型值,即一个字节大小(-128到127之间数据)。
JDK也提供方法可自定义缓存最大值,两种方式:
如果你通过分析应用的JVM内存占用情况,发现-128到255之间的数据占用的内存比较多,可将缓存最大值从127调到255,但JDK没有提供设置最小值方法。
# 方法一 -Djava.lang.Integer.IntegerCache.high=255 # 方法二 -XX:AutoBoxCacheMax=255
因为56处于-128和127之间,i1和i2会指向相同的享元对象,所以i1==i2返回true。而129大于127,并不会被缓存,每次都会创建一个全新的对象,也就是说,i3和i4指向不同的Integer对象,所以i3==i4返回false。
实际上,除了Integer类型之外,其他包装器类型,比如Long、Short、Byte等,也都利用了享元模式来缓存-128到127之间的数据。比如,Long类型对应的LongCache享元工厂类及valueOf()函数代码如下所示:
private static class LongCache { private LongCache(){} static final Long cache[] = new Long[-(-128) + 127 + 1]; static { for(int i = 0; i < cache.length; i++) cache[i] = new Long(i - 128); } } public static Long valueOf(long l) { final int offset = 128; if (l >= -128 && l <= 127) { // will cache return LongCache.cache[(int)l + offset]; } return new Long(l); }
平时开发对下面这样三种创建整型对象的方式,优先用后两种:
// 第一种创建方式不会用到IntegerCache Integer a = new Integer(123); // 后两种创建方法可用IntegerCache缓存,返回共享对象 Integer a = 123; Integer a = Integer.valueOf(123);
极端案例:
若程序需创建1万个 -128~127 之间的Integer对象:
- 使用第一种创建方式,需分配1万个Integer对象的内存空间
- 使用后两种创建方式,最多只需分配256个Integer对象的内存空间
6.5 String
String s1 = "JavaEdge"; String s2 = "JavaEdge"; String s3 = new String("JavaEdge"); // true System.out.println(s1 == s2); // false System.out.println(s1 == s3);
跟Integer设计相似,String利用享元模式复用相同字符串常量(即“JavaEdge”)。JVM会专门开辟一块存储区来存储字符串常量,即“字符串常量池”,对应内存存储结构示意图:
不同点:
- Integer类要共享对象,是在类加载时,一次性全部创建好
- 字符串,没法预知要共享哪些字符串常量,所以无法事先创建,只能在某字符串常量第一次被用到时,存储到常量池,再用到时,直接引用常量池中已存在的
7 竞品
7.1 V.S 单例
- 单例模式,一个类只能创建一个对象
- 享元模式,一个类可创建多个对象,每个对象被多处代码引用共享。类似单例的变体:多例。
还是要看设计意图,即要解决啥问题:
- 享元模式是为对象复用,节省内存
- 单例模式是为限制对象个数
7.2 V.S 缓存
享元模式得实现,通过工厂类“缓存”已创建好的对象。“缓存”实际上是“存储”,跟平时说的“数据库缓存”、“CPU缓存”、“MemCache缓存”是两回事。平时所讲的缓存,主要为提高访问效率,而非复用。
7.3 V.S 对象池
C++内存管理由程序员负责。为避免频繁地进行对象创建和释放导致内存碎片,可以预先申请一片连续的内存空间,即对象池。每次创建对象时,我们从对象池中直接取出一个空闲对象来使用,对象使用完成之后,再放回到对象池中以供后续复用,而非直接释放掉。
虽然对象池、连接池、线程池、享元模式都是为复用,但对象池、连接池、线程池等池化技术中的“复用”和享元模式中的“复用”是不同概念:
- 池化技术中的“复用”可以理解为“重复使用”,主要目的是节省时间(比如从数据库池中取一个连接,不需要重新创建)。在任意时刻,每一个对象、连接、线程,并不会被多处使用,而是被一个使用者独占,当使用完成之后,放回到池中,再由其他使用者重复利用
- 享元模式中的“复用”可以理解为“共享使用”,在整个生命周期中,都是被所有使用者共享的,主要目的是节省空间
X 总结
- 单例模式是为保证对象全局唯一
- 享元模式是为实现对象复用,节省内存。缓存是为提高访问效率,而非复用
- 池化技术中的“复用”理解为“重复使用”,主要为节省时间
Integer的-128到127之间整型对象会被事先创建好,缓存在IntegerCache类。当使用自动装箱或valueOf()创建这个数值区间的整型对象时,会复用IntegerCache类事先创建好的对象。IntegerCache类就是享元工厂类,事先创建好的整型对象就是享元对象。
String类,JVM开辟一块存储区(字符串常量池)存储字符串常量,类似Integer的IntegerCache。但并非事先创建好需要共享的对象,而是在程序运行期间,根据需要创建和缓存字符串常量
享元模式对GC不友好。因为享元工厂类一直保存对享元对象的引用,导致享元对象在无任何代码使用时,也不会被GC。因此,某些情况下,若对象生命周期很短,也不会被密集使用,利用享元模式反倒浪费更多内存。务必验证享元模式真的能大大节省内存吗。