💕"只有首先看到事情的可能性,才会有发生的机会。"💕
作者:Mylvzi
文章主要内容:多线程编程设计模式(单例,阻塞队列,定时器,线程池)
本文主要讲解多线程编程中常用到的设计模式,包括单例模式,阻塞队列,定时器和线程池,
包括所有设计模式在java标准库的使用,源码讲解,模拟实现
一.设计模式的概念
简单来说,设计模式就是程序员的棋谱
,在日常的开发中,我们经常会遇到一些经典场景,针对这些经典场景,大佬们就总结出了一套行之有效的代码规范,帮助我们更加合理规范的进行编程(就像象棋开局最经典的当头炮,马来跳一样,这就是一个经典场景)
同时呢,设计模式也是计算机中非常重要的一门学科,日常最常使用的设计模式有两种
- 单例模式
- 工厂模式
下面来分别进行讲解
二.单例模式
1.概念
单例模式指的是单个实例
,即我们希望有的类只有一个对象,在之前的学习过程其实我们也遇到过,比如Mysql中JDBC的DataSource,它用于描述服务器的位置,他就只能有一个对象.
日常生活中其实也能找到类似的场景,比如你只能有一个对象,你要是有多个对象肯定是要出问题的,还要被骂渣男,为了保证单例,大佬们就创建出了属于单例的设计模式,来帮助我们应对这个场景
可能有人会说,不就是一个实例吗,那我就new一次不就行了么?当然是可以的,但是你怎么就能保证你只new一次呢?人非圣贤孰能无过,是人总会犯错的,但是机器不会啊,我们可以让机器帮助我们检查我们写的代码是否满足单例模式,不满足直接就报错了.不要认为自己一定不会犯错~有机器帮你检查不是更香吗
其实,通过机器校验来避免编程错误也是一种很常见的方式,有很多我们经常犯的错误都可以交给编译器进行检查,比如:
- final 我们希望一个变量不被修改,就将其设置为final,一旦我们修改,编译器就报错
- interface 当我们规定一个类实现一个接口,就必须要重写接口内部的抽象方法,否则会报错
- @override 重写方法 如果重写方法时,方法名称,返回值,参数列表错误,编译器就会报错
但是呢,对于单例模式来说,在语法上并没有类似上述的检查机制,只能通过一些编程技巧来让机器给我们检查
2.单例模式的分类
单例模式主要分为两类:
- 饿汉模式
- 懒汉模式
下面进行讲解
1.饿汉模式
首先创建出一个类,类名称为SingleTon
class SingleTon {}
单例模式最重要的是实例只有一个,那么我们就将其设置为类成员
class SingleTon { // instance 就是唯一的实例 private static SingleTon instance = new SingleTon(); }
还要保证类外只能获取到instance,而不能对instance进行修改,所以只提供getInstance方法即可,同时将构造方法也设置为私有的,这样在类外就无法构造新的实例
class SingleTon { // instance 就是唯一的实例 private static SingleTon instance = new SingleTon(); // 只提供获取方法 不提供set方法 public static SingleTon getInstance() { return instance; } // 将构造方法设置为私有的 private SingleTon(){}; public static void main(String[] args) { // 直接通过类来获取唯一的实例 SingleTon s = SingleTon.getInstance(); // 如果尝试再次创建一个新的实例 就会报错 // SingleTon s1 = new SingleTon(); } }
这样就完成了饿汉模式的设计,总结来说,需要注意的点有三个
- 将唯一的实例设置为static
- 只提供获取方法,不提供set设置方法
- 将构造方法设置为私有的,保证类外无法创建新的实例
接下来看懒汉模式的设计
2.懒汉模式
懒这个词在计算机世界中其实是一个褒义词,可以说,正是因为人类的懒,才促进了计算机的快速发展,当然不仅仅是计算机,还有其他很多物品的出现都是因为懒,蒸汽机的出现帮助我们少走很多路,我们只需坐在车里休息即可,计算器的出现让我们不用在拿着纸笔算,Excle的出现帮助我们节省了很多重复操作…等等等等所以,懒其实是一种追求效率
的体现(比如笔者最近的博客就是通过MD进行编写的,感觉效率大大提高,还有Vim)
理解了懒的含义,就很容易理解懒汉模式了,懒汉模式就是比饿汉模式效率更高
的单例模式,他们最主要的区别在于创建唯一实例的时机不同
我们观察下饿汉模式下,唯一实例的创建时机
可见,饿汉模式下,唯一的实例instance是在类加载的时候就创建了,但是说,我们真的需要一开始就使用么?很多情况下并不是这样的,我们并不需要提前准备,而是应该做到随用随创
,我们什么时候想使用,再去创建出来唯一的实例.
就像大学中的很多水课要考试一样,如果一开始就认真听课,努力去背诵,不如等到考试前几天再去背,反正都是背了立马就忘记,帮助我们省去了很多的无意义时间,懒汉模式就是这样,我们并不需要一开始就创建出实例,而是在我们第一次使用的时候再去创建,下面是懒汉模式的代码实现
class SingleTonLazy { // instance是唯一的实例 现将其设置为null private static SingleTonLazy instance = null; // 只提供获取方法 public static SingleTonLazy getInstance() { if(instance == null) { // 为null 代表是第一次使用 需要创建 instance = new SingleTonLazy(); } return instance; } // 将构造方法设置为私有的 private SingleTonLazy(){ }; }
懒汉模式虽然效率提高了,但是,他也带来了一些线程安全问题,什么安全问题呢 ?我们学习过一个最经典的线程安全问题就是多个线程针对同一个变量进行修改就会引发线程安全问题,在懒汉模式中,创建实例的时机就会引发这样的问题,我们是先进行判断再去创建一个实例,创建这个过程就是一个修改的过程,有可能线程1先判断完之后,并没有创建出实例,而是紧接着线程2去执行判断+创建
这样的操作,此时已经通过线程2 创建出了唯一的实例,但是线程1已经判断完了,他只会执行new部分的代码,这样两个线程都创建出了一个实例,这就违背了单例模式
如何解决呢?使用synchronized进行加锁,保证判断+创建
这两步操作是原子的
// 只提供获取方法 public static SingleTonLazy getInstance() { synchronized(SingleTonLazy.class) { if(instance == null) { // 为null 代表是第一次使用 需要创建 instance = new SingleTonLazy(); } } return instance; }
加了锁之后就正确了吗?不是的,此时又产生了一个调度开销
问题.观察我们的代码,虽然我们加了锁,保证两个线程不会发生交叉执行的情况,但是我们之后每次获取实例的时候都需要先进行加锁,判断instance是否为null,但实际上我们只需要在第一次
创建实例的时候进行判断+创建实例,一旦instance被创建好,以后就不需要进行判断了,直接return instance即可.而且,判断之前还需要进行加锁,也就是以后的每次获取都需要加锁,但加锁其实有一定的开销的,每次都加锁会影响效率的~
解决方法也很简单,我们只需在第一次加锁即可,之后的每次直接return
// 只提供获取方法 public static SingleTonLazy getInstance() { // 最外面一层的if 加锁的频率太多了 是为了减少加锁的次数 避免不必要的开销 // 属于一种优化操作 // 内层的if 仅仅是懒汉模式的特性 只有在调用的时候采取创建出对象 if(instance == null) { // 加锁 是保证if 和new 操作的原子性 synchronized (SingleTonLazy.class) { if (instance == null) { // 为null 代表是第一次使用 需要创建 instance = new SingleTonLazy(); } } } return instance; }
注意理解两层if的实际含义与作用,外层的if是为了减少锁的开销,内层的if是懒汉模式的特征
写到这里,懒汉模式的代码其实还没写完(汗流浃背了吧),还存在一些问题,这就涉及到我们之前学习过的指令重排序问题,指令重排序是编译器的一种优化方式,编译器为了提高效率有可能更改指令的执行顺序,对于懒汉模式来说,其中的new操作底层其实分为三步执行:
- 在内存中为对象开辟空间
- 创建出具体的对象
- 将对象在内存中的地址返还给引用instance
其中1的执行顺序是固定的,只能排在第一位,但是2,3的执行顺序是可以改变的!可以是先传地址,再创建(3,2)也可以先创建,在传地址(2,3),如果是(3,2)就可能出现问题!!!
如果发生了指令重排序,就有可能引发上述问题.
这个问题其实在现实生活中也能遇到,比如买房子,买的房子分为两类:精装房和毛坯房,无论是精装的还是毛坯的,都需要现有房子对应的空间,但是是先装修还是先给钥匙是不固定的,先装修再给钥匙就是精装房(先创建具体对象,再返还地址)先给钥匙,再装修就是毛坯房(先给地址,再创建具体的对象),但是最后的结果是一样的,你都买到了房子
如何解决呢?使用volatile修饰变量来解决指令重排序!
private static volatile SingleTonLazy instance = null;
懒汉模式的完整代码:
class SingleTonLazy { // 初始设置为null 这样就不会在类加载 的时候就创建出实例 // 添加volatile是为了禁止new操作的 指令重排序 private static volatile SingleTonLazy instance = null; // 只提供获取方法 public static SingleTonLazy getInstance() { // 最外面一层的if 加锁的频率太多了 是为了减少加锁的次数 避免不必要的开销 // 属于一种优化操作 // 内层的if 仅仅是懒汉模式的特性 只有在调用的时候采取创建出对象 if(instance == null) { // 加锁 是保证if 和new 操作的原子性 synchronized (SingleTonLazy.class) { if (instance == null) { // 为null 代表是第一次使用 需要创建 instance = new SingleTonLazy(); } } } return instance; } // 将构造方法设置为私有 private SingleTonLazy() { }; }
说明:有的同学可能对于指令重排序哪里有些问题,认为为什么线程1执行完new操作的1,3之后会调度到线程2呢?他不是加锁了吗?要知道只有两个线程执行到加锁代码部分的时候才会发生锁竞争,另一个线程要等待,但实际上,我们的线程2并没有执行到加锁部分,在外层的if中判定instance非空,直接跳到return部分,并没有进入到加锁部分,也就不涉及到两个线程之间的锁竞争.
总结一下懒汉模式代码的注意事项
- 懒汉模式和饿汉模式最大的区别在于instance的创建时机,懒汉模式是随用随创,饿汉模式是在类加载的时候就直接创建
- 为了避免两个线程针对同一变量进行修改这样的线程安全问题,我们对
判断+创建
部分的代码进行synchronized加锁 - 为了进一步的减少开销,我们在加锁的外围进一步进行判断,保证只有第一次获取对象的时候加锁,其余时间不加锁
- new操作可能涉及到指令重排序问题,还要进一步的对实例instance使用volatile进行修饰,禁止编译器进行指令重排序
补充:如果继续深究懒汉模式的代码,他其实还有两个问题需要考虑:
- 使用反射能否打破单例?
- 使用序列化/反序列化能否打破单例 ?
这两种情况非常少见,但这里也给出对应的场景,一下内容了解即可
多线程编程设计模式(单例,阻塞队列,定时器,线程池)(二)+https://developer.aliyun.com/article/1413586