设计模式---单例模式

一、概述

单例模式涉及到一个类,该类负责创建自身的对象,并且每次创建出来的对象都是同一个,同时对外提供获取该类唯一对象的方法

 

二、分类

单例模式分为两种

1、懒汉式 : 类加载的时候便会创建该类的对象

2、饿汉式 : 类加载的时候不会创建对象,只有在使用的时候才会去创建该类的对象

 

三、案例

3.1、饿汉式

通过静态成员变量或者静态代码块的方式实现

1、静态成员变量方式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class Singleton {
    // 构造方法私有化,防止外界创建对象,因为每次通过 new 的方式创建的对象都会在堆内存中开辟一块空间,创建出来的对象就不是同一个了,违背了单例模式的定义
    private Singleton() {
        System.out.println("通过构造方法创建对象");
    }
 
    private static Singleton instance = new Singleton();
 
    public static Singleton getInstance() {
        return instance;
    }
}
 
// 测试类
public class SingletonDemo {
    public static void main(String[] args) {
        for (int i = 1; i <= 5; i++) {
            Singleton instance = Singleton.getInstance();
            System.out.println(instance);
        }
    }
}

2、静态代码块方式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class Singleton {
    private Singleton() {
        System.out.println("通过构造方法创建对象");
    }
 
    private static Singleton instance;
 
    static {
        instance = new Singleton();
    }
 
    public static Singleton getInstance() {
        return instance;
    }
}

静态成员变量和静态代码块方式实现单例模式本质上是一样的,通过上面的测试结果可以看出,构造方法被调用了一次,并且每次获取到的对象都是同一个,符合单例模式的要求

但是使用懒汉式单例模式有一个问题,比如有些对象我们暂时不需要使用,但是这些对象随着类的加载已经创建出来了,这势必会造成 内存浪费

 

3.2、懒汉式

饿汉式会造成内存空间的浪费,为了解决这个问题,我们还可以使用懒汉式的方式去实现单例模式,单例对象不随类的加载而创建,只有真正需要使用的时候才会去创建

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class Singleton {
    private Singleton() {
        System.out.println("通过构造方法创建对象");
    }
 
    private static Singleton instance;
 
    public static Singleton getInstance() {
        // 对象如果已经创建了则直接返回该单例对象
        if(Objects.isNull(instance)){      // 代码 A
            instance = new Singleton();    // 代码 B
        }
        return instance;
    }
}

这种方式在单线程下是可行的,但是在并发条件下会有线程安全问题

线程 1 执行了代码 A,判断 instance 为空,在准备执行代码 B 的时候,CPU 的执行权被线程 2 抢占,此时 instance 仍为空

线程 2 获取到 CPU 执行权,执行代码 A,发现 instance 为空,然后执行代码 B,调用 new Singleton() 创建了一次对象,然后 CPU 的执行权又被线程 1 抢占了

线程 1 重新获取到 CPU 的执行权,执行代码 B,又调用构造方法,创建了 Singleton 的对象

可以看出,构造方法被调用两次,违背了单例模式的定义(每次创建出来的对象都是同一个)

1
2
3
4
5
6
7
8
9
10
11
12
13
// 测试代码
public class SingletonDemo {
    public static void main(String[] args) {
        Runnable runnable = () -> {
            Singleton instance = Singleton.getInstance();
            System.out.println("创建的对象内存地址值为: " + instance + "当前执行的线程是: " + Thread.currentThread().getName());
        };
        for (int i = 0; i < 5; i++) {
            Thread thread = new Thread(runnable);
            thread.start();
        }
    }
}

从结果可以看出,5 个线程调用了 5 次构造方法,创建出了 5 个不同 Singleton 对象,违背了单例模式原则,那么怎么解决并发条件下的线程安全问题呢,

可以使用 Synchronized 关键字

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class Singleton {
    private Singleton() {
        System.out.println("通过构造方法创建对象");
    }
 
    private static Singleton instance;
 
    public synchronized static Singleton getInstance() {
        // 对象如果已经创建了则直接返回该单例对象
        if(Objects.isNull(instance)){
            instance = new Singleton();
        }
        return instance;
    }
}

 

