多线程代码案例-1 单例模式

单例模式

单例模式是开发中常见的设计模式。

设计模式 ,是我们在编写代码时候的一种软性的规定 ,也就是说,我们遵守了设计模式,代码的下限就有了一定的保证。设计模式有很多种,在不同的语言中,也有不同的设计模式,设计模式也可以被认为是对编程语言语法的补充

单例即单个实例(对象),某个类在一个进程中,只应该创建出一个实例(原则上不应该创建出多个实例),使用单例模式,可以对我们的代码进行一个更为严格的校验和检查。

举个例子:有时候,代码中需要管理/持有大量的数据,此时有一个对象就可以了。比如:我需要一个对象管理10G的数据,如果我们不小心创建出多个对象,内存空间就会成倍地增长。

如何保证只有唯一的对象呢?我们可以选择"君子之约地方式",写一个文档,文档上约定,每个接手维护代码的程序员,都不能对这个类创建多个实例(很显然,这种约定并不靠谱)我们期望让机器(编译器)能够对代码中的指定类,对创建的实例个数进行检验。如果发现创建出了多个实例,就直接编译报错,但是Java语法中本身没有办法直接约定某个对象能创建出几个实例,那么就需要程序员使用一些技巧来实现这样的效果。

实现单例模式的方式有很多种,这里介绍两种实现方式:饿汉模式和懒汉模式。

1 饿汉模式

代码如下:

java 复制代码
//饿汉模式
//期望这个类只能有唯一的实例(一个进程中)
class Singleton{
    private static Singleton instance = new Singleton();//在这个类被加载时,就会初始化这个静态成员,实例创建的时机非常早------饿汉
    public static Singleton getInstance(){//其他代码想要使用这个类的实例就需要通过这个方法进行获取,
        // 不应该在其他代码中重新new这个对象而是使用这个方法获取这个现有的对象
        return instance;
    }
    private Singleton(){
        //其他代码就没法new了
    }
}

在这个类中,我们创建出了唯一的对象,被static修饰,说明这个变量是类变量,(由类对象所拥有(每个类的类对象只存在一个),在类加载的时候,它就已经被初始化了)

而将构造方法设为私有,就使得只能在当前类里面创建对象了,其他位置就不能再创建对象了,因此这个instance指向的对象就是唯一的对象。

其他代码要想使用这个类的实例,就需要通过这个getInstance()方法获取这个对象,而无法在其他代码中new一个对象。

上述代码,称为"饿汉模式",是单例模式中的一种简单的写法,"饿" 形容"非常迫切",实例在类加载的时候就创建了,创建的时机非常早,相当于程序一启动,实例就创建了。

但是,上面的代码,面对反射,是无能为力的,也就是说,仍然可以通过反射来创建对象,但反射是属于非常规的编程手段,代码中随意使用反射是非常糟糕的。

2 懒汉模式

"懒"这个词,并不是贬义词,而是褒义词。社会能进步,科技能发展,生产效率提高,有很大部分原因都是因为懒。

举个生活中的例子(不考虑卫生):

假如我每次吃完饭就洗碗,那我每次就需要洗全部的碗;但是如果我每次吃完饭把碗放着,等到下次吃饭的时候再洗,此时,如果我只要用到两个碗,那我就只需要洗两个碗就行了,很明显洗两个碗要比洗全部碗更加高效。
在计算机中,"懒"的思想就非常有意思,它通常代表着更加高效

比如有一个非常大的文件(10GB) ,使用编辑器打开这个文件,如果是按照"饿汉"的方式 ,编辑器就会先把这10GB的数据都加载到内存中 ,然后再进行统一的展示。(但是加载了这么多数据,用户还是需要一点一点地看,没法一下子看完这么多)

如果是按照"懒汉"地方式,编辑器就会只读取一小部分数据(比如只读取10KB),把这10KB先展示出来,然后随着用户进行翻页之类的操作,再继续展示后面的数据。

