多线程编程设计模式(单例,阻塞队列,定时器,线程池)(一)+https://developer.aliyun.com/article/1413584
1.反射
反射这种机制能够拿到类的所有方法,包括你的私有的构造方法,在懒汉模式下,我们将构造方法设置为private就是为了保证类外拿不到类的构造方法,但是通过反射这种机制就有可能拿到私有的构造方法,从而违背单例模式的原则
public static void main(String[] args) throws NoSuchMethodException, InvocationTargetException, InstantiationException, IllegalAccessException { // 通过反射获取到私有的构造方法 Class<?> c1 = SingleTonLazy.class; Constructor<SingleTonLazy> con = (Constructor<SingleTonLazy>) c1.getDeclaredConstructor(); con.setAccessible(true); // 创建出了一个新的实例 SingleTonLazy s1 = con.newInstance(); SingleTonLazy s2 = SingleTonLazy.getInstance(); System.out.println(s1 == s2);// 输出false }
通过反射创建出的新的实例违背了单例模式的原则,解决方式有多种,可以设置计数器来记录创建instance的次数,也可以直接在构造方法中设置相应的条件,也可以使用枚举类型,因为枚举类型无法通过反射获取他的实例
public enum SingletonEnum { INSTANCE; // 单例实例 // 可以在枚举类型中添加其他方法或属性 } public class Demo { public static void main(String[] args) throws NoSuchMethodException, InvocationTargetException, InstantiationException, IllegalAccessException { // 尝试通过反射创建实例 Class<?> c1 = SingletonEnum.class; Constructor<SingletonEnum> con = (Constructor<SingletonEnum>) c1.getDeclaredConstructor(String.class, int.class); con.setAccessible(true); SingletonEnum s1 = SingletonEnum.INSTANCE; SingletonEnum s2 = SingletonEnum.INSTANCE; System.out.println(s1 == s2); // 输出true,说明是同一个实例 // 尝试通过反射创建实例,这里会抛出异常,因为枚举类型无法通过反射实例化 try { SingletonEnum s3 = con.newInstance("someString", 42); } catch (Exception e) { System.out.println("Exception caught: " + e); } } }
2.序列化
3.总结:饿汉模式和懒汉模式的对比
1.饿汉模式只涉及到变量的读操作,是天然线程安全的;懒汉模式既要读取,又要修改所以会涉及到线程安全问题
三.生产者消费者模型
1.阻塞队列
阻塞队列
是一种特殊的队列,它具有以下两种重要的性质
- 线程安全
- 带有阻塞特性
- 当队列为满时,继续往队列中添加数据,阻塞等待,直到队列中有数据被拿出
- 当队列为空时,继续从队列中删除数据,阻塞等待,直到队列中有新的数据添加进来
阻塞队列最常见的使用就是用于实现生产者消费者
模型
2.基本概念
生产者消费者模型是多线程编程设计模式中常用的一种,通过使用阻塞队列将生产者和消费者分离,从而实现更加健壮,更加高效的代码
举一个日常生活中常见的例子"包饺子",我们知道包饺子需要擀面皮和包饺子,现在假设你家里要包饺子,我们有以下两种分配方式:
- 一半人去擀面皮,一半人去包饺子
- 只有一个人擀面皮,其余人都去包饺子
对于方案1来说,其实效率并不是很高,因为你家只有一个擀面杖,当一个人擀面皮的时候,另一个也需要擀面皮的人就需要等待,对于方案2来说,是一个效率更高的方案,挑一个擀面皮最熟练的来擀面皮,其余人都去包饺子,假设我擀面皮我就不断地产出饺子皮(生产者),其余人就不断地利用饺子皮包饺子(消费者),这就构成了一个生产者消费者模型
我生产出来的饺子皮需要有地方放啊,一般都是放在盖帘上,包饺子的人就从这个盖帘上拿饺子皮,这个盖帘就相当于阻塞队列
当我擀饺子皮的速度很快时,就会有大量的饺子皮没有被包成饺子,这时候我就可以休息等待(队列为满发生阻塞)
当我擀饺子皮的速度赶不上包饺子的速度时,包饺子的人就可以等待(队列为空发生阻塞)
那为什么要使用生产者消费模型呢?引入阻塞队列的意义是什么?下面讲解生产者消费者模型的意义
3.生产者消费者模型的意义
1.解耦合
耦合度适用于反应代码之间联系性的一种表征,当代码之间的联系性越高,耦合度也就越高;反之,耦合度就越低;对于我们日常的编码来说,我们应该追求低耦合度的代码,因为低耦合度的代码更加健壮,也更加易于修改,尤其是在分布式系统中,更需要低耦合度来保证服务器更加高效的运行,下面是一个简单的分布式系统
对于这种分布式系统来说,服务器A和服务器B直接交互,此时两个服务器之间的耦合度是很高的,这会带来两个问题:
- 当服务器B出现问题时,会直接影响到A,进而影响到整个机房的运行
- 如果添加一个服务器C和A进行交互,A中需要修改的代码很多
综上,这种交互方式的耦合度高,应对风险的能力低,且不便于进行修改,如果引入阻塞队列实现的生产者消费模型就能降低耦合度
服务器A和服务器B之间是通过阻塞队列进行交互的,当服务器B发生问题时,并不会直接影响到服务器A,降低了服务器之间的耦合性;如果想要添加一个新的服务器C,也不需要对服务器A进行修改,服务器A 可以直接利用阻塞队列和服务器C进行交互
一般将阻塞队列单独封装成一个服务器程序,专门用于其他服务器之间进行交互,也称为消息队列
2.削峰填谷
削峰填谷是地理学中外动力地质作用对于地球表面的影响,他会降低较高的山峰,升高较低的谷地,他是一种平衡
的思想,阻塞队列也能起到类似的作用
当短时间内用户发生了大量请求时(比如整点抢票,往往网站会崩溃),这些请求就会传递给服务器A,而因为服务器A和服务器B是直接进行交互的,也就是服务器A处理多少数据,服务器B就处理多少数据.但实际上,两个服务器执行的是不同的业务,不同的逻辑,以及不同的硬件资源,这就导致他们处理数据的能力也是不尽相同的,尤其是当服务器B是分布式系统中较为脆弱的数据库时,就有可能因为短时间内大量数据的涌入导致服务器B崩溃,进而影响服务器A的正常工作
发生崩溃的原因在于服务器B短时间内处理数据超过了其本身能处理的范围,服务器B一次只能处理一个请求,而你直接传入了四个请求,服务器B肯定会发生崩溃,如果引入阻塞队列就能很好的解决上述问题
服务器A将要处理的请求传入到阻塞队列之中,因为服务器B一次只能处理一个请求,所以他一次就从阻塞队列中拿取一个请求,仍然维持自己的处理速度,虽然对于用户请求的相应变慢了,但总比让整个服务器崩溃好,通过阻塞队列这种类似于缓冲剂的作用,就能很好的应对短时间内大量请求的问题,增加了抗风险能力(而且这种大量请求往往是短时间的,并不会持续)
4.阻塞队列的使用
在java的标准库内部,提供了现成的阻塞队列供我们使用,BlockingQueue
public interface BlockingQueue<E> extends Queue<E>
BlockingQueue实际上是一个接口,需要通过实例化他的对象来使用,实现方式一般有两种:
- 基于数组实现
- 基于链表实现
// 都需要指定容量 没有不含参数的构造方法 BlockingQueue<Integer> queue1 = new ArrayBlockingQueue<>(10); BlockingQueue<Integer> queue2 = new LinkedBlockingQueue<>(10);
一个简单的使用例子
// 创建一个阻塞队列 BlockingQueue<Integer> queue = new ArrayBlockingQueue<>(10); // 生产者线程 Thread producer = new Thread(() ->{ try { for (int i = 0; i < 5; i++) { // 向阻塞队列中添加数据 queue.put(i); System.out.println("produce:" + i); Thread.sleep(1000); } } catch (InterruptedException e) { throw new RuntimeException(e); } }); // 消费者模型 Thread consumer = new Thread(() ->{ try { for (int i = 0; i < 5; i++) { // 从阻塞队列中获取数据并打印 Integer ret = queue.take(); System.out.println("consume:" + ret); } } catch (InterruptedException e) { throw new RuntimeException(e); } }); // 开启两个线程 producer.start(); consumer.start(); }
补充:BlockingQueue本质上是一个接口,在Java的标注库内部有四个实现他的类
这四个类分别适用于不同的场景,如果需要任务的执行具有优先级使用PriorityBlockingQueue,如果任务的数量恒定且变化不大,可以使用ArrayBlockingQueue,如果任务的数量不恒定且变化较大,可以使用LinkedBlockingQueue(因为链表方便插入删除数据)
5.阻塞队列的模拟实现
BlockingQueue 的实现方式有两种,基于数组或链表,此处模拟实现数组的BlockingQueue
首先我们对传入的数据的使用有一定的要求先进入的数据要最先被使用,也就是FIFO,所以我们要使用队列,使用普通的队列当然也能实现,但是考虑到实现的简单性,这里我们采用循环队列实现(实际上,java中的源码也是使用循环队列实现的)
1.基本属性
// 这里采用数组实现 数组内部存放String类型的数据 private String[] elem = new String[100]; private volatile int front;// 指向首元素 private volatile int rear;// 指向尾元素 private volatile int cnt = 0;// 用于判满 // 用于加锁的对象 private Object locker = new Object();
说明:
- 此处采用计数器的方式来解决循环队列的判满
- 使用volatile的原因在于有可能出现多个线程针对同一个变量进行修改的线程安全问题
2.基本操作
1.put
// put 向阻塞队列中存入数据 public void put(String data) throws InterruptedException { synchronized(locker) { // 如果为满 就要阻塞等待 // 此处要使用while循环 while(cnt == elem.length) { // 使用wait进行等待 wait必须搭配synchronized进行使用 // 直到队列中有元素被take出去 才能进行唤醒 locker.wait(); // 被唤醒之后还要再次判断当前队列是否为满 } // 不为空 在队尾存入数据 this.elem[rear] = data; rear = (rear+1) % elem.length; cnt++; locker.notify();// 用于唤醒take方法中的wait操作 } }
2.take
// take 从队列中获取对应的数据 public String take() throws InterruptedException { synchronized(locker) { // 如果为空就阻塞等待 直到有新的元素进入到队列之中 while(cnt == 0) { // 使用wait进行阻塞等待 // 当有新的元素被添加进入到队列后 就唤醒 locker.wait(); // 被唤醒之后还要再次判断当前队列是否为空 } // 不为空 取出数据 String ret = this.elem[front]; front = (front+1) % elem.length; cnt--; locker.notify();// 用于唤醒put方法内部的wait操作 return ret; } }
上述代码其实有一个小问题,也是经常被忽视的一个问题.实际上wait被唤醒的方式有两种
- 通过wait对象使用notify进行唤醒
- 因为
InterruptedException
异常被唤醒
以put操作为例,如果wait是通过第二种方式进行唤醒,此时队列还是满的,添加新的数据就会覆盖掉之前的数据,发生数据的丢失;同样的,如果take中的wait也是通过异常的方式进行唤醒,就会取出非法数据.所以,wait唤醒之后我们还需要进一步的去判断当前队列的状态,这就构成了一个判断的循环,所以要使用while循环来判断满/空
其实在java的官方文档中也建议wait方法应该总是在循环中使用此方法
注意,这里官方文档还给出了使用循环的理由"interrupts and spurious wakeups are possible"中断和虚假唤醒都是有可能的.
中断是指在当前线程wait的时候,有可能被其他线程调用interrupt方法导致线程中断,接着抛出异常,让线程继续执行剩余代码,但是这并不是我希望的wait被唤醒的逻辑;
虚假唤醒(suprious wakeups)指被操作系统或JVM引起的不正常的唤醒,这通常是由于线程的调度或是操作系统等引起的,同样这也不是我们期望的唤醒逻辑,要重新进行判断
多线程编程设计模式(单例,阻塞队列,定时器,线程池)(三)+https://developer.aliyun.com/article/1413588