原创

设计模式基础(一)——单例模式

对于单例的概念,我觉得没必要解释太多,你一看就能明白。我们所说的单例模式,一般特指JVM进程内的单例,确切的说是限定在ClassLoader范围内的单例。

我们在程序中使用单例模式(Singleton Design Pattern),目的一般是处理资源访问的冲突,或者从业务概念上,有些数据在系统中只应保存一份,那也比较适合设计为单例类,比如配置类、全局流水号生成器等。

一、实现方式

我们先来看看有哪几种方式可以实现单例模式。要实现一个单例,无外乎关注下面几个点:

  • 构造函数需要是 private 访问权限的,这样才能避免外部通过 new 创建实例;
  • 考虑对象创建时的线程安全问题;
  • 考虑是否支持延迟加载;
  • 考虑 getInstance() 性能如何(是否加锁)。

1.1 饿汉式

饿汉式的实现方式比较简单。在类加载的时候,instance 静态实例就已经创建并初始化好了,所以,instance 实例的创建过程是线程安全的。

public class IdGenerator { 
  private AtomicLong id = new AtomicLong(0);
  private static final IdGenerator instance = new IdGenerator();

  private IdGenerator() {}

  public static IdGenerator getInstance() {
    return instance;
  }

  public long getId() { 
    return id.incrementAndGet();
  }
}

有人说,这种实现方式不好,因为不支持延迟加载,如果实例占用资源多(比如占用内存多)或初始化耗时长(比如需要加载各种配置文件),提前初始化实例是一种浪费资源的行为,最好的方法应该在用到的时候再去初始化。

我个人反对这种观点,并且认为如果在生产环境确实需要使用单例模式,应该采用饿汉式,理由如下:

  1. 如果初始化耗时长,那我们最好不要等到真正要用它的时候,才去执行这个耗时长的初始化过程,这会影响到系统的性能(比如,在响应客户端接口请求的时候,做这个初始化操作,会导致此请求的响应时间变长,甚至超时);
  2. 如果实例占用资源多,按照 fail-fast 原则,那我们也希望在程序启动时就将这个实例初始化好,如果资源不够,就会在程序启动时报错(比如 Java 中的 PermGen Space OOM),及早发现问题。

1.2 懒汉式

懒汉式相对于饿汉式的优势是支持延迟加载,比如下面这种方式,是采用了双重锁检测的懒汉式,提升了获取单例对象的性能:


public class IdGenerator { 
  private AtomicLong id = new AtomicLong(0);
  private static IdGenerator instance;
  private IdGenerator() {}
  public static IdGenerator getInstance() {
    if (instance == null) {
      synchronized(IdGenerator.class) { // 此处为类级别的锁
        if (instance == null) {
          instance = new IdGenerator();
        }
      }
    }
    return instance;
  }
  public long getId() { 
    return id.incrementAndGet();
  }
}

有人说,上面这种方式可能因为指令重排序,导致 IdGenerator 对象被 new 出来,并且赋值给 instance 之后,还没来得及初始化就被使用了。只有很低版本(JDK1.5以下)的 Java 才会有这个问题,高版本的 JDK 已经解决了这个问题(把对象 new 操作和初始化操作设计为原子操作,就自然能禁止重排序)。

1.3 静态内部类方式

还有一种方式,是利用 Java 的静态内部类,它有点类似饿汉式,但又能做到延迟加载:

public class IdGenerator { 
  private AtomicLong id = new AtomicLong(0);
  private IdGenerator() {}

  private static class SingletonHolder{
    private static final IdGenerator instance = new IdGenerator();
  }

  public static IdGenerator getInstance() {
    return SingletonHolder.instance;
  }

  public long getId() { 
    return id.incrementAndGet();
  }
}

SingletonHolder 是一个静态内部类,当外部类 IdGenerator 被加载的时候,并不会创建 SingletonHolder 实例对象。只有当调用 getInstance() 方法时,SingletonHolder 才会被加载,这个时候才会创建 instance。

instance 的唯一性、创建过程的线程安全性,都由 JVM 来保证。所以,这种实现方法既保证了线程安全,又能做到延迟加载。

1.4 枚举方式

最后,还有一种很简单的实现方式,就是基于枚举类型来实现。这种实现方式通过 Java 枚举类型本身的特性,保证了对象创建的线程安全性和唯一性:

public enum IdGenerator {
  INSTANCE;
  private AtomicLong id = new AtomicLong(0);

  public long getId() { 
    return id.incrementAndGet();
  }
}

二、模式种类

2.1 线程内单例

我们上述将的单例模式是指ClassLoader内的单例,如何要实现线程内单例呢?所谓线程内单例,指的是线程内唯一,线程间可以不唯一。我们通过一个 HashMap 来存储对象,其中 key 是线程 ID,value 是对象。这样我们就可以做到,不同的线程对应不同的对象,同一个线程只能对应一个对象。

实际上,Java 语言本身提供了 ThreadLocal 工具类,可以更加轻松地实现线程唯一单例。


public class IdGenerator {
  private AtomicLong id = new AtomicLong(0);

  private static final ConcurrentHashMap<Long, IdGenerator> instances
          = new ConcurrentHashMap<>();

  private IdGenerator() {}

  public static IdGenerator getInstance() {
    Long currentThreadId = Thread.currentThread().getId();
    instances.putIfAbsent(currentThreadId, new IdGenerator());
    return instances.get(currentThreadId);
  }

  public long getId() {
    return id.incrementAndGet();
  }
}

2.2 集群单例

还有一种单例模式,就是所谓的集群内单例。集群相当于多个进程构成的一个集合,“集群唯一”就相当于是进程内唯一、进程间也唯一。也就是说,不同的进程间共享同一个对象,不能创建同一个类的多个对象。

实现集群内单例的核心思路,其实跟利用分布式锁控制访问共享资源是一个道理,只是将创建/销毁单例对象的过程用分布式锁加以控制,保证每次只有一个节点能做创建/销毁的操作:

public class IdGenerator {
  private AtomicLong id = new AtomicLong(0);
  private static IdGenerator instance;
  private static SharedObjectStorage storage = FileSharedObjectStorage();
  private static DistributedLock lock = new DistributedLock();

  private IdGenerator() {}

  public synchronized static IdGenerator getInstance() 
    if (instance == null) {
      lock.lock();
      instance = storage.load(IdGenerator.class);
    }
    return instance;
  }

  public synchroinzed void freeInstance() {
    storage.save(this, IdGeneator.class);
    instance = null; //释放对象
    lock.unlock();
  }

  public long getId() { 
    return id.incrementAndGet();
  }
}

// IdGenerator使用举例
IdGenerator idGeneator = IdGenerator.getInstance();
long id = idGenerator.getId();
IdGenerator.freeInstance();

上面是一段伪代码,我们把单例对象序列化并存储到外部共享存储区(比如文件)。进程在使用这个单例对象的时候,需要先从外部共享存储区中将它读取到内存,并反序列化成对象,然后再使用,使用完成之后还需要再存储回外部共享存储区。多个不同进程之间的访问通过分布式锁来控制。

三、开源示例

在Netty开源框架中,NioEventLoop 通过 select() 不断轮询注册的 I/O 事件,Netty 提供了选择策略 SelectStrategy 控制 select 循环行为,包含CONTINUESELECTBUSY_WAIT 三种策略。

SelectStrategy 对象的默认创建,就是采用饿汉式单例,源码如下:

final class DefaultSelectStrategy implements SelectStrategy {

    static final SelectStrategy INSTANCE = new DefaultSelectStrategy();

    private DefaultSelectStrategy() { }

    @Override
    public int calculateStrategy(IntSupplier selectSupplier, boolean hasTasks) throws Exception {
        return hasTasks ? selectSupplier.get() : SelectStrategy.SELECT;
    }
}

四、总结

本章,我介绍了设计模式中最为人熟悉的单例模式,并介绍了开源框架Netty中对单例模式的运用。生产环境如需使用单例模式,建议采用饿汉式。另外,我们也必须认识到,单例模式存在以下的问题:

  • 单例对 OOP 特性的支持不友好;
  • 单例会隐藏类之间的依赖关系;
  • 单例对代码的扩展性不友好;
  • 单例对代码的可测试性不友好;
  • 单例不支持有参数的构造函数

所以,我们需要根据项目的实际情况,评估是否使用单例模式,建议优先考虑通过工厂模式、IOC 容器(比如 Spring IOC 容器)来保证。

正文到此结束

感谢赞赏~

本文目录