加载10GB的时间会很长,但是加载10KB却只是一瞬间的事情......

懒汉模式,区别于饿汉模式,创建实例的时机不一样了,创建实例的时机会更晚,一直到第一次使用getInstance方法时才会创建实例。

代码如下(注意:这是一个不完整的代码,因为还有一些线程安全问题需要解决~~):

java 复制代码
//懒汉的方式实现单例模式

class SingletonLazy{
    private static SingletonLazy instance = null;
    public static  SingletonLazy getInstance(){//饿汉模式是在类加载的时候就创建实例了,懒汉则会晚很多,且如果程序用不到这个方法就会省下了
                if (instance == null) {//如果首次调用就创建实例
                    instance = new SingletonLazy();
                }
            }
        }
        //不是则返回之前创建的引用
        return instance;
    }
    private SingletonLazy(){

    }
}

**第一行代码中仍然是先创建一个引用,但是这个引用不指向任何的对象。**如果是首次调用getInstance方法,就会进入if条件,创建出对象并且让当前引用指向该对象。如果是后续调用getInstance方法,由于当前的instance已经不是null了,就会返回我们之前创建的引用了。

这样设定,仍然可以保证,该类的实例是唯一一个,与此同时,创建实例的时机就不再是程序驱动了,而是当第一次调用getInstance的时候,才会创建。。

而第一次调用getInstance这个操作的执行时机就不确定了,要看程序的实际需求,大概率会比饿汉这种方式要晚一些,甚至有可能整个程序压根用不到这个方法,也就把创建的操作给省下了。

有的程序,可能是根据一定的条件,来决定是否要进行某个操作,进一步来决定是否要创建实例。

3 单例模式与线程安全

上面我们介绍的关于单例模式只是一个开始,接下来才是我们多线程的真正关键问题。即:上述我们编写的饿汉模式和懒汉模式,是否是线程安全的?

饿汉模式:

java 复制代码
//饿汉模式
//期望这个类只能有唯一的实例(一个进程中)
class Singleton{
    private static Singleton instance = new Singleton();//在这个类被加载时,就会初始化这个静态成员,实例创建的时机非常早------饿汉
    public static Singleton getInstance(){//其他代码想要使用这个类的实例就需要通过这个方法进行获取,
        // 不应该在其他代码中重新new这个对象而是使用这个方法获取这个现有的对象
        return instance;
    }
    private Singleton(){
        //其他代码就没法new了
    }
}

对于饿汉模式来说,getInstance直接返回instance这个实例,这个操作,本质上就是一个 的操作(多个线程同时读取同一变量,是不会产生线程安全问题的 )。因此,在多线程下,它是线程安全的。

懒汉模式 :

java 复制代码
//懒汉的方式实现单例模式

class SingletonLazy{
    private static SingletonLazy instance = null;
    public static  SingletonLazy getInstance(){//饿汉模式是在类加载的时候就创建实例了,懒汉则会晚很多,且如果程序用不到这个方法就会省下了
                if (instance == null) {//如果首次调用就创建实例
                    instance = new SingletonLazy();
                }
            
        
        //不是则返回之前创建的引用
        return instance;
    }
    private SingletonLazy(){

    }
}

再看懒汉模式,在懒汉模式中,代码中有 的操作(return instance),又有的操作(instance = new SingletonLazy())。 很明显,这是一个有线程安全问题的代码!!!

问题1:线程安全问题

因为多线程之间是随机调度,抢占是执行的,如果t1和 t2 按照下列的顺序执行代码,就会出现问题。

如果是t1和t2按照上述情况操作,就会导致实例被new了两次,这就不是单例模式了,就会出现bug了!!!

那如何解决当前的代码bug,使它变为一个线程安全的代码呢?

加锁~~

知道要加锁了?那大家不妨想想:如果我把锁像如下代码这样加下去,是否线程就安全了呢?

