0

【多线程】 实现单例模式 ( 饿汉、懒汉 ) 实现线程安全的单例模式 (双重效验锁)

 1 year ago
source link: https://blog.51cto.com/panyujie/5434370
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.



什么是单例模式

要求我们代码中的某个类,只能有一个实例,不能有多个实例。
实例就是对象。
就是说某个类只能new 一个对象,不能new多个对象。

这种单例模式,在实际开发中是非常常见的,也是非常有用的。
开发中的很多“概念”,天然就是单例的。

比如:

​ 使用JDBC操作数据库,此时数据库连接可以通过数据库连接池数据库连来获取

    DataSource ds=new MySqlDataSource();

    Connection c=ds.getConnection() ;//获取数据库连接

连接池就可以使用单例模式,创建唯一的一个对象.

大部分跟数据有关的东西,服务器里面只存一份。那么,就都可以使用“单例模式”来进行表示。


单例模式的两种经典实现

单例模式中有两个典型实现:

  1. 饿汉模式
  2. 懒汉模式

我们来通过一个生活上的例子来给大家讲讲什么是饿汉模式,什么是懒汉模式。

  1. 第一种情况:
    假设我们中午吃饭的时候,一家人用了4个碗。然后吃完之后,马上就把碗给洗了
    这种情况,就是饿汉模式

  2. 第二种情况:
    中午吃饭的时候,一家人用了4个碗。然后吃完之后,碗先放着,不着急洗
    等待晚上吃饭的时候,发现只需要2个碗。
    那么就将 4个没洗的碗 中,洗出2个碗,拿来用。吃完之后,碗先放着,不着急洗。
    如果下一顿只用一个玩,就洗出1个碗
    就是用多少,拿多少。少的不够,多的不要
    这就是懒汉模式

但是在计算机中,普遍认为 懒汉模式 比 饿汉模式好。
主要因为 懒汉模式 的效率更高

也很好理解:洗 2 个 碗,肯定比洗4个碗轻松。

所以用几个洗几个。
根据需要,进行操作。

“懒” 这个字一般 在计算机中,是一个褒义词。


1. 饿汉模式 (线程安全)

  1. 饿汉模式的类在类被加载的过程中就会立刻实例化一个对象 所以后续无论如何操作 只要严格使用get方法 就不会出现其他的实例
  2. 由上可知 饿汉模式是线程安全
  3. 他也会有一个问题就是即使我不使用这个类 他还会创建一个实例来占用我们的内存 这就导致他的效率就不高
  4. 这个版本的单个实例不能有其他实例变量, 不然还是会出现非线性安全问题 (非线程安全问题就是有个线程对用一个实例变量修改造成数据的脏读)
//饿汉模式
class Singleton{
    // 1、使用 static 创建一个实例,并且立即进行实例化,
    private  static  Singleton instance = new Singleton();
    // 2、为了防止程序员在其他地方不小心new这个 Singleton,需要把这个类的构造方法设置为 private
    private Singleton(){};
    //3、提供一个方法,让外面能够拿到唯一的实例。
    public static Singleton getInstance(){
        return instance;
    }

}

分析:

(1) 为什么是线程安全的?

由于在类加载的时候就初始化了(第2行),只有一份,所以是线程安全的.

(2) 缺点

还没有使用,就浪费了内存空间.

new对象(没有执行类加载,会先执行,再执行成员变量+实例代码块+构造方法),可能抛异常,也就是还没有调用getInstance,在类加载时就抛出了异常,那么以后调用getInstance方法时,都是会出现问题的.


2. 懒汉模式 (线程不安全)

  1. 懒汉模式就改进了饿汉模式的缺点 他只有在使用的时候才会让该类去实例化一个对象 并且此后再去获取对象只能获取这一个对象
  2. 所以我们一般认为懒汉模式比饿汉模式效率更高
  3. 但是懒汉模式也有缺点他线程不安全 这点在可以优化解决

由于比较懒,你让我干活,我才开始,否则我不会主动干的~

​ 说人话就是: 类加载时,不急着初始化对象,第一次调用,初始化对象,后边再次调用,直接返回第一次创建好的对象.

//单例模式 - 懒汉模式
class Singleton2{
    //1、现在就不是立即初始化实例
    private static Singleton2 instance;// 默认值:Null
    //2、把构造方法设为 private
    private Singleton2(){};
    //3、提供一个公开的方法,来获取这个 单例模式的唯一实例
    public static Singleton2 getInstance(){
        // 只有当我们真正用到这个实例的时候,才会真正去创建这个实例
        if(instance == null){
            instance = new Singleton2();
        }
        return instance;
    }
}

public class Test20 {
    public static void main(String[] args) {
        Singleton2 instance = Singleton2.getInstance();
    }
}

分析:

为什么是线程不安全的?

getInstance()方法中,总共有三行Java代码。

而且其中instance = new Singleton() 会被分解成三行字节码指令,相当于并发并行的对多行共享变量的操作,所以是线程不安全的.

【多线程】 实现单例模式 ( 饿汉、懒汉 ) 实现线程安全的单例模式 (双重效验锁)_双重效验锁_02

区别:

饿汉模式 和 懒汉模式 的唯一区别:

在于 创建实例的时机不一样。

