什么是享元模式?应用场景有哪些?


面试考察点

  1. 核心思想理解:面试官不仅仅是想知道享元模式的定义,更是想看你能否讲清楚 "内部状态" 和 "外部状态" 的区分,这是享元模式的灵魂。

  2. JDK 源码关联:能否说出 IntegerIntegerCacheString 的常量池、Long 的缓存等 JDK 中享元模式的经典应用。面试官最爱问的就是 "Integer 127 和 128 的区别"。

  3. 性能优化意识:能否从内存优化的角度理解享元模式的价值,以及它在实际项目中如何应用。

核心答案

一句话定义:享元模式通过共享对象来减少内存占用,适用于大量相似对象的场景。它将对象的状态分为 "内部状态"(可共享)和 "外部状态"(不可共享),内部状态相同的对象只保留一份,外部状态由客户端传入。

生活中的例子:去网吧上网。每台电脑都有自己的位置号(外部状态,各不相同),但操作系统都是 Windows 10(内部状态,可以共享)。网吧老板不需要给每台电脑装一套不同的系统,大家共享同一个镜像就行。这样既省硬盘空间,又方便管理。

上面的图展示了享元模式的核心结构:

  • FlyweightFactory(享元工厂):维护一个享元池(通常是一个 Map),客户端请求时先从池中查找,有就直接返回已有对象,没有才创建新对象并放入池中。
  • Flyweight(享元对象):包含内部状态(可共享的部分),通过方法参数接收外部状态(不可共享的部分)。
  • 内部状态 vs 外部状态:这是享元模式的核心区分。内部状态是对象中不随环境变化的部分,可以共享;外部状态是随环境变化的部分,不能共享,必须由客户端每次传入。

深度解析

一、手写享元模式

用 "围棋" 来举例。围棋只有黑白两种棋子(内部状态),但每个棋子的位置不同(外部状态)。一局棋可能下 200+ 步,如果每步都创建一个新棋子对象,那就创建了 200 多个对象。但用享元模式,只需要 2 个对象——一个黑棋、一个白棋。

// 享元接口
public interface ChessPiece {
    void display(int x, int y); // x, y 是外部状态
}

// 具体享元:黑棋
public class BlackPiece implements ChessPiece {
    private String color = "黑"; // 内部状态,可共享

    @Override
    public void display(int x, int y) {
        System.out.println(color + "棋 → 位置(" + x + ", " + y + ")");
    }
}

// 具体享元:白棋
public class WhitePiece implements ChessPiece {
    private String color = "白"; // 内部状态,可共享

    @Override
    public void display(int x, int y) {
        System.out.println(color + "棋 → 位置(" + x + ", " + y + ")");
    }
}

享元工厂:

public class ChessFactory {

    // 享元池:缓存已创建的对象
    private static final Map<String, ChessPiece> pool = new HashMap<>();

    // 获取棋子对象:有则返回,无则创建
    public static ChessPiece getPiece(String color) {
        if (!pool.containsKey(color)) {
            System.out.println("创建新棋子:" + color);
            if ("黑".equals(color)) {
                pool.put(color, new BlackPiece());
            } else {
                pool.put(color, new WhitePiece());
            }
        }
        return pool.get(color);
    }
}

使用:

// 200 步棋,只创建 2 个对象
ChessPiece p1 = ChessFactory.getPiece("黑"); // 创建新棋子:黑
p1.display(3, 4);

ChessPiece p2 = ChessFactory.getPiece("白"); // 创建新棋子:白
p2.display(10, 15);

ChessPiece p3 = ChessFactory.getPiece("黑"); // 复用已有的黑棋对象!
p3.display(5, 7);

ChessPiece p4 = ChessFactory.getPiece("白"); // 复用已有的白棋对象!
p4.display(8, 12);

// 验证:p1 和 p3 是同一个对象
System.out.println(p1 == p3); // true
System.out.println(p2 == p4); // true

输出:

创建新棋子:黑
黑棋 → 位置(3, 4)
创建新棋子:白
白棋 → 位置(10, 15)
黑棋 → 位置(5, 7)
白棋 → 位置(8, 12)
true
true

关键点:黑棋第二次请求时不再创建新对象,而是直接返回享元池中已有的。200 步棋只有 2 个棋子对象在内存里,而不是 200 个。

二、JDK 中的经典应用:Integer 缓存

这是面试中最常考的享元模式应用。先看一道经典面试题:

Integer a = 127;
Integer b = 127;
System.out.println(a == b); // true

Integer c = 128;
Integer d = 128;
System.out.println(c == d); // false

为什么 127 是 true,128 是 false?因为 Integer 内部有一个缓存池 IntegerCache,缓存了 -128 到 127Integer 对象:

// java.lang.Integer.IntegerCache(JDK 源码简化版)
private static class IntegerCache {
    static final Integer[] cache = new Integer[256];

    static {
        // 缓存 -128 ~ 127 的 Integer 对象
        for (int i = 0; i < cache.length; i++) {
            cache[i] = new Integer(i - 128);
        }
    }
}

// Integer.valueOf() 方法
public static Integer valueOf(int i) {
    if (i >= -128 && i <= 127) {
        return IntegerCache.cache[i + 128]; // 返回缓存对象
    }
    return new Integer(i); // 超出范围,创建新对象
}
  • -128~127:直接从缓存池取,所有相同值共享同一个对象(==true
  • 超出范围:每次 new 一个新对象(==false

这就是典型的享元模式——IntegerCache 就是享元工厂,缓存池就是享元池。内部状态是数值本身,没有外部状态(Integer 是不可变对象)。

类似的缓存还有

  • Long:同样缓存 -128~127
  • Short:同样缓存 -128~127
  • Byte:缓存所有值(-128~127)
  • Character:缓存 0~127

三、JDK 中的经典应用:String 常量池

String 的常量池也是享元模式的经典实现。

String s1 = "hello";
String s2 = "hello";
System.out.println(s1 == s2); // true,共享常量池中的对象

String s3 = new String("hello");
System.out.println(s1 == s3); // false,堆上新建了对象

字面量 "hello" 放在字符串常量池中,相同内容只存一份。通过 new String() 创建的则在堆上新分配内存。JDK 7 之后字符串常量池从永久代移到了堆中,进一步优化了内存管理。

四、线程池也是享元?

严格来说,线程池和数据库连接池并不属于经典的享元模式,但思想相似——都是通过 "池化" 复用对象,避免频繁创建销毁。

区别在于:享元模式关注的是 共享对象的内部状态,减少内存占用;线程池关注的是 复用对象本身,减少创建/销毁的开销。虽然都用了 "池" 这个概念,但解决的问题不同。

五、实际应用场景

场景享元对象内部状态外部状态
Integer 缓存Integer 对象数值(-128~127)
String 常量池String 对象字符串内容
围棋程序棋子对象颜色(黑/白)位置坐标
文本编辑器字符格式对象字体、字号、颜色字符内容、位置
游戏中的树树对象树的种类、纹理坐标位置
地图标记图标对象图标样式经纬度

享元模式特别适合这类场景:对象数量庞大,但大部分对象的内部状态是相同的。通过共享内部状态,可以把对象数量从几万降到几十。

面试高频追问

  1. Integer a = 127Integer b = 127 为什么 a == btrue

    • 因为 Integer 内部有缓存池(IntegerCache),-128 到 127 之间的值会复用缓存对象,指向同一个引用。超出范围则每次创建新对象。
  2. 享元模式和对象池有什么区别?

    • 享元模式关注共享对象的内部状态,目的是减少内存占用。对象池关注复用对象避免频繁创建销毁,目的是提高性能。思想相似,但解决的问题不同。
  3. 享元模式有什么缺点?

    • 增加了系统复杂度,需要区分内部状态和外部状态。如果享元对象很多且不常复用,维护享元池反而浪费内存。另外,外部状态需要客户端每次传入,使用上不如直接持有对象方便。

常见面试变体

  • "Integer 的缓存机制是怎样的?"
  • "String 常量池用到了什么设计模式?"
  • "如何设计一个节省内存的围棋程序?"
  • "享元模式和单例模式有什么区别?"

记忆口诀

核心思想:共享内部状态,外部状态靠传入;用池子缓存对象,能复用就不新建。

JDK 典型应用Integer 缓存 -128~127,String 常量池存字面量。

内部 vs 外部:内部可共享(颜色、字体),外部各不同(位置、内容)。

总结

享元模式的精髓就是 "共享复用,减少内存"。面试时先说概念和内部/外部状态的区分,然后重点讲 Integer 缓存池和 String 常量池这两个 JDK 经典应用,最后和对象池做区分。能把 Integer 127 和 128 的面试题讲清楚,面试官基本就认可你对享元模式的理解了。