深入剖析单例模式的实现

单例的实现

饿汉模式

最朴实无华且能保证没有并发问题的就是提前初始化(饿汉模式,忽略这个叫什么,不重要)。

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

先抛一个疑问:这里没有任何同步的手段,为什么这么写是线程安全的?

这种提前初始化的方式的问题也很明显:因为一般使用单例的场景,这个对象的初始化都是比较重的操作,提前初始化会影响类的加载速度。所以就需要延迟初始化,即第一次使用的时候再初始化这个单例对象。

懒汉模式

朴素版

public class LazyInstance {
    private static ExpensiveObject instance =null;
    
    public static ExpensiveObject getInstance(){
        if (instance == null){//在instance没有初始化之前,有两个线程同时调用该方法,那么进来的时候,都去判断null,判断都会成功。所以各自会new一个对象返回,不能保证返回的的对象是单例
            return new ExpensiveObject();
        }
        return instance;
    }
}

这么写的问题也很明显了,线程不安全。在多线程环境下,两个线程同时执行if语句,那么他们拿到的时不同的对象,不是单例的。

所以这玩意就简单了,加锁就好了

public class LazyInstance {
   private static ExpensiveObject instance =null;

    public static ExpensiveObject getInstance(){
        synchronized (LazyInstance.class){// 不会有问题,但是每次获得instanc都需要先加锁,加锁释放锁会严重影响效率。实际上一旦instance初始化完成,就没有多线程问题了
            if (instance == null){
                instance = new ExpensiveObject();
            }
            return instance;
        }
    }
}

ps:提个小疑问:如果这里直接在方法上加synchronized修饰能保证线程安全么?

这么做,确实没有线程安全的问题了,但问题也很明显,每次获取对象都需要加锁,单实际上真的需要加锁的只是初始化对象的时候,而初始化确实只需要执行一次。所以这么写的问题是锁的粒度太大了,影响效率,改进方式就是见笑锁的粒度。

public class LazyInstance {
 private static ExpensiveObject instance =null; 

	public static ExpensiveObject getInstance() {
            if (instance == null) {// 两个线程同时进入该方法,判断为null,都为true
                synchronized (LazyInstance.class) {//线程A获得锁,线程B等待。线程A返回后,线程B拿到锁进入同步块,再次new一个,线程B返回的就不是单例了
                    instance = new ExpensiveObject();
                }
            }
        return instance;
    }
}

这个搞又回去了实际上,引入了线程不安全,注释中说的很明显了。

所以终极版本出现了,就是注明的DCL(Double Check Locking)版本

public class LazyInstance {
   private static ExpensiveObject instance =null;
	
    public static ExpensiveObject getInstance() {
        if (instance == null) {// 先检查,后加锁,初始化后就不用加锁了,解决效率问题。
            synchronized (LazyInstance.class) {
               if(instance==null{// 加锁后再判断,解决多个线程同时执行if,来争抢锁,前后获得锁后获取到多个对象实例。
                  instance = new  ExpensiveObject();
              }     
            }
          }
        return instance;
    }
}

这真的就没问题了么?

第一:多线程环境下instance是不是有可见性问题,比如线程A初始化了,但是还没有同步到主存,或者线程B没有去获取主存中的最新的instance,那么线程B看到的instance还是null,那么也就破坏了单例。

第二:指令重排。实例化一个对象并不是原子的。它需要经历:1. 分配内存。2. 执行构造方法。3. 将初始化好的对象复制给引用。而这个过程是可能指令重排的。如果重排后的顺序是3-->2-->1,那么另外一个线程就可能拿到未初始化完的对象,这个时候使用instance的属性,就是默认值,可能产生潜在的npe。

都说到这了,解决这两个问题也很简单,那就是将instance申明为volitale,禁用cpu缓存,禁用指令重排,这两个问题就解决了。

内部类实现

public class LazyInstance{
    public static ExpensiveObject getInstance() {
         return Instance.instance;
    }

   private static class Instance{
    	private static final instance = new ExpensiveObject();
    }
}

这里是利用了jvm的类的初始化机制来实现延迟初始化单例对象以及保证线程安全的。

  1. 1延迟初始化。jvm并不是在启动的时候将classpath上的所有类都加载近内存,而是第一次使用的时候发现没有加载,才会从外部将类加载近内存,并进行初始化的。
  2. 对于一个类的所有static域,编译器都是收集到方法中,在类的初始化阶段,jvm会执行这个方法,且jvm会保证这个方法执行的线程安全,即一定只有一个线程会执行方法。所以static变量的初始化一定是线程安全的。

明白了这里,那么开头的疑问也就自解了。

单例的反序列化问题

使用DCL+volitale实现单例/内部类实现单例,在没有序列化和反序列化的场景中,确实就什么问题了,但是如果有反序列化的场景呢?反序列化出来的对象就不一定是单例的了。

《effective java》中提到提供了一个利用枚举来实现单例的方式,但是给的例子很不起眼,也没多说原因。然后在网上就有很多地方的实现方式入下:

public class Singleton {
    private static instance = null;
	private Singleton(){}