饿汉模式 是 类加载时,创建。
懒汉模式 是 首次使用时,创建。

所以懒汉模式就更懒一些,不用的时候,不创建;等到用用的时候,再去创建。
这样做的目的,就是节省资源


其实在计算机很多其它场景中,也会涉及这情况。

一个典型的案例:

notepad 这样的程序(记事本软件),在打开大文件的时候是很慢的。
假如,你要打开一个 1G 大小的文件,此时 notepad 就会尝试把这 1 G 的 所有内容都读到内存中
将 1G 的数据量 存入 内存,显然是非常慢的。
不管你要不要,全部都给你。
这就是 饿汉模式

问题也随之而来:这些数据,我们真的能全部用得到吗?显示是不太可能的。
因此就会浪费很多资源。

像一些其他的程序,在打开大文件的时候就有优化。
假设也是打开 1G的文件,但是只先加载这一个屏幕中能显示出来的部分。
看到哪,加载到哪里。这样不会用空间上的浪费
这就是 懒汉模式


实现线程安全的单例模式

1. 懒汉模式+synchronized静态同步方法 (线程安全但效率差)

说到让一个代码线程安全,我们自然而然的就想到加锁!
但是问题就在于:在哪个地方加锁合适呢?
其实也很好观察,将 if 语句的执行操作 给 加锁,使其两个操作为原子性。
直白来说: 就是 if 语句 打包成“一个整体”,就跟前面分析 count++ 一样。
一致性执行完。

【多线程】 实现单例模式 ( 饿汉、懒汉 ) 实现线程安全的单例模式 (双重效验锁)_线程安全_03

加锁范围 一定要包含 if 语句!!!
要不然没有效果,就像下面这样!

【多线程】 实现单例模式 ( 饿汉、懒汉 ) 实现线程安全的单例模式 (双重效验锁)_懒汉模式_04

本来我们是想将 读 和 写 操作,打包成一个整体,
但是现在只是 针对写操作进行加锁,这时候就跟没加锁 一样,是没有区别的。

请大家注意!并不是代码中有 synchronized,一定就是线程安全的。
这需要看 synchronized 加的位置,也要正确。
所以 synchronized 写的位置。不能随便。

回过头来,我们再来看一下 synchronized 锁的对象写我们应该些什么。

//单例模式 - 懒汉模式
class Singleton2{
    //1、就不是立即初始化实例
    private static volatile Singleton2  instance;// 默认值:Null
    //2、把构造方法设为 private
    private Singleton2(){};
    //3、提供一个公开的方法,来获取这个 单例模式的唯一实例
    public static Singleton2 getInstance(){
            // 只有当我们真正用到这个实例的时候,才会真正去创建这个实例
            synchronized(Singleton2.class){
                if(instance == null){
                    instance = new Singleton2();
                }
            }
        return instance;
    }
}
【多线程】 实现单例模式 ( 饿汉、懒汉 ) 实现线程安全的单例模式 (双重效验锁)_懒汉模式_05

虽然我们确实通过上述加锁操作,解决了 if 语句 的原子性问题。

但是!这样的程序,还存在这几个问题!

1.代码执行效率问题

【多线程】 实现单例模式 ( 饿汉、懒汉 ) 实现线程安全的单例模式 (双重效验锁)_线程安全_06

2、指令重排序
虽然其他线程再调用 单例线程的时候,也是加了 synchronized 的。
减缓了循环速度,从而保证了 内存可见性。
但是!还有一个问题,来看下面。

【多线程】 实现单例模式 ( 饿汉、懒汉 ) 实现线程安全的单例模式 (双重效验锁)_单例模式_07

此时,我们才完成一个线程安全的单例模式 - 懒汉模式
1、正确的位置加锁
2、双重if判定
3、volatile关键字


2. 懒汉模式+二次判断(双重校验锁,线程安全且效率高)

  • 双重校验: 两个if.
  • 锁: synchronized
  • 注意使用静态变量(引用) 使用volatile.
class Singleton {
    private static volatile Singleton instance = null;
    private Singleton() {}
    public static Singleton getInstance() { 
        if (instance == null) {  //第5行
            synchronized (Singleton.class) { //第6行
                if (instance == null) {  //第7行
                    instance = new Singleton(); //第8行
                } 
        }
        return instance; //第9行
    }
}

分析:

  • ①对象初始化前,多线程调用getInstance(),需要保证线程安全,即执行第5,6,7,8行.
  • ②对象初始化以后,只执行 if 判断和 return 语句,即只执行第5,9行.

​ ② 明显比 ① 执行的情况多很多,所以考虑不加锁,提高效率.

​ ② 不需要加锁,可以使用volatile保证可见性.

即:

​ 第5行: 初始化完成之后,不需要加锁,使用volatile修饰变量,保证可见性,能满足线程安全,代码行本身就是原子性. 可以并行并发的执行,提高了效率.

​ 第6行: 没有初始化完成时,创建对象需要加锁来保证线程安全.

​ 第7行: 竞争锁失败的线程,还会执行同步代码块,需要再次判断,保证只初始化一次.

​ 第9行: 引用使用了volatile关键字,还有建立内存屏障,禁止指令重排序的功能(new 分解的三条指令: 分配内存空间,初始化对象,赋值给变量)



About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK