一、单例模式
在有些系统中,为了节省内存资源、保证数据内容的一致性,对某些类要求只能创建一个实例 ( 也就是唯一对象 ),这就是所谓的单例模式。单例模式下,整个进程中,只能有一个实例。
1. 饿汉模式
注意:
① 饿汉模式在类加载时,就已经创建好了对象。当我们使用该类的 getInstance 方法的时候,直接就能够使用这个对象了;当我们不使用该类的方法的时候,这个对象也依然在那里存在着。
② 正所谓单例模式,我们把构造方法设为 private,防止类外面调用构造方法,这也就禁止了调用者在其他地方创建实例的机会。这样一来,我们只能通过 getInstance 方法来获取实例。
程序清单1:
public class Test1 { static class Singleton { //这里的构造方法须写成 private private Singleton () { } private static Singleton instance = new Singleton(); public static Singleton getInstance() { return instance; } } }
线程安全
在程序清单1中,如果多个线程调用 getInstance 方法,并不会出现线程不安全,因为在 getInstance 方法中,只存在一个 return 操作,即一个读操作,那么既然多个线程同时进行读操作,不涉及修改,即可认为线程是安全的。
2. 懒汉模式
通过 getInstance 方法来获取到实例,首次调用该方法的时候,才真正创建实例 ( 懒加载 / 延时加载 )
程序清单2:
public class Test2 { static class Singleton { private Singleton() { } private static Singleton instance = null; public static Singleton getInstance() { if (instance == null) { instance = new Singleton(); } return instance; } public static void main(String[] args) { //通过这个方法获取到实例,就能保证只有唯一实例 Singleton instance = Singleton.getInstance(); } } }
线程不安全的情况
在程序清单2 中,如果实例已经创建完毕,后续再调用 getInstance 方法,这就和饿汉模式一样了,此时不涉及修改操作,那么此时仍然是线程安全的。但如果实例尚未创建,此时就会涉及修改,如果确实存在多个线程同时修改,就会造成线程不安全。
假设出现如下情况,线程1 和 线程2 并发运行,因为实例未创建出来,所以两个线程同时 new 了两个对象,这就造成了:多个线程修改同一个变量,那么就会造成线程不安全。此外,在这个执行顺序中,new 操作触发了两次,这是不允许的,因为正所谓单例模式,就要求只能 new 一个对象。
(1) 加锁一
这是一种典型的错误写法!此处的线程不安全,主要是因为 if 判断操作和 = 赋值操作不具有原子性。而要想解决这里的线程不安全,就需要把这两个操作变成原子性的。需要使用 synchronized 把它们给包裹上,当包裹加锁后,我们就知道了,某个线程竞争锁成功后,一定会先将被包裹的代码执行完。
(2) 加锁二
如果这样加锁,线程安全问题就解决了,但又引入了新的问题:
在程序清单2 中,创建实例之前,也就是最初的情况下, 我们需要判断是否涉及线程不安全的问题。而在实例创建之后,我们不再涉及修改问题,CPU和内存之间只涉及读操作,那么,我们只在首次调用的时候加锁即可,在后续的调用中,就不加锁。
因为加锁本身是一个开销比较大的操作,如果在不涉及线程不安全的情况下,就没有必要牺牲多余开销,这可能会让这个代码的速度降低很多倍。
(3) 加锁三
正确的加锁方式
(4) 添加关键字 volatile
加锁现在是没问题了,但是我们又得思考一个问题:
后续其他线程再次调用 getInstance 方法的时候,也会进行外层的 if (instance == null) 判断,但有可能会由于编译器的优化,CPU 是从寄存器读到缓存的 instance 值,而不是内存修改过的值,这样一来,是否进入 if 语句的下面,我们不能够确定!可想而知,就会出错。
所以为了防止这一现象,我们为 instance 属性加上关键字 volatile,这就保证了内存可见性,即 CPU 总是可以从内存中读取这个值。
(5) 多线程安全的懒汉模式( 最终优化版本 )
程序清单3:
public class Test3 { static class Singleton { private Singleton() { } private static volatile Singleton instance = null; public static Singleton getInstance() { if (instance == null) { synchronized (Singleton.class) { if (instance == null) { instance = new Singleton(); } } } return instance; } public static void main(String[] args) { //通过这个方法获取到实例,就能保证只有唯一实例 Singleton instance = Singleton.getInstance(); } } }
(6) 回顾最终代码步骤
① 假设有三个线程开始执行 getInstance 方法,通过外层的 if (instance == null) 知道了实例还没有创建的消息,于是开始竞争同一把锁。
② 假设线程1 率先获取到锁,此时线程1 通过里层的 if (instance == null) 进一步确认实例是
否已经创建,如果没创建,就把这个实例创建出来。
③ 当线程1 释放锁之后,线程2 和 线程3 也拿到锁,也通过里层的 if (instance == null) 来
确认实例是否已经创建,发现实例已经创建出来了,就不再创建了。
④ 后续的线程,不必加锁,直接就通过外层 if (instance == null) 就知道实例已经创建了,从
而不再加锁解锁了,降低了开销。
二、阻塞式队列
1. 什么是阻塞式队列
阻塞队列是一种特殊的队列,其也遵循 " 先进先出 " 的原则。此外,它是一种线程安全的数据结构,并且具有以下特性:
当队列为空的时候,尝试出队列,就会产生阻塞,直到有其他线程往队列中插入元素。
当队列满的时候,尝试入队列,也会产生阻塞,直到有其他线程从队列中取走元素。
阻塞队列的一个典型应用场景就是 " 生产者消费者模型 ",这是一种非常典型的开发模型。
在 Java 标准库中,内置了一个 BlockingQueue 这样的类来实现了阻塞队列的功能。
程序清单4:
public class Test { public static void main(String[] args) throws InterruptedException { // 使用实现类 LinkedBlockingDeque 创建对象,其内部是基于链表来实现的 BlockingDeque<String> queue = new LinkedBlockingDeque<>(); //put 方法带有阻塞功能,但 offer 方法不带有阻功能 //我们一般使用 put 方法 queue.put("hello"); String str = queue.take(); System.out.println(str); System.out.println(queue); String str2 = queue.take(); System.out.println(str2); } }
在程序清单4 中,当代码执行到第二个 take 方法的时候,程序就不走了。因为发生了阻塞,前面我们提到,当队列为空的时候,尝试出队列,就会产生阻塞。因为第一次 take 就已将
" hello " 这唯一的元素拿出来了,当我们将队列打印出来的时候,发现就为空了。
输出结果:
2. 生产者消费者模型
生产者消费者是一个在服务器开发中非常实用的编程手段。
在计算机中,生产者是一组线程,消费者是另一组线程,交易场所就是阻塞队列。
生产者消费者模型就是通过一个容器来解决生产者和消费者的强耦合问题。
生产者和消费者彼此之间不直接通讯,而通过阻塞队列来进行通讯,所以生产者生产完数据之后不用等待消费者处理,直接扔给阻塞队列,消费者不找生产者要数据,而是直接从阻塞队列里取。
3. 生产者消费者模型的用途
(1) 解耦合
解耦合的意思就是:减少模块之间的依赖性,提高程序的独立性。
举个例子:有不同的人想购买木材来满足不同的需求,比方说,家具公司就需要木头来制造家居产品,也有收藏者喜欢高等木头,喜欢拿回家观赏…那么,如果这几类人都从伐木者那里获取到材料的话,将会很麻烦,因为伐木者不能够满足你的需求,也许你是家具公司的人、也许你是收藏者、也许你是商人…那么这些人都对伐木者具有依赖性,因为他们都想从伐木者这里买到木材。
然而,伐木者可以将木材卖给木材厂,木材厂经处理过后,那么不同类型的消费者就可以根据自己需求去木材厂获取自己想要的木材数量,自己想要的木材类型,此时,我们就可以将木材厂想象成一个中转站,或者说是一个流动的仓库。
而上述例子中的生产者就是伐木者,消费者就是不同类型的人们,阻塞队列就是木材厂。那么所谓解耦合,我们就明白了,生产者不关心消费者买的是什么木头,它只需要去砍树卖给木材厂就行了,消费者也不需要关心木头从哪来的,我只需要买到我想要的木材类型就行了。
(2) 削峰填谷
举个例子,我们都知道,每年双11 活动都很火爆,假设有成千上万的顾客从网店上购买同一品牌的鞋子衣服,而如果店主只收到一个发货请求,就记录一下,那没什么。但如果只在瞬间,就有成千上百的顾客购买了,那么店主肯定就忙不过来了,他只能准备一张在线表格,将所有的购买数据、收获地址等等统计下来,之后才能方便后续发货。
很显然,上面的网店店主就是生产者,买家就是消费者,在线表格就是阻塞队列。所以我们就能明白什么是 “削峰填谷” 的含义了,有些时候,生产者或消费者一下子难以发出或接受巨大的数据量,那么就可以将阻塞队列作为缓冲区,之后再由多线程来慢慢处理每个数据。
程序清单5:简易的生产者消费者模型
import java.util.concurrent.BlockingDeque; import java.util.concurrent.LinkedBlockingDeque; public class Test { public static void main(String[] args) { //1. 创建一个交易场所 BlockingDeque<Integer> queue = new LinkedBlockingDeque<>(); //2. 创建一个线程 producer 作为生产者 Thread producer = new Thread() { @Override public void run() { for (int i = 1; i <= 10000; i++) { System.out.println("生产元素:" + i); try { queue.put(i); Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } } } }; producer.start(); //3. 创建一个线程 consumer 作为消费者 Thread consumer = new Thread() { @Override public void run() { while (true) { try { Integer value = queue.take(); System.out.println("消费元素:" + value); } catch (InterruptedException e) { e.printStackTrace(); } } } }; consumer.start(); try { producer.join(); consumer.join(); } catch (InterruptedException e) { e.printStackTrace(); } } }
输出结果:
4. 实现环形队列
我们自己实现一个阻塞队列,它也是一个环形队列,那么我们先实现一个环形队列,不考虑扩容问题。
程序清单6:
public class Test { static class BlockingQueue { private int[] elem = new int[1000]; private int start = 0; private int end = 0; private int size = 0; //用当前的 size 来区分阻塞队列是满还是空 /** * 往阻塞队列中放元素 */ private void put (int data) { //阻塞队列满了 if (size == elem.length) { return; } //end 到达末尾,回到起始位置 if (end >= elem.length) { end = 0; } elem[end] = data; end++; size++; } /** * 从阻塞队列中拿元素 */ private int take () { if (size == 0) { return -1; } //start 到达末尾,回到起始位置 if (start >= elem.length) { start = 0; } size--; return elem[start++]; } } public static void main(String[] args) { BlockingQueue queue = new BlockingQueue(); queue.put(1); queue.put(2); queue.put(3); queue.put(4); System.out.println(queue.take()); System.out.println(queue.take()); System.out.println(queue.take()); System.out.println(queue.take()); } }
输出结果:正确无误。
5. 将阻塞队列应用到生产者消费者模型之中
我们可以将程序清单6 中的环形队列转换成一个阻塞队列,只需要将一些 return 操作转换成 某个对象实现 wait 和 notify 即可;之后再应用到生产者消费者模型中去。
程序清单7:
public class Test { static class BlockingQueue { private int[] elem = new int[1000]; private int start = 0; private int end = 0; private int size = 0; //用当前的 size 来区分阻塞队列是满还是空 private Object locker = new Object(); /** * 往阻塞队列中放元素 */ private void put (int data) throws InterruptedException { synchronized (locker) { //阻塞队列满了 if (size == elem.length) { locker.wait(); //阻塞队列满了,就阻塞等待 } //end 到达末尾,回到起始位置 if (end >= elem.length) { end = 0; } elem[end] = data; end++; size++; locker.notify(); //notify 用来唤醒 take 方法中阻塞队列空的情况 } } /** * 从阻塞队列中拿元素 */ private int take () throws InterruptedException { synchronized (locker) { //阻塞队列空了 if (size == 0) { locker.wait(); } //start 到达末尾,回到起始位置 if (start >= elem.length) { start = 0; } size--; locker.notify(); //notify 用来唤醒 put 方法中阻塞队列满的情况 return elem[start++]; } } } public static void main(String[] args) throws InterruptedException{ //1. 创建一个交易场所 BlockingQueue queue = new BlockingQueue(); //2. 创建一个线程 producer 作为生产者 Thread producer = new Thread() { @Override public void run() { for (int i = 1; i <= 10000; i++) { System.out.println("生产元素:" + i); try { queue.put(i); Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } } } }; producer.start(); //3. 创建一个线程 consumer 作为消费者 Thread consumer = new Thread() { @Override public void run() { while (true) { try { Integer value = queue.take(); System.out.println("消费元素:" + value); } catch (InterruptedException e) { e.printStackTrace(); } } } }; consumer.start(); try { producer.join(); consumer.join(); } catch (InterruptedException e) { e.printStackTrace(); } } }
输出结果:
图解分析: