写点什么

并发编程:一次搞定单例模式

发布于: 2021 年 03 月 19 日

模式是脱离语言的。


一、单例的模式由来


多线程要操作同一个对象,保证对象的唯一性。


如何解决?


实例化过程只实例化一次。


单例模式的四大原则


1.构造方法私有化 2.以静态方法或者枚举返回实例 3.确保实例只有一个,尤其是多线程环境 4.确保反序列化时,不会重新构建对象


我们常见的单例模式有:


  • 饿汉模式

  • 懒汉模式

  • 双重检索模式

  • 静态内部类模式

  • 枚举模式


二、单例模式的分类


1.饿汉模式


public class HungrySingleton {
    /**     * 1.加载的时候就产生实例对象     */    private static HungrySingleton instance = new HungrySingleton();
    /**     * 2.构造方法私有化     */    private HungrySingleton(){}
    /**     * 3.提供返回实例对象的静态方法     */
    public static HungrySingleton getInstance(){        return instance;    }
    public static void main(String[] args) {
        for (int i = 0; i < 50; i++) {
            new Thread(() ->{
                System.out.println(HungrySingleton.getInstance());            }).start();        }    }}
复制代码


饿汉模式安全性:


  • 在 HungrySingleton 类被类加载器加载的时候已经被实例化,所有只有这一次,以空间换时间,线程是安全的。

  • 效率问题

  • 没有延迟加载,如果创建后不被使用,占内存,影响性能


2.懒汉式


public class LazySingleton {
    /**     * 1.不进行初始化     */    private static LazySingleton instance = null;

    /**     * 2.构造方法私有化     */    private LazySingleton(){};

    /**     * 3.使用的时候进行初始化     */    public static LazySingleton getInstance(){        if(Objects.isNull(instance)){            instance = new LazySingleton();        }        return instance;    }
    public static void main(String[] args) {        /**         * 模拟100个线程进行并发操作         */        for (int i = 0; i <10000; i++) {            new Thread(() ->{                System.out.println(LazySingleton.getInstance());            }).start();        }    }}
复制代码


懒汉模式安全性:LazySingleton 是在方法被调用后才创建对象,用时间换空间,在多线程环境下存在风险。


3.双重检索懒汉模式(Double -Check -Lock)


public class DCLSingleton {
    /**     * 1.不进行初始化     */    private static DCLSingleton instance = null;
    /**     * 2.构造方法私有化     */    private DCLSingleton(){}

    /**     * 3.利用双重检索的方式进行初始化操作     */    public static DCLSingleton getInstance(){        if(Objects.isNull(instance)){            synchronized (DCLSingleton.class){                if(Objects.isNull(instance)){                    instance = new DCLSingleton();                }            }        }        return instance;    }}
复制代码


上面的代码:在获取实例对象 getInstance()的方法中,我们首先判断 instance 是否为空,如果为空,则锁定 DCLSingleton.class,并再次检查 instance 是否为空,如果还为空则创建 DCLSingleton 的一个实例。


我们假如有两个线程 A,B 同时调用 getInstance()方法,他们会同时发现 instance ==null,于是同时对 DCLSingleton.class 加锁,此时 JVM 保证只有一个线程能够加锁成功(假如是线程 A),另外一个线程会处于等待状态(假如是线程 B);线程 A 会创建一个 DCLSingleton 实例,之后释放锁,锁释放后,线程 B 被唤醒,线程 B 再次尝试加锁,此时是可以加锁成功的,加锁成功后,线程 B 检查 Objects.isNull(instance)时会发现,已经创建过 DCLSingleton 实例了,所以线程 B 不会再创建一个 DCLSingleton 实例。


这看上去一切都很完美,无懈可击,但实际上这个 getInstance()方法并不完美,问题出在哪里呢,出在 new 操作上,我们以为的 new 操作应该是:


  1. 分配一块内存 M

  2. 在内存 M 上初始化 DCLSingleton 对象;

  3. 然后 M 的地址赋值给 instance 变量


但可能经过 JVM 优化过后的执行顺序是这样的:

1.分配一个内存 M;

2.将 M 的地址赋值 instance 变量

3.最后再内存上初始化 DCLSingleton 对象。


优化后会导致什么问题呢? 我们假设线程 A 先执行 getInstance()方法,当执行完指令 2 时恰好发生了线程切换,切换了线程上 B 上;如果此时线程 B 也执行 getInstance()方法,那么线程 B 在执行第一个判断时会发现 instance != null ,所以直接返回 instance,而此时的 instance 是没有初始化过的,如果我们这个时候访问 instance 的成员变量就可能会触发空指针异常。



