Head First 单例模式

44 阅读10分钟

一、定义

单例模式:确保一个类只有一个实例,并提供一个全局访问点。
来看看下面的类图:

singleton.png

单例模式有三种不同的实现,应对不同的场景

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选举。

  • 实现原理:\
  1. 所有服务实例在启动时,都去zookeeper的一个指定路径(如services/my-service/leader)下尝试创建一个临时节点。
  2. 由于zookeeper保证节点路径唯一,最终只有一个服务实例能创建成功。
  3. 创建成功的那个实例就成为Leader或主单例,开始执行只有它才能做的任务(如发送定时任务、处理特定数据)。
  4. 其他服务实例在该节点上设置watch监听。
  5. 如果Leader实例宕机,它与zookeeper的会话结束,其创建的临时节点会自动被删除。
  6. 其他服务实例通过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命令实现分布式锁。

  • 实现原理
  1. 所有服务实例在启动时,都尝试向Redis执行SET singleton_lock <identifier> NX PX 30000
    • NX:仅当key不存在时设置。
    • PX 30000:设置key的过期时间是30秒。
  2. 只有一个实例能设置成功,即获得锁,成为主单例。
  3. 获得锁的实例需要启动一个守护线程,定期(比如每10秒)去续期这个锁,防止因任务执行时间过长导致锁自动过期。
  4. 如果主实例宕机,锁最终会因过期而释放,其他实例可以重新竞争。
  5. 未获得锁的实例则不断重试获取锁。
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. 将单例服务化

这是一种更彻底,更符合微服务架构思想的方案。

  • 实现原理
  1. 将需要单例的功能(例如ID生成、全局配置)独立出来,部署成一个单独的服务。
  2. 这个服务本身可以是一个集群,但其内部状态是统一的(例如:ID生成器使用数据库序列号或者Redis原子操作)。
  3. 其他所有服务都通过RPC或http来调用这个单一的服务,从而在逻辑上保证了单例效果。
  • 优点
    • 职责清晰:单例逻辑被封装在独立的服务中
    • 高可用:服务本身可以集群部署,通过负载均衡对外提供服务,避免了单点故障。
    • 易于扩展和维护。

四、总结

模式优点缺点适用范围
经典单例模式实现简单、高效在分布式系统中完全无效单一JVM应用
基于ZK/Etcd可靠,一致性强,有现成工具需要维护zk集群,性能稍低分布式系统,需要精确的leader选举
基于Redis性能高,实现相对简单需要保证Redis高可用,锁的续期逻辑需谨慎分布式系统,对性能要求较高
服务化职责分离,高可用,易扩展引入了网络调用延迟和复杂性微服务架构

在Java分布式系统中的建议

  1. 按需选择:
    • 如果你的“单例”需要执行后台任务(如定时调度),基于Zookeeper的Leader选举是行业标准做法。
    • 如果你的“单例”是提供一种无状态的工具能力(如生成全局ID),将其服务化是最佳选择。在该服务内部,可以使用数据库序列、Redis原子incr或Snowflake算法等保证ID的唯一性。
    • 如果是一个短时、高并发的互斥资源访问,可使用基于Redis的分布式锁。
  2. 使用成熟框架:优先使用Curator(对于zookeeper)或者Redisson(对于Redis),他们封装了复杂的细节和边界情况,比自己从头实现要可靠的多。

参考文章:blog.csdn.net/yitiaoxiany…