jones's technical blog

  • 首页
  • 文章归档
  • 默认分类
  • 关于页面

  • 搜索
博客系统 linux 酸酸乳

为什么要使用单例

发表于 2020-05-06 | 分类于 设计模式 | 0 | 阅读次数 148

单例设计模式(Singleton Design Pattern)理解起来很简单, 一个类只允许创建一个对象或者实例, 这个类就是单例类, 这种设计模式叫做单例模式,

实战案例: 表示全局唯一类

如果有些数据在系统中只应保留一份, 那么比较适合设计成单例类.

比如配置信息类. 在系统中我们只有一个配置文件, 当配置文件被加载到内存后, 以对象的形式存在, 所以理所应当只有一份.

再比如唯一递增 ID 生成器, 如果程序中有两个对象, 那就会存在生成重复ID 的情况, 所以, 应该讲 ID 生成器设为单例.


import java.util.concurrent.atomic.AtomicLong;
public class IdGenerator {
  /**
   * AtomicLong是一个Java并发库中提供的一个原子变量类型,
   * 它将一些线程不安全需要加锁的复合操作封装为了线程安全的原子操作,
   * 比如下面会用到的incrementAndGet().
   */
  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();
  }
}

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

其实, 这么写, 并不优雅, 还存在一些问题, 至于存在什么问题, 下篇文章讲解

如何实现一个单例? 有哪些方法实现单例?

要实现一个单例, 需要关注的点无外乎就下面几个

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

1. 饿汉式

饿汉式的实现方式比较简单, 在类加载的时候, instance 就已经创建并初始化好了, 所以 instance 实例的创建过程是线程安全的. 不过, 这样的实现方式不支持延迟加载(在真正用到IdGenerator时候再创建实例), 具体代码实现如下所示:


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();
  }
}

有人说饿汉式不支持延迟加载, 如果实例占用资源多(比如占用内存多) 或初始化耗时长(比如需要加载各种配置文件), 提前初始化实例是浪费资源的行为. 最好的方法应该是用到的时候去初始化, 但是我个人不认同这种观点.

如果初始化耗时长, 那我们最好不要等到用到的时候, 才去执行耗时长的初始化, 这会影响到系统的性能(比如, 在响应客户端接口请求的时候, 做这个初始化操作, 会导致此请求的响应时间变长, 甚至超时), 采用饿汉式实现方式, 将耗时的初始化操作提前到程序启动的时候完成, 这样就能避免在程序运行的时候, 再去初始化导致的性能问题.

如果实例占用资源多, 按照 fail-fast的设计原则(有问题及早暴露), 那我们也希望在程序启动时就将这个实例初始化好, 如果资源不够, 就会在程序启动的时候触发报错(比如 Java 中的 PermGen Space OOM), 我们可以立即去修复. 这样也能避免在程序运行一段时间后, 突然因为初始化这个实例占用资源过多, 导致系统崩溃, 影响系统的可用性.

2. 懒汉式

懒汉式的优势是支持延迟加载, 代码实现如下:


public class IdGenerator { 
  private AtomicLong id = new AtomicLong(0);
  private static IdGenerator instance;
  private IdGenerator() {}
  public static synchronized IdGenerator getInstance() {
    if (instance == null) {
      instance = new IdGenerator();
    }
    return instance;
  }
  public long getId() { 
    return id.incrementAndGet();
  }
}

懒汉式的缺点很明显, 我们给getInstance()方法加了个大锁(synchronized), 导致这个函数的并发度很低. 这个函数在单例试用期间, 一直会被调用, 如果这个单例类偶尔被用到, 这种实现方式还能被接收. 但是如果频繁的用到, 那频繁加锁, 释放锁, 会导致性能瓶颈问题, 这种方式不可取.

3. 双重检测

饿汉式不支持延迟加载,懒汉式有性能问题,不支持高并发. 那再来看一种既支持延迟加载、又支持高并发的单例实现方式,也就是双重检测实现方式.

在这种实现方式中,只要 instance 被创建之后,即便再调用 getInstance() 函数也不会再进入到加锁逻辑中了. 所以,这种实现方式解决了懒汉式并发度低的问题。具体的代码实现如下所示:


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 之后,还没来得及初始化(执行构造函数中的代码逻辑),就被另一个线程使用了.

要解决这个问题, 需要给 instance 成员变量加volatile关键字, 禁止指令重排序才行. 实际上只有很低版本(jdk1.5以下)的 Java 才会有这个问题. 我们现在用的高版本的 Java 已经在 JDK 内部实现中解决了这个问题(解决的方法很简单,只要把对象 new 操作和初始化操作设计为原子操作,就自然能禁止重排序).

4. 静态内部类

再来看一种比双重检测更加简单的实现方法, 就是利用 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是一个静态内部类, 当外部类加载的时候, 并不会加载SingletonHolder对象, 只有调用getInstance()方法的时候, SingletonHolder才会被加载, 这个时候才会创建 instance. instance 的唯一性, 创建过程的安全性, 都有 JVM 来保证. 所以这种实现方法既保证了线程安全, 又能做到延迟加载.

5. 枚举

枚举是最简答的实现方式, 这种方式通过Java 枚举类型本身的特性, 保证了实例创建的线程安全型和实例的唯一性, 代码如下:

public enum IdGenerator {
  INSTANCE;
  private AtomicLong id = new AtomicLong(0);
 
  public long getId() { 
    return id.incrementAndGet();
  }
}
jones wechat
更多精彩内容请关注微信公众号
  • 本文作者: jones
  • 本文链接: https://www.lushuaiyu.com/archives/为什么要使用单例
  • 版权声明: 本博客所有文章除特别声明外,均采用CC BY-NC-SA 3.0 许可协议。转载请注明出处!
# 博客系统 # linux # 酸酸乳
Centos 7 安装 hexo
单一职责原则 - 如何判断某个类的职责是否足够单一
  • 文章目录
  • 站点概览
jones

jones

程序猿

46 日志
16 分类
3 标签
Github E-mail
Creative Commons
0%
© 2021 jones
主题 - NexT.Pisces