享元模式

设计模式笔记:享元模式 (Flyweight Pattern)

一、一句话概括

通过共享尽可能多的相似对象来有效地支持大量细粒度对象的复用,从而最大限度地减少内存占用和对象创建的开销。

二、为什么需要它 (Why - The Pain Point)

想象一个场景:你正在开发一个图形编辑器或一个游戏,需要在屏幕上绘制数百万个粒子、树木或字符。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Particle {
// 每个粒子都有很多属性
private int x, y; // 位置 (经常变化)
private double velocity; // 速度 (经常变化)
private Color color; // 颜色 (可能只有几种)
private Sprite sprite; // 粒子贴图 (可能只有几种)
private int size; // 大小 (可能只有几种)
// ...
}

// 客户端代码
List<Particle> particles = new ArrayList<>();
for (int i = 0; i < 1_000_000; i++) {
// 每次都创建一个全新的粒子对象
particles.add(new Particle(x, y, v, color, sprite, size));
}

痛点:内存爆炸和性能瓶燗

如果每个粒子都是一个独立的对象,并且有一百万个粒子,那么你将创建一百万个 Particle 对象。即使 color, sprite, size 这些属性在很多粒子之间都是相同的,它们依然在每个对象中都被存储了一遍。

这会导致:

  1. 巨大的内存消耗:重复的数据占用了大量的堆内存,可能导致 OutOfMemoryError
  2. 性能下降:频繁地创建和销毁大量对象会给 Java 的垃圾回收器(GC)带来巨大压力,导致程序卡顿。

核心问题:当系统中存在大量相似对象,导致内存和性能问题时,如何优化?

三、它是什么 (What - The Solution)

享元模式的解决方案是:将对象的状态进行拆分

它将一个对象的状态分为两部分:

  1. **内部状态 (Intrinsic State)**:

    • 这部分状态是可以被共享的,它不随外部环境的变化而变化。
    • 它通常被存储在“享元对象”的内部。
    • 在我们的例子中,color, sprite, size 就是内部状态。对于所有红色的、使用同样贴图的粒子,这部分状态是完全相同的。
  2. **外部状态 (Extrinsic State)**:

    • 这部分状态是不可以被共享的,它随外部环境的变化而变化,是每个对象特有的。
    • 它不能存储在享元对象内部,必须由客户端在每次调用享元对象的方法时,作为参数传入
    • 在我们的例子中,x, y, velocity 就是外部状态,因为每个粒子的位置和速度都是独一无二的。

核心思想和角色:

  1. **享元接口 (Flyweight)**:
    • 定义了享元对象对外提供的接口。
    • 它的方法通常需要接收外部状态作为参数。例如,draw(int x, int y)
  2. **具体享元 (Concrete Flyweight)**:
    • 实现了享元接口。
    • 它包含了内部状态
    • 多个“逻辑上”不同的对象可以共享同一个具体享元实例。
  3. **享元工厂 (Flyweight Factory)**:
    • 这是模式的关键。它负责创建和管理享元对象
    • 它内部通常有一个池(如 HashMap)来缓存已经创建的享元对象。
    • 当客户端请求一个享元对象时,工厂会先检查池中是否已存在具有相同内部状态的对象。如果存在,则直接返回该共享实例;如果不存在,则创建一个新的实例,存入池中,然后返回。
  4. **客户端 (Client)**:
    • 客户端不直接创建享元对象,而是通过享元工厂来获取。
    • 客户端负责存储和维护所有对象的外部状态
    • 在调用享元对象的方法时,客户端需要将对应的外部状态传递进去。

四、怎么做 (How - The Blueprint)

基本步骤(以绘制不同颜色的圆形为例):

  1. **定义享元接口 Shape**,包含一个 draw(int x, int y) 方法,xy 是外部状态。
  2. **创建具体享元类 Circle**,实现 Shape 接口。它的内部状态是 colordraw 方法会使用内部的 color 和传入的 x, y 来绘制圆形。
  3. **创建享元工厂 ShapeFactory**。
    • 内部维护一个 Map<String, Shape>key 是颜色,valueCircle 对象。
    • 提供一个 getCircle(String color) 方法。此方法检查 Map 中是否已有该颜色的 Circle,有则返回,无则创建、放入 Map 再返回。
  4. 客户端要画100个圆形,它会循环100次。每次循环中:
    • 从工厂获取一个特定颜色的 Circle 对象(可能是共享的)。
    • 计算出这个圆的 x, y坐标(外部状态)。
    • 调用 circle.draw(x, y)

Java 示例代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
import java.util.HashMap;
import java.util.Map;

// 1. 享元接口 (Flyweight)
interface Shape {
// draw 方法接收外部状态作为参数
void draw(int x, int y);
}

