HeaFirst设计模式-单件模式[单例模式](Singleton Pattern)

深入分析单件模式

本次主要介绍的内容有

  • 单件模式
  • 单线程下的单件模式实现
  • 多线程下实现单件模式出现的问题分析
  • JMM内存模型
  • 多线程下的单件模式实现的三种方式

这些内容,可以从最根本理解单例模式的代码,不信你就来看看吧。

单件模式:

确保一个类只有一个实例,并提供一个全局访问点。

单线程下的单件模式的实现

在单线程下,不存在线程安全的问题,所以完成一个单件模式非常容易。
Singleton

package com.bestqiang.singleton;

/**
 * @author BestQiang
 */
public class Singleton {
    private static Singleton uniqueInstance;

    // 这里是其他的有用实例化变量
    private Singleton() {}

    // 用getInstance方法实例化对象,并返回这个实例。
    public static Singleton getInstance() {
      if(uniqueInstance == null) {
          uniqueInstance = new Singleton();
      }
        // 不为空就直接返回,保证只有一个实例
        return uniqueInstance;
    }
}

Main线程中调用getInstance方法获取实例

package com.bestqiang.singleton;


/**
 * @author BestQiang
 */
public class Main {
    public static void main(String[] args) {
        for (int i = 0; i < 10; i++) {
            Singleton instance = Singleton.getInstance();
            System.out.println(instance);
        }
    }
}

打印结果如下,这里可以看出,打印出的地址都是相同的,说明获取的是同一个实例。

com.bestqiang.singleton.Singleton@4554617c
com.bestqiang.singleton.Singleton@4554617c
com.bestqiang.singleton.Singleton@4554617c
com.bestqiang.singleton.Singleton@4554617c
com.bestqiang.singleton.Singleton@4554617c
com.bestqiang.singleton.Singleton@4554617c
com.bestqiang.singleton.Singleton@4554617c
com.bestqiang.singleton.Singleton@4554617c
com.bestqiang.singleton.Singleton@4554617c
com.bestqiang.singleton.Singleton@4554617c

多线程下实现单件模式出现的问题

在这里我用线程池开启了10个线程,分别调用getInstance()方法获取对象,并打印响应的线程名和对象的地址:

package com.bestqiang.multithreading;

import com.bestqiang.singleton.Singleton;

import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;

/**
 * @author BestQiang
 */
public class Main {
    public static void main(String[] args) {

        ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(10, 10, 5L, TimeUnit.SECONDS, new ArrayBlockingQueue<Runnable>(5));
        try {
            for (int i = 0; i < 10; i++) {
                threadPoolExecutor.execute(() -> {
                    Singleton ins = Singleton.getInstance();
                    System.out.println(Thread.currentThread().getName() + "\t 对象地址: " + ins);
                });
            }
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            threadPoolExecutor.shutdown();
        }

    }
}

运行结果:

pool-1-thread-8	 对象地址: com.bestqiang.singleton.Singleton@4c680a74
pool-1-thread-7	 对象地址: com.bestqiang.singleton.Singleton@42970371
pool-1-thread-5	 对象地址: com.bestqiang.singleton.Singleton@4c680a74
pool-1-thread-3	 对象地址: com.bestqiang.singleton.Singleton@4c680a74
pool-1-thread-4	 对象地址: com.bestqiang.singleton.Singleton@4c680a74
pool-1-thread-10	 对象地址: com.bestqiang.singleton.Singleton@4c680a74
pool-1-thread-1	 对象地址: com.bestqiang.singleton.Singleton@3f41a236
pool-1-thread-6	 对象地址: com.bestqiang.singleton.Singleton@4c680a74
pool-1-thread-9	 对象地址: com.bestqiang.singleton.Singleton@4c680a74
pool-1-thread-2	 对象地址: com.bestqiang.singleton.Singleton@4c680a74

Process finished with exit code 0

上图中,地址出现了不同的现象,这不是单例模式吗?为什么获取的对象会出现不同?

内存不可见问题:

当判断 if(uniqueInstance == null) 时,不同线程的本地内存都有uniqueInstance 的副本,这个副本可以理解为从主内存获取,然后放到本地内存,如下图JMM内存模型所示,注意这个本地内存是虚拟的,其实并不存在。
HeaFirst设计模式-单件模式[单例模式](Singleton Pattern)_第1张图片
当线程更改本地内存中的值的时候,会刷新到主内存。使用的时候,本地内存有副本,那就不必再从主内存加载值了。
比如现在线程A和线程B使用不同的CPU执行,
第一种情况:
现在线程A启动,发现本地内存没有uniqueInstance的副本,然后就从主内存获取,获取后,新建了Singleton对象,赋值给 uniqueInstance,然后刷新给主内存。线程B启动时发现自己本地内存没有uniqueInstance,然后从主内存获取,存在本地缓存中,此时这个变量已经被线程A赋值过了,不为空,就直接返回这个对象,这种情况下,是正常的。
HeaFirst设计模式-单件模式[单例模式](Singleton Pattern)_第2张图片
第二种情况,线程A启动,发现本地内存没有uniqueInstance的副本,然后就从主内存获取,获取后,新建了Singleton对象,赋值给 uniqueInstance,然后刷新给主内存。线程B启动的时候,发现本地内存没有uniqueInstance的副本,然后就从主内存获取,存在本地缓存中(此时线程A修改的值还没有刷新给主内存),获取后,新建了Singleton对象,赋值给 uniqueInstance,然后刷新给主内存。这样一来,就出现了单件模式出现不同对象的情况,造成这种情况的是内存不可见问题导致的。

原子性问题:

如果内存不可见问题有人不了解,那么下面这个问题应该很多人都有所了解
当判断 if(uniqueInstance == null) 时,假设现在uniqueInstance 不存在内存可见性的问题,这个操作包含两步,第一步是从主内存获取,第二部是进行比较,那么A线程获取的时候是null,接下来一瞬间此时B线程对uniqueInstance 进行了修改,产生了一个实例,并刷新到了主内存,但是A线程并不知道,紧接着继续比较,这时候为null,A线程会都执行到方法内部,创建对象,出现了两个实例,对于这种问题,可以使用加锁的方式来解决。

多线程下的单件模式实现的三种方式

第一种:加锁解决线程安全问题

从上面导致线程不安全的问题中,我们了解到单件模式中导致线程不安全的有两个重要因素,可见性和原子性,那么如何解决?加锁是一种较好的方式:
代码如下:

package com.bestqiang.multithreading;

import com.bestqiang.singleton.Singleton;

/**
 * @author BestQiang
 */
public class Singleton1 {
    private static Singleton1 uniqueInstance;
    
    // 其他有用的实例化的变量
    private Singleton1() {};
    
    public static synchronized Singleton1 getInstance() {
        if(uniqueInstance == null) {
            uniqueInstance = new Singleton1();
        }
        return uniqueInstance;
    }
    
    // 其他有用的方法
}

有同学在这里可能会疑惑,为什么加synchronized锁就解决了原子性和可见性的问题?
这里我科普一下:
synchronized块是Java提供的一种原子性内置锁,Java中的每个对象都可以把它单做一个同步锁来使用,这些Java内置的使用者看不到的锁被称为内部锁,也叫监视器锁。内置锁是排它锁,也就是当一个线程获取这个锁后,其他线程必须等待该线程释放锁后才能获取该锁。

synchronized的内存语义(重点):

进入synchronized块的内存语义是把在synchronized块内使用到的变量从线程的工作内存中清除,这样在synchronized块内使用到该变量时就不会从线程的工作内存中获取,而是直接冲主内存中获取。退出synchronized块的内存语义是把在synchronized块内对共享变量的修改刷新到主内存。

从它的内存语义中可得,它解决了变量的可见性的问题。它是Java提供的一种原子内置锁,解决了原子性的问题,二者都得到解决,所以,用它来实现同步方法,非常合适。

第二种:使用“急切”创建实例,而不用延迟实例化的做法

上面的加同步锁的方法,会大大降低程序的性能,只有第一次执行此方法时,才真正需要同步。换句话说,一旦设置好uniqueInstance变量,就不再需要同步这个方法了。之后每次调用这个方法,同步都是一种累赘。
如何改善呢?有一种方法简单有效,就是使用“急切”创建实例。话不多说,代码亮出来,就能明白了:

package com.bestqiang.multithreading;

import com.bestqiang.singleton.Singleton;

/**
 * @author BestQiang
 */
public class Singleton2 {
    // 在静态初始化器中创建单件。这段代码保证了线程安全。
    private static Singleton2 uniqueInstance = new Singleton2();
    private Singleton2() {};
    public static Singleton2 getInstance() {
        return uniqueInstance;
    }
}

