1. 引言

单例模式是软件开发中最常用的设计模式之一。它确保一个类在整个应用程序生命周期中只有一个实例,并提供全局访问点。

单例模式的典型应用场景包括:

  • 高效管理有限数据库连接的数据库连接池
  • 集中应用日志功能的日志实例
  • 存储应用级配置的配置管理器
  • 在多个组件间维护共享数据的缓存管理器
  • 管理并发操作工作线程的线程池

但在多线程环境中实现单例模式时,事情会变得复杂。没有适当的线程安全保证,多个线程可能同时创建多个实例,破坏单例的核心承诺,并可能导致资源冲突或状态不一致。这会导致资源冲突、状态不一致和不可预测的应用行为。

本文将探讨在Java中实现线程安全单例的各种方法,分析它们的权衡和最佳实践。

2. 单例模式的经典问题

我们先来看看为什么基本的延迟初始化单例实现在多线程环境中会失败

这是一个典型的非线程安全单例实现:

public class BasicSingleton {
    private static BasicSingleton instance;
    
    private BasicSingleton() {}
    
    public static BasicSingleton getInstance() {
        if (instance == null) {
            instance = new BasicSingleton();
        }
        return instance;
    }
}

这个实现在单线程应用中完美运行。但在多线程环境中,会出现竞态条件:

  1. 线程A调用getInstance()发现instancenull
  2. 线程B同时调用getInstance()也发现instancenull
  3. 两个线程都继续创建新实例
  4. 应用现在有了多个单例实例,违反了模式原则

我们使用CountDownLatch来演示这个竞态条件,让线程并行运行:

@Test
void givenMultipleThreads_whenUsingBasicSingleton_thenMultipleInstancesCreated() 
  throws InterruptedException {
    int threadCount = 100;
    CountDownLatch latch = new CountDownLatch(threadCount);
    Set<BasicSingleton> instances = ConcurrentHashMap.newKeySet();
    
    for (int i = 0; i < threadCount; i++) {
        new Thread(() -> {
            instances.add(BasicSingleton.getInstance());
            latch.countDown();
        }).start();
    }
    
    latch.await(5, TimeUnit.SECONDS);
    assertTrue(instances.size() > 1, "Multiple instances created due to race condition");
}

这个测试展示了并发访问如何创建多个实例,破坏单例契约。在正确的单例中,实例数量应该是1。但由于竞态条件,我们可能得到多个实例。

3. 同步访问器:简单安全

我们可以让getInstance()方法同步:

public static synchronized SynchronizedSingleton getInstance() {
    if (instance == null) {
        instance = new SynchronizedSingleton();
    }
    return instance;
}

这保证了互斥,但引入了性能开销,因为每次访问都会进行同步

@Test
void givenMultipleThreads_whenUsingSynchronizedSingleton_thenOnlyOneInstanceCreated() {
    Set<Object> instances = ConcurrentHashMap.newKeySet();
    IntStream.range(0, 100).parallel().forEach(i ->
      instances.add(SynchronizedSingleton.getInstance()));
    assertEquals(1, instances.size());
}

这种方法简单直接,在低并发场景或单例创建很少被访问的情况下很有效。

4. 饿汉初始化:通过类加载保证线程安全

饿汉单例使用静态字段初始化:

public class EagerSingleton {
    private static final EagerSingleton INSTANCE = new EagerSingleton();
    
    private EagerSingleton() {}
    
    public static EagerSingleton getInstance() {
        return INSTANCE;
    }
}

它本质上是线程安全的,因为JVM保证类初始化是原子操作。缺点是什么?即使从未使用,实例也会被创建,这对于昂贵资源可能不是最优选择:

@Test
void whenCallingEagerSingleton_thenSameInstanceReturned() {
    assertSame(EagerSingleton.getInstance(), EagerSingleton.getInstance());
}

当单例保证在启动时就需要时,这种模式是理想选择。

5. 双重检查锁定(DCL):延迟且高效

DCL结合了延迟初始化和减少同步:

public class DoubleCheckedSingleton {
    private static volatile DoubleCheckedSingleton instance;
    
    private DoubleCheckedSingleton() {}
    
    public static DoubleCheckedSingleton getInstance() {
        if (instance == null) {
            synchronized (DoubleCheckedSingleton.class) {
                if (instance == null) {
                    instance = new DoubleCheckedSingleton();
                }
            }
        }
        return instance;
    }
}

这种模式既延迟又线程安全,但需要将实例变量声明为volatile

@Test
void givenDCLSingleton_whenAccessedFromThreads_thenOneInstanceCreated() {
    List<Object> instances = Collections.synchronizedList(new ArrayList<>());
    IntStream.range(0, 100).parallel().forEach(i ->
      instances.add(DoubleCheckedSingleton.getInstance()));
    assertEquals(1, new HashSet<>(instances).size());
}

这种方法通过在实例初始化后避免同步来提高性能。volatile关键字确保跨线程的更改可见性。它适用于性能重要的高并发环境。

6. Bill Pugh单例:延迟且优雅

Bill Pugh单例技术使用静态内部类:

public class BillPughSingleton {
    private BillPughSingleton() {
    }
    
    private static class SingletonHelper {
        private static final BillPughSingleton BILL_PUGH_SINGLETON_INSTANCE = new BillPughSingleton();
    }
    
    public static BillPughSingleton getInstance() {
        return SingletonHelper.BILL_PUGH_SINGLETON_INSTANCE;
    }
}

该类在系统引用它之前不会被加载,这确保了延迟和线程安全,无需同步

@Test
void testThreadSafety() throws InterruptedException {
    int numberOfThreads = 10;
    CountDownLatch latch = new CountDownLatch(numberOfThreads);
    Set<BillPughSingleton> instances = ConcurrentHashMap.newKeySet();
    
    for (int i = 0; i < numberOfThreads; i++) {
        new Thread(() -> {
            instances.add(BillPughSingleton.getInstance());
            latch.countDown();
        }).start();
    }
    
    latch.await(5, TimeUnit.SECONDS);
    
    assertEquals(1, instances.size(), "All threads should get the same instance");
}

7. 枚举单例:最简单的线程安全单例

枚举提供了强大的单例解决方案。JVM只实例化枚举值一次:

public enum EnumSingleton {
    INSTANCE;
    
    public void performOperation() {
        // 单例操作在这里
    }
}

Java在加载枚举时只实例化枚举常量一次,确保它们本质上是线程安全的:

@Test
void givenEnumSingleton_whenAccessedConcurrently_thenSingleInstanceCreated()
  throws InterruptedException {
    Set<EnumSingleton> instances = ConcurrentHashMap.newKeySet();
    CountDownLatch latch = new CountDownLatch(100);
    
    for (int i = 0; i < 100; i++) {
        new Thread(() -> {
            instances.add(EnumSingleton.INSTANCE);
            latch.countDown();
        }).start();
    }
    
    latch.await();
    assertEquals(1, instances.size());
}

它还防止序列化和反射攻击。

8. 结论

在并发Java应用中,单例实现的线程安全至关重要。虽然同步方法实现简单,但代价高昂——在高并发下扩展性不佳。目前最佳选择包括:

  • Bill Pugh单例:适用于大多数场景
  • **双重检查锁定(DCL)**:用于性能关键的延迟初始化
  • 枚举单例:简单且安全

每种方法以不同的权衡解决相同问题。根据应用需求选择最适合的方案。完整的源代码和测试可在GitHub上获取。


原始标题:How to Implement a Thread-Safe Singleton in Java? | Baeldung