小言_互联网的博客

一文唬住所有面试官:懒汉式单例模式中的线程安全问题

264人阅读  评论(0)

问题

懒汉模式相对饿汉模式来说大大减少了内存空间的消耗,但是存在线程安全问题。

代码

public class LazySimpleSingleton {
    private LazySimpleSingleton(){}
    //静态块,公共内存区域
    private static LazySimpleSingleton lazy = null;
    public static LazySimpleSingleton getInstance(){
        if(lazy == null){
            lazy = new LazySimpleSingleton();
        }
        return lazy;
    }
}
public class ExectorThread implements Runnable{
    @Override
    public void run() {
        LazySimpleSingleton singleton = LazySimpleSingleton.getInstance();
//        ThreadLocalSingleton singleton = ThreadLocalSingleton.getInstance();
        System.out.println(Thread.currentThread().getName() + ":" + singleton);
    }
}
public class LazySimpleSingletonTest {
    public static void main(String[] args) {
        Thread t1 = new Thread(new ExectorThread());
        Thread t2 = new Thread(new ExectorThread());
        t1.start();
        t2.start();
        System.out.println("End");
    }
}

Idea中多线程断点调试

每个断掉都需要右击断点,并点击Thread


然后开始调试

可以看到这里是有多个线程的
这个时候按F8(调到下一步)
然后不论是Thread-0还是Thread-1都是运行到了如图这里

可能一(线程一前一后进入同一段代码)

然后分别选择Thread-0 Thread-1 分别按照以前以后进入,定位到这一行

在Thread-0中通过按F8跳转到return lazy,即已经有了lazy,如下图这个lazy是815

那么之前的Thread-1继续通过F8往下走的时候,也就不会再走if(lazy == null)里面的内容了,而是直接返回之前创建好的815

然后分别将两个线程按F8至最后。

结果:

如图,两个的结果是一样的

可能二(两个线程同时进入,同时返回)

Thread-0和Thread-1都同时进入到如下这行代码,都还没有进行初始化

Thread-0走完,准备return816

Thread-1也走完,准备return

这个时候发现直接是return了817,给覆盖掉了

这个时候再将所有的线程都走完

发现还是一样的。

虽然,最后还是一样的,但是内部其实已经实例化了两次,只不过后面执行比较慢的线程把前面执行快的线程覆盖了

可能三(两个线程同时进入,一前一后返回)

Thread-0和Thread-1都同时进入到如下这行代码,都还没有进行初始化

然后将Thread-0全部执行完

Thread-1全部执行完

这个时候就是两个不一样的了。

总结

通过上面的三种可能,能够看到如果是同时进入的话,可能最后显示的是两个实例(如上可能三),也可能最后显示的一个实例(如上可能二,淡这只是一个假象),即只要是同时进入的都会创建两个实例。
之后一前一后进入的时候才会是一个实例。

解决

synchronized

synchronized 关键字

通过多线程断点的方式再次模拟一次
Thread-0还是进入到这里

Thread-1一开始是在这里的

这个时候Thread-1通过F8尝试进入到同步代码块

发现报错了,不支持的线程,不允许访问
仔细看下

Thread-1也因此变成了Monitor状态,Thread-0是Running状态。
只有当Thread-0执行完了之后,Thread-1才会变成Running状态。

那么我们让Thread-0走出同步代码块,发现Thread-1变成Running了

这个时候通过F8,发现已经有值了,所以跳过了lazy = new LazySimpleSingleton();,直接进行return了之前Thread-0,new好的。

这个时候最后的结果才是没有障眼法的真正的为一个实例。

synchronized问题

虽然在JDK1.6之后对synchronized性能优化了许多,但是还是不可避免的存在一定的性能问题。
因为这个synchronized可能会造成整个类的操作被锁住

因为它修饰的方法是被static修饰的

Double Check

public class LazyDoubleCheckSingleton {
    private volatile static LazyDoubleCheckSingleton lazy = null;

