【单例模式】保证线程安全实现单例模式

前言:本文是对经典设计模式之一——单例模式的介绍并讨论单例模式的具体实现方法。


文章目录

  • 一. 什么是单例模式
  • 二. 实现单例模式
    • 1. 饿汉式
    • 2. 懒汉式
      • 2.1 懒汉式实现单例模式的优化(一)
      • 2.2 懒汉式实现单例模式的优化(二)
    • 3. 饿汉式和懒汉式的对比

一. 什么是单例模式

以下单例模式的概念:

单例模式是一种创建型设计模式,它确保一个类只有一个实例,并提供了一个全局访问点来访问该实例。

“说人话”版本:单例模式是指某个类在程序运行过程中当且仅当会被实例出一个对象的设计模式。

为什么要使用单例模式?
在一个程序中,若多个地方都需要用到一个类的某些方法且这些方法实现的功能完全一样时,如果实例化出多个对象,会造成内存空间的浪费,占用系统资源。
例如:当我们在Java程序中需要进行数据库操作时,首先需要获得一个数据源(DataSource)来确定数据库的唯一网络资源位置,要进行数据库操作只需通过同一个数据源建立连接,在这个场景下 数据源对象 只需要一个,从而避免了系统资源的浪费。
【单例模式】保证线程安全实现单例模式_第1张图片


二. 实现单例模式

实现单例模式有以下两个关键点:

  1. 单例模式下类只能有一个实例化的对象,因此该类不能通过构造方法任意实例化,其构造方法应该私有化
  2. 想获得该类的实例对象,可以通过类的静态方法来获取。

单例模式按实现的方式可以分为以下两种:

  • 饿汉式:在类加载时就创建出对象
  • 懒汉式:在获取对象实例时才创建对象(使用时)

1. 饿汉式

饿汉即形容一个人在肚子饥饿时便一次性把自己吃撑,后续便不再进食。饿汉式实现单例模式即使一个类在程序的类加载的阶段便创建出对象,后续程序中想使用该对象就可以直接获取。(这里可以简单理解为程序启动后类就会被实例化)

饿汉式实现单例模式可将代码实现分为以下几步:
1.定义一个由私有的、不可修改的、静态的类属性并进行实例化。
2.将构造方法私有化
3.定义一个方法,使类属性可以被获取。

具体的代码实现如下:

class SingleTon1 {
    //饿汉模式,即在类加载时就实例化出对象
    private final static SingleTon1 instance = new SingleTon1();
    
    // 使构造方法私有化,保证类的实例只能被创建一个
    private SingleTon1() {}
	
	// 通过静态方法获取类对象
    public static SingleTon1 getInstance() {
        return instance;
    }
}

2. 懒汉式

懒汉即形容一个人在饥饿时才选择进食且不一次性吃饱,等待后续饥饿便再次进食。懒汉式实现单例模式即在第一次调用方法获取类的实例对象时才进行创建,后续程序中想使用该对象就可以直接获取。

懒汉式实现单例模式可将代码实现分为以下几步:
1.声明一个私有的、静态的类属性。
2.将构造方法私有化
3.定义一个方法,使类属性可以被获取;当该方法被调用时,判断类属性的值并决定是否进行类的实例化。

具体的代码实现如下:

class SingleTon2 {
 
    private static SingleTon2 instance;
	
	// 使构造方法私有化,保证类的实例只能被创建一个
    private SingleTon2() {}

	// 判断是否存在实例对象,没有则创建对象并放回
    public static SingleTon2 getInstance() {
        if(instance == null) {
        	instance = new SingleTon2();
        }
        return instance;
    }
}

在饿汉式创建单例对象的基础上,我们只做出了微小的改动便实现了懒汉式单例模式。那么上面的代码是否就是正确的呢?
答案是:不完全正确。因为上述代码在单线程环境中运行没有问题,但在多线程的环境下就可能出现“错误”,导致理想中的单例模式被打破

下面模拟在多线程环境下使用上述懒汉模式代码获取实例对象,程序中用一个静态成员变量 count 来记录类被实例化的次数

class SingleTon3 {

    public static int count;

    private static SingleTon3 instance;

    // 使构造方法私有化,保证类的实例只能被创建一个
    private SingleTon3() {}

    // 判断是否存在实例对象,没有则创建对象并返回
    public static SingleTon3 getInstance() {
        if(instance == null) {
            instance = new SingleTon3();
            count++;
        }
        return instance;
    }
}

public class Demo25 {

    public static void main(String[] args) throws InterruptedException {
        Thread[] threads = new Thread[10];
        for (int i = 0; i < 10; i++) {
            threads[i] = new Thread(() -> {
                SingleTon3 instance = SingleTon3.getInstance();
            });
            threads[i].start();
        }
        
        // 等待所有线程执行完毕
        for (int i = 0; i < 10; i++) {
            threads[i].join();
        }
        // 获取类的实例化次数
        System.out.println(SingleTon3.count);
    }

}

