10k

设计模式之美-课程笔记25-单例模式

为什么说支持懒加载的双重检测不比饿汉式更优?

  • 什么是单例以及为什么使用单例模式
  • 单例模式存在哪些问题
  • 单例与静态类的区别
  • 有何替代的方案

为什么使用单例模式

单例设计模式:一个类只允许创建一个对象(或者实例)。

实战案例1:处理资源访问冲突

在这个例子中自定义实现了一个往文件中打印日志的Logger类。

public class Logger {
  private FileWriter writer;
  
  public Logger() {
    File file = new File("/Users/wangzheng/log.txt");
    writer = new FileWriter(file, true); //true表示追加写入
  }
  
  public void log(String message) {
    writer.write(message);
  }
}

// Logger类的应用示例:
public class UserController {
  private Logger logger = new Logger();
  
  public void login(String username, String password) {
    // ...省略业务逻辑代码...
    logger.log(username + " logined!");
  }
}

public class OrderController {
  private Logger logger = new Logger();
  
  public void create(OrderVo order) {
    // ...省略业务逻辑代码...
    logger.log("Created an order: " + order.toString());
  }
}
  1. 这个代码的问题是:所有的Logger实例都会写到同一个文件,可能存在覆盖的情况。因为可能出现了同时写。

    image-20230725155820678

  2. 可以通过加锁的方式解决。Java中可以用synchronized加互斥锁,同一个时刻只允许一个线程调用执行log函数。

public class Logger {
  private FileWriter writer;

  public Logger() {
    File file = new File("/Users/wangzheng/log.txt");
    writer = new FileWriter(file, true); //true表示追加写入
  }
  
  public void log(String message) {
    synchronized(this) {
      writer.write(mesasge);
    }
  }
}
  1. 但是还是解决不了覆写的问题!因为这个锁是一个对象锁。不同的对象之间并不共享同一把锁。多线程下不同的对象有各自的锁,所以这个确保同一时间只有一个对象在写这个文件的锁并不会有作用。

    img

  2. 而且FileWriter本身就是线程安全的(内部有锁)。所以外部加锁属于多此一举。

  3. 所以换个类级别的锁就可以。

public class Logger {
  private FileWriter writer;

  public Logger() {
    File file = new File("/Users/wangzheng/log.txt");
    writer = new FileWriter(file, true); //true表示追加写入
  }
  
  public void log(String message) {
    synchronized(Logger.class) { // 类级别的锁
      writer.write(mesasge);
    }
  }
}
  1. 其他的方式还有分布式锁、并发队列等,但是实现思路都相对复杂。对比起来单例就简单些。他的优势在于不需要创建很多的Logger对象,节省内存,也节省系统文件句柄(操作系统资源)。
 public class Logger {
  private FileWriter writer;
  private static final Logger instance = new Logger();

  private Logger() {
    File file = new File("/Users/wangzheng/log.txt");
    writer = new FileWriter(file, true); //true表示追加写入
  }
  
  public static Logger getInstance() {
    return instance;
  }
  
  public void log(String message) {
    writer.write(mesasge);
  }
}

// Logger类的应用示例:
public class UserController {
  public void login(String username, String password) {
    // ...省略业务逻辑代码...
    Logger.getInstance().log(username + " logined!");
  }
}

public class OrderController {  
  public void create(OrderVo order) {
    // ...省略业务逻辑代码...
    Logger.getInstance().log("Created a order: " + order.toString());
  }
}

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

从业务概念上,如果有的数据在系统中直营保存一份,那就比较适合设计为单例类。

比如配置信息类,当配置文件被加载到内存中,全局应该只有一份。再比如唯一递增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();

如何实现一个单例

几个关注点:

  • 私有构造函数,避免外部通过new创建实例;
  • 考虑对象创建时的线程安全;
  • 考虑是否支持延迟加载;
  • 考虑getInstance()性能(是否加锁)

简单说就是全局一个实例,可以私有构造函数实现,而且要保证创建时线程安全,线程安全保证上要考虑性能问题(锁),最后就是可选是否要做懒加载

1. 饿汉式

What

在类加载的时候静态实例就已经创建并且初始化了,所以instance的创建过程是线程安全的(?)。不过他不支持延迟加载(在真正用到的时候再去创建实例)。

How
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();
  }
}
Why
  1. 实现简单
  2. 不支持延迟加载(可能会在初始化时候占用过多资源或者消耗过长时间)
    1. 有人建议在使用的时候加载
      1. 但是还是建议在没用到的时候就初始化好(提前),这样在用到的时候就不会阻塞。
      2. 而且基于fail fast的设计原则(有问题及早暴露)。

2. 懒汉式

what

相对于饿汉式,就是增加了延迟加载。

how
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();
  }
}
Why
  1. 支持延迟加载
  2. 缺点:
    1. 饿汉式中说的不在开始的时候加载的问题
    2. 为了保证线程安全加了synchronized,导致并发度很低(1)。如果他被频繁调用到的话,频繁地加锁、释放锁等问题可能会造成性能瓶颈。

