背了 23 种设计模式,单例模式的饿汉懒汉双重校验锁张口就来,享元模式的「共享对象、减少内存」也能说个大概。
结果面试官一句:「那你说说,享元模式和单例模式的核心区别是什么?分别在什么场景下用?」当场卡壳。
今天这篇文章,我就用一线开发踩过的坑,从底层原理、代码实现、场景落地、面试满分回答四个维度,把这两个模式讲得明明白白。看完你不仅能在面试中轻松拿捏这个必考题,工作中也能直接套用,写出更优雅、性能更好的代码。
一、先搞懂本质:单例模式,到底解决了什么问题?
单例模式是所有程序员入门设计模式的第一课,但很多人只背了写法,没懂核心本质。
核心定义
单例模式的核心,是保证一个类在整个应用的生命周期中,有且仅有一个实例,并提供一个全局唯一的访问点。
它解决的核心问题是:对于创建和销毁成本极高、需要保证数据全局一致性的对象,避免重复创建带来的资源浪费和数据混乱。
给大家一个最形象的类比:单例模式就像一个公司的 CEO,整个公司就只有这一个,不管哪个部门找他,都是同一个人,不会因为市场部找就变出一个 CEO,技术部找又变出另一个。
单例模式的标准实现(面试常写)
先给大家最经典的双重校验锁懒汉单例,线程安全、性能优秀,面试写这个直接加分,同时附上类图。
/**
* 双重校验锁懒汉单例模式(线程安全,生产&面试推荐)
*/
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 个核心特点
- 实例唯一性:整个应用中,永远只有一个实例,不会创建第二个;
- 访问全局性:提供全局的访问点,任何地方都能获取到这个唯一实例;
- 实例自控性:实例的创建由类自己控制,外部无法通过 new、反射等方式创建新实例。
典型应用场景
Spring 容器中的单例 Bean、数据库连接池、全局配置管理器、日志对象、线程池核心控制器。
二、再搞懂核心:享元模式,到底是个啥?
很多人对享元模式一知半解,甚至把它当成「多例单例」,核心原因是没搞懂它的灵魂:内部状态与外部状态的分离。
核心定义
享元模式(Flyweight Pattern)是一种结构型设计模式,核心思想是通过共享已经存在的细粒度对象,减少对象的创建数量,从而降低内存占用,提升系统性能。
它解决的核心问题是:系统中存在大量重复的细粒度对象,重复创建导致内存占用飙升,甚至 OOM。
还是给大家一个形象的类比:享元模式就像共享单车。
- 单车本身的车架、车轮、品牌,是固定不变的,属于内部状态,可以被所有用户共享;
- 骑车的人、骑行的路线、骑行的时间,是可变的,属于外部状态,每个用户使用的时候都不一样,不能共享。
你不会为了骑 10 分钟单车,就去买一辆新车,而是直接扫码复用已经存在的单车,这就是享元模式的核心:复用已有对象,避免重复创建。
享元模式的灵魂:内部状态 vs 外部状态
这是理解享元模式的关键,也是面试必考点,必须记死:
- 内部状态:对象中固定不变的、可以在多个场景中共享的属性,创建后不可修改,是对象可以被共享的核心依据;
- 外部状态:对象中可变的、随场景变化的属性,只能由调用方在使用时传入,不能存储在共享对象中,不可共享。
享元模式的标准实现(经典围棋案例)
用最经典的围棋棋子案例给大家演示,一盘棋 300 多步,用享元模式只需要创建 2 个对象,内存直接降 99%。
- 享元接口
/**
* 享元接口:围棋棋子
*/
public interface ChessFlyweight {
// 落子方法,传入外部状态:棋子的坐标位置
void putChess(int x, int y);
}
- 具体享元类
/**
* 具体享元类:具体的棋子实例
*/
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);
}
}
- 享元工厂(核心,管理享元池)
/**
* 享元工厂:统一管理棋子享元对象,维护享元池
*/
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();
}
}
- 客户端测试代码
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% 的程序员。
全维度对比表
| 对比维度 | 单例模式 | 享元模式 |
|---|---|---|
| 核心目的 | 保证类的实例全局唯一,避免重复创建导致的资源浪费和数据不一致 | 通过共享细粒度对象,减少对象创建数量,降低内存占用 |
| 实例数量 | 严格控制为 1 个,整个应用生命周期中只有唯一一个实例 | 不固定,由内部状态的种类决定,最少 1 个,最多 N 个(N 为内部状态种类数) |
| 状态设计 | 单例对象的所有状态全局共享,通常设计为无状态对象,避免线程安全问题 | 严格分离内部状态(可共享、不可变)和外部状态(不可共享、调用方传入),仅共享内部状态 |
| 实现方式 | 类自身控制实例创建,私有构造 + 静态方法提供全局访问点 | 由享元工厂统一管理对象池,外部通过工厂获取共享对象,自身不控制实例数量 |
| 设计类型 | 创建型设计模式(关注对象的创建方式) | 结构型设计模式(关注对象的组合和复用) |
| 线程安全关注点 | 关注实例创建的线程安全,保证多线程下不会创建多个实例 | 关注内部状态的不可变性,保证共享对象在多线程下不会被修改 |
| 典型应用场景 | 配置管理器、数据库连接池、Spring 单例 Bean、日志对象 | String 常量池、Integer 缓存池、文本编辑器字符对象、电商商品分类复用、棋牌游戏棋子 |
什么时候绝对不能用?
- 单例模式:对象需要有独立的状态,每个实例的属性都不一样的场景,绝对不能用(比如用户对象)。
- 享元模式:对象的内部状态种类多、重复度低的场景,绝对不能用,不如直接创建对象。
四、实战落地:工作中怎么选?
给大家一个可以直接套用的判断公式:
- 如果你需要整个应用中,这个类只能有一个实例,不能有第二个,比如全局配置管理器、数据库连接池,直接用单例模式。
- 如果你需要处理大量重复的细粒度对象,这些对象有固定不变的核心属性,只有少量可变属性,比如文本编辑器的字符、电商的商品分类、棋牌游戏的棋子,直接用享元模式。
如果这篇文章对你有帮助,别忘了点赞、收藏、关注,我会持续分享更多开发、AI应用开发、Agent、LLM、架构设计、面试干货。
我们下期再见!