设计模式(4)—— 创建型 ——单例(Singleton)

版权声明:如有任何疑问,欢迎@我:[email protected] https://blog.csdn.net/qq_37206105/article/details/84076934

导航

首先通过懒汉式的单例模式简单代码实现作为开头,发现有线程安全问题,并且在此懒汉模式代码上进行改进,衍生出同步懒汉设计模式双重检查懒汉设计模式。另外还有静态内部类方式实现单例,它是一种基于类初始化的延迟加载解决方案。

与懒汉式相对应的是饿汉式单例模式,其在类加载时就进行初始化实例,所以并不存在懒汉式单例模式存在的线程同步安全问题

除以上探讨的单例模式实现外,还列举了三种实现的单例模式的方法:枚举类容器ThreadLocal

单例模式介绍——摘要

定义: 保证一个类仅有一个实例,并提供一个全局访问点
类型:创建型
适用场景:想确保任何情况下只有一个实例
优点:

  1. 在内存中只有一个实例,减少内存开销
  2. 避免资源的多重占用
  3. 设置全局访问点,严格控制访问

缺点:没有接口,扩展困难

重点关注几点:

  • 私有构造器
  • 线程安全
  • 延迟加载

后期更新:

  • 序列化,反射相关安全性

懒汉式

上代码:

/**
 * 懒汉。顾名思义,等我们用到的时候再实例化
 */
public class LazySingleton {

    private static LazySingleton instance = null;      //初始化,为null

    //私有构造器:外部不允许直接通过new运算获取对象实例
    private LazySingleton(){

    }

    //静态方法,外面方面直接通过静态方法来获取实例,不需先实例化类
    public static LazySingleton getInstance(){
        if(singleton == null){
            singleton = new LazySingleton();
        }
        return singleton;
    }

}

上面代码很容易理解,外部只能通过唯一的访问点LazySingleton.getInstance()来获取对象实例。
但是对于单线程来说,这样写没有问题。到了多线程我们再分析getInstance代码块,很容易发现问题。我们假设现在有两个线程同时来获取实例

/**
 * 测试用例
 * 同时开两个线程,对最简单的懒汉单例模式进行测试
 */
public class LazyTest {

    //主线程
    public static void main(String[] args) {

        //第一个线程
        new Thread( () ->{
            LazySingleton singleton = LazySingleton.getInstance();
            System.out.println( "Current Singleton :" + singleton );
        } ).start();

        //第二个线程
        new Thread( () -> {
            LazySingleton singleton = LazySingleton.getInstance();
            System.out.println( "Current Singleton :" + singleton );
        } ).start();

    }

}

现在分析会出现什么情况,最简单的当然是第一个线程开始运行,知道它运行结束,再轮到第二个线程开始运行。然而我们查看两个线程的分别调用getInstance的执行图:

LazySingleton执行用例图
线程1执行①代码之后,正要执行singleton = new LazySingleton() 的时候,由于CPU线程调度,使线程2开始执行。线程2一路执行下去。并且返回Singleton实例对象。而后,线程1继续执行,直到结束。
很清晰的知道,LazySingleton已经被实例化两次,违反了Singleton的原则。

有一系列的对最基本的懒汉式改进的方法。

懒汉式——同步(synchronized)

最容易改进的方法,只需添加synchronized关键字即可。

// 添加关键字同步synchronized,加锁
public synchronized static LazySingleton getInstance(){
        if(singleton == null){
            singleton = new LazySingleton();
        }
        return singleton;
}

当添加此关键字的时候,我们再看到上面两个线程的getInstance 执行图 。 当①执行之后,开始试图进入运行线程2的代码。但是此时该代码是加锁的,线程2会被 阻塞 ,待线程1运行结束之后,线程2方可执行。如此就 避免了if判断的线程错误 。最后只能获得一个唯一的实例。

但是:

  • 同步锁的加锁解锁较为消耗资源。
  • synchronized 关键字修饰static函数的时候,其实相当于synchronized整个类,范围较大,不利于控制。

懒汉式——双重检查(Double Check)

上代码:

public class LazyDoubleCheckSingleton {

    private volatile static LazyDoubleCheckSingleton instance = null;      //初始化,为null

    //私有构造器:外部不允许直接通过new运算获取对象实例
    private LazyDoubleCheckSingleton(){

    }

    public static LazyDoubleCheckSingleton getInstance(){
        if(instance == null){
            synchronized (LazyDoubleCheckSingleton.class){
                if(instance == null) {
                
                	/****2, 3可以重排序。       
                    *****使用volatile关键字禁止重排序
                    *****/
                    
                    // 1. 为对象分配内存
                    // 2. 初始化对象
                    // 3. 设置instance指向刚分配的内存地址
                    instance = new LazyDoubleCheckSingleton();
                }
            }
        }
        return instance;
    }
}

synchronized关键字

从上面的代码看到,所谓double check就是进行双重判断。相比于直接在static方法上用synchronized,在局部代码块用synchronized修饰,配合double check使用在性能上更胜一筹。然而如此一来却有一个坑。

volatile关键字修饰就是为了填补这个坑的。

volatile关键字

注意到用于修饰instance 的volatile关键字。这里集中看到这行代码:
instance = new LazyDoubleCheckSingleton();
这行代码并不是原子操作。所谓原子操作,可以简单理解为此操作一步执行,不可拆分。 具体到这个例子中,
instance = new LazyDoubleCheckSingleton();的执行步骤如下:

  1. 为对象分配内存
  2. 初始化对象
  3. 设置instance指向刚分配的内存地址
  4. 外部可以开始对instance进行访问以及其它操作