代码的可能结果如下:(因为多线程的抢占式执行,每次的执行结果可能并不相同)
【单例模式】保证线程安全实现单例模式_第2张图片

2.1 懒汉式实现单例模式的优化(一)

为什么会出现上述现象呢?饿汉式实现单例模式是否也会出现这种现象?
最根本的原因是:在多线程环境下对一个共享的数据进行了修改操作。当 instance 还未被实例化时,因为线程的抢占式执行,导致出现了多个线程同时执行到了 if 条件的判断,这些线程都认为 instance 未被实例化,因此各自初始化了一个类对象,造成了单例模式被打破。(执行情况如下图)
通过以上分析,我们很容易知道通过饿汉式的实现方式并不会出现“单例模式被破坏”的现象,因为他的类属性在类加载时便已初始化完毕,且获取该属性时并不涉及修改操作,因此饿汉式保证了在单线程或多线程下的绝对安全。
【单例模式】保证线程安全实现单例模式_第3张图片

如何防止这种情况的发生呢?
在多线程的场景中,毫无疑问使用 synchronized 对修改操作进行加锁是其中的一个解决办法。

如何进行有效加锁?
由上图可以知道,导致出现类被多次实例的原因在于 if 语句的判断出现错误,因此想要进行有效加锁,需要每个未获取锁的线程在进行 if 语句的判断前进入阻塞状态,等待第一个获取锁的线程示例出一个类对象时,其他的线程才可进行 类属性是否为空的判断。(代码如下)

class SingleTon2 {

    private static SingleTon2 instance;
    
    private SingleTon2() {}
    
    public static SingleTon2 getInstance() {
    	// 对 if 条件判断语句进行加锁操作
         synchronized (SingleTon2.class) {
             if(instance == null) {
                 instance = new SingleTon2();
             }
         }
        return instance;
    }
}

上述代码实际上已经能够保证多线程下的安全问题,可初始化了类对象后,后续对 if条件的判断 其实已经失去了加锁的必要性,因为类属性已被实例化,多余的加锁操作会增加系统的开销,增加程序的运行时间。
因此,我们需要对是否进行加锁再进行一次判断。(修改代码如下)

private static volatile SingleTon2 instance;
	private static SingleTon2 instance;
	
    private SingleTon2() {}
    
    public static SingleTon2 getInstance() {
        // 第一次对象实例化后,后续并不涉及 修改操作,无需重复加锁判断
        if(instance == null) {
            // 在多线程 并发执行下,防止 创建多个实例
            synchronized (SingleTon2.class) {
                if(instance == null) {
                    instance = new SingleTon2();
                }
            }
        }
        return instance;
    }

2.2 懒汉式实现单例模式的优化(二)

上述已经完美解决了类属性被多次实例化的线程安全问题,但其实还存在另一个潜在的安全问题:因 new() 操作触发的指令重排序造成的多线程安全问题。
什么是指令重排序?
JVM 在保证最终代码执行逻辑不变的情况下,对某一段指令的执行顺序做出了调整,从而提高了程序的执行效率。

new()操作实际会被拆分为以下3步:
1.申请一块内存空间
2.在内存空间上利用构造方法构造对象
3.把对象在内存中的地址赋值给 instance 引用

当第一个线程调用静态方法获取类属性时,因 new()操作触发了指令重排序,先执行了第1、3步操作,此时 instance引用不为空,但还未对对象的属性和方法进行初始化。若此时后续的线程经过 if 判断后得到了 instance 引用,并使用了这个还没初始化的非法对象的属性或方法时,就可能出现不可预期的错误。

因此,instance 属性需要用 volatile 关键字来禁止指令重排序。(代码如下)

class SingleTon2 {
    // 禁止指令重排序, 防止未实例完成的对象里的属性 被非法使用
    private static volatile SingleTon2 instance;
    
    private SingleTon2() {}
    
    public static SingleTon2 getInstance() {
        // 第一次对象实例化后,后续并不涉及 修改操作,无需重复加锁判断
        if(instance == null) {
            // 在多线程 并发执行下,防止 创建多个实例
            synchronized (SingleTon2.class) {
                if(instance == null) {
                    instance = new SingleTon2();
                }
            }
        }
        return instance;
    }
}

3. 饿汉式和懒汉式的对比

  1. 饿汉式在程序启动后的类加载阶段就创建出类对象,能够直接使用实例对象;懒汉式在使用时才加载。
  2. 饿汉式不存在多线程安全问题;懒汉式可能存在多线程安全问题,需要对代码实现进行优化。
  3. 对内存要求不高的场景中可以直接使用饿汉式写法;对内存要求高的场景下,可以使用懒汉式写法,在需要使用时才创建对象。

以上就是本篇文章的全部内容了,如果这篇文章对你有些许帮助,你的点赞、收藏和评论就是对我最大的支持。
另外,文章可能存在许多不足之处,也希望你可以给我一点小小的建议,我会努力检查并改进。

你可能感兴趣的:(多线程专题,单例模式,笔记,java)