老司机开高铁带你深入理解单例模式(sigleton)线程安全问题

今天,趁着大家都在改bug,而我又不是特别忙的情况下,深入的学习了一下单例模式。

下面就让我们来聊一聊单例模式:

顾名思义,单例模式的含义就是在整个应用程序,一个类应该只会有一个实例对象。

1.懒汉式:延迟加载,用到的时候再创建对象。
优点:节省内存空间,缺点:使用不当会造成线程安全问题
2.饿汉式:不管你用不用,都会强制创建出一个对象。
优点:线程安全,缺点:浪费内存空间

这里,我们详细说一下懒汉式,也是面试官经常会问到的

创建单例模式,线程安全的三种方式:

1首先,我们要讲一下利用静态内部类来创建单例模式:
public class StaticSignlton {

    /**
     * 首先我们要创建一个私有的构造方法,因为此类不可以让外部new对象
     */
    private StaticSignlton() {
    }

    /**
     * 获取单例对象
     *
     * @return
     */
    public static StaticSignlton createStaticSignlton() {
        return StaticSignltonFactory.staticSignlton;
    }

    /**
     * 静态成员初始化,JVM会加锁
     */
    private static class StaticSignltonFactory {

        private static StaticSignlton staticSignlton = new StaticSignlton();
    }
}

有的小伙伴这里会有疑问,为什么使用静态内部类的方式会是线程安全的?
解释:因为成员变量是在类初始化第一次(并且永远只会有一次)进行加载构造对象的,而且在加载过程中还会被JVM加锁,不管第一次同时有多少个线程访问,只会有一个线程先访问,访问第一次的时候会创建对象。因为前面有线程已经创建了对象,所以不管后面多少次再调用拿到的结果都是一样的,所以是线程安全的。

class Test {
    public static void main(String[] args) {
        StaticSignlton s1 = StaticSignlton.createStaticSignlton();
        StaticSignlton s2 = StaticSignlton.createStaticSignlton();
        System.out.println("s1=s2>>>>>>" + (s1 == s2));
    }

此时的运行结果:
image.png
2*双重检查锁单例模式:
public class Student {

    private static Student stu = null;

    private Student() {
    }

    /**
     * @return
     */
    public static Student createLockSignlton() {
        if (null == stu) {
            //在代码块加锁,提升效率
            synchronized (Student.class) {
                //思考一下,加锁之后为什么还要再次判断一次非空?
                if (null == stu) {
                    stu = new Student();
                }
            }
        }
        return stu;
    }
}
上图,思考一下,如果不加第二个非空验证会结果怎么样?

答:同一时间点有线程1、线程2访问createLockSignlton方法,都会跳过第一个非空判断,执行synchronized同步代码块,此时会有线程1先执行同步代码块,线程2则在线程锁池缓冲区等待。当线程1行完之后,创建了Student实例并返回,但是由于两个线程在同一时间点访问createLockSignlton方法,都跳过了第一个非空判断,所以线程1创建完成对象之后,线程2的stu指向的还是空引用,当线程2进入同步代码块时,它还会再new一个新的Student实例返回,如果此时有100个线程同时访问,会新创建100个实例对象,会造成线程不安全问题。所以需要在synchronized代码块中加入非空验证,确保每次都返回同一个实例。

正常情况下,上图的双重锁单例方法是没有问题的,但是由于计算机的飞速发展,CPU也是有缓存的,下面举例说明一种情况:
在这里,我们引出另外一个知识点:stu = new Student(),对象的实例化过程,JVM做了什么?

1.开辟空间:根据new 后面的class类型,去开辟指定内存空间大小
2.初始化对象中的参数,赋值
3.将开辟的内存空间地址赋值给stu引用

第2、3步骤是可以不按照顺序的,可以是1、2、3,也可以是1、3、2
是因为当两段代码如果没有必然的依赖性,JVM会进行指令重排序,所以说对象创建的过程,除了步骤1不会变,后面的顺序都可以变

当多个cpu操作内存中同一个对象的时候,cpu会将结果先缓存到当前cpu。此时如果有cpu2也访问了并操作了对象,会出现数据不一致的问题。结合上面的内容就是cpu1创建了对象,把它放在了cpu1(线程1)的缓存中,并没有及时将新创建的stu地址刷新到内存,cpu2(线程2)获得是内存中不是最新的stu引用(实际就是空的地址),会造成不可见问题,导cpu2(线程2)再次创建对象。
这块内容涉及到了cpu缓存,内存缓存和磁盘的关系,有兴趣的小伙伴们可以自行下去了解一下,这里就不详细说了,毕竟咱们讲的是如何写出一个线程安全的单例模式 哈~

解决可见性和有序性的问题:

实例变量中加入volatile关键字
1.禁止指令重新排序(内存屏障--读屏障、写屏障)
2.禁止CPU缓存的使用

结合上图实例的意思就是:cpu1创建对象时候会立马刷新到内存中,cpu2获得的永远是最新的,所以不会发生cpu1在创建对象时缓存,cpu2获得的引用依旧是空

//添加volatile关键字解决可见性和有序性
private static volatile Student stu = null;
3*利用枚举(ENUM)方式:
public enum Sigleton {

    //创建一个实例
    INSTACE;
    
    public static Sigleton getInstace() {

        return INSTACE;
    }
}

上图的枚举方式创建单例对象是最佳选择,因为它能有效的避免反射和序列化方式带来的破坏。

破坏单例对象的两种方式:

1.利用反射机制
2.序列化方式

防御破坏的三种机制:

1.反射防御机制:
在创建单例对象方法中加入非空判断,如果不为空,就返回实例

2.序列化的防御机制:
只需在类中加入一个方法:

   /**
     * 序列化会默认调用
     *
     * @return
     */
    private Object readResolve() {
        return stu;
    }

在反序列化的时候,源码中会调用此方法,直接返回实例

有兴趣的小伙伴们可以读一下ObjectInputStream.readObject()方法:

如果发现有readResolve()方法,它会直接调用方法返回,不会重新new一个新的对象

3.利用枚举的方式进行创建单例:
底层JDK内部已经帮我们实现了禁止反射和序列创建枚举对象


image.png

序列化时会调用Enum.valueOf()方法:


image.png

你可能感兴趣的:(老司机开高铁带你深入理解单例模式(sigleton)线程安全问题)