设计模式(二)——单例模式详解

本文详细探讨了Java中的单例模式,包括其定义、多种实现方式如饿汉式、懒汉式、加锁形式、双重检查+锁模式以及如何通过volatile解决线程安全问题。此外,还分析了单例模式可能被破坏的情况,如序列化和反射,并提出了使用枚举创建单例以确保其安全性。
摘要由CSDN通过智能技术生成

相信大家都听过单例模式,在学习设计模式的时候,第一个接触的设计模式大多也是单例模式。在面试中也频繁的出现——写出两种单例模式,单例模式的各种实现方式等等。单例模式看似简单,但仔细思考,深入研究后会发现有很多值得我们学习和注意的地方。接下来,就深入的研究一下单例模式。

什么是单例模式

单例模式是属于创建模式的一种设计模式,在内存中创建对象只会创建一次对象。在我们开发中频繁的创建同一个对象并且作用不变的时候,为了减少频繁的创建对象使得内存飙升带来的问题,我们可以使用单例模式进行解决。单例模式只会创建一个对象,让所有的调用者都共享这一个对象。

单例模式的实现方式

单例模式有两种基本的实现方式:

饿汉式:在类加载的时候就创建好对象,等待程序需调用。
由于在加载的时候就被创建,如果这个对象不被使用,就会造成内存空间的浪费

懒汉式:在实际使用的时候才会创建对象

由于线程安全问题,单例模式还有其他的形式,接下来我们来看看这些具体的实现方式。

饿汉式

饿汉式在类加载时已经创建好对象,在程序调用时直接返回该单例对象即可,即我们在编码时就已经指明了要马上创建这个对象,不需要等到被调用时再去创建。代码实现如下:

public class Singleton{
    // 私有成员变量: 在类被加载的时候直接创建好对象,在内存中只会有一个对象,类卸载时,对象也会消亡
    private static final Singleton singleton = new Singleton();
    // 私有构造方法,防止外部创建对象
    private Singleton(){}
    // 调用者调用的方法
    public static Singleton getInstance() {
        return singleton;
    }
}

懒汉式

懒汉式是在被程序调用的时候判断对象是否已经被创建(obj == null),如果没有被创建则创建该对象并返回,如果已经创建,则直接返回该对象。实现代码如下:


public class Singleton {
    
    private static Singleton singleton;
    
    private Singleton(){}
    
    public static Singleton getInstance() {
    	// 判断对象是否存在 
        if (singleton == null) {
        	// 不存在,创建对象
            singleton = new Singleton();
        }
        // 返回对象
        return singleton;
    }
    

我们看一下下边的图片:

在这里插入图片描述
假如现在有两个线程,thread 1刚刚执行完第10行代码,此时thread 2抢占了时间片,正巧也是刚刚执行完第10行。注意此时两个线程都要执行第12行的代码,也就是创建对象,换言之线程1和线程2各自new了一个对象,这两个对象并不是同一个对象。也就是说这种形式的单例模式是有线程安全问题的。

我们可以使用以下代码进行测试:

public class client {
    public static void main(String[] args) {
        for (int i = 0; i < 10; i++) {
            new Thread(() -> {
                Singleton instance = Singleton.getInstance();
                System.out.println(Thread.currentThread().getName() + ">>>" +instance);
            }, "Thread" + i).start();
        }
    }
}

查看输出结果:
在这里插入图片描述
可以看到创建的对象并不是同一个对象。这个代码并不是每次执行都会有效果,需要多执行几次。

加锁的形式

懒汉式单例模式是有线程安全问题的,遇到线程安全问题,最容易的办法就是加锁,那么代码就变成了下边的方式:

public static synchronized Singleton getInstance() {
        if (singleton == null) {
            singleton = new Singleton();
        }
        return singleton;
    }
    // 或者
    public static Singleton getInstance() {
        synchronized(Singleton.class) {
            if (singleton == null) {
                singleton = new Singleton();
            }
        }
        return singleton;
    }

现在锁已经加好了,没有了线程安全的风险。但是也迎来的新的问题——性能问题。在多线程的情况下,每次获取对象都要先获取锁,程序的性能会大大降低。

我们加锁的目标是为了在创建对象的时候保证线程安全,在单例中只会创建一个对象,那么对象已经创建的时候就不需要加锁了,只有对象需要创建的时候才需要加锁,所以在方法上加锁的方式就被废弃了

双重检查+锁模式

上边的加锁模式已经实现了线程安全,但是还是有一个小问题,多线程环境下,线程之间争抢资源,同步等待,效率低下。那我们可以在进入同步代码块之前就进行检查对象是不是已经创建,如果已经创建就不进入同步代码块。那么代码就如下所示:

 public static Singleton getInstance() {
        if (singleton == null) {  
            synchronized(Singleton.class) { 
                if (singleton == null) { 
                    singleton = new Singleton();
                }
            }
        }
        return singleton;
    }

这段代码已经解决了线程安全和性能的问题,我们来分析一下这段代码:
在这里插入图片描述
假设有A,B,C,D四个线程。A,B,C三个线程都执行完上图第16行代码,此时C线程获得时间片, 执行完毕,C线程结束,此时A,B线程要开始执行了,A获得执行权进入同步代码块,此时的对象由于C线程执行,已经不是null,所以18行的判断是假,A线程直接返回。此时D线程获取执行权,此时的16行已经不是null,也是直接返回,不需要再去和其他线程一样等待就直接返回,提高了效率。

因为需要两次判空,且对类对象加锁,该懒汉式写法也被称为双重检查+锁模式

双重检查+锁结合volatile模式

计算机在执行程序时,为了提高性能,编译器和处理器尝尝会对指令重排,一般分为一下三种:
在这里插入图片描述
什么是指令重排呢:比如下边的代码:


```java
		int a = 1; // 1
        int b = 2; // 2
        a = a + 1; // 3
        b = a * a; // 4

``

  1. 那么程序按照1234的顺序可以正常输出,不影响我们最初的逻辑。
  2. 按照2134,1324顺序和1234也是没有违背最初的逻辑。
  3. 但是如果说4123的顺序可不可以呢?肯定是不行的,此时a,b变量都没声明,没有办法进行使用(依赖性)。

JVM在保证最终结果正确的情况下,可以不按照程序编码的顺序执行语句,尽可能提高程序的性能

对JVM有了解的同学应该知道对象在创建的时候分为三步:

(1)分配内存空间,
(2)初始化实例,
(3)返回内存地址给引用。

在使用构造器创建对象时,编译器可能会进行指令重排序。假设线程 A 在执行创建对象时,(2)和(3)进行了重排序,如果线程 B 在线程 A 执行(3)时拿到了引用地址,并在第一个检查中判断 singleton != null 了,但此时线程 B 拿到的不是一个完整的对象,在使用对象进行操作时就会出现问题。

所以,这里使用 volatile 修饰 singleton 变量,就是为了禁止在实例化对象时进行指令重排序。

使用volatile关键字可以防止指令重排序,所以现在修改单例模式代码:

public class Singleton {
    
    private static volatile Singleton singleton;
    
    private Singleton(){}
    
    public static Singleton getInstance() {
        if (singleton == null) { 
            synchronized(Singleton.class) {
                if (singleton == null) {
                    singleton = new Singleton();
                }
            }
        }
        return singleton;
    }
    
}

对volatile 想要详细了解的请参考: volatile全面解析

单例的破坏

无论是双重检查锁还是饿汉式,终究敌不过反射和序列化,它们俩都可以把单例对象破坏产生多个对象。

序列化破坏

执行下边的代码,返回的两个对象并不是一个对象,单例模式被破坏。

public static void main(String[] args) {
    // 创建输出流
    ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("Singleton.file"));
    // 将单例对象写到文件中
    oos.writeObject(Singleton.getInstance());
    // 从文件中读取单例对象
    File file = new File("Singleton.file");
    ObjectInputStream ois =  new ObjectInputStream(new FileInputStream(file));
    Singleton newInstance = (Singleton) ois.readObject();
    // 判断是否是同一个对象
    System.out.println(newInstance == Singleton.getInstance()); // false

反射破坏

执行下边的代码,返回的两个对象并不是一个对象,单例模式被破坏。


public static void main(String[] args) {
    // 获取类的显式构造器
    Constructor<Singleton> construct = Singleton.class.getDeclaredConstructor();
    // 可访问私有构造器
    construct.setAccessible(true); 
    // 利用反射构造新对象
    Singleton obj1 = construct.newInstance(); 
    // 通过正常方式获取单例对象
    Singleton obj2 = Singleton.getInstance(); 
    System.out.println(obj1 == obj2); // false
}

解决方法

枚举创建单例

枚举方式可以说是最简单的单例模式了,同时可以解决线程安全的问题:
写法如下:

public enum Singleton {  
    INSTANCE;  
}  

(1)Enum 类内部使用Enum 类型判定防止通过反射创建多个对象

(2)Enum 类通过写出(读入)对象类型和枚举名字将对象序列化(反序列化),通过 valueOf() 方法匹配枚举名找到内存中的唯一的对象实例,防止通过反序列化构造多个对象

(3)枚举类不需要关注线程安全、破坏单例和性能问题,其创建对象的时机与饿汉式单例类似。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

壹升茉莉清

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值