面试官:知道享元模式与单例模式的区别吗?

49 阅读10分钟

背了 23 种设计模式,单例模式的饿汉懒汉双重校验锁张口就来,享元模式的「共享对象、减少内存」也能说个大概。
结果面试官一句:「那你说说,享元模式和单例模式的核心区别是什么?分别在什么场景下用?」当场卡壳。

image.png

今天这篇文章,我就用一线开发踩过的坑,从底层原理、代码实现、场景落地、面试满分回答四个维度,把这两个模式讲得明明白白。看完你不仅能在面试中轻松拿捏这个必考题,工作中也能直接套用,写出更优雅、性能更好的代码。

一、先搞懂本质:单例模式,到底解决了什么问题?

单例模式是所有程序员入门设计模式的第一课,但很多人只背了写法,没懂核心本质。

核心定义

单例模式的核心,是保证一个类在整个应用的生命周期中,有且仅有一个实例,并提供一个全局唯一的访问点。

它解决的核心问题是:对于创建和销毁成本极高、需要保证数据全局一致性的对象,避免重复创建带来的资源浪费和数据混乱。

给大家一个最形象的类比:单例模式就像一个公司的 CEO,整个公司就只有这一个,不管哪个部门找他,都是同一个人,不会因为市场部找就变出一个 CEO,技术部找又变出另一个。

单例模式的标准实现(面试常写)

先给大家最经典的双重校验锁懒汉单例,线程安全、性能优秀,面试写这个直接加分,同时附上类图。

image.png

/**
 * 双重校验锁懒汉单例模式(线程安全,生产&面试推荐)
 */
public class Singleton {
    // 私有静态实例,volatile禁止指令重排,避免多线程下空指针
    private static volatile Singleton instance;

    // 私有构造方法:核心!禁止外部通过new创建实例
    private Singleton() {
        // 防止反射破坏单例
        if (instance != null) {
            throw new RuntimeException("禁止通过反射创建单例实例");
        }
    }

