• 六、单例模式


    1、单例模式的基本概念

    单例模式就是指在整个系统中,某个对象实例只存在一份,单例模式有很多应用场景,属于GoF23中设计模式中的创建型模式,借助单例模式可以解决系统的资源,单例模式看似简单,但是想要实现一个正确的单例模式却不并不是很简单。
    从单例对象的加载时机来划分,单例模式有可以分为饿汉式和懒汉式,所谓的饿汉式就是指提前准备好单例对象,即使单例对象目前还没有使用,懒汉式就是指只有第一次用到单例对象时才会去创建单例对象。
    单例模式提供了一个对象全局访问点,通过该访问点获取的单例对象可以保证是唯一的。

    2、单例模式角色

    1. Singleton,单例对象是全局唯一的对象,并且单例对象自己需要提供创建自己的方法
    2. Client,客户端对象,使用单例对象

    3、类图

    在这里插入图片描述

    4、单例模式的标准实现

    单例模式有很多实现方式,其中正确的有很多种,错误的也有很多种,这里先对正确的实现进行分析

    4.1 饿汉式

    饿汉式单例模式会在单例对象被使用之前就被创建。

    4.1.1 静态成员方式

    首先类在加载的时候,会首先初始化静态成员,其次JVM在加载类的时候,会确保类只会被加载一次,因此静态成员只会被初始化一次。

    package correct.impl1;
    
    public class Singleton {
        private final static Singleton SINGLETON = new Singleton();
    
        public static Singleton getInstance() {
            return SINGLETON;
        }
    
        public static void main(String[] args) {
            Singleton instance1 = Singleton.getInstance();
            Singleton instance2 = Singleton.getInstance();
            System.out.println(instance1 == instance2);
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    4.1.2 枚举类实现

    将单例对象定义成一个枚举类,枚举类的成员天然就是单例的,并且还不可以被序列化,因此可以防止因为序列化产生多个对象,这也是Effective Java里面推荐的方式。

    package correct.impl2;
    
    public enum Singleton {
        SINGLETON;
    
        public static void main(String[] args) {
            Singleton instance1 = Singleton.SINGLETON;
            Singleton instance2 = SINGLETON.SINGLETON;
    
            System.out.println(instance1 == instance2);
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12

    4.2 懒汉式

    懒汉式指的是,只有枚举类对象第一次被使用时才会被创建,否则不会创建,相比饿汉式懒汉式显然更节约系统资源。懒汉式有很多种实现方法,而且极容易写出错误的实现。

    4.2.1 给方法加锁

    加锁的方式对应的就是不加锁的错误实现方式,在错误案例里面会讲。加锁可以保证两个事情,一个是将获取单例对象的方法在多个线程之间互斥进行,并且锁的获取和释放可以保证A线程对instance变量的写入可以被B线程可见,因为有锁的出现,因此可以进行先检查在初始化。当A线程获取到锁的时候,会初始化instance,B线程获取到锁时,此时发现instance已经被初始化,因此直接返回A线程初始化的对象,可以做到全局单例,但是加锁会严重影响效率。

    package correct.impl3;
    
    public class Singleton {
        private static Singleton instance;
    
        public static synchronized Singleton getInstance() {
            if (instance == null) {
                instance = new Singleton();
            }
            return instance;
        }
    
        public static void main(String[] args) {
            Singleton instance1 = Singleton.getInstance();
            Singleton instance2 = Singleton.getInstance();
            System.out.println(instance1 == instance2);
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    4.2.2 给代码块加锁+双重检查+volatile

    4.2.1中给出的方法中,锁的范围太大,覆盖了整个方法,在线程竞争比较激烈的情况下,会严重影响程序的执行效率,因此4.2.2中采用给代码块加锁的方式,减小临界区的大小,加速锁的获取和释放速度。双重检查和volatile是为了保证单例的正确性,二者少其一,均不能正确实现单例模式(不是说会产生多个对象,而是在访问单例对象的时候可能出现错误),原因在错误案例中会分析。

    package correct.impl4;
    
    public class Singleton {
        // 不能去除volatile
        private volatile static Singleton instance;
    
        public static Singleton getInstance() {
            // 第一层判断只是初步过滤,如果去掉第一层,也没什么不妥
            // 但是会导致效率下降,因为所有的线程还是一上来就尝试竞
            // 锁
            if (instance == null) {
                // 在并发严重的情况下,大部分线程都会进入这个if
                synchronized (Singleton.class) {
                    // 但是只有一个线程可以进入该代码块
                    // 其他线程阻塞,该线程判断是否为null
                    // 发现为null
                    if (instance == null) {
                        instance = new Singleton();
                    }
                    // instance被实例化完成
                    // 当初始化instance的线程退出代码块的时候
                    // 只有之前进入第一个if的那些线程,才会继续获取锁
                    // 但是此时instance一定不为null
                    // 因此不会参与实例化对象,会立马释放锁
                    // 后续大部分线程都会被阻挡在第一个if语句
                    // 因此可以提高效率
                }
            }
            // 返回
            return instance;
        }
    
        public static void main(String[] args) {
            Singleton instance1 = Singleton.getInstance();
            Singleton instance2 = Singleton.getInstance();
            System.out.println(instance1 == instance2);
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    5.3 静态内部类的方式

    如果在一个类中定义一个静态内部类,那么在外部类加载的时候,并不会加载静态内部类,只有访问静态内部类或者其成员的时候才会导致静态内部类的加载,同时JVM会保证任何类的加载都只会执行一次,内部类也是如此,因此可以借助内部类方法实现单例的懒汉式。

    package correct.impl5;
    
    public class Singleton {
        private static class InstanceHolder {
            public static final Singleton INSTANCE = new Singleton();
        }
    
        public static Singleton getInstance() {
            // 用户调用该静态方法会导致Singleton类的加载
            // 而访问InstanceHolder的静态属性INSTANCE
            // 又会导致INSTANCE被初始化(只被初始化一次)
            return InstanceHolder.INSTANCE;
        }
    
        public static void main(String[] args) {
            Singleton instance1 = Singleton.getInstance();
            Singleton instance2 = Singleton.getInstance();
            System.out.println(instance1 == instance2);
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20

    5、单例模式的错误实现带原因分析

    单例模式的模式的错误实现大多集中在懒汉式的实现方式上面,这里给出的是几种常见的懒汉式单例模式错误实现。

    5.1 无锁+单重检查

    错误原因分析

    1. A线程和B线程同时执行getInstance()
    2. A比较快,先判断了if,然后发现为空进入if,很不幸,CPU时间片用完了,A被换下,B上
    3. B也是上来就判断if,发现为空,进入if
    4. A和B都进入了if,后面会发生什么?
    5. A和B都创建了instance,然后返回,导致出现了两个Singleton对象
    package error.impl1;
    
    public class Singleton {
        private static Singleton instance;
    
        public static Singleton getInstance() {
            if (instance == null) {
                instance = new Singleton();
            }
            return instance;
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12

    5.2 代码块加锁+单重检查

    下面这种方式,加锁基本上没有起到作用

    1. A和B线程同时判断if
    2. A先进入,然后获得锁,创建对象返回
    3. B再进入,然后获得锁,创建对象返回
    4. 系统存在两个Singleton对象

    只要同一时刻有多个线程进入if,锁就和没加一样。

    package error.impl2;
    
    public class Singleton {
        private static Singleton instance;
    
        public static Singleton getInstance() {
            if (instance == null) {
                synchronized(Singleton.class) {
                    instance = new Singleton();
                }
            }
            return instance;
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14

    5.2 加锁+双重检查+无volatile

    这种方式的错误是最难发现的,因为他不会导致系统出现两个对象,但是会导致某个线程可能会访问到未初始化完毕的对象,这肯定是不行的,因为一个对象还没有初始化完毕,那么访问他的属性或者方法时就有可能出现问题,比如出现空指针异常等等。
    这里要先说明一下,instance在多个线程之间不存在内存可见性问题,因为有锁的存在,线程在执行临界区代码的时候,会将对共享变量的修改直接写回内存,因此instance不会存在内存可见性问题,这里导致问题的是new Singleton()可能会发生重排序,没错,就是因为这个重排序导致某个线程可能访问到未初始化完全的对象,从而可能导致错误。
    new一个对象,并不是一个原子的指令,它是由多条指令组合而成,这里假设new由三条指令组合,分别是

    1. 给对象分配堆空间
    2. 初始化对象成员
    3. 将堆地址写入instance引用

    如果new严格按照123的顺序执行,不会有任何问题,关键是2和3可能发生重排序,假设发生了2和3的重排序即

    1. 给对象分配堆空间
    2. 将堆地址写入instance引用
    3. 初始化对象成员

    然后此时有A线程和B线程

    1. A快一点先判断了if,此时B还没有到达战场
    2. 然后A又快一点,先抢到了锁,进入第二个if,此时B依然没有到达战场
    3. 然后A判断instance为null成立
    4. 给对象分配堆空间
    5. 将堆地址写入instance引用
    6. 此时A时间片恰好用完,然后B此时恰好杀到战场,这个时候B判断第一个if是否成立,因为A已经将instance赋值了,此时if一定成立,所以B直接返回。
    7. 此时B拿到的就是一个未初始化完成的对象的引用
    8. 然后A继续执行完成对象初始化
    9. 返回对象

    现在解释为什么要加volatile,因为volatile可以限制重排序,加了之后2和3就不会被重排序了

    package error.impl3;
    
    public class Singleton {
        private static Singleton instance;
    
        public static Singleton getInstance() {
            if (instance == null) {
                synchronized(Singleton.class) {
                    if (instance == null) {
                        instance = new Singleton();
                    }
                }
            }
    
            return instance;
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
  • 相关阅读:
    官方烧录软件烧写2023.10版本树莓派镜像
    电脑突然开机无反应,怎么办
    spring异步线程任务Async,自定义配置线程池,Java
    Slope
    oracle截取字符串前几位用substr函数如何操作?
    Java 对象拷贝原理剖析及最佳实践
    计组笔记——CPU的指令流水
    JavaEE初阶--------第七章 HashMsp、HashTable 和 ConcurrentHashMap 之间的区别
    打破总分行数据协作壁垒,DataOps在头部股份制银行的实践|案例研究
    Blazor实战——Known框架多表增删改查
  • 原文地址:https://blog.csdn.net/xichengfengyulou/article/details/127658279