zoukankan      html  css  js  c++  java
  • 一文唬住所有面试官:懒汉式单例模式中的线程安全问题

    @

    问题

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

    代码

    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糖果罐
    扫码关注
  • 相关阅读:
    在HTML中使用JavaScript
    网站发布流程
    React组件
    React渲染和事件处理
    Java IO(三)
    Java IO(二)
    Java IO(一)
    Java常用类库
    Java集合框架(四)
    Java集合框架(三)
  • 原文地址:https://www.cnblogs.com/LeesinDong/p/12246605.html
Copyright © 2011-2022 走看看