    public static Singleton getInstance(){
        return Singleton.SINLGETON.getInstance(); // 拿到的是内部枚举常量值的属性。由于枚举常量一定只有一个,从而保证单例
    }

    private enum SingletonEnum{ // 内部枚举
        SINGLETON;
        private Singleton singleton;

        SingletonEnum(){
            singleton = new Singleton();
        }

        public Singleton getInstance(){
            return singleton;
        }
    }
}

其实就是将内部类的class换成了enum。

首先说结论,这么做和内部类没有本质区别。java的枚举实际上是个语法糖,enum关键字后面实际是一个实现了Enum接口的类,所以这里实现的单例本质和内部类实现的单例是一样的,没啥区别。

《effective java》中建议的是如下实现:

public enum Singleton {
	INSTANCE;
  public void doSomething(){
  	// 这里就是单例对象中的行为,放到枚举中了
    }
}
单例对象使用
public static void main(String[] args){
	Singleton.INSTANCE.doSomething();
}

细品,这真的是高手,对面向对象和单例模式的理解确实深入刻骨。

番外篇

说明一下内部类/内部枚举的实现方式,反序列化后不是单例了

单例类:

public class ExpensiveObject implements Serializable {
    private String nanme;
    public ExpensiveObject(String nanme) {
        this.nanme = nanme;
    }
    public ExpensiveObject() {
    }
    public String getNanme() {
        return nanme;
    }
    public void setNanme(String nanme) {
        this.nanme = nanme;
    }
}

单例的实现(和上面是一样的,贴这方便看)

class LazyInstance {
    private static ExpensiveObject instance = null;
    public static ExpensiveObject getInstance() {
        if (instance == null) {
            synchronized (LazyInstance.class) {
                if (instance == null) {
                    instance = InstanceEnum.INSTANCE.instance;
                }
            }
        }
        return instance;
    }
    private enum InstanceEnum {
        INSTANCE;
        private ExpensiveObject instance;
        InstanceEnum() {
            this.instance = new ExpensiveObject("aaaaa");
        }
    }
}

测试代码:

public static void main(String[] args) throws IOException, ClassNotFoundException {
        ExpensiveObject instance = LazyInstance.getInstance();

        FileOutputStream out = new FileOutputStream("./instance");
        ObjectOutputStream oos = new ObjectOutputStream(out);
        oos.writeObject(instance);
        oos.flush();

        FileInputStream in = new FileInputStream("./instance");
        ObjectInputStream ois = new ObjectInputStream(in);
        ExpensiveObject instanceFromFile = (ExpensiveObject) ois.readObject();
        
        System.out.println(instance == instanceFromFile);
        System.out.println(instance.getNanme());
        System.out.println(instanceFromFile.getNanme());
    }

输出:

深入剖析单例模式的实现_第1张图片

可以看到,从文件中反序列化的和原来内存中的并不是同一个对象。

因为这里是使用了jdk的序列化,那么可以在ExpensiveObject重写一个readResolve()方法,实现单例

深入剖析单例模式的实现_第2张图片

输出:

深入剖析单例模式的实现_第3张图片

但是我这么写,实际上反序列化的对象并不是从文件中读出来的,是调用单例方法获得的对象。而且,这里的方案是使用的是jdk的序列化和反序列化,而实际生产中,几乎没人用jdk的序列化。

用其他方式实现单例,验证反序列方式也是一样的。

ps:第二个疑问答案,是否可以直接在getInstance()中加synchronized来实现线程安全,答案是可以的,在static方法上加synchronized,和上述的写法是一样的,但是如果不是static方法,就要注意了,非static方法相当于synchronized(this),当心多把锁防护一个临界区的情况。

特别说明:在java中,所有的代码一定是类的属性,所以不存在全局变量的,但是也有一些场景,其实是相当于全局变量的,比如常量,静态方法,单例模式等,这些其实都是全局共享一份,从面相对象的角度来说,这其实是不符合面相对象编程思想的,更多的这是一个面向过程方式,所有也有说法将单例作为一个反模式,但是面向过程一定是坏事?个人觉得并不一定,只要避开面向过程的一些问题,在面向对象编程语言中,有面向过程的代码无伤大雅,有的时候也是需要的,比如全局的工具方法等。

你可能感兴趣的:(java基础)