一、定义
单例模式:确保一个类只有一个实例,并提供一个全局访问点。
来看看下面的类图:
单例模式有三种不同的实现,应对不同的场景
1、懒汉式(也叫延迟实例化)
为什么叫懒汉式?
因为类实例化的代码是在运行时真正用到的时候才执行的。就好像人不会提前准备好,等用到的时候才着手干。
public class LazySingleton {
/**
* 静态的变量,持有LazySingleton的实例
*/
private static LazySingleton instance;
/**
* 私有构造方法
*/
private LazySingleton() {}
/**
* 这个方法是线程不安全的
* @return
*/
public static LazySingleton getInstance() {
if (instance == null) {
instance = new LazySingleton();
}
return instance;
}
}
在上述代码中,实现了懒汉式的单例模式。但是它不是线程安全的。当在多线程环境下运行时,可能导致会有多个实例对象。
下面就要将上述代码改造成线程安全的。
第一种方法:使用同步锁synchronized
通过增加syncronized关键字到getInstance方法中,迫使每个线程在进入这个方法之前,要先等候别的线程离开该方法。
public class SafeLazySingleton {
/**
* 静态的变量,持有SafeLazySingleton的实例
*/
private static SafeLazySingleton instance;
/**
* 私有构造方法
*/
private SafeLazySingleton() {}
/**
* 这个方法通过使用同步锁synchronized确保是线程安全的,但是会导致性能降低
* @return
*/
public static synchronized SafeLazySingleton getInstance() {
if (instance == null) {
instance = new SafeLazySingleton();
}
return instance;
}
}
第二种方法:在JVM初次加载类时就完成实例化
依赖JVM在加载这个类时马上创建唯一的单件实例。JVM保证在任何线程访问instance静态变量之前,一定先创建此实例。
2、饿汉式
为什么叫饿汉式?
不是在需要时才创建实例,而是在JVM加载类时马上创建实例,表现出非常急切的状态。就像人饿久了就会非常急切的想吃饭一样。
public class HungrySingleton {
// 在静态初始化器中创建单件。这行代码保证了线程安全
private static HungrySingleton instance = new HungrySingleton();
private HungrySingleton() {}
public static HungrySingleton getInstance() {
return instance;
}
}
第三种方法:使用双重检查加锁
利用双重检查加锁,首先检查是否实例已经创建了,如果尚未创建,才进行同步。这样一来,只会第一次同步。
3、双重检查加锁
指的是:volatile关键字和同步锁synchronized
volatile关键字确保当instance变量被初始化成Singleton实例时,多个线程正确的处理instance变量。
synchronized关键字确保多线程同步执行。
public class DoubleCheckLockSingleton {
// 使用volatile关键字确保instance是线程安全的
private static volatile DoubleCheckLockSingleton instance;
private DoubleCheckLockSingleton() {}
public static DoubleCheckLockSingleton getInstance() {
if (instance == null) {
// 只有在instance是null的时候才会进入使用同步锁
synchronized (DoubleCheckLockSingleton.class) {
if (instance == null) {
instance = new DoubleCheckLockSingleton();
}
}
}
return instance;
}
}
| 线程安全方式 | 优点 | 缺点 | 使用场景 |
|---|---|---|---|
| synchronized关键字 | 简单又有效的实现线程安全 | 严重降低性能。其实只有第一次调用getInstance方法时才真正需要同步。一旦设置好instance变量,就不需要同步了。但是现状是每次调用都会同步 | 应用程序可以接受synchronized带来的额外负担,并且genInstance方法并不会被频繁调用 |
| 饿汉式单例 | 线程安全 | 过早的创建实例,如果实例一直没有被用到或者实例占用资源很大,容易造成资源浪费 | 1. 应用程序总是创建并使用单件实例。2. 应用程序在创建和运行时方面的负担不太繁重,想要急切的创建单件实例。 |
| 双重检查加锁 | 1. 线程安全。2. 只有第一次访问时会使用syncronized锁,所以并不会降低性能。 | 代码实现比前两种复杂 | 任何需要单例类的场景中 |
二、使用场景
1、SpringBoot中的单例模式
在SpringBoot中,单例模式是依赖注入容器默认管理Bean的方式,通过@Service、@Component等注解标记的类会被自动注册为单例Bean,确保在整个应用上下文中仅存在一个实例。
实现方式与默认行为:SpringBoot使用IoC容器管理Bean的生命周期,默认作用域为单例。例如:使用@Service注解的类:
@Service
public class GreetingServiceImpl implements GreetingService {
@Override
public String greet(String name) {
return "Hello, " + name + "!";
}
}
通过@Autowired注入时,无论在多少个组件中使用,获取的都是同一个实例。
线程安全与最佳实践:单例Bean必须是无状态的,避免在实例中保存用户相关数据,否则可能引发线程安全问题。正确做法是将状态存储在方法参数或者局部变量中,而非成员变量。
自定义作用域与延迟加载:虽然默认为单例,但可通过@Scope("prototype")显示指定为多例模式(每次获取新实例)。对于单例Bean,可通过使用@Lazy注解实现懒加载,延迟初始化直到首次注入时才创建实例。
与传统单例模式的区别:Spring的单例由容器管理,无需手动编写getInstance方法或同步锁,简化了代码并避免了线程安全问题,同时提供了更灵活的配置选项。
三、分布式架构中如何使用单例模式?如何确保全局只有一个实例?
在分布式系统中,单例模式的核心目标是确保某个类在整个系统中只有一个实例。例如,你可能需要一个全局的ID生成器、配置管理中心或任务调度器。但分布式环境的跨节点特性使得传统单例(例如Java JVM内的单例)失效。因为每个节点可以独自加载类并创建实例。因此,需要借助外部协调机制来实现全局唯一性。
分布式单例的核心就是把单例的控制权从单JVM延伸到多JVM。
以下是几种主流的解决方案:
1. 借助外部中间件实现
这是最常用、最专业的做法。思想是让一个外部系统来决定哪个实例是主实例。
1.1 使用Zookeeper/Etcd实现
Zookeeper的临时节点和watch机制非常适合实现分布式锁和Leader选举。
- 实现原理:\
- 所有服务实例在启动时,都去zookeeper的一个指定路径(如services/my-service/leader)下尝试创建一个临时节点。
- 由于zookeeper保证节点路径唯一,最终只有一个服务实例能创建成功。
- 创建成功的那个实例就成为Leader或主单例,开始执行只有它才能做的任务(如发送定时任务、处理特定数据)。
- 其他服务实例在该节点上设置watch监听。
- 如果Leader实例宕机,它与zookeeper的会话结束,其创建的临时节点会自动被删除。
- 其他服务实例通过Watch收到节点删除的通知,然后再次发起创建临时节点的竞争,选举出新的Leader。
public class DistributeSingleton {
private final CuratorFramework client;
private final String lockPath;
public DistributeSingleton(CuratorFramework client, String lockPath) {
this.client = client;
this.lockPath = lockPath;
}
public void start() throws Exception {
// 使用Curator提供的LeaderSelector工具类
LeaderSelectorListener listener = new LeaderSelectorListenerAdapter() {
@Override
public void takeLeadership(CuratorFramework client) throws Exception {
// 当称为Leader时,此方法被调用
System.out.println("成为Leader,开始执行单例任务");
// 这里执行只有单例才能做的任务
// 这个方法会一直阻塞,直到本实例失去Leadership(如会话断开)
// 所以通常这里会是一个循环,直到收到中断信号
try {
while (!Thread.currentThread().isInterrupted()) {
// 执行任务
Thread.sleep(500);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
System.out.println("失去Leadership");
}
}
};
LeaderSelector selector = new LeaderSelector(client, lockPath, listener);
selector.autoRequeue(); //自动参与重新选举
selector.start();
}
}
1.2 使用Redis实现
利用Redis的set key value NX PX timeout命令实现分布式锁。
- 实现原理
- 所有服务实例在启动时,都尝试向Redis执行
SET singleton_lock <identifier> NX PX 30000。- NX:仅当key不存在时设置。
- PX 30000:设置key的过期时间是30秒。
- 只有一个实例能设置成功,即获得锁,成为主单例。
- 获得锁的实例需要启动一个守护线程,定期(比如每10秒)去续期这个锁,防止因任务执行时间过长导致锁自动过期。
- 如果主实例宕机,锁最终会因过期而释放,其他实例可以重新竞争。
- 未获得锁的实例则不断重试获取锁。
import java.util.concurrent.TimeUnit;
public class RedisDistributeSingleton {
private final RedissonClient redisson;
private final String lockKey;
private RLock lock;
public RedisDistributeSingleton(RedissonClient redisson, String lockKey) {
this.redisson = redisson;
this.lockKey = lockKey;
}
public void start() {
lock = redisson.getLock(lockKey);
// 尝试获取锁,如果获取成功,则执行任务
// 这里使用异步方式,避免阻塞主线程
new Thread(() -> {
while (true) {
try {
// 尝试加锁,waitTime为0,leaseTime为30s
if (lock.tryLock(0, 30, TimeUnit.SECONDS)) {
try {
System.out.println("获得分布式锁,成为单例");
// 执行单例任务
while (true) {
try {
// 执行业务逻辑
} catch (Exception e) {
throw new RuntimeException(e);
}
Thread.sleep(1000);
// 续期。Redisson的看门狗机制会自动续期,这里只需保持线程活跃
}
} finally {
lock.unlock();
System.out.println("释放分布式锁");
}
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
// 未获取到锁,稍后重试
try {
Thread.sleep(50000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
}
}).start();
}
}
2. 将单例服务化
这是一种更彻底,更符合微服务架构思想的方案。
- 实现原理
- 将需要单例的功能(例如ID生成、全局配置)独立出来,部署成一个单独的服务。
- 这个服务本身可以是一个集群,但其内部状态是统一的(例如:ID生成器使用数据库序列号或者Redis原子操作)。
- 其他所有服务都通过RPC或http来调用这个单一的服务,从而在逻辑上保证了单例效果。
- 优点
- 职责清晰:单例逻辑被封装在独立的服务中
- 高可用:服务本身可以集群部署,通过负载均衡对外提供服务,避免了单点故障。
- 易于扩展和维护。
四、总结
| 模式 | 优点 | 缺点 | 适用范围 |
|---|---|---|---|
| 经典单例模式 | 实现简单、高效 | 在分布式系统中完全无效 | 单一JVM应用 |
| 基于ZK/Etcd | 可靠,一致性强,有现成工具 | 需要维护zk集群,性能稍低 | 分布式系统,需要精确的leader选举 |
| 基于Redis | 性能高,实现相对简单 | 需要保证Redis高可用,锁的续期逻辑需谨慎 | 分布式系统,对性能要求较高 |
| 服务化 | 职责分离,高可用,易扩展 | 引入了网络调用延迟和复杂性 | 微服务架构 |
在Java分布式系统中的建议
- 按需选择:
- 如果你的“单例”需要执行后台任务(如定时调度),基于Zookeeper的Leader选举是行业标准做法。
- 如果你的“单例”是提供一种无状态的工具能力(如生成全局ID),将其服务化是最佳选择。在该服务内部,可以使用数据库序列、Redis原子incr或Snowflake算法等保证ID的唯一性。
- 如果是一个短时、高并发的互斥资源访问,可使用基于Redis的分布式锁。
- 使用成熟框架:优先使用Curator(对于zookeeper)或者Redisson(对于Redis),他们封装了复杂的细节和边界情况,比自己从头实现要可靠的多。