其中静态单件在类的生命周期的连接的阶段创建,JVM在类的初始化方法中创建。然后在jdk1.8的环境下存在堆中,类的元信息存在方法区。
对于类的生命周期和JVM,可以从下面两篇文章做一下了解
"init"与"clinit"的区别
深入分析ClassLoader工作机制

因为uniqueInstance 创建过后就没有再改动,所以,不会出现线程安全的问题。

第三种:用“双重检查加锁”,在getInstance()中减少使用同步

利用双重检查加锁(double-checked locking),首先检查是否实例已经创建 了,如果尚未创建,"才"进行同步。这样一来,只有第一次会同步,这正是我们想要的。

代码如下:

package com.bestqiang.multithreading;

import com.bestqiang.singleton.Singleton;

/**
 * @author BestQiang
 */
public class Singleton3 {
    private volatile static Singleton3 uniqueInstance;

    // 这里是其他的有用实例化变量
    private Singleton3() {}

    // 用getInstance方法实例化对象,并返回这个实例。
    public static Singleton3 getInstance() {
        // 检查视力,如果不存在,就进入同步区块,只有第一次,才彻底的执行这里的代码
        if(uniqueInstance == null) {
            // 进入区块后,再检查一次,如果仍是null,才创建实例
            synchronized (Singleton.class) {
                if (uniqueInstance == null) {
                    uniqueInstance = new Singleton3();
                }
            }
        }
        // 不为空就直接返回,保证只有一个实例
        return uniqueInstance;
    }
}

上面的代码中,为了解决对象创建时的指令重排序问题,使用了volatile关键字。为了解决原子性的问题,使用了synchronized 加锁。

volatile关键字(重要)

关于Java中的volatile关键字,在这里做一下介绍:
上面介绍了使用锁的方式可以解决共享变量内存可见性的问题,但是使用锁太笨重因为它会带来线程上下文的切换开销。对于解决内存可见性问题,Java还提供了一种弱形式的同步,也就是使用volatile关键字。该关键字可以确保对一个变量的更新对其他线程马上可见。当一个变量被声明为volatile时,线程在写入变量时不会吧值缓存再寄存器或者其他地方,而是会把值刷新回主内存。当其他线程读取该共享变量时,会从主内存重新获取最新值,而不是使用当前线程的工作内存中的值。volatile的内存语义和synchronized有相似之处,具体来说就是,当线程写入了volatile变量值时就等价于线程退出synchronized同步块(把写入工作内存的变量值同步到主内存),读取volatile变量值时就相当于进入同步块( 先清空本地内存变量值,再从主内存获取最新值)。

第一个方法中提到,synchronized 可解决可见性和原子性的问题,为什么还要用双重锁呢,仔细看看,第一个 if(uniqueInstance == null) 判断存在原子性的问题,因为是先取,后比较,取过来之后可能又会更改,所以在里面嵌套一个 if(uniqueInstance == null),里面这个是加锁的,加上happens-before规则可以保证原子性和可见性,保证uniqueInstance直接从主存中获取,而且,在第一次创建后,因为里面有原子性内置锁,所以uniqueInstance不会再更改,因此外面的 if(uniqueInstance == null) 其实是安全的了,因为获取后,可以保证不再更改,不会因为原子性而造成线程不安全的问题。这样,就做到了只在第一次同步一次,避免了锁影响性能,而又可以懒加载对象。

上面的操作乍一看是没问题的,但是其实存在问题。

对象创建分为三步:

  1. 分配对象的内存空间。memory = allocate();

  2. 初始化对象。instance = memory;

  3. 设置instance指向内存空间。ctorInstance(memory);

这不是一个原子性操作,但即使不是原子性,这个操作也是没问题的,问题出在这个操作会进行重排序,可能第二部和第三步的顺序会发生变化,这时候第3步如果先执行,那么判断对象的值会依然为空,导致其他对象继续创建,导致单例模式的失败。

为什么要用volatile关键字呢?原因是volatile不仅仅可以保证程序的可见性,而且可以禁止指令重排序。至此,这个问题解决了。

注意: jdk 1.4 及更早的版本中,许多JVM对于volatile关键字的实现会导致双重检查加锁的失效。如果不能使用Java 1.4以上的版本,而必须使用旧版的Java,就请不要利用此技巧实现单件模式。

本次对单例模式的实现做了相对深入的分析,希望读完这篇文章的朋友都能有所收获,共同进步。

参考的书籍:
《并发编程之美》,
《HeadFirst设计模式》

你可能感兴趣的:(java语言,设计模式)