    private LazyDoubleCheckSingleton(){}
    public static LazyDoubleCheckSingleton getInstance(){
        if(lazy == null){
            synchronized (LazyDoubleCheckSingleton.class){
                if(lazy == null){
                    lazy = new LazyDoubleCheckSingleton();
                    //1.分配内存给这个对象
                    //2.初始化对象
                    //3.设置lazy指向刚分配的内存地址
                    //4.初次访问对象
                }
            }
        }
        return lazy;
    }
}

因为synchronized关键字如果修饰静态方法的话,会将整个类锁住,所以将synchronized放在方法里面。
可是如果这么写的话

 if(lazy == null){
            synchronized (LazyDoubleCheckSingleton.class){
               // if(lazy == null){
                    lazy = new LazyDoubleCheckSingleton();
                    //1.分配内存给这个对象
                    //2.初始化对象
                    //3.设置lazy指向刚分配的内存地址
                    //4.初次访问对象
               // }
            }
        }
        return lazy;

即没有里面的双重检查,会导致Thread-0在执行lazy = new LazyDoubleCheckSingleton();的时候,Thread-1无法执行,但是,当Thread-0执行完这句话之后,Thread-1就能够进来同样执行这句话了,所以实际上还是创建了两次实例。

所以至此就很明了了,需要进行双重检测,if(lazy == null){}

为什么最外面还要有一个if判断?

总的来说就是为了减小开销、提升效率。

最里面的知道是为了保证实例的唯一性,但是最外层的判断是为什么呢?

那就来假设一下
代码如下

public static LazyDoubleCheckSingleton getInstance(){
        
            synchronized (LazyDoubleCheckSingleton.class){
                if(lazy == null){
                    lazy = new LazyDoubleCheckSingleton();
                    //1.分配内存给这个对象
                    //2.初始化对象
                    //3.设置lazy指向刚分配的内存地址
                    //4.初次访问对象
                }
            }
     
        return lazy;
    }

这意味着什么?没错里面的判断使得保证了实例的唯一性,但是因为外层没有判断,所以导致里面的synchronized相关代码都是无条件执行的,即每个线程执行到这里都需要获得一个内部锁,锁的获得、释放的开销(包括上下文切换、内存同步等)也就无条件的存在了。相反的加上不为null的判断之后,就能在一定程度上减少所有的线程都经过这里的可能,从而减少开销。

同时能够提升效率,假象线程一已经实例化了对象,此时线程二持有这把锁,线程三只能等待带线程二执行完,而如果有了外层的判断,线程三就不需要等待了直接返回lazy的值。

指令重排 – volatile

private volatile static LazyDoubleCheckSingleton lazy = null;
 lazy = new LazyDoubleCheckSingleton();
                    //1.分配内存给这个对象
                    //2.初始化对象
                    //3.设置lazy指向刚分配的内存地址
                    //4.初次访问对象

上面的这行代码,其实在cpu中是执行了下面的四个操作,这里的2和3其实顺序是可能颠倒的,即指令重排问题。为了解决这个问题,需要在lazy前面加上volatile关键字。

内部类(最好的)

package com.gupaoedu.vip.pattern.singleton.lazy;
//懒汉式单例
//这种形式兼顾饿汉式的内存浪费,也兼顾synchronized性能问题
//完美地屏蔽了这两个缺点
//史上最牛B的单例模式的实现方式
public class LazyInnerClassSingleton {
    //默认使用LazyInnerClassGeneral的时候,会先初始化内部类
    //如果没使用的话,内部类是不加载的
    private LazyInnerClassSingleton(){
        if(LazyHolder.LAZY != null){
            throw new RuntimeException("不允许创建多个实例");
        }
    }

    //每一个关键字都不是多余的
    //static 是为了使单例的空间共享
    //保证这个方法不会被重写,重载
    public static final LazyInnerClassSingleton getInstance(){
        //在返回结果以前,一定会先加载内部类
        return LazyHolder.LAZY;
    }

    //默认不加载
    private static class LazyHolder{
        private static final LazyInnerClassSingleton LAZY = new LazyInnerClassSingleton();
    }
}

全程没有使用synchronized关键字
当外面的类LazyInnerClassSingleton 加载的时候,会首先去加载内部类LazyHolder,内部类比外部类要优先加载。

