文章目录
单例模式
设计模式初步引入
啥是设计模式?
- 设计模式好⽐象棋中的 “棋谱”. 红⽅当头炮, ⿊⽅⻢来跳. 针对红⽅的⼀些⾛法, ⿊⽅应招的时候有⼀些固定的套路. 按照套路来⾛局势就不会吃亏.软件开发中也有很多常⻅的 “问题场景”. 针对这些问题场景, ⼤佬们总结出了⼀些固定的套路. 按照这个套路来实现代码, 也不会吃亏, 不针对某一种语言, 而是针对某种开发场景
- 设计模式并不是只有
23
种, 因为之前有些大佬写了一本书叫设计模式
,重点讨论了23种, 但事实上存在更多种的设计模式 - 设计模式与框架的区别就是, 设计模式在开发中是
软性要求(不一定遵守)
, 但是框架是硬性要求(一定要遵守)
简单点一句话总结
设计模式是前人根据一些开发场景给出的一些经验之谈, 所以设计模式并不针对某一种语言
为何存在单例模式
- 单例模式能保证某个类在程序中只存在唯⼀⼀份实例, ⽽不会创建出多个实例.
这⼀点在很多场景上都需要. 比如 JDBC 中的 DataSource 实例就只需要⼀个,再
比如如果一个类的创建需要加载的数据量非常的庞大(GB级别), 那我们不希望这
个类频繁的创建销毁(开销很大), 我们可能只是希望创建一次就可以了
饿汉式单例模式
顾名思义, 这种方式实现的单例模式十分"饥渴", 不管使用不使用都会提前new一个对象
流程如下
- 构造方法私有化
- 定义一个静态的类对象用以返回
- 提供一个公开的静态接口来获取唯一的对象
测试代码如下
/**
* 下面定义一个类来测试饿汉式单例模式
*/
class HungrySingleton{
// 提供一个静态的变量用来返回
private static HungrySingleton hungrySingleton = new HungrySingleton();
// 构造方法私有化(在外部不可以构造对象)
private HungrySingleton(){
}
// 提供一个获取实例的静态公开接口
public static HungrySingleton getInstance(){
return hungrySingleton;
}
}
public class DesignPatternTest {
public static void main(String[] args) {
// 对饿汉式单例的测试
HungrySingleton instance1 = HungrySingleton.getInstance();
HungrySingleton instance2 = HungrySingleton.getInstance();
// 测试两者是不是一个对象
System.out.println(instance1 == instance2);
}
}
测试结果
很明显, 用这种方式创建的实例都是只有一份的…
饿汉式缺陷以及是否线程安全
首先饿汉式的单例模式缺陷是非常明显的
- 饿汉式不管我们使用这个对象与否, 都会在类加载的时期(因为是静态对象)构建一个这样的对象, 但我们想要达成的效果是, 在我们不需要这种类的实例的时候, 我们不去进行构造对象的操作(变主动为被动)来减少内存等相关资源的开销
但是饿汉式单例一定是线程安全的
- 构建对象的时期是类加载的时候, 后期不同线程对于这个实例的操作也仅仅是涉及到读操作, 不涉及修改操作, 所以当然是线程安全的, 不存在线程安全问题, 但是另一种实现的模式就不一定了
懒汉式单例模式
上面说了饿汉式单例模式的缺陷, 我们尝试使用懒汉式单例的方式去解决这个问题, 也就是仅仅在需要的时候进行new对象的操作
最基础的懒汉单例模式
构造的逻辑
- 构造方法私有化
- 提供一个静态的对象用来返回(暂时不new对象)
- 提供一个公开访问的静态接口来返回唯一的对象
代码测试(最基础的版本)
/**
* 下面定义一个类来测试懒汉式单例模式
*/
class LazySingleton{
// 提供一个静态的变量用来返回
private static LazySingleton lazySingleton = null;
// 构造方法私有化(不可以在外部new对象)
private LazySingleton(){
}
// 提供一个公开的获取实例的接口
public static LazySingleton getInstance(){
if(lazySingleton == null){
lazySingleton = new LazySingleton();
}
return lazySingleton;
}
}
public class DesignPatternTest {
public static void main(String[] args) {
// 对懒汉式单例的测试
LazySingleton instance1 = LazySingleton.getInstance();
LazySingleton instance2 = LazySingleton.getInstance();
// 测试两者是不是一个对象
System.out.println(instance1 == instance2);
}
}
基础懒汉式缺陷以及是否线程安全
这个就和上面饿汉有较大的区别了, 虽然解决了在需要的时候进行new对象, 上面的基础版本的懒汉式在单线程的环境下肯定是没问题的, 但是在多线程的环境下就不好说了…看下面的分析
如果在多线程的环境下(我们假设有t1, t2)是下图的执行顺序
很明显这是一种类似串行的执行策略
但是还可能是下图的情况
t1线程判断完毕之后没有来得及进行new对象, t2线程紧接着进行了一次完整的new对象的过程, 此时t1线程又进行了一次new对象的过程, 很明显, 我们上面的情况进行了两次构造对象的过程, 同时拿到的对象也不一致
我们通过Thread.sleep()
的方式进行延迟观察看是否会发生
/**
* 下面定义一个类来测试懒汉式单例模式
*/
class LazySingleton{
// 提供一个静态的变量用来返回
private static LazySingleton lazySingleton = null;
// 构造方法私有化(不可以在外部new对象)
private LazySingleton(){
}
// 提供一个公开的获取实例的接口
public static LazySingleton getInstance() throws InterruptedException {
if(lazySingleton == null){
Thread.sleep(1000);
lazySingleton = new LazySingleton();
}
return lazySingleton;
}
}
public class DesignPatternTest {
private static LazySingleton instance1 = null;
private static LazySingleton instance2 = null;
public static void main(String[] args) throws InterruptedException {
// 创建两个线程获取实例
Thread t1 = new Thread(() -> {
try {
instance1 = LazySingleton.getInstance();
} catch (InterruptedException e) {
e.printStackTrace();
}
});
Thread t2 = new Thread(() -> {
try {
instance2 = LazySingleton.getInstance();
} catch (InterruptedException e) {
e.printStackTrace();
}
});
// 开启两个线程
t1.start();
t2.start();
// 睡眠等待一下
Thread.sleep(2000);
System.out.println(instance1 == instance2);
}
}
很明显, 这样的懒汉式的代码是线程不安全的, 那要如何进行改进呢???
懒汉式单例模式的改进
之前我们说了, 要想保证线程是安全的, 有几种解决方式, 这里面我们就采取加锁, 因为其实
判断是不是null和对象应该是一个整体的原子性的操作
改进之后的代码
/**
* 下面定义一个类来测试懒汉式单例模式(改进版)
*/
class LazySingleton{
// 提供一个静态的变量用来返回
private static LazySingleton lazySingleton = null;
// 构造方法私有化(不可以在外部new对象)
private LazySingleton(){
}
// 提供一个公开的获取实例的接口
public static LazySingleton getInstance() {
// 我们把if判断和new对象通过加锁打包为一个原子性的操作(这里使用类对象锁)
synchronized (LazySingleton.class){
if(lazySingleton == null){
lazySingleton = new LazySingleton();
}
}
return lazySingleton;
}
}
public class DesignPatternTest {
private static LazySingleton instance1 = null;
private static LazySingleton instance2 = null;
public static void main(String[] args) {
// 在多线程中获取实例
Thread t1 = new Thread(() -> {
instance1 = LazySingleton.getInstance();
});
Thread t2 = new Thread(() -> {
instance2 = LazySingleton.getInstance();
});
System.out.println(instance1 == instance2);
}
}
这时候肯定是一个线程安全的代码了, 但是思考可不可以进一步改进呢???
当我们已经new个一次对象之后, 如果后续的线程想要获取这个对象, 那就仅仅是一个读
操作了, 根本不涉及对对象的修改, 但是我们每次都使用锁这样的机制就会造成阻塞, 也就会导致程序的效率下降, 所以我们对代码进行了下面的修改在外层再加一个if判断
改进的方法如下
// 提供一个公开的获取实例的接口
public static LazySingleton getInstance() {
// 我们把if判断和new对象通过加锁打包为一个原子性的操作(这里使用类对象锁)
if (lazySingleton == null) {
synchronized (LazySingleton.class) {
if (lazySingleton == null) {
lazySingleton = new LazySingleton();
}
}
}
return lazySingleton;
}
我们的两个if的含义
- 第一个
if
: 判断对象是否创建完毕, 如果创建了, 只是一个读操作 - 第二个
if
: 判断是不是需要new对象
可能初学多线程的时候, 看上述代码觉得很迷惑, 但其实这是因为之前我们写的程序都是单线程的情况, 单线程中执行流只有一个, 两次相同的if判断其实是没有必要的, 但是多线程的条件下, 是多个执行流, 相同的逻辑判断条件也可能产生不同的结果
完整代码(变量volatile)
关于变量是否会产生指令重排序和内存可见性
问题, 我们直接加上volatile
即可
/**
* 下面定义一个类来测试懒汉式单例模式(完整改进版)
*/
class LazySingleton {
// 提供一个静态的变量用来返回
private volatile static LazySingleton lazySingleton = null;
// 构造方法私有化(不可以在外部new对象)
private LazySingleton() {
}
// 提供一个公开的获取实例的接口
public static LazySingleton getInstance() {
// 我们把if判断和new对象通过加锁打包为一个原子性的操作(这里使用类对象锁)
if (lazySingleton == null) {
synchronized (LazySingleton.class) {
if (lazySingleton == null) {
lazySingleton = new LazySingleton();
}
}
}
return lazySingleton;
}
}
阻塞队列
生产者消费者模型
关于生产者消费者模型, 其实是生活中抽象出来的一个模型案例, 我们举一个包饺子的例子来简单解释一下
在包饺子的过程中, 存在一个擀饺子皮的人, 我们称之为生产者, 擀出来的饺子皮放到一个竹盘上, 这个竹盘相当于一个中间的媒介, 生产者生产的物质在上面与消费者进行交互, 而包饺子的人就是一个消费者, 从中间媒介中取出东西, 也就是消费的过程, 我们的中间的竹盘相当于一个缓冲, 如果包饺子的人包的快的话, 就需要等待做饺子皮的人, 如果做饺子皮的人做的快的话, 当竹盘放不下的时候就需要阻塞等待
上面的情景抽象成生产者消费者模型, 擀饺子皮的人是生产者, 竹盖是阻塞队列, 包饺子的人是消费者
生产者消费者模型的案例以及优点
请求与响应案例
生产者消费者模型我们举一个"请求响应的案例"
图中我们也有解释, 越靠上游的消耗的资源越少
假设我们现在出现一个秒杀的请求, 上游可能还可以运行, 但是下游的服务器由于并发量过大就直接崩溃了
所以我们一般会对上面提供服务的逻辑进行改变
添加一个中间的结构(阻塞队列, 或者说消息队列)进行缓冲
在真实的开发场景当中, 阻塞队列甚至会单独的部署为一台服务器, 这种独立的服务器结构叫做消息队列, 可见其重要性
解耦合
生产者消费者模型的一个重要的优点就是让消费者和生产者解耦合
- 根据上面的模型分析, 不管是生产者还是消费者都是面向阻塞队列来进行任务的执行的, 所以就降低了两者之间的耦合度, 将来想要修改这个模型的工作内容, 也只需要面向阻塞队列操作更改(其实相当于接口), 如果没有这种机制的话, 我们想要更改一个操作逻辑, 就需要同时修改消费者与生产者的代码结构…, 我们先前学习的接口其实就是一种解耦合的策略, 其核心就是减少耦合度, 便于对代码结构进行调整
削峰填谷
刚才我们的那个模型就说了, 如果消息请求量非常大的时候, 如果没有消息队列的存在, 就会对下游的服务器产生较大的影响, 甚至会导致服务器崩溃
下图是正常情况下消息队列的工作示意图, 添加的任务加入消息队列, 然后下游的服务器以一个相对稳定的效率从队列中取出来任务进行处理
下图是当任务量激增的时候, 虽然任务量激增, 但是依旧进入消息队列进行等待处理, 此时下游的服务器对任务的处理的效率基本不变, 所以可以保证处理的稳定性, 不至于让下游服务器崩溃, 因为这个消息一般都是一阵一阵的激增, 所以等到下一轮消息量减少的时候, 对先前消息队列的数据进行清理即可…
阻塞队列的内置API
下图是我们相关的阻塞队列的内置API继承逻辑
关于构造方法
ArrayBlockingQueue
: 必须指定大小
LinkedBlockQueue
: 可以指定也可以不指定
关于offer
和poll
与put
和take
的区别
首先是offer
和poll
这两个方法也可以使阻塞队列产生阻塞的效果, 但是我们可以指定一个最大的等待时间
我们使用下面的代码测试
/**
* 关于阻塞队列的相关测试
*/
public class ThreadTest {
public static void main(String[] args) {
// 生成一个阻塞队列(指定队列的大小为100)
BlockingQueue<Integer> blockingQueue = new ArrayBlockingQueue<Integer>(100);
// 创建两个线程测试
Thread producer = new Thread(() -> {
for(int i = 0; i < 1000; i++){
try {
blockingQueue.offer(i, 10L, TimeUnit.SECONDS);
System.out.println("生产了元素: " + i);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
// 消费者线程
Thread consumer = new Thread(() -> {
while(true){
try {
// 进行休眠
Thread.sleep(1000 * 1);
int elem = blockingQueue.take();
System.out.println("消费了元素: " + elem);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
producer.start();
consumer.start();
}
}
分析下这个程序的执行的逻辑
- 在程序启动的很短的时间内, 由于阻塞队列的容量还有空余, 所以会大量的生产元素直到阻塞队列满了, 因为消费者线程是每一秒钟消耗一个元素, 所以存在等待时间, 我们上述代码设置的最大的等待时间是
10s
, 所以根本来不及等待到最大的时间点就可以进行取出元素…
put
和take
方法
- 这组方法和上组方法的区别就是, 这个方法是当队列满或者队列空, 我们进行无限期的阻塞…, 直到队列中的元素不为空或者不为满就可以进行操作
/**
* 关于阻塞队列的相关测试
*/
public class ThreadTest {
public static void main(String[] args) {
// 生成一个阻塞队列(指定队列的大小为100)
BlockingQueue<Integer> blockingQueue = new ArrayBlockingQueue<Integer>(100);
// 创建两个线程测试
Thread producer = new Thread(() -> {
for(int i = 0; i < 1000; i++){
try {
blockingQueue.put(i);
System.out.println("生产了元素: " + i);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
// 消费者线程
Thread consumer = new Thread(() -> {
while(true){
try {
// 进行休眠
Thread.sleep(1000 * 1);
int elem = blockingQueue.take();
System.out.println("消费了元素: " + elem);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
producer.start();
consumer.start();
}
}
最后的执行结果如下
在短时间之内进行大量的生产之后开始隔一秒拿出一个元素, 生产一个元素
阻塞队列的模拟实现
关于wait和while的搭配使用
上面是我们的JDK帮助文档
对wait
使用的建议(其实就是源码), 我们官方文档中提倡wait
的使用建议和while
循环搭配, 而不是和if搭配…原因下面解释
模拟实现
其实就是一个循环队列, 在put
方法加入元素的时候如果队列是满的就进行阻塞, 在take
方法拿出元素的时候如果队列是空的也进行阻塞(使用wait
), 然后put
方法添加了一个元素之后, 使用notify
方法对take
正在阻塞的线程进行唤醒(随机唤醒), 下面是实现代码
/**
* 自己实现一个阻塞队列
* 1. 使用循环数组
* 2. 使用wait-notify进行线程见的通信
* 3. 关于wait的使用的while机制
*/
public class MyBlockingQueue {
// 我们定义这个阻塞队列中的元素是int类型
private int capacity = 0;
private int[] queue = null;
// 构造方法
public MyBlockingQueue(int capacity) {
this.capacity = capacity;
queue = new int[capacity];
}
// 定义队首尾的指针以及元素个数
private int first = 0;
private int last = 0;
private int size = 0;
// 判断队列是否为空
private boolean isEmpty() {
return size == 0;
}
// 判断队列是否是满的
private boolean isFull() {
return size == capacity;
}
// put操作
public void put(int val) throws InterruptedException {
while (isFull()) {
// 此时进入阻塞等待
synchronized (this) {
this.wait();
}
}
queue[last] = val;
last = (last + 1) % capacity;
size++;
// 随机唤醒一个线程
synchronized (this) {
this.notify();
}
}
// take操作
public int take() throws InterruptedException {
while (isEmpty()) {
// 此时进入阻塞等待
synchronized (this) {
this.wait();
}
}
int res = queue[first];
first = (first + 1) % capacity;
size--;
// 随机唤醒一个线程
synchronized (this) {
this.notify();
}
return res;
}
}
class Test {
public static void main(String[] args) {
// 对实现的队列进行测试
MyBlockingQueue myBlockingQueue = new MyBlockingQueue(100);
// 创建生产者线程进行测试
Thread producer = new Thread(() -> {
for(int i = 0; i < 1000; i++){
try {
myBlockingQueue.put(i);
System.out.println("生产了元素: " + i);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
// 创建消费者线程进行测试
Thread consumer = new Thread(() -> {
for(int i = 0; i < 1000; i++){
try {
Thread.sleep(1000);
int getElem = myBlockingQueue.take();
System.out.println("消费了元素: " + getElem);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
// 启动两个线程
producer.start();
consumer.start();
}
}
瞬间产出100个元素之后进行阻塞, 产出一个消耗一个…
为什么要使用while代替if
// put操作
public void put(int val) throws InterruptedException {
if(isFull()) {
// 此时进入阻塞等待
synchronized (this) {
this.wait();
}
}
queue[last] = val;
last = (last + 1) % capacity;
size++;
// 随机唤醒一个线程
synchronized (this) {
this.notify();
}
}
// put操作
public void put(int val) throws InterruptedException {
while (isFull()) {
// 此时进入阻塞等待
synchronized (this) {
this.wait();
}
}
queue[last] = val;
last = (last + 1) % capacity;
size++;
// 随机唤醒一个线程
synchronized (this) {
this.notify();
}
}
我们分析一下两个相同的操作, 使用while
和if
的区别
这一张图片揭示了为什么使用wait
搭配while
使用更加合理