而java编译器在编译时有个指令重排序的概念。在这里的意思就是2,3执行步骤可能会交换(但并不影响4,也就是说并不影响最终的结果),这样做的好处是根据具体情况来调整执行顺序,提高执行效率。

那么这就会导致隐藏的程序bug。现在假定线程1的执行顺序如下

  1. 为对象分配内存
  2. 设置instance指向刚分配的内存地址
  3. 初始化对象
  4. 外部可以开始对instance进行访问以及其它操作

现在假定当上面的步骤2执行完毕。CPU线程调度,轮到线程2开始执行
getInstance()代码。执行第一步:if(instance == null){/*...*/},很显然,instace已经指向了分配的内存地址,所以instance != null。所以线程2的执行结果是直接返回instace对象。线程2中的应用层代码可以直接获取到instace实例并且开始使用,但是值得注意的是我们的instance并没有执行过初始化对象这一步骤,而我们知道,一个对象没有完成对象初始化就开始使用,在某些情况下是非常严重的错误,程序bug。

而我们的代码private volatile static LazyDoubleCheckSingleton instance = null;中的volatile就是为了禁止指令重排序的。

当然我们 还有一种解决方案: 就是上面的2,3指令执行步骤在别的线程看来是不可见的,也就是说,别的线程是把2,3这两个步骤看成一个整体,外部不能介入其中执行的。

这种解决方案就是下面要介绍的基于***静态内部类***的解决方案。

静态内部类方式(基于类初始化的延迟加载解决方案)

直接上代码,代码很容易。重要的是理解内在JVM机制。

public class OuterSingleton {

    private OuterSingleton(){
    }

    public static OuterSingleton getInstance(){
        return InnerClass.staticInnerClassSingleton;
    }

    private static class InnerClass{
        private static OuterSingleton staticInnerClassSingleton = new OuterSingleton();
    }
}

我们要获取的单例,为OuterSingleton类的单例。而静态内部类作为创建这个实现单例的内在机制。

上面代码很容易看懂,值得注意的是两个private:一个是构造器的private(之前的几个单例模式也说过,外部只能通过getInstance获取单例对象,防止new一个对象);一个是静态内部类的private,静态内部类只是实现单例的内在机制,不应暴露给外部。

为什么静态内部类能够代替volatile关键字,解决指令重排序的问题?

JVM在类的初始化阶段( Class被加载之后~~线程使用之前 )执行类的初始化(也就是我们前面所说的 1,2,3 或者 1,3,2阶段)。类的初始化期间,类会去获取一个锁,此锁用于同步多个线程对于一个类的初始化

静态内部类方式的单例模式

其中类被初始化会发生在以下几种情况:什么时候类会被初始化

饿汉式

从名字可以看出,饿汉式与懒汉式相对应。

饿汉式单例模式实现较为简单,在类加载时就完成了初始化操作,如此避免了线程同步问题。当然缺点也是因为在类加载时就完成了初始化,没有了懒汉式的延迟加载效果。

public class HungerySingleton {

   public static final HungerySingleton instance;

   static{
        instance = new HungerySingleton();
   }

   private HungerySingleton(){

   }

   public static HungerySingleton getInstance(){
       return instance;
   }
}

当然,也能使用如下方法:

public class HungerySingleton {

   public static final HungerySingleton instance = new HungerySingleton();

   private HungerySingleton(){

   }

   public static HungerySingleton getInstance(){
       return instance;
   }
}

枚举类(Enum)

public enum EnumSingleton {
    INSTANCE{
        protected  void methodTest(){
            System.out.println("Method test.");
        }
    };
    
    protected abstract void methodTest();
    
    private Object data;

    public Object getData() {
        return data;
    }

    public void setData(Object data) {
        this.data = data;
    }
    
    public static EnumInstance getInstance(){
        return INSTANCE;
    }
}

容器式

  • 通过容器存储 <key,Object>,也就是一个key对应于一个类的单个实例。
  • 优点:通过key-value容器存储,当单例过多时,方便统一管理,节省资源。
  • 缺点:线程不安全(下面的代码实现)
import org.apache.commons.lang3.StringUtils;

import java.util.HashMap;
import java.util.Map;

public class ContainerSingleton {

    private ContainerSingleton(){
    }
    
    //or `new HashMap<String, Object>();`
    private static Map<String,Object> singletonMap = new HashMap<>();

    public static void putInstance(String key,Object instance){
        if(StringUtils.isNotBlank(key) && instance != null){
            if(!singletonMap.containsKey(key)){
                singletonMap.put(key,instance);
            }
        }
    }

    public static Object getInstance(String key){
        return singletonMap.get(key);
    }
}

ThreadLocal方式

public class ThreadLocalSingleton {

    private static final ThreadLocal<ThreadLocalSingleton> instance =
            new ThreadLocal<ThreadLocalSingleton>() {
                @Override
                protected ThreadLocalSingleton initialValue() {
                    return new ThreadLocalSingleton();
                }
            };


    private ThreadLocalSingleton(){

    }

    public static ThreadLocalSingleton getInstance(){
        return instance.get();
    }
}

这里所说的单例是相对于线程来说的。也就是说在 同一个线程内,都共享一个单例的内存空间;而对不同线程来说,获取到的是各自线程的单例。 如图:
LocalThread单例模式图

猜你喜欢

转载自blog.csdn.net/qq_37206105/article/details/84076934
今日推荐