java 复制代码
class SingletonLazy{
    private static SingletonLazy instance = null;
    Object locker = new Object;
    public static  SingletonLazy getInstance(){//饿汉模式是在类加载的时候就创建实例了,懒汉则会晚很多,且如果程序用不到这个方法就会省下了
                if (instance == null) {//如果首次调用就创建实例
                   sychronized(locker){
                    instance = new SingletonLazy();
                    }
                  }
        
        //不是则返回之前创建的引用
        return instance;
    }
    private SingletonLazy(){

    }
}

答案很显然:不行!!!因为如上述代码加锁仍然会发生刚才那样的线程不安全的情况。

所以这里如果想要代码正确执行,需要把if和new两个操作,打包成一个原子的操作(即加锁加在if语句的外面)

java 复制代码
class SingletonLazy{
    private static SingletonLazy instance = null;
    Object locker = new Object;
    public static  SingletonLazy getInstance(){//饿汉模式是在类加载的时候就创建实例了,懒汉则会晚很多,且如果程序用不到这个方法就会省下了
            synchronized(locker){    
            if (instance == null) {//如果首次调用就创建实例
                  
                    instance = new SingletonLazy();
                    
                    }
                }  
        
        //不是则返回之前创建的引用
        return instance;
    }
    private SingletonLazy(){

    }
}

此时因为t1拿到了锁,t2进入阻塞,等t1执行完毕后(创建完对象后),t2进行判断,此时因为t1已经创建好了对象,所以t2就只能返回当前对象的引用了。

多线程的代码是非常复杂的,代码稍微变化一点,结论就可能截然不同。千万不能认为,代码中加了锁就一定线程安全,不加锁就一定线程不安全,具体问题要具体分析,要分析这个代码在各种调度执行顺序下不同的情况,确保每种情况都不会出现bug!!!

问题2:效率问题

上述代码还存在的另一个问题是效率问题:试想一下,当你创建完这个单例对象,你每次获取这个单例对象时(是读的操作,并不会有线程问题),每次都要去加锁、解锁,然后才能返回这个对象。(注意:加锁、解锁耗费的空间和时间都是很大的)。

所以为了优化上面的代码,我们可以再加上一层if,如果instance为null(需要执行写操作),考虑到线程安全问题,就需要加锁;如果instance不为null了,就不需要加锁了。

java 复制代码
class SingletonLazy{
    private static SingletonLazy instance = null;
    Object locker = new Object;
    public static  SingletonLazy getInstance(){//饿汉模式是在类加载的时候就创建实例了,懒汉则会晚很多,且如果程序用不到这个方法就会省下了
        if(instance == null){
            synchronized(locker){    
            if (instance == null) {//如果首次调用就创建实例
                  
                    instance = new SingletonLazy();
                    
                    }
                }
            }    
        
        //不是则返回之前创建的引用
        return instance;
    }
    private SingletonLazy(){

    }
}

上面的代码,有两重完全相同if判断条件,但是他们的作用是完全不同的:

第一个if是判断是否需要加锁,第二个if是判断是否要创建对象!!!

**巧合的是,两个if条件相同,但是他们的作用是完全不同的,这样就实现了双重校验锁。**在以后的学习中,还可能出现两个if条件是相反的情况。

问题3:指令重排序问题

这个代码还有一点问题需要解决:我们之前在线程安全的原因中讲过的**:指令重排序问题就在懒汉模式上出现了~~**

指令重排序,也是编译器优化的一种方式。编译器会在保证逻辑不变的前提下,为了提高程序的效率,调整原有代码的执行顺序。

再举个生活中的例子:

我妈让我去超市买东西:西红柿、鸡蛋、黄瓜、茄子。

超市摊位分布图如下:

如果我按我妈给的顺序,那就会走出这样的路线:

上述方案虽然也能完成我妈给的任务,但如果我对超市已经足够熟悉了,我就能够在保证逻辑不变