从上面的测试结果能看出,构造方法被调用一次,创建的对象都是同一个,符合单例模式,但是通过在方法上使用 synchronized 的方式会存在性能问题,多个线程调用该方法创建 Singleton 对象的时候都在同步等待,多线程就失去了意义

我们可以尝试缩小锁的粒度

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class Singleton {
    private Singleton() {
        System.out.println("通过构造方法创建对象");
    }
 
    private static Singleton instance;
 
    public static Singleton getInstance() {
        synchronized (Singleton.class) {       // 代码 A
            if (Objects.isNull(instance)) {    // 代码 B
                instance = new Singleton();    // 代码 C
            }
        }
        return instance;
    }
}

这样就不需要同步整个方法了,只有在创建对象的时候才去同步,效率提高了,但是这样仍然会存在问题

线程 1 执行到代码 B 的时候,CPU 执行权发生切换

线程 2 获取到了 CPU 执行权,执行代码 A 的时候只能同步等待,CPU 执行权发生切换

线程 3 获取到了 CPU 执行权,执行代码 A 的时候也只能同步等待

同步等待和锁竞争的问题仍然没有解决,那么还有没有更好的办法呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class Singleton {
    private Singleton() {
        System.out.println("通过构造方法创建对象");
    }
 
    private static Singleton instance;
 
    public static Singleton getInstance() {
        // 假设有 10 个线程同时调用 getInstance() 方法创建单例对象,此处判断如果已经存在 instance 对象,那么直接返回
        // 没有则继续创建,降低了多线程环境下的锁竞争问题
        if (Objects.isNull(instance)) {
            // 并发条件下,经过 Objects.isNull(instance) 过滤后,只会有少量的线程能执行到这里,假设最终只剩下 线程 1、线程 2、线程 3,这样就降低了需要同步等待的线程数量
            synchronized (Singleton.class) {
                if (Objects.isNull(instance)) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

10 个线程,最终只有线程 1、2、3 需要同步等待,降低了锁竞争次数,是不是这样就完美的解决了所有问题呢

其实并不是,一个对象的创建包含 3 个部分

1、在堆中为 Singleton 对象开辟内存空间

2、调用构造方法初始化对象,为成员变量赋值

3、将内存地址值指向堆中分配的内存空间

正常情况下依次执行步骤 1、2、3 之后,对象才算是真正的创建完成

但是步骤 2 与步骤 3 没有数据依赖关系,执行顺序是不确定的,如果先执行了步骤 3,然后再执行步骤 2,此时获取到的 Singleton 对象不为空,但是属性为空,对于属性,基本类型会有默认值,引用类型是 null,在具体业务使用对象中属性的时候就有可能抛出空指针异常

为了解决这个问题,需要禁止指令重排,这个时候就需要使用 volatile 关键字

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class Singleton {
    private Singleton() {
        System.out.println("通过构造方法创建对象");
    }
    // volatile 禁止指令重排
    private static volatile Singleton instance;
 
    public static Singleton getInstance() {
        if (Objects.isNull(instance)) {
            synchronized (Singleton.class) {
                if (Objects.isNull(instance)) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

 

posted @   变体精灵  阅读(46)  评论(0编辑  收藏  举报
编辑推荐:
· AI与.NET技术实操系列(二):开始使用ML.NET
· 记一次.NET内存居高不下排查解决与启示
· 探究高空视频全景AR技术的实现原理
· 理解Rust引用及其生命周期标识(上)
· 浏览器原生「磁吸」效果!Anchor Positioning 锚点定位神器解析
阅读排行:
· DeepSeek 开源周回顾「GitHub 热点速览」
· 物流快递公司核心技术能力-地址解析分单基础技术分享
· .NET 10首个预览版发布:重大改进与新特性概览!
· AI与.NET技术实操系列(二):开始使用ML.NET
· 单线程的Redis速度为什么快?
点击右上角即可分享
微信分享提示