设计模式-单例模式

196 阅读5分钟

设计模式是每个程序员在工作和求职中都涉及的区域,想要真正掌握好设计模式的精髓并运用到工作中去,却并不容易,本文将针对单例模式模式中的双重检查锁模式进行详细讲解,并顺便介绍下其他的单例模式。

概念

所谓单例,就是整个程序有且仅有一个实例。该类负责创建自己的对象,同时确保只有一个对象被创建。在Java,一般常用在工具类的实现或创建对象需要消耗资源。

单例模式的特点

  • 类构造器私有
  • 持有自己类型的属性
  • 对外提供获取实例的静态方法

单例的各种实现方式

  1. 双重检查模式
public class Singleton {  
    private volatile static Singleton singleton;  
    private Singleton (){}  
    public static Singleton getSingleton() {  
        if (singleton == null) {  
            synchronized (Singleton.class) {  
                if (singleton == null) {  
                    singleton = new Singleton();  
                }  
            }  
        }  
        return singleton;  
    }  
}

双重检查的作用:

第一重检查:减少不必要的上锁,将锁的粒度细化,避免不必要的上锁,使得最外层所有的人都得排队等待,导致资源浪费;即先判断对象有没有实例化,有的话直接取值就行,没有实例化的则进行加锁实例化;

第二重检查:在多线程的情况下,有可能已经有之前获取过锁的线程已经实例化了该对象,为了防止多次实例化,此处需要进行第二次检查;假如有A、B两个线程,都通过了第一层检查,然后A先获取锁,实例化对象后释放锁,此时B获取到锁,这个时候如果不去进行第二重检查,则B也会实例化对象,并把A的给覆盖掉;

指令重排的概念: 一般来说,处理器为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的。

在某些情况下,这种优化会带来一些执行的逻辑问题,主要的原因是代码逻辑之间是存在一定的先后顺序,在并发执行情况下,会发生二义性,即按照不同的执行逻辑,会得到不同的结果信息,

int a = 10; //执行顺序1
int b = 20; //执行顺序2
int c = a + b; //执行顺序3

理论上,虚拟机在执行代码的时候,会按照上面的顺序1-》2-》3执行,但是编译器可能会进行相应的优化导致执行顺序变成2-》1-》3,这个操作就叫指令重排。虽然执行结果依然是正确的,但是在多线程的情况下这种指令重排可能会造成灾难性的影响。

首先我们先看一下new Object()底层实现的步骤:

public class Main {
    public static void main(String[] args) {
        CarServiceImpl carService = new CarServiceImpl();
    }
}
// 对应的字节码如下:
public class com.spring.learn.Main {
  public com.spring.learn.Main();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return

  public static void main(java.lang.String[]);
    Code:
       // 1、创建对象实例,并分配内存
       0: new           #2                  // class com/spring/learn/service/impl/CarServiceImpl
       // 复制栈顶地址,并将其压入栈顶
       3: dup
       // 2、调用构造器方法,初始化对象
       4: invokespecial #3                  // Method com/spring/learn/service/impl/CarServiceImpl."<init>":()V
       // 3、存入局部方法变量表
       7: astore_1
       8: return
}

从字节码可以看到创建一个对象实例,可以分为三步:

  • 1.分配对象内存;
  • 2.调用构造器方法,执行初始化;
  • 3.将对象引用赋值给变量;

虚拟机实际运行时,以上指令可能发生重排序。以上步骤2、3可能发生重排序,但是并不会重排序1的顺序。也就是说1这个指令都需要先执行,因为2、3指令需要依赖1指令执行结果。

在上述双重检查锁的示例代码中,如果线程ThreadA获取到锁,并在创建实例的时候发生了指令重排序,即先执行步骤3-》再执行步骤2,在执行步骤3的时候,此时ThreadB在最外层检查的时候发现对象不为null,然后直接拿到一个还未初始化的对象,造成ThreadB线程访问时发生异常。

volatile的作用

  • 保证可见性。使用 volatile定义的变量,将会保证对所有线程的可见性;
  • 禁止指令重排序优化。由于volatile禁止对象创建时指令之间重排序,所以其他线程不会访问到一个未初始化的对象,从而保证安全性。

使用局部变量优化

public class Singleton {  
    private volatile static Singleton singleton;  
    private Singleton (){}  
    public static Singleton getSingleton() {
        Singleton singletonLocal = this.singleton;
        if (singletonLocal == null) {  
            synchronized (Singleton.class) {  
                if (singletonLocal == null) {  
                    singletonLocal = new Singleton(); 
                    this.singleton = singletonLocal
                }  
            }  
        }  
        return this.singleton;  
    }  
}

可以看到方法内部使用局部变量,首先将实例变量值赋值给该局部变量,然后再进行判断。最后内容先写入局部变量,然后再将局部变量赋值给实例变量。使用局部变量相对于不使用局部变量,可以提高性能。主要是由于volatile变量创建对象时需要禁止指令重排序,这就需要一些额外的操作,而局部变量的话,线程安全的,可以节省这些额外的操作。

  1. 懒汉模式
public class Singleton {  
    private static Singleton instance;  
    private Singleton (){}  
  
    public static Singleton getInstance() {  
        if (instance == null) {  
            instance = new Singleton();  
        }  
        return instance;  
    }  
}

优点:延迟初始化,在第一次使用的时候才会创建这个对象实例。

缺点:线程不安全。

  1. 饿汉模式
public class Singleton {  
    private static Singleton instance = new Singleton();  
    private Singleton (){}  
    public static Singleton getInstance() {  
        return instance;  
    }  
}

优点:线程安全。

缺点:程序启动的时候就初始化,容易产生垃圾,和资源浪费。

  1. 静态内部类单例模式
public class Singleton { 
    private Singleton(){
    }
    public static Singleton getInstance(){  
        return Inner.instance;  
    }  
    private static class Inner {  
        private static final Singleton instance = new Singleton();  
    }  
} 

只有第一次调用getInstance方法时,虚拟机才加载Inner并初始化instance ,只有一个线程可以获得对象的初始化锁,其他线程无法进行初始化,保证对象的唯一性。目前此方式是所有单例模式中最推荐的模式,但具体还是根据项目选择。