// 2. 具体享元 (Concrete Flyweight)
class Circle implements Shape {
// 内部状态,可以被共享
private final String color;

public Circle(String color) {
this.color = color;
System.out.println("Creating a new " + color + " circle. (This should be infrequent)");
}

@Override
public void draw(int x, int y) {
// 使用内部状态(color)和外部状态(x, y)
System.out.println("Drawing a " + color + " circle at (" + x + ", " + y + ")");
}
}

// 3. 享元工厂 (Flyweight Factory)
class ShapeFactory {
// 享元池
private static final Map<String, Shape> circleMap = new HashMap<>();

public static Shape getCircle(String color) {
// 先从池中查找
Shape circle = circleMap.get(color);

if (circle == null) {
// 如果池中没有,则创建一个新的,并放入池中
circle = new Circle(color);
circleMap.put(color, circle);
}
return circle;
}
}

// 4. 客户端 (Client)
public class FlyweightPatternDemo {
private static final String[] colors = {"Red", "Green", "Blue", "Black", "White"};

public static void main(String[] args) {
System.out.println("--- Drawing 20 circles ---");
for (int i = 0; i < 20; i++) {
// 从工厂获取享元对象
String randomColor = getRandomColor();
Circle circle = (Circle) ShapeFactory.getCircle(randomColor);

// 客户端维护外部状态
int x = getRandomCoordinate();
int y = getRandomCoordinate();

// 调用方法,并传入外部状态
circle.draw(x, y);
}

// 尽管画了20次,但实际上只创建了不超过5个Circle对象
}

private static String getRandomColor() {
return colors[(int) (Math.random() * colors.length)];
}

private static int getRandomCoordinate() {
return (int) (Math.random() * 100);
}
}

五、UML 类图

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
@startuml
skinparam classAttributeIconSize 0
hide empty members

interface Shape {
+ void draw(int x, int y)
}

class Circle implements Shape {
' Intrinsic state
- final String color
+ Circle(String color)
+ void draw(int x, int y)
}

class ShapeFactory {
- {static} Map<String, Shape> circleMap
+ {static} Shape getCircle(String color)
}

class FlyweightPatternDemo {
+ {static} void main(String[] args)
}

' Relationships
ShapeFactory ..> Circle : creates
FlyweightPatternDemo ..> ShapeFactory : uses
FlyweightPatternDemo ..> Shape : uses
@enduml

图示解读:

  • FlyweightPatternDemo (客户端) 不直接 new Circle(),而是通过 ShapeFactory 获取 Shape 对象。
  • ShapeFactory 内部管理着 Circle 对象的创建和缓存,确保相同内部状态的 Circle 只被创建一次。

六、优缺点分析

优点 (Pros):

  1. 极大地减少内存占用:通过共享对象,显著降低了系统中对象的数量,从而节省内存。
  2. 提升性能:减少了对象的创建和销毁次数,减轻了 GC 的压力,提高了系统性能。

缺点 (Cons):

  1. 增加了系统复杂性:需要将对象的状态分解为内部和外部状态,这可能会使代码逻辑变得更复杂,不易理解。
  2. 外部状态的管理:客户端需要自己负责管理所有对象的外部状态,增加了客户端的负担。
  3. 线程安全问题:享元对象是共享的,如果其内部状态(虽然理论上不应该)被意外修改,需要考虑线程安全问题。

七、在 Java 中的应用

享元模式在 Java 核心库中有非常经典的应用。

  • **java.lang.String 的字符串常量池 (String Pool)**:

    • 当你写 String s1 = "abc";String s2 = "abc"; 时,JVM 不会创建两个 “abc” 对象。它会检查字符串常量池,发现 “abc” 已经存在,于是让 s2s1 指向同一个对象。这里的 “abc” 字符串就是享元对象,它的字符内容是内部状态。
  • java.lang.Integer.valueOf(int i):

    • 为了优化,Java 缓存了从 -128 到 127 的 Integer 对象。当你调用 Integer.valueOf(10) 时,它会从一个缓存数组中返回一个预先创建好的 Integer 对象,而不是 new Integer(10)
    • Integer i1 = 100;Integer i2 = 100;i1 == i2 的结果是 true
    • Integer i3 = 200;Integer i4 = 200;i3 == i4 的结果是 false(因为超出了缓存范围)。
    • 这正是享元模式的应用:Integer 对象是享元,数值是内部状态。
  • 数据库连接池: 连接池中的每个 Connection 对象可以被看作是享元对象。它们被多个线程共享,以避免频繁地创建和销毁昂贵的数据库连接。

总而言之,享元模式是一种以空间换时间、优化性能的强大模式。