单例模式

1、为什么使用单例(能解决什么问题)

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

在 UserController 和 OrderController 中,我们分别创建两个 Logger 对象。在 Web 容器的 Servlet 多线程环境下,如果两个 Servlet 线程同时分别执行 login() 和 create() 两个函数,并且同时写日志到 log.txt 文件中,那就有可能存在日志信息互相覆盖的情况。

解决方案:

(1)加类级别的锁,因为创建了两个 Logger 对象,所以加对象级别的锁没用。(额外说下:FileWriter本身就是线程安全的)

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

(2)分布式级别的锁,可以使用Redis,不过实现一个安全可靠、高性能的分布式锁也不是件容易的事。

(3)并发队列(比如 Java 中的 BlockingQueue)也可以解决这个问题:多个线程同时往并发队列里写日志,一个单独的线程负责将并发队列中的数据,写入到日志文件。这种方式实现起来也稍微有点复杂。

(4)单例模式

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 号码生成器,如果程序中有两个对象,那就会存在生成重复 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();

2、单例的实现方式

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

构造函数需要是 private 访问权限的,这样才能避免外部通过 new 创建实例;

——考虑对象创建时的线程安全问题;

——考虑是否支持延迟加载;

——考虑 getInstance() 性能是否高(是否加锁)。

(1)饿汉式

通过定义静态的成员变量,以保证单例对象可以在类初始化的过程中被实例化。这其实是利用了ClassLoader的线程安全机制。ClassLoader的loadClass方法在加载类的时候使用了synchronized关键字。

public class Singleton {
    
    private static Singleton instance = new Singleton();
  
    private Singleton(){}
    
    public static Singleton getInstance(){
        return instance;
    }
}

有人觉得这种实现方式不好,因为不支持延迟加载,如果实例占用资源多(比如占用内存多)或初始化耗时长(比如需要加载各种配置文件),提前初始化实例是一种浪费资源的行为。最好的方法应该在用到的时候再去初始化。不过,我个人并不认同这样的观点。如果初始化耗时长,那我们最好不要等到真正要用它的时候,才去执行这个耗时长的初始化过程,这会影响到系统的性能(比如,在响应客户端接口请求的时候,做这个初始化操作,会导致此请求的响应时间变长,甚至超时)。采用饿汉式实现方式,将耗时的初始化操作,提前到程序启动的时候完成,这样就能避免在程序运行的时候,再去初始化导致的性能问题。如果实例占用资源多,按照 fail-fast 的设计原则(有问题及早暴露),那我们也希望在程序启动时就将这个实例初始化好。如果资源不够,就会在程序启动的时候触发报错(比如 Java 中的 PermGen Space OOM),可以立即去修复。这样也能避免在程序运行一段时间后,突然因为初始化这个实例占用资源过多,导致系统崩溃,影响系统的可用性。

(2)懒汉式

懒汉式相对于饿汉式的优势是支持延迟加载,劣势就是加了一把大锁(synchronized),性能较低,所以这种方式在现实中基本不会用。

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

(3)双重检测

既支持延迟加载,又解决了懒汉式性能低的问题。