3. 双重检测

What

既支持延迟加载,又支持高并发的实现。

How

只要instance被创建后,即便再调用getInstance也不会进入到加锁逻辑。

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

上述代码存在的问题是:CPU指令重排->IdGenerator类的对象悲观健在创建并赋值后,还没初始化(执行构造函数的代码逻辑)就被另一个线程使用了。这样另一个线程使用了一个没有完全初始化的对象。

要解决这个问题,给instance加一个volatile关键字禁止指令重拍即可。

4. 静态内部类

what

类似饿汉式,但是又能支持延迟加载。

how
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保证。

5. 枚举

what

借助Java枚举类型本身的特性,保证创建的线程安全和全局唯一。

How
public enum IdGenerator {
  INSTANCE;
  private AtomicLong id = new AtomicLong(0);
 
  public long getId() { 
    return id.incrementAndGet();
  }
}

为什么不推荐使用单例模式?又有何替代方案?

1. 单例对OOP特性支持不友好

  1. 单例对抽象、继承、多态的支持都不好。
  2. 抽象: 比如之前的IdGenerator,违背了基于接口而非实现的原则。都是一个实现(因为只有一个实例)。日后如果希望对不同的业务有不同的id生成算法,就需要在需要修改的业务的地方全都修改。
  3. 继承和多态:不友好。不是不支持,但是实现出来的代码看着会比较难懂。单例相当于放弃了这些特性,放弃了扩展性。

2. 单例会隐藏类之间的依赖关系

  1. 普通的类之间通过构造函数、函数参数传递,明显的展现出来依赖关系,但是单例直接调用即可,无法通过参数直接看到关系,需要进去函数实现查看。

3. 单例对代码扩展性不友好

  1. 如果未来有一天我们需要两个或者多个实例,那整个代码改动就很大。

  2. 举个例子: 数据库连接池。

    在系统设计初期我们觉得一个数据库连接池链接MySQL即可,方便控制数据库链接资源的消耗。但是时间长了发现部分SQL运行的很慢,我们希望将慢SQL和快SQL分离开来,慢SQL独享一个数据库连接池,避免影响其他的语句。

  3. 这个时候单例的实现会影响上述这个需求的扩展,变得难和不灵活。

4. 对可测试性不友好

  1. 单例的实现是私有、静态的方式,无法mock
  2. 单例持有的成员变量是一种全局的,导致测试用例在不同的地方使用都会改变,相互影响。

5. 不支持有参数的构造函数

  1. 例如数据库连接池的例子,无法传入一些池子大小的参数。
  2. 一种解决方法是先强制调用一个init方法传参,再允许调用getInstance获取实例。这个时候在init中已经参数传入而且实例创建了。
public class Singleton {
  private static Singleton instance = null;
  private final int paramA;
  private final int paramB;

  private Singleton(int paramA, int paramB) {
    this.paramA = paramA;
    this.paramB = paramB;
  }

  public static Singleton getInstance() {
    if (instance == null) {
       throw new RuntimeException("Run init() first.");
    }
    return instance;
  }

  public synchronized static Singleton init(int paramA, int paramB) {
    if (instance != null){
       throw new RuntimeException("Singleton has been created!");
    }
    instance = new Singleton(paramA, paramB);
    return instance;
  }
}

Singleton.init(10, 50); // 先init,再使用
Singleton singleton = Singleton.getInstance();
  1. 另一种方法是将参数放到getInstance中。
public class Singleton {
  private static Singleton instance = null;
  private final int paramA;
  private final int paramB;

  private Singleton(int paramA, int paramB) {
    this.paramA = paramA;
    this.paramB = paramB;
  }

  public synchronized static Singleton getInstance(int paramA, int paramB) {
    if (instance == null) {
      instance = new Singleton(paramA, paramB);
    }
    return instance;
  }
}

Singleton singleton = Singleton.getInstance(10, 50);

这个代码是有问题的,需要改!他的参数只有在第一次调用的时候才会生效,后续因为单例的原因都穿不进去到实例了。

Singleton singleton1 = Singleton.getInstance(10, 50);
Singleton singleton2 = Singleton.getInstance(20, 30);

我的修改思路是给参数加上私有的setter,在获取单例的时候,如果已经存在了,那么就取用setter给已有的实例属性去做修改。

public class Singleton {
  private static Singleton instance = null;
  private final int paramA;
  private final int paramB;

  private Singleton(int paramA, int paramB) {
    this.paramA = paramA;
    this.paramB = paramB;
  }

  public synchronized static Singleton getInstance(int paramA, int paramB) {
    if (instance == null) {
      instance = new Singleton(paramA, paramB);
    } 
    instance.setParamA(paramA);
    instance.setParamB(paramB);
    return instance;
  }
  
  private void setParamA(int a) {
   if (instance == null) return;
   this.paramA = a;
  }
  
  private void setParamB(int b) {
   if (instance == null) return;
   this.paramB = b;
  }
}

Singleton singleton = Singleton.getInstance(10, 50);
  1. 第三个解决思路是将参数放在另一个全局变量中。Config 是一个存储了 paramA 和 paramB 值的全局变量。里面的值既可以像下面的代码那样通过静态常量来定义,也可以从配置文件中加载得到。实际上,这种方式是最值得推荐的。
public class Config {
  public static final int PARAM_A = 123;
  public static final int PARAM_B = 245;
}

public class Singleton {
  private static Singleton instance = null;
  private final int paramA;
  private final int paramB;

  private Singleton() {
    this.paramA = Config.PARAM_A;
    this.paramB = Config.PARAM_B;
  }

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

有何替代解决方案?

  1. 使用静态方法。
// 静态方法实现方式
public class IdGenerator {
  private static AtomicLong id = new AtomicLong(0);
  
  public static long getId() { 
    return id.incrementAndGet();
  }
}
// 使用举例
long id = IdGenerator.getId();

但是他也有很多问题,不支持延迟加载呀,可测性差,单例的问题他也基本都有。

  1. 将单例生成的对象以参数的方式传递给函数(或者构造函数参数的方式给类),可以解决依赖之间关系不明显的问题。不过其他问题还是没法解决。
// 1. 老的使用方式
public demofunction() {
  //...
  long id = IdGenerator.getInstance().getId();
  //...
}

// 2. 新的使用方式:依赖注入
public demofunction(IdGenerator idGenerator) {
  long id = idGenerator.getId();
}
// 外部调用demofunction()的时候,传入idGenerator
IdGenerator idGenerator = IdGenerator.getInsance();
demofunction(idGenerator);

类对象的全局唯一性可以有其他方式保证。可以用单例类,也可以程序员自己保证(不创建第二个)。

如何设计实现一个集群环境下的分布式单例模式?

  • 如何理解单例模式中的唯一性?
  • 如何实现线程唯一的单例?
  • 如何实现集群环境下的单例?
  • 如何实现一个多例模式?

如何理解单例中的唯一性

  1. 一个类只允许创建唯一一个对象。范围:进程而非线程。

如何实现线程唯一的单例

  1. 不同的线程还是有各自自己的实例。
  2. 实现:通过一个map记录保存线程和实例对应关系。
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 = new Thread.currentThread.getId();
        instances.putIfAbsent(currentThreadId, new IdGenerator());
        return instances.get(currentThreadId);
    }
    
    publc long getId() {
        return id.incrementAndGet();
    }
}
  1. Java中还有ThreadLocal并发工具类,轻松实现线程唯一单例。

如何实现集群环境下的单例

  1. 集群相当于多个进程构成的集合。集群唯一意味着多个进程共享这个实例。
  2. 实现的思路是将其序列化并放在外部存储。某个进程使用的时候从外存取这个对象,反序列化,使用,然后用完后序列化放回去。
  3. 一个进程获取到这个对象后要加锁以确保其他进程获取不到。
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();

如何实现一个多例

  1. 对应单例模式的概念,多例就是可以创建有限(规定数量)多个实例。
public class BackendServer {
  private long serverNo;
  private String serverAddress;

  private static final int SERVER_COUNT = 3;
  private static final Map<Long, BackendServer> serverInstances = new HashMap<>();

  static {
    serverInstances.put(1L, new BackendServer(1L, "192.134.22.138:8080"));
    serverInstances.put(2L, new BackendServer(2L, "192.134.22.139:8080"));
    serverInstances.put(3L, new BackendServer(3L, "192.134.22.140:8080"));
  }

  private BackendServer(long serverNo, String serverAddress) {
    this.serverNo = serverNo;
    this.serverAddress = serverAddress;
  }

  public BackendServer getInstance(long serverNo) {
    return serverInstances.get(serverNo);
  }

  public BackendServer getRandomInstance() {
    Random r = new Random();
    int no = r.nextInt(SERVER_COUNT)+1;
    return serverInstances.get(no);
  }
}
  1. 还有另外一种理解:同一类型的只能创建一个对象,不同类型的可以创建多个对象。
public class Logger {
  private static final ConcurrentHashMap<String, Logger> instances
          = new ConcurrentHashMap<>();

  private Logger() {}

  public static Logger getInstance(String loggerName) {
    instances.putIfAbsent(loggerName, new Logger());
    return instances.get(loggerName);
  }

  public void log() {
    //...
  }
}

//l1==l2, l1!=l3
Logger l1 = Logger.getInstance("User.class");
Logger l2 = Logger.getInstance("User.class");
Logger l3 = Logger.getInstance("Order.class");

有点像工厂模式。

这个还是单例,只不过是概念上对于多例的解读和实现。

Thoughts? Leave a comment

Comments
  1. 10kJul 30, 2023:

    to do refresh: 1. ThreadLocal 2. volatile 3. synchronized