这里注意内部类LazyHolder中的逻辑,默认是不执行的,猛地一看,这个内部类中是饿汉式的,但是只有当getInstance()去调用这个方法的时候才执行private static final LazyInnerClassSingleton LAZY = new LazyInnerClassSingleton();,巧妙的利用了内部类的特性。

这也是性能最优的一种方式。

反射攻击

如果这里的构造方法是如下代码。

private LazyInnerClassSingleton(){
       
    }


通过反射的方式,就要调用private的构造方法,也是可以的,这样的到的还是两个实例。

所以需要将构造方法改成

 private LazyInnerClassSingleton(){
        if(LazyHolder.LAZY != null){
            throw new RuntimeException("不允许创建多个实例");
        }
    }

如果偷偷的用构造方法实例化的话,会抛出异常,从而防止了反射攻击。

序列化攻击


import java.io.Serializable;

//反序列化时导致单例破坏
public class SeriableSingleton implements Serializable {

    //序列化就是说把内存中的状态通过转换成字节码的形式
    //从而转换一个IO流,写入到其他地方(可以是磁盘、网络IO)
    //内存中状态给永久保存下来了

    //反序列化
    //讲已经持久化的字节码内容,转换为IO流
    //通过IO流的读取,进而将读取的内容转换为Java对象
    //在转换过程中会重新创建对象new

    public  final static SeriableSingleton INSTANCE = new SeriableSingleton();
    private SeriableSingleton(){}

    public static SeriableSingleton getInstance(){
        return INSTANCE;
    }

 

}
package com.gupaoedu.vip.pattern.singleton.test;

import com.gupaoedu.vip.pattern.singleton.seriable.SeriableSingleton;

import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;