public class Singleton {
​
    private volatile static Singleton instance;
​
    private Singleton(){}
​
    public static Singleton getInstance(){
        if (instance == null){ //出于性能考虑
            synchronized (Singleton.class) { //类级别的锁
                if (instance == null){  //出于安全考虑
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

——为什么需要双重检测

同步块外层检查:这个检查是从性能当面考虑的,如果每次检查都加同步锁,显然性能是很低的,所以加这个检查保证只在第一次实例化时加锁。

同步块内层检查:这个检查是从安全方面考虑的,例如SingletonClass有一个属性int count = 3,当线程A和B获取对象时同时进入了外层检查,然后线程A拿到了Synchronized锁,实例化了对象并进行了累加操作,此时count=4,然后线程B在线程A释放锁之后获取到了锁权限,但是不管不顾的又进行了一次实例化,此时的singleton被重新实例化,count=3,这就会出问题了。

——为什么要加 volatile 修饰

CPU 指令重排序可能导致在 Singleton 类的对象被关键字 new 创建并赋值给 instance 之后,还没来得及初始化(执行构造函数中的代码逻辑),就被另一个线程使用了。这样,另一个线程就使用了一个没有完整初始化的 Singleton 类的对象。

(4)静态内部类

这种方式比双重检测更简单,利用静态内部类的机制来实现延迟加载。

public class Singleton {
​
    private Singleton(){}
​
    private static class SingletonHolder{
        private static final Singleton instance = new Singleton();
    }
​
    public static Singleton getInstance(){
        return SingletonHolder.instance;
    }
}

SingletonHolder 是一个静态内部类,当外部类 IdGenerator 被加载的时候,并不会创建 SingletonHolder 实例对象。只有当调用 getInstance() 方法时,SingletonHolder 才会被加载,这个时候才会创建 instance。instance 的唯一性、创建过程的线程安全性,都由 JVM 来保证。所以,这种实现方法既保证了线程安全,又能做到延迟加载。

关于静态内部类: 《effective java》里面说静态内部类只是刚好写在了另一个类里面,实际上和外部类没什么附属关系,所以二者是独立加载的。

(5)枚举

JVM会保证枚举对象的唯一性,因此每个枚举类型和定义的枚举变量在JVM中都是唯一的。

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

(6)CAS方式

上面的方式都直接或间接使用了 synchronized,CAS则完全没有用到。

public class Singleton {
​
    private static final AtomicReference<Singleton> INSTANCE = new AtomicReference<>();
​
    private Singleton(){}
​
    public static Singleton getInstance(){
        for (;;){
            Singleton singleton = INSTANCE.get();
            if (singleton != null){
                return singleton;
            }
            singleton = new Singleton();
            if(INSTANCE.compareAndSet(null,singleton)){
                return singleton;
            }
        }
    }
}

用CAS的优点在于不需要使用传统的锁机制来保证线程安全,CAS是一种基于忙等待的算法,依赖底层硬件的实现,相对于锁它没有线程切换和阻塞的额外消耗,可以支持较大的并行度。

CAS的一个重要缺点在于如果忙等待一直执行不成功(一直在死循环中),会对CPU造成较大的执行开销。另外,代码中,如果N个线程同时执行到 singleton = new Singleton();的时候,会有大量对象被创建,可能导致内存溢出。

3、单例模式存在的问题

——单例对 OOP 特性的支持不友好

——单例对代码的扩展性不友好

——单例不支持有参数的构造函数

解决方式:

为了保证全局唯一,除了使用单例,还可以用静态方法来实现。不过,静态方法这种实现思路,并不能解决上面提到的问题。如果要完全解决这些问题,要从根上,寻找其他方式来实现全局唯一类。比如,通过工厂模式、IOC 容器(比如 Spring IOC 容器)来保证,由程序员自己来保证(自己在编写代码的时候自己保证不要创建两个类对象)。

4、单例的作用域

(1)一般意义上的单例都是指进程作用域

比如我们编写的代码通过编译、链接,组织在一起形成可执行文件(如Windows中的exe文件),当运行可执行文件时,操作系统会启动一个进程,将这个执行文件从磁盘加载到自己的进程地址空间(可以理解操作系统为进程分配的内存存储区,用来存储代码和数据)。接着,进程就一条一条地执行可执行文件中包含的代码。

上面我们说的单例都是在进程中唯一的,因为进行间是不共享地址空间的。

(2)线程唯一的单例

实现线程间唯一,通过一个 HashMap 来存储对象,其中 key 是线程 ID,value 是对象。这样我们就可以做到,不同的线程对应不同的对象,同一个线程只能对应一个对象。可以使用 ThreadLocal。

public class Singleton {
​
    private static final ThreadLocal<Singleton> INSTANCE = new ThreadLocal<Singleton>(){
        @Override
        protected Singleton initialValue() {
            return new Singleton();
        }
    };
​
    private Singleton(){}
​
    public static Singleton getInstance(){
        return INSTANCE.get();
    }
}

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

5、多例模式

单例指的是一个类只能创建一个对象,而多例则是指一个类型只能创建一个对象。

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");

这种多例模式的理解方式有点类似工厂模式。它跟工厂模式的不同之处是,多例模式创建的对象都是同一个类的对象,而工厂模式创建的是不同子类的对象。

 
posted @ 2024-04-10 16:53  jingyi_up  阅读(3)  评论(0编辑  收藏  举报