的情况下(买到4种菜),调整原有买菜的执行顺序,提高买菜效率:

返回到代码中:

java 复制代码
   instance = new SingletonLazy();

上面这行代码,可以拆分为三个步骤:

1、申请一段内存空间。

2、调用构造方法,创建出当前实例。

3、把这个内存地址赋给instance这个引用。

上述代码可以按1、2、3这个顺序来执行,但是编译器也可能会优化成1、3、2这个顺序执行。这两种顺序在单线程下都是能够完成任务的。

1就相当于买了个房子

2相当于装修房子

3相当于拿到了房子的钥匙

通过1、2、3得到的房子,拿到的房子已经是装修好的,称为"精装房";通过1、3、2得到的房子,拿到的房子需要自己装修,称为"毛坯房",我们买房子时,上面的两种情况都可能发生。

但是,如果在多线程环境下,指令重排序就会引入新问题了。

上述代码中,由于 t1 线程执行完 1 3 步骤(申请一段内存空间,把内存空间的地址赋给引用变量,但并没有进行 2 调用构造方法的操作,会导致 instance指向的是一个未被初始化的对象)之后调度走,此时 instance 指向的是一个非 null 的,但是是未初始化的对象,此时 t2 线程判定 instance == null 不成立,就会直接 return,如果 t2 继续使用 instance 里面的属性或者方法,就会出现问题,引起代码的逻辑出现问题。

那么我们应该如何解决当前问题呢?

volatile关键字

之前讲过volatile有两个功能:

1、保证内存可见性:每次访问变量都必须要重新读取内存,而不会优化为读寄存器/缓存。

**2、禁止指令重排序:**针对被volatile修饰的变量的读写操作的相关指令,是不能被重排序的。

懒汉模式的完整代码:

java 复制代码
//经典面试题!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
package Thread;
//懒汉的方式实现单例模式
//线程不安全,它在多线程环境下可能会创建多个实例
class SingletonLazy{
    //这个引用指向唯一实例,这个引用先初始化为null,而不是立即创建实例
private volatile static SingletonLazy instance = null;//针对这个变量的读写操作就不能重排序了
private static Object locker;
//第一次if判定是否要加锁,第二次if判定是否要创建对象
    //双重校验锁
    public static  SingletonLazy getInstance(){//饿汉模式是在类加载的时候就创建实例了,懒汉则会晚很多,且如果程序用不到这个方法就会省下了
        //加锁效率不高,且容易导致阻塞,所以再加一个判断提高效率
        if(instance ==null) {//判断是否为空,为空再加锁
            //不为空,说明是后续的调用就无需加锁了
            synchronized (locker) {
                if (instance == null) {//如果首次调用就创建实例
                    instance = new SingletonLazy();
                }
            }
        }
        //不是则返回之前创建的引用
        return instance;
    }
    private SingletonLazy(){

    }
}
相关推荐
南部余额7 分钟前
Python OOP核心技巧:如何正确选择实例方法、类方法和静态方法
开发语言·python
supingemail13 分钟前
面试之 Java 新特性 一览表
java·面试·职场和发展
星星点点洲19 分钟前
【Java】应对高并发的思路
java
LDM>W<22 分钟前
黑马点评-用户登录
java·redis
保利九里28 分钟前
数据类型转换
java·开发语言
Uranus^31 分钟前
使用Spring Boot与Spring Security构建安全的RESTful API
java·spring boot·spring security·jwt·restful api
蚂蚁在飞-32 分钟前
Golang基础知识—cond
开发语言·后端·golang
Aiden Targaryen32 分钟前
Windows/MacOS WebStorm/IDEA 中开发 Uni-App 配置
java·uni-app·webstorm
啾啾Fun42 分钟前
【Java微服务组件】分布式协调P1-数据共享中心简单设计与实现
java·分布式·微服务
Brilliant Nemo44 分钟前
Vue2项目中使用videojs播放mp4视频
开发语言·前端·javascript