public class SeriableSingletonTest {
    public static void main(String[] args) {

        SeriableSingleton s1 = null;
        SeriableSingleton s2 = SeriableSingleton.getInstance();

        FileOutputStream fos = null;
        try {
            fos = new FileOutputStream("SeriableSingleton.obj");
            ObjectOutputStream oos = new ObjectOutputStream(fos);
            oos.writeObject(s2);
            oos.flush();
            oos.close();


            FileInputStream fis = new FileInputStream("SeriableSingleton.obj");
            ObjectInputStream ois = new ObjectInputStream(fis);
            s1 = (SeriableSingleton)ois.readObject();
            ois.close();

            System.out.println(s1);
            System.out.println(s2);
            System.out.println(s1 == s2);

        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}


上面的s1是先将类写入文件,再从类中读出来。
s2是公国getInstance()方法实例化。
最后的结果是不一样的。

解决方法一(重写ReadResolve方法)

SeriableSingleton 中重写readResolve方法

   private  Object readResolve(){
        return  INSTANCE;
    }

即SeriableSingleton 完整代码

package com.gupaoedu.vip.pattern.singleton.seriable;

import java.io.Serializable;



//反序列化时导致单例破坏
public class SeriableSingleton implements Serializable {

    //序列化就是说把内存中的状态通过转换成字节码的形式
    //从而转换一个IO流,写入到其他地方(可以是磁盘、网络IO)
    //内存中状态给永久保存下来了

    //反序列化
    //讲已经持久化的字节码内容,转换为IO流
    //通过IO流的读取,进而将读取的内容转换为Java对象
    //在转换过程中会重新创建对象new

    public  final static SeriableSingleton INSTANCE = new SeriableSingleton();
    private SeriableSingleton(){}

    public static SeriableSingleton getInstance(){
        return INSTANCE;
    }

    private  Object readResolve(){
        return  INSTANCE;
    }

}

分析

为什么重写readResolve方法就可以了?

注意踏实怎么转化成这个类的?
首先通过readObject方法

再点到这个方法里面,往下

读取二进制的对象,点击去,往下

如果构造方法不为null,就初始化,虽然我们的构造方法是private的,但是只要有构造方法,就会初始化。
因为返回true,所以重新创建了对象,所以自然s1和s2是不相等的两个对象。

回到ObjectInputSteam类中的往下(在上面的desc.newInstance下面)

即虽然上面已经newInstance了,但是这里还是会判断是否有ReadResolve方法,如果有的话,就会执行这个ReadResolve方法。

至此,虽然已经new instance了,但是因为我们重写了jdk提供给我们的开放借口,所以真正返回的其实是单例类中的单例

 private  Object readResolve(){
        return  INSTANCE;
    }

而这个方法在哪里?

通过反射获得名字为ReadResolve的方法。

总结

重写readResolve方法,只不过是覆盖了反序列化出来的对象,还是创建了两次,放生在Jvm层面,相对来说比较安全,之前反序列化出来的对象被gc回收了。

解决方法二(注册式单利,即枚举式单利 《Effective Java》)

package com.gupaoedu.vip.pattern.singleton.register;


//常量中去使用,常量不就是用来大家都能够共用吗?
//通常在通用API中使用
public enum EnumSingleton {
    INSTANCE;
    private Object data;
    public Object getData() {
        return data;
    }
    public void setData(Object data) {
        this.data = data;
    }
    public static EnumSingleton getInstance(){
        return INSTANCE;
    }
}
    public static void main(String[] args) {
        try {
            EnumSingleton instance1 = null;

            EnumSingleton instance2 = EnumSingleton.getInstance();
            instance2.setData(new Object());

            FileOutputStream fos = new FileOutputStream("EnumSingleton.obj");
            ObjectOutputStream oos = new ObjectOutputStream(fos);
            oos.writeObject(instance2);
            oos.flush();
            oos.close();

            FileInputStream fis = new FileInputStream("EnumSingleton.obj");
            ObjectInputStream ois = new ObjectInputStream(fis);
            instance1 = (EnumSingleton) ois.readObject();
            ois.close();

            System.out.println(instance1.getData());
            System.out.println(instance2.getData());
            System.out.println(instance1.getData() == instance2.getData());

        }catch (Exception e){
            e.printStackTrace();
        }
    }

结果

能够保证相等了。

抠细节(jad jad)

jad简单介绍

介绍:class反编译工具
下载地址:https://varaneckas.com/jad/
安装:解压到任意目录,解压后的到两个文件(jad.exe、Readme.txt)
配置环境变量:在path中添加jad.exe所在的目录(比如当前的jad.exe在F:)那就直接配置F:就好了
比如我的:

配置完之后需要重新启动cmd窗口然后在任意路径输入jad


说明成功了。

实干家

因为是maven项目,在target文件下找到需要反编译的class文件,并复制路径

然后执行jad + filepath(不要有中文最好,我直接吧class文件拖放到了桌面)

最后生成的jad结尾的文件,这个文件在哪里呢?
如上入执行jad命令的时候,在哪里执行的就会存放咋哪里,如图我实在桌面执行的,所以就会存放在桌面

然后用notepad++等工具打开查看。

可以看到反编译的真实的代码,和我们idea中看到的是不一样的。

是怎么实现单例的?
注意静态代码块中的内容

 static 
    {
        INSTANCE = new EnumSingleton("INSTANCE", 0);
        $VALUES = (new EnumSingleton[] {
            INSTANCE
        });
    }

是没有无参的构造方法的,而且是在static静态代码块中进行初始化的,即饿汉式的写法,饿汉式的单例是线程安全的,那么回过头开始如何避免序列化破坏单例的?

同样回去继续跟源码

进入readEnum

通过jdk的valueOf方法加入class名字和枚举中的name确定一个值。

那么是通过什么来保证不会被反射攻击的呢?

如上图,通过反射的方法实例化,通过反编译代码,我们知道最终的代码是没有空参的构造方法的,这里模拟一下两个参数,颠倒这个newInstance方法里面

可以看到这里得到当前clazz的modifires(比如public等),如果得到的是enum,即枚举的话,直接就不实例化,直接就会抛出如上的异常,跟我们console中的到的一样。

总结

避免单例模式被反射或者序列化攻击的话,最好通过枚举的方式进行解决,因为在jdk层面已经帮我们做的很好了。
当然通过重写ReadResolve方法的方式也行,但是最好还是通过枚举的方式。
《Effective Java》这本书中也是这么说的。

Java糖果罐
扫码关注

转载:https://blog.csdn.net/dataiyangu/article/details/104110166
查看评论
* 以上用户言论只代表其个人观点,不代表本网站的观点或立场