1. 多线程案例
1.1 单例模式
单例模式能保证某个类在程序中只存在唯一一份实例,不会创建出多个实例(这一点在很多场景上都需要,比如 JDBC 中的 DataSource 实例就只需要一个
tip:设计模式就是编写代码过程中的 “软性约束”,不是强制的;框架就是编写代码过程中的 “硬性约束”,针对一些特定的场景问题,基本的代码逻辑是固定的
单例模式具体的实现方式有很多,最常见的是 “饿汉” 和 “懒汉” 两种
1.1.2 饿汉模式
//创建一个单例的类,饿汉方式实现
//饿 的意思是 “迫切”
//在类被加载的时候,就会创建出这个单例的实例
class Singleton {
private static Singleton instance = new Singleton();
public static Singleton getInstance() {
return instance;
}
//单例模式的最关键部分
private Singleton() { }
}
public class Demo24 {
public static void main(String[] args) {
Singleton s1 = Singleton.getInstance();
Singleton s2 = Singleton.getInstance();
System.out.println(s1 == s2);
}
}
其中的构造方法用 private 修饰,意味着在类的外面就无法调用构造方法,也就无法创建实例了
只要不在其他代码中 new 这个类,每次使用都通过 getInstance 来获取实例,这个类就是单例的了
单例主要解决的问题就是防止别人不小心 new 了对象
tip:
单例模式只能避免失误,不能应对 “故意攻击”,如:使用 反射 或 序列化反序列化 能打破上述单例模式
单例模式的前提是 “一个进程中”,如果有多个 Java 进程,自然是每个进程中都可以有一个实例了
1.1.3 懒汉模式
//懒汉模式实现的单例模式
class SingletonLazy {
//此处先把这个实例的引用设为 null,不着急创建实例
private static SingletonLazy instance = null;
public static SingletonLazy getInstance() {
if (instance == null) {
instance = new SingletonLazy();
}
return instance;
}
private SingletonLazy() { }
}
public class Demo25 {
public static void main(String[] args) {
SingletonLazy s1 = SingletonLazy.getInstance();
SingletonLazy s2 = SingletonLazy.getInstance();
System.out.println(s1 == s2);
}
}
计算机中谈到的 “懒” 是褒义词,意思是,效率会更高,懒汉模式推迟了创建实例的时机,第一次使用的时候才会创建实例
上述代码中,当首次调用 getInstance,由于此时引用为 null,就会进入 if 分支创建实例,后续再重复调用 getInstance 就不会创建实例了,直接返回
1.1.4 饿汉模式和懒汉模式的线程安全问题
观察饿汉模式的代码,发现其只有读操作,不涉及到修改操作,所以没有线程安全问题
而懒汉模式中有 if 判定和其中的修改操作,这种代码模式是典型的线程不安全代码,因为判定和修改之间可能涉及到线程的切换,如下图:
上述例子中就创建了两个实例,虽然第二次创建覆盖了第一次的值,使得第一次创建的实例没有引用指向,很快就会被垃圾回收机制给消除掉,但是仍然认为上述代码是存在 bug 的
tip:在实际场景中,构造方法内部可能会执行很多逻辑,假设现有 100G 数据,加载到内存中需要 10 分钟,若是上述代码构造实例来管理加载数据到内存中,耗时就会翻倍成 20 分钟
1.1.5 通过加锁来解决懒汉模式中的线程安全问题
问题一:
上面加锁之后确实解决了线程安全问题,但是当已经 new 完对象后,if 分支就再也进不去了,后续的代码就应该是单纯的读操作,此时 getInstance 不加锁也是线程安全的
问题二:
但是当前代码的写法只要调用 getInstance 都会触发加锁操作,虽然没有线程安全问题了,但是会因为加锁产生阻塞,影响到性能
通过加一个条件判断,改进该问题:
tip:
创建的局部变量,处于 JVM 内存的 “栈” 区域中;new 出来的对象,处于 JVM 内存的 “堆” 区域中
对于整个 JVM 进程来说,堆是只有一份,线程之间公用的;栈则是每个线程有自己独立的(这是 Java 语法的限制,t1 无法访问 t2 栈上的变量,C++、系统原生 API 中不存在这样的限制,任何一个变量都是可以给其他线程用的)
正是因为变量的共享是常态,所以就容易触发多个线程修改同一个变量,从而引起线程安全问题
问题三:
该代码还可能会因为指令重排序,引起线程安全问题(指令重排序也是一种编译器的优化方式)
编译器可能会按照 1 2 3 的顺序来执行,也可能按照 1 3 2 的顺序来执行,对于单线程来说,先执行 2 和先执行 3 本质上是一样的,但是在多线程的环境下,按照 1 3 2 的顺序来执行可能会出现问题:
为解决该问题,引入关键字 volatile,编译器就发现 instance 是易失的,围绕这个变量的优化就会非常克制,不仅在读取变量的优化上克制,也会在修改变量的优化上克制,上述的 1 2 3 操作不会再成为 1 3 2 了
tip:Java 中的 voiatile 两个功能
1) 保证内存可见性
2) 禁止指令重排序(针对赋值操作)
1.2 阻塞队列
1.2.1 概念
阻塞队列是一种特殊的队列,也遵循 “先进先出” 原则,是一种线程安全的数据结构(标准库中原有的队列 Queue 和其子类默认都是线程不安全的),并且具有以下特性:
- 当队列满的时候,继续入队列就会阻塞,直到有其他线程从队列中取走元素
- 当队列空的时候,继续出队列就会阻塞,直到有其他线程往队列中插入元素
阻塞队列的一个典型应用场景就是 “生产者消费者模型”,是一种非常典型的开发模型
1.2.2 生产者消费者模型
生产者消费者模型就是通过一个容器来解决生产者和消费者的强耦合问题
生产者和消费者彼此之间不直接通讯,而通过阻塞队列来进行通讯,所以生产者生产完数据之后不用等待消费者处理,直接扔给阻塞队列,消费者不找生产者要数据,而是直接从阻塞队列中取
优点一:阻塞队列就相当于一个缓冲区,平衡了生产者和消费者的处理能力(削峰填谷)
比如:在“双十一秒杀”的场景下,服务器同一时刻可能会收到大量的支付请求,如果直接处理这些支付请求,服务器可能扛不住(每个支付请求的处理都需要比较复杂的流程),这时就可以把这些请求放到一个阻塞队列中,然后再由消费者线程慢慢处理每个支付请求,有效进行“削峰”,防止服务器被突然到来的一波请求直接冲垮
如上图,A 中请求突然激增,若是没有阻塞队列,B 很可能就挂