4.双重检索(DCL)+ volatile


public class DCLSingleton {
    /**     * 1.不进行初始化     */    private volatile static DCLSingleton instance = null;
    /**     * 2.构造方法私有化     */    private DCLSingleton(){}

    /**     * 3.利用双重检索的方式进行初始化操作     */    public static DCLSingleton getInstance(){        if(Objects.isNull(instance)){            synchronized (DCLSingleton.class){                if(Objects.isNull(instance)){                    instance = new DCLSingleton();                }            }        }        return instance;    }}
复制代码


volatile 的作用:


1.保证可见性:


对共享变量的修改,其它线程马上能感知到 ,这样 volatile 可以确保 instance 每次都会在主内存中读取,保证 instance 的一致性


2.保证有序性:


重排序:(编译阶段,指令优化阶段会进行重排序)as-if-serial:重排序后在单线程不影响程序的执行结果,对多线程有影响 volatile 原则:volatile 之前的代码不能调整到它的后面 volatile 之后的代码不能调整到它的前面位置不变化


5.静态内部类模式


public class Singleton {    /**     * 1.构造方法私有化     */    private Singleton(){}
    /**     * 2.定义静态内部类     *      * 声明类的时候,成员变量中不声明实例变量,而放到内部静态类中     */    private static class SingletonHolder{        private static Singleton instance = new Singleton();    }
    /**     * 3.返回实例对象     */    public static Singleton getInstance(){        return SingletonHolder.instance;    }}
复制代码


静态内部类的优点:


外部类加载时并不需要立即加载内部类,内部类不被加载则不去初始化 instance,故而不占用内存,即当 Singleton 第一次被加载时,并不需要加载 SingleHolder,只有当 getInstance()方法第一次被调用时,才会去初始化 instance,第一次调用 getInstance()方法会导致 JVM 加载 SingletonHolder 类,这种方法不仅能保证线程安全,也能保证单例的唯一性,同时也延迟了到单例的实例化。


补充知识

当初始化一个类时,如果其父类还未进行初始化,会先触发其父类的初始化。


  1. 遇到 new、getstatic、setstatic 或者 invokestatic 这 4 个字节码指令时,对应的 java 代码场景为:new 一个关键字或者一个实例化对象时、读取或设置一个静态字段时(final 修饰、已在编译期把结果放入常量池的除外)、调用一个类的静态方法时

  2. 使用 java.lang.reflect 包的方法对类进行反射调用的时候,如果类没进行初始化,需要先调用其初始化方法进行初始化。

  3. 当初始化一个类时,如果其父类还未进行初始化,会先触发其父类的初始化。

  4. 当虚拟机启动时,用户需要指定一个要执行的主类(包含 main()方法的类),虚拟机会先初始化这个类。

  5. 使用 JDK 1.7 等动态语言支持时,如果一个 java.lang.invoke.MethodHandle 实例最后的解析结果 REF_getStatic、REF_putStatic、REF_invokeStatic 的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,则需要先触发其初始化。


这 5 种情况被称为是类的主动引用,注意,这里《虚拟机规范》中使用的限定词是"有且仅有",那么,除此之外的所有引用类都不会对类进行初始化,称为被动引用。静态内部类就属于被动引用的行列。


那么,是不是可以说静态内部类单例就是最完美的单例模式了呢?其实不然,静态内部类也有着一个致命的缺点,就是传参的问题,由于是静态内部类的形式去创建单例的,故外部无法传递参数进去,例如 Context 这种参数,所以,我们创建单例时,可以在静态内部类与 DCL 模式里自己斟酌。


6.枚举模式


public class EnumSingleton {
    /**     * 1.构造方法私有化     */    private EnumSingleton(){}
    private enum SingletonHolder{        //创建一个枚举对象,该对象天生为单例        INSTANCE;        private EnumSingleton instance;
        SingletonHolder(){            instance = new EnumSingleton();        }
        public EnumSingleton getInstance(){            return instance;        }    }
    /**     * 对外暴露一个获取EnumSingleton对象的静态方法     */    public static EnumSingleton getInstance(){        return SingletonHolder.INSTANCE.instance;    }
}
复制代码


用户头像

还未添加个人签名 2020.09.07 加入

还未添加个人简介

评论

发布
暂无评论
并发编程:一次搞定单例模式