    // 全局唯一的实例访问点
    public static Singleton getInstance() {
        // 第一次校验:实例已创建则直接返回,无需抢锁,提升性能
        if (instance == null) {
            // 类锁,保证同一时间只有一个线程进入
            synchronized (Singleton.class) {
                // 第二次校验:防止多线程等待锁时,重复创建实例
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }

    // 业务方法
    public void businessMethod() {
        System.out.println("单例实例的业务方法执行");
    }
}

单例模式的 3 个核心特点

  1. 实例唯一性:整个应用中,永远只有一个实例,不会创建第二个;
  2. 访问全局性:提供全局的访问点,任何地方都能获取到这个唯一实例;
  3. 实例自控性:实例的创建由类自己控制,外部无法通过 new、反射等方式创建新实例。

典型应用场景

Spring 容器中的单例 Bean、数据库连接池、全局配置管理器、日志对象、线程池核心控制器。

二、再搞懂核心:享元模式,到底是个啥?

很多人对享元模式一知半解,甚至把它当成「多例单例」,核心原因是没搞懂它的灵魂:内部状态与外部状态的分离。

核心定义

享元模式(Flyweight Pattern)是一种结构型设计模式,核心思想是通过共享已经存在的细粒度对象,减少对象的创建数量,从而降低内存占用,提升系统性能。

它解决的核心问题是:系统中存在大量重复的细粒度对象,重复创建导致内存占用飙升,甚至 OOM

还是给大家一个形象的类比:享元模式就像共享单车。

  • 单车本身的车架、车轮、品牌,是固定不变的,属于内部状态,可以被所有用户共享;
  • 骑车的人、骑行的路线、骑行的时间,是可变的,属于外部状态,每个用户使用的时候都不一样,不能共享。

你不会为了骑 10 分钟单车,就去买一辆新车,而是直接扫码复用已经存在的单车,这就是享元模式的核心:复用已有对象,避免重复创建。

image.png

享元模式的灵魂:内部状态 vs 外部状态

这是理解享元模式的关键,也是面试必考点,必须记死:

  • 内部状态:对象中固定不变的、可以在多个场景中共享的属性,创建后不可修改,是对象可以被共享的核心依据;
  • 外部状态:对象中可变的、随场景变化的属性,只能由调用方在使用时传入,不能存储在共享对象中,不可共享。

享元模式的标准实现(经典围棋案例)

用最经典的围棋棋子案例给大家演示,一盘棋 300 多步,用享元模式只需要创建 2 个对象,内存直接降 99%

image.png

  1. 享元接口
/**
 * 享元接口:围棋棋子
 */
public interface ChessFlyweight {
    // 落子方法,传入外部状态:棋子的坐标位置
    void putChess(int x, int y);
}
  1. 具体享元类
/**
 * 具体享元类:具体的棋子实例
 */
public class ConcreteChess implements ChessFlyweight {
    // 内部状态:棋子颜色,用final修饰,创建后不可变,可共享
    private final String color;

    // 构造方法仅传入内部状态
    public ConcreteChess(String color) {
        this.color = color;
    }

    @Override
    public void putChess(int x, int y) {
        System.out.printf("棋子颜色:%s,落子位置:(%d, %d)%n", color, x, y);
    }
}
  1. 享元工厂(核心,管理享元池)
/**
 * 享元工厂:统一管理棋子享元对象,维护享元池
 */
public class ChessFlyweightFactory {
    // 享元池:key为内部状态(棋子颜色),value为共享的享元对象
    private static final Map<String, ChessFlyweight> CHESS_POOL = new HashMap<>();

    // 私有构造,禁止外部创建工厂实例
    private ChessFlyweightFactory() {}

    // 全局获取享元对象的方法
    public static ChessFlyweight getChess(String color) {
        // 池中有对应对象,直接返回
        if (CHESS_POOL.containsKey(color)) {
            return CHESS_POOL.get(color);
        }
        // 池中无对应对象,创建后放入池再返回
        ChessFlyweight newChess = new ConcreteChess(color);
        CHESS_POOL.put(color, newChess);
        System.out.printf("创建了新的%s棋子对象,放入享元池%n", color);
        return newChess;
    }

    // 获取池内对象数量,测试用
    public static int getPoolSize() {
        return CHESS_POOL.size();
    }
}
  1. 客户端测试代码
public class Client {
    public static void main(String[] args) {
        // 落4个棋子,2黑2白
        ChessFlyweight black1 = ChessFlyweightFactory.getChess("黑");
        black1.putChess(1, 1);
        ChessFlyweight black2 = ChessFlyweightFactory.getChess("黑");
        black2.putChess(2, 2);

        ChessFlyweight white1 = ChessFlyweightFactory.getChess("白");
        white1.putChess(3, 3);
        ChessFlyweight white2 = ChessFlyweightFactory.getChess("白");
        white2.putChess(4, 4);

        // 验证:同颜色的棋子是同一个对象
        System.out.println("两个黑棋对象是否相同:" + (black1 == black2));
        System.out.println("两个白棋对象是否相同:" + (white1 == white2));
        System.out.println("享元池中的对象数量:" + ChessFlyweightFactory.getPoolSize());
    }
}

运行结果

创建了新的黑棋子对象,放入享元池
棋子颜色:黑,落子位置:(1, 1)
棋子颜色:黑,落子位置:(2, 2)
创建了新的白棋子对象,放入享元池
棋子颜色:白,落子位置:(3, 3)
棋子颜色:白,落子位置:(4, 4)
两个黑棋对象是否相同:true
两个白棋对象是否相同:true
享元池中的对象数量:2

可以看到,我们落了 4 个棋子,但只创建了 2 个对象,所有同颜色的棋子都是同一个共享实例,这就是享元模式的威力。

JDK 中的经典应用(面试必提)

  • String 常量池:JVM 方法区的字符串常量池,相同字面量的字符串会被共享,String a = "abc"; String b = "abc"; 中 a 和 b 指向同一个对象。
  • Integer 缓存池Integer.valueOf() 方法会缓存 - 128 到 127 之间的 Integer 对象,数字在范围内直接返回缓存的共享实例,超出范围才会新建对象,是标准的享元模式实现。

三、核心对比:享元模式 vs 单例模式,别再搞混了!

这是文章的核心,也是面试的核心考点,我从 8 个维度做了完整对比,再讲透 3 个本质区别,看完直接超过 90% 的程序员。

image.png

全维度对比表

对比维度单例模式享元模式
核心目的保证类的实例全局唯一,避免重复创建导致的资源浪费和数据不一致通过共享细粒度对象,减少对象创建数量,降低内存占用
实例数量严格控制为 1 个,整个应用生命周期中只有唯一一个实例不固定,由内部状态的种类决定,最少 1 个,最多 N 个(N 为内部状态种类数)
状态设计单例对象的所有状态全局共享,通常设计为无状态对象,避免线程安全问题严格分离内部状态(可共享、不可变)和外部状态(不可共享、调用方传入),仅共享内部状态
实现方式类自身控制实例创建,私有构造 + 静态方法提供全局访问点由享元工厂统一管理对象池,外部通过工厂获取共享对象,自身不控制实例数量
设计类型创建型设计模式(关注对象的创建方式)结构型设计模式(关注对象的组合和复用)
线程安全关注点关注实例创建的线程安全,保证多线程下不会创建多个实例关注内部状态的不可变性,保证共享对象在多线程下不会被修改
典型应用场景配置管理器、数据库连接池、Spring 单例 Bean、日志对象String 常量池、Integer 缓存池、文本编辑器字符对象、电商商品分类复用、棋牌游戏棋子

什么时候绝对不能用?

  • 单例模式:对象需要有独立的状态,每个实例的属性都不一样的场景,绝对不能用(比如用户对象)。
  • 享元模式:对象的内部状态种类多、重复度低的场景,绝对不能用,不如直接创建对象。

image.png

四、实战落地:工作中怎么选?

给大家一个可以直接套用的判断公式:

  1. 如果你需要整个应用中,这个类只能有一个实例,不能有第二个,比如全局配置管理器、数据库连接池,直接用单例模式
  2. 如果你需要处理大量重复的细粒度对象,这些对象有固定不变的核心属性,只有少量可变属性,比如文本编辑器的字符、电商的商品分类、棋牌游戏的棋子,直接用享元模式

如果这篇文章对你有帮助,别忘了点赞、收藏、关注,我会持续分享更多开发、AI应用开发、Agent、LLM、架构设计、面试干货。

我们下期再见!