文章目录
- 一、概述
-
- [1.1 结构与角色](#1.1 结构与角色)
- [1.2 适用场景](#1.2 适用场景)
- 二、内部状态与外部状态
-
- [2.1 概念定义](#2.1 概念定义)
- [2.2 以围棋为例](#2.2 以围棋为例)
- [2.3 状态分离的原则](#2.3 状态分离的原则)
- 三、实现方式
-
- [3.1 基础实现------围棋棋子系统](#3.1 基础实现——围棋棋子系统)
- [3.2 内存对比](#3.2 内存对比)
- [3.3 实际应用示例------树木渲染系统](#3.3 实际应用示例——树木渲染系统)
- [四、JDK 源码中的享元](#四、JDK 源码中的享元)
-
- [4.1 Integer 缓存池](#4.1 Integer 缓存池)
- [4.2 String 常量池](#4.2 String 常量池)
- [4.3 Byte、Short、Long、Character 缓存池](#4.3 Byte、Short、Long、Character 缓存池)
- 五、总结
一、概述
在软件开发中,经常会遇到这样的场景:系统中存在大量相同或相似的对象,如果每个对象都独立创建,会消耗大量内存,导致性能下降。例如,一个文本编辑器中可能出现成千上万个相同字体的字符对象,一个棋类游戏中可能需要创建大量的棋子对象------其中很多棋子的属性(颜色、形状)是相同的,不同的只是位置。
享元模式(Flyweight Pattern)正是为了解决这个问题而诞生的------它运用共享技术有效地支持大量细粒度对象的复用,通过共享已经存在的对象,大幅减少需要创建的对象数量,从而降低内存占用、提高性能。
"Flyweight"一词来源于拳击比赛,指的是最轻量级的选手(蝇量级),在设计中象征着"轻量化"对象。
生活中的享元模式例子比比皆是:
- 围棋棋子:一局围棋需要 361 个交叉点,但棋子只有黑白两种颜色。黑子和白子是共享的享元对象,不同的只是棋子的位置
- 字符串常量池:Java 中的字符串字面量会被放入常量池,相同内容的字符串共享同一个对象,而不是每次都创建新对象
- 线程池:数据库连接池中的连接对象是共享的,多个请求可以复用同一个连接,而不是每次都新建连接
核心:运用共享技术有效地支持大量细粒度对象的复用,通过共享已存在的对象减少内存占用
1.1 结构与角色
享元模式包含以下角色:
缓存/共享
实现
实现
Client 客户端
FlyweightFactory 享元工厂
Flyweight 抽象享元
ConcreteFlyweight 具体享元
UnsharedConcreteFlyweight 非共享享元
- Flyweight(抽象享元):声明享元对象的公共接口,通过这个接口享元对象可以接受并作用于外部状态
- ConcreteFlyweight(具体享元) :实现抽象享元接口,并存储内部状态(可共享的状态)
- UnsharedConcreteFlyweight(非共享享元):不需要共享的享元子类,通常那些不需要共享的外部状态被包装在此类中
- FlyweightFactory(享元工厂):创建和管理享元对象,确保合理地共享享元对象。当客户端请求一个享元对象时,工厂先检查是否已存在,存在则返回已有对象,不存在则创建新对象
- Client(客户端):维护对享元对象的引用,存储享元对象的外部状态
1.2 适用场景
- 系统中存在大量相同或相似的对象,造成内存的大量浪费
- 对象的大部分状态可以外部化,可以将外部状态传入对象中
- 需要缓冲池的场景,如字符串常量池、数据库连接池、线程池
二、内部状态与外部状态
享元模式的核心概念是区分内部状态 和外部状态,这是理解享元模式的关键。
2.1 概念定义
- 内部状态(Intrinsic State) :存储在享元对象内部,可以共享的状态。内部状态不会随环境的改变而改变,因此多个客户端可以共享同一个享元对象的内部状态
- 外部状态(Extrinsic State) :随环境的改变而改变,不可共享的状态。外部状态由客户端保存,在需要时传入享元对象中
传入外部状态
传入外部状态
传入外部状态
享元对象
内部状态 - 可共享
客户端1
客户端2
客户端3
2.2 以围棋为例
围棋棋子是最经典的享元模式示例:
| 维度 | 内部状态(可共享) | 外部状态(不可共享) |
|---|---|---|
| 围棋棋子 | 颜色(黑/白) | 位置(坐标) |
| 文本编辑器字符 | 字体、字号、颜色 | 位置、行号、列号 |
| 树木渲染 | 树的种类、纹理 | 位置、大小、旋转角度 |
| 地图标记 | 图标样式 | 经纬度坐标 |
以围棋为例,一局围棋最多 361 个棋子,但颜色只有黑和白两种。如果为每个棋子都创建一个独立的对象,需要 361 个对象;而使用享元模式,只需要 2 个享元对象(黑子、白子),外部状态(位置)由客户端维护。
不使用享元:361 个棋子对象,每个都存储颜色和位置 → 内存占用高
使用享元:2 个棋子享元对象(黑、白)+ 361 个位置信息 → 内存占用低
2.3 状态分离的原则
- 内部状态与外部状态分离后,相同的内部状态可以被多个客户端共享
- 享元对象只保留内部状态,外部状态在方法调用时作为参数传入
- 判断一个状态是内部还是外部的标准:该状态是否在多个对象之间共享
关键点:享元模式的本质是"分离变与不变"------将不变的部分(内部状态)抽取出来共享,将变化的部分(外部状态)交给客户端管理。
三、实现方式
享元模式的核心实现思路是:定义享元接口和具体享元类,通过享元工厂缓存和共享享元对象,客户端从工厂获取享元对象并将外部状态作为参数传入。
3.1 基础实现------围棋棋子系统
以围棋为例,棋子颜色(黑/白)是内部状态,棋子位置(坐标)是外部状态:
缓存
缓存
实现
实现
客户端
ChessFlyweightFactory 享元工厂
BlackPiece 黑子享元
WhitePiece 白子享元
ChessFlyweight 抽象享元
(1)抽象享元------棋子
java
/**
* 抽象享元:棋子
*/
public interface ChessFlyweight {
/**
* 获取颜色(内部状态)
*
* @return 棋子颜色
*/
String getColor();
/**
* 下棋操作
*
* @param x 横坐标(外部状态)
* @param y 纵坐标(外部状态)
*/
void place(int x, int y);
}
(2)具体享元------黑子、白子
java
/**
* 具体享元:黑子
* 内部状态:颜色为黑色
*/
public class BlackPiece implements ChessFlyweight {
/**
* 内部状态------颜色
*/
private final String color = "黑色";
@Override
public String getColor() {
return color;
}
@Override
public void place(int x, int y) {
System.out.println(color + "棋子落在(" + x + ", " + y + ")");
}
}
/**
* 具体享元:白子
* 内部状态:颜色为白色
*/
public class WhitePiece implements ChessFlyweight {
/**
* 内部状态------颜色
*/
private final String color = "白色";
@Override
public String getColor() {
return color;
}
@Override
public void place(int x, int y) {
System.out.println(color + "棋子落在(" + x + ", " + y + ")");
}
}
(3)享元工厂------棋子工厂
java
import java.util.HashMap;
import java.util.Map;
/**
* 享元工厂:棋子工厂
* 管理享元对象的缓存池,确保享元对象可以被共享
*/
public class ChessFlyweightFactory {
/**
* 享元池------缓存已创建的享元对象
*/
private static final Map<String, ChessFlyweight> POOL = new HashMap<>();
/**
* 获取享元对象
* 如果池中已存在则直接返回,否则创建新对象并放入池中
*
* @param color 棋子颜色(内部状态)
* @return 享元对象
*/
public static ChessFlyweight getChess(String color) {
if (POOL.containsKey(color)) {
System.out.println("从缓存池中获取" + color + "棋子");
return POOL.get(color);
}
ChessFlyweight chess;
if ("黑色".equals(color)) {
chess = new BlackPiece();
} else if ("白色".equals(color)) {
chess = new WhitePiece();
} else {
throw new IllegalArgumentException("不支持的颜色:" + color);
}
POOL.put(color, chess);
System.out.println("创建新的" + color + "棋子");
return chess;
}
/**
* 获取享元池大小
*
* @return 池中享元对象的数量
*/
public static int getPoolSize() {
return POOL.size();
}
}
(4)客户端调用
java
public class ChessDemo {
public static void main(String[] args) {
// 第一手:黑子落在(15, 15)
ChessFlyweight black1 = ChessFlyweightFactory.getChess("黑色");
black1.place(15, 15);
// 创建新的黑色棋子
// 黑色棋子落在(15, 15)
// 第二手:白子落在(16, 16)
ChessFlyweight white1 = ChessFlyweightFactory.getChess("白色");
white1.place(16, 16);
// 创建新的白色棋子
// 白色棋子落在(16, 16)
// 第三手:黑子落在(3, 3)
ChessFlyweight black2 = ChessFlyweightFactory.getChess("黑色");
black2.place(3, 3);
// 从缓存池中获取黑色棋子
// 黑色棋子落在(3, 3)
// 验证享元共享------black1 和 black2 是同一个对象
System.out.println("black1 和 black2 是否同一对象:" + (black1 == black2));
// black1 和 black2 是否同一对象:true
// 享元池大小------只有 2 个享元对象
System.out.println("享元池大小:" + ChessFlyweightFactory.getPoolSize());
// 享元池大小:2
}
}
关键点 :虽然下了 3 手棋,但只创建了 2 个棋子对象(黑子、白子各 1 个),第 3 手的黑子复用了第 1 手创建的黑子对象。外部状态(坐标)作为参数传入
place()方法,不存储在享元对象中。
3.2 内存对比
假设一局围棋下了 200 手,对比使用和不使用享元模式的内存占用:
| 方式 | 对象数量 | 内存占用 | 说明 |
|---|---|---|---|
| 不使用享元 | 200 个棋子对象 | 高(每个对象存储颜色+位置) | 每个棋子都是独立对象 |
| 使用享元 | 2 个享元对象 + 200 个位置 | 低(颜色共享,位置由客户端管理) | 相同颜色的棋子共享享元对象 |
对比:享元模式将 200 个棋子对象的内部状态压缩为 2 个享元对象,大幅减少了内存占用。对象数量越多、内部状态越相似,享元模式的优势越明显。
3.3 实际应用示例------树木渲染系统
以游戏中的树木渲染系统为例,需要渲染大量树木,每棵树的种类和纹理是内部状态(可共享),位置和大小是外部状态(不可共享):
缓存
缓存
缓存
实现
实现
实现
客户端
TreeFactory 享元工厂
OakTree 橡树享元
PineTree 松树享元
MapleTree 枫树享元
TreeFlyweight 抽象享元
(1)抽象享元------树木
java
/**
* 抽象享元:树木
*/
public interface TreeFlyweight {
/**
* 获取树木类型(内部状态)
*
* @return 树木类型
*/
String getType();
/**
* 渲染树木
*
* @param x 横坐标(外部状态)
* @param y 纵坐标(外部状态)
* @param size 大小(外部状态)
*/
void render(int x, int y, int size);
}
(2)具体享元------橡树、松树、枫树
java
/**
* 具体享元:橡树
* 内部状态:类型为橡树,纹理为橡树纹理
*/
public class OakTree implements TreeFlyweight {
private final String type = "橡树";
private final String texture = "橡树纹理"; // 模拟纹理数据(实际中可能是大图片)
@Override
public String getType() {
return type;
}
@Override
public void render(int x, int y, int size) {
System.out.println("渲染" + type + ":纹理=" + texture
+ ",位置=(" + x + ", " + y + "),大小=" + size);
}
}
/**
* 具体享元:松树
*/
public class PineTree implements TreeFlyweight {
private final String type = "松树";
private final String texture = "松树纹理";
@Override
public String getType() {
return type;
}
@Override
public void render(int x, int y, int size) {
System.out.println("渲染" + type + ":纹理=" + texture
+ ",位置=(" + x + ", " + y + "),大小=" + size);
}
}
/**
* 具体享元:枫树
*/
public class MapleTree implements TreeFlyweight {
private final String type = "枫树";
private final String texture = "枫树纹理";
@Override
public String getType() {
return type;
}
@Override
public void render(int x, int y, int size) {
System.out.println("渲染" + type + ":纹理=" + texture
+ ",位置=(" + x + ", " + y + "),大小=" + size);
}
}
(3)享元工厂------树木工厂
java
import java.util.HashMap;
import java.util.Map;
/**
* 享元工厂:树木工厂
*/
public class TreeFactory {
private static final Map<String, TreeFlyweight> POOL = new HashMap<>();
/**
* 获取树木享元对象
*
* @param type 树木类型
* @return 享元对象
*/
public static TreeFlyweight getTree(String type) {
if (POOL.containsKey(type)) {
return POOL.get(type);
}
TreeFlyweight tree;
switch (type) {
case "橡树":
tree = new OakTree();
break;
case "松树":
tree = new PineTree();
break;
case "枫树":
tree = new MapleTree();
break;
default:
throw new IllegalArgumentException("不支持的树木类型:" + type);
}
POOL.put(type, tree);
return tree;
}
/**
* 获取享元池大小
*
* @return 池中享元对象的数量
*/
public static int getPoolSize() {
return POOL.size();
}
}
(4)客户端调用
java
public class TreeDemo {
public static void main(String[] args) {
// 渲染 100 棵树木------只需要 3 个享元对象
String[] types = {"橡树", "松树", "枫树"};
for (int i = 0; i < 10; i++) {
for (String type : types) {
TreeFlyweight tree = TreeFactory.getTree(type);
tree.render(i * 10, i * 5, 5 + i);
}
}
// 渲染了 30 棵树,但只创建了 3 个享元对象
System.out.println("享元池大小:" + TreeFactory.getPoolSize());
// 享元池大小:3
// 输出示例:
// 渲染橡树:纹理=橡树纹理,位置=(0, 0),大小=5
// 渲染松树:纹理=松树纹理,位置=(0, 0),大小=5
// 渲染枫树:纹理=枫树纹理,位置=(0, 0),大小=5
// ...
}
}
优势体现:渲染 30 棵树只使用了 3 个享元对象,纹理数据(可能是大图片)只加载了 3 次。如果渲染 1000 棵树,不使用享元模式需要加载 1000 次纹理,而享元模式只需要加载 3 次。
四、JDK 源码中的享元
享元模式在 JDK 中有着广泛的应用,最典型的就是包装类缓存和字符串常量池。
4.1 Integer 缓存池
java.lang.Integer 是享元模式的经典应用。Integer 在 -128 到 127 之间的值会被缓存,多次获取同一个值时返回的是同一个对象。
java
public final class Integer extends Number implements Comparable<Integer> {
/**
* 缓存池:缓存 -128 到 127 的 Integer 对象
* 这就是享元模式的体现------共享相同内部状态的享元对象
*/
private static class IntegerCache {
static final int low = -128;
static final int high;
static final Integer[] cache;
static {
// 默认缓存到 127
int h = 127;
// 可以通过 -XX:AutoBoxCacheMax=<size> 修改上限
high = h;
cache = new Integer[(high - low) + 1];
int j = low;
for (int i = 0; i < cache.length; i++) {
cache[i] = new Integer(j++); // 预先创建享元对象
}
}
private IntegerCache() {
}
}
/**
* 获取 Integer 对象
* 如果值在缓存范围内,返回缓存的对象(享元)
* 否则创建新对象
*/
public static Integer valueOf(int i) {
if (i >= IntegerCache.low && i <= IntegerCache.high) {
return IntegerCache.cache[i + (-IntegerCache.low)];
}
return new Integer(i);
}
}
在这个例子中:
- 内部状态(可共享):int 值(-128 ~ 127)
- 享元工厂 :
IntegerCache - 享元池 :
Integer[] cache数组
验证享元共享:
java
public class IntegerFlyweightDemo {
public static void main(String[] args) {
Integer a = Integer.valueOf(127);
Integer b = Integer.valueOf(127);
System.out.println("127 是否同一对象:" + (a == b));
// 127 是否同一对象:true(在缓存范围内,共享享元对象)
Integer c = Integer.valueOf(128);
Integer d = Integer.valueOf(128);
System.out.println("128 是否同一对象:" + (c == d));
// 128 是否同一对象:false(超出缓存范围,各自创建新对象)
Integer e = Integer.valueOf(-128);
Integer f = Integer.valueOf(-128);
System.out.println("-128 是否同一对象:" + (e == f));
// -128 是否同一对象:true(在缓存范围内,共享享元对象)
}
}
说明 :
Integer.valueOf()在缓存范围内返回的是同一个享元对象(==为 true),超出范围则创建新对象(==为 false)。这就是为什么推荐使用equals()而不是==来比较 Integer 的值。
类似的包装类缓存还有:Long、Short、Byte、Character(0~127)都有缓存池。
4.2 String 常量池
java.lang.String 是另一个享元模式的经典应用。JVM 维护一个字符串常量池,相同内容的字符串字面量指向同一个对象。
java
public class StringFlyweightDemo {
public static void main(String[] args) {
// 字符串字面量------自动放入常量池
String s1 = "hello";
String s2 = "hello";
System.out.println("字面量是否同一对象:" + (s1 == s2));
// 字面量是否同一对象:true(常量池共享)
// new String------堆上创建新对象,不共享
String s3 = new String("hello");
System.out.println("new String 是否同一对象:" + (s1 == s3));
// new String 是否同一对象:false
// intern()------将字符串放入常量池并返回引用
String s4 = s3.intern();
System.out.println("intern 后是否同一对象:" + (s1 == s4));
// intern 后是否同一对象:true(intern 返回常量池中的引用)
}
}
在这个例子中:
- 内部状态(可共享):字符串内容
- 享元工厂:JVM 的字符串常量池
- 享元池:方法区中的字符串常量池
说明 :字符串字面量在编译期就会被放入常量池,运行时相同内容的字面量共享同一个对象。
intern()方法可以手动将字符串放入常量池,实现享元共享。
4.3 Byte、Short、Long、Character 缓存池
除了 Integer,其他包装类也使用了享元模式:
| 包装类 | 缓存范围 | 缓存大小 |
|---|---|---|
Byte |
-128 ~ 127 | 256 个 |
Short |
-128 ~ 127 | 256 个 |
Long |
-128 ~ 127 | 256 个 |
Character |
0 ~ 127 | 128 个 |
Integer |
-128 ~ 127(默认) | 256 个(可扩展) |
Boolean |
TRUE / FALSE | 2 个 |
注意 :
Byte、Short、Long、Character的缓存范围是固定的,无法像 Integer 那样通过 JVM 参数调整。Boolean只有两个享元对象TRUE和FALSE。
五、总结
享元模式的核心思想是运用共享技术有效地支持大量细粒度对象的复用,通过区分内部状态和外部状态,将可共享的内部状态抽取出来,大幅减少对象数量和内存占用。
优点:
- 减少内存占用:通过共享享元对象,大幅减少需要创建的对象数量
- 提高性能:减少对象的创建和垃圾回收开销
- 减少资源占用:对于需要加载大量资源(如纹理、字体)的对象,享元模式避免重复加载
- 集中管理:享元工厂统一管理享元对象的生命周期
缺点:
- 增加系统复杂度:需要区分内部状态和外部状态,增加了代码的复杂度
- 外部状态管理:外部状态需要客户端自行维护,如果外部状态较多,会增加客户端的复杂度
- 线程安全问题:享元对象被多个客户端共享,如果享元对象的方法不是无状态的,需要考虑线程安全
- 运行时间换空间:享元模式通过共享减少内存占用,但读取享元对象需要经过工厂查找,可能略微增加运行时间
适用场景:
- 系统中存在大量相同或相似的对象,造成内存的大量浪费
- 对象的大部分状态可以外部化,可以将外部状态传入对象中
- 需要缓冲池的场景,如字符串常量池、数据库连接池、线程池
- 对象的内部状态是有限的、可枚举的
内部状态 vs 外部状态:内部状态是可共享的、不随环境变化的状态,存储在享元对象内部;外部状态是不可共享的、随环境变化的状态,由客户端维护并在方法调用时传入。享元模式的本质是"分离变与不变",将不变的部分(内部状态)抽取出来共享。
参考博客:
享元模式 | 菜鸟教程:https://www.runoob.com/design-pattern/flyweight-pattern.html