多线程案例
1、案例一:线程安全的单例模式
单例模式
单例模式是设计模式的一种
什么是设计模式?
设计模式好比象棋中的 "棋谱",红方当头炮,黑方马来跳,针对红方的一些走法,黑方应招的时候有一些固定的套路,按照套路来走局势就不会吃亏,也就发明了一组"棋谱",称为设计模式
软件开发中也有很多常见的 "问题场景",针对一些典型的场景,给出了一些典型的解决方案
有两个设计模式是非常常见的
其一是单例模式 ,其二是工厂模式
单例模式 => 单个 实例 (对象)
在有些场景中,有的特定的类,只能创建出一个实例,不应该创建多个实例
单例模式能保证某个类在程序中只存在唯一一份实例, 而不会创建出多个实例,这种单例模式,在实际开发中是非常常见,也非常有用的,开发中的很多 "概念" 天然就是单例,JDBC,DataSource,这样的对象,就应该是单例的
Java 里实现单例模式的方式有很多,单例模式的两种典型实现:
- 饿汉模式
- 懒汉模式
举例:洗碗
1.中午这顿饭,使用了4个碗,吃完之后,立即把这4个碗给洗了~~[饿汉]
⒉中午这顿饭,使用了4个碗,吃完之后,先不洗。晚上这顿,只需要2个碗,然后就只洗2个即可~~[懒汉]
第二种是更加高效的操作,---般是褒义词 (在计算机中提高效率)
饿汉的单例模式,是比较着急地去进行创建实例
懒汉的单例模式,是不太着急地去创建实例,只是在用的时候,才真正创建
1.1、饿汉模式
java
private static Singleton instance;
注意:
-
类里面使用 static 修饰的成员,应该叫做 "类成员" => "类属性 / 类方法",相当于这个属性对应的内存空间在类对象里面
不加 static 修饰的成员,叫做 "实例成员" => "实例属性 / 实例方法"
静态变量 属于类,存储在方法区,随着的类加载而加载,
成员变量 属于对象,存储在堆中,随着对象的创建而创建
-
static 是让当前 instance 属性是类属性了
-
一个类对象在一个 Java 进程中是唯一实例的 (JVM保证的),类属性是长在类对象上的,进一步的也就保证了类的 static 成员也是只有一份的
-
-
类对象 != 对象
类:就相当于实例的模板,基于模板可以创建出很多的对象来
对象(实例)
- java 代码中的每个类,都会在编译完成后得到 .class文件,类对象,就是 .class 文件
JVM 运行时就会加载这个 .class 文件读取其中的二进制指令,并解析,在内存中构造出对应的类对象 (类加载),形如 Singleton.class) - 类对象里就有 .class 文件中的一切信息
包括:类名是啥,类里有哪些属性,每个属性叫啥名字,每个属性叫啥类型,每个属性是 public private...
基于这些信息,才能实现反射
- java 代码中的每个类,都会在编译完成后得到 .class文件,类对象,就是 .class 文件
java
// 通过 Singleton 这个类来实现单例模式,保证 Singleton 这个类只有唯一实例
class Singleton {
// 1.使用 static 创建一个实例,并且立即进行实例化
// 这个 instance 对应的实例,就是该类的唯一实例
private static Singleton instance = new Singleton();
// 2.提供一个方法,让外面能够拿到唯一实例
public static Singleton getInstance() {
return instance;
}
// 3.为了防止程序猿在其他地方不小心地 new 这个 Singleton,就可以把构造方法设为 private
// 把构造方法设为 private.在类外面,就无法通过 new的方式来创建这个 Singleton实例了!
private Singleton() {};
}
public class demo1 {
public static void main(String[] args) {
Singleton instance = Singleton.getInstance();
Singleton instance2 = Singleton.getInstance();
System.out.println(instance == instance2); // true 两个引用相同
}
}
针对这个唯一实例的初始化,比较着急,类加载阶段,就会直接创建实例
(程序中用到了这个类,就会立即加载)
饿汉模式中 getlnstance,仅仅是读取了变量的内容
如果多个线程只是读同一个变量,不修改,此时仍然是线程安全的
1.2、懒汉模式 - 单线程
java
class Singleton2 {
// 1.就不是立即就初始化实例.
private static Singleton2 instance = null;
// 2.把构造方法设为 private
private Singleton2() {}
// 3.提供一个方法来获取到上述单例的实例
// 只有当真正需要用到这个实例的时候,才会真正去创建这个实例
public static Singleton2 getInstance() {
if (instance == null) {
instance = new Singleton2();
}
return instance;
}
}
只有在真正使用到 getInstance 的时候才会真的创建实例
一个典型的案例:
notepad
这样的程序,在打开大文件的时候是很慢的 (你要打开一个1G大小的文件,此时 notepad 就会尝试把这 1G 的所有内容都读到内存中 ) [饿汉]
像一些其他的程序,在打开大文件的时候就有优化 (要打开 1G 的文件,但是只先加载这---个屏幕中能显示出来的部分) [懒汉]
1.3、懒汉模式 - 线程安全
真正要解决的问题,是实现一个线程安全的单例模式
线程安全不安全,具体指的是多线程环境下,并发的调用 getInstance 方法,是否可能存在 bug
------懒汉模式 与 饿汉模式 在多线程环境下,是否线程安全?
-
饿汉模式这里,多线程调用,只是涉及到"读操作"
-
懒汉模式中,包含读操作和修改操作,存在线程安全问题

上述罗列出了一种可能的排序情况,实际情况是有很多种
通过上述分析,就可以看出,当前这个代码中是存在bug,可能导致实例被创建出多份来
如何保证懒汉模式的线程安全呢?加锁!
可不是说,代码中有了 synchronized 就---定线程安全,synchronized 加的位置也得正确,不能随便写
本质是读,比较,写,这三个操作不是原子的。这就导致了 t2 读到的值可能是 t1 还没来得及写的(脏读),导致多次 new;所以要把锁加在外面,此时才能保证 读操作 和 修改操作 是一个整体

使用这里的类对象作为锁对象
(类对象在一个程序中只有唯------份,就能保证多个线程调用 getInstance 的时候都是针对同一个对象进行的加锁)
java
public static Singleton2 getInstance() {
synchronized (Singleton.class) { // 类对象作为锁对象
if (instance == null) {
instance = new Singleton2();
}
}
return instance;
}

1.4、懒汉模式 - 锁竞争
当前虽然加锁之后,线程安全问题得到解决了,但是又有了新的问题:
对于刚才这个懒汉模式的代码来说, 线程不安全,是发生在 instance 被初始化之前的 ,未初始化的时候,多线程调用 getinstance,就可能同时涉及到读和修改 ,但是一旦 instance 被初始化之后,后续调用 getlnstance,此时 instance 的值一定是非空的,if 判断不成立,也就线程安全了,因此就会直接触发 return,getlnstance 就只剩下两个读操作,相当于一个是比较操作,一个是返回操作,这两个都是读操作
而按照上述的加锁方式,无论代码是初始化之后,还是初始化之前。加锁是有开销的,每次调用 getinstance 都会进行加锁 ,也就意味着即使是初始化之后 (已经线程安全了),但是仍然存在大量的锁竞争 加锁确实能让代码保证线程安全,也付出了代价 (程序的速度就慢了)
所以为啥不推荐使用 vector hashtable ?? 就是因为这俩类里面就是在无脑加锁
改进方案,在加锁这里再加上一层条件判定即可 ,对象还没创建,才进行加锁;对象创建过了,就不再加锁了,
条件就是当前是否已经初始化完成 (instance == null)
java
class Singleton2 {
// 1.就不是立即就初始化实例.
private static Singleton2 instance = null;
// 2.把构造方法设为 private
private Singleton2() {}
// 3.提供一个方法来获取到上述单例的实例
// 只有当真正需要用到这个实例的时候,才会真正去创建这个实例
public static Singleton2 getInstance() {
if (instance == null) {
synchronized (Singleton.class) { // 类对象作为锁对象
if (instance == null) {
instance = new Singleton2();
}
}
}
return instance;
}
}
这俩条件---模一样,只是一个美丽的巧合而已,这俩条件起到的效果 / 预期的目的是完全不---样的
上面的条件判定的是是否要加锁
下面的条件判定的是是否要创建实例
碰巧这两个目的都是判定 instance 是否为 null
在这个代码中,看起来两个---样的 if 条件是相邻的,但是实际上这两个条件的执行时机 是差别很大的!
加锁可能导致线程阻塞,当执行到锁结束之后,执行到第二个 if 的时候,第二个 if 和第一个 if 之间可能已经隔了很久的时间,沧海桑田。程序的运行内部的状态,这些变量的值,都可能已经发生很大改变了。 如外层条件是 10:16 执行的,里层条件可能是 10:30 执行的,此时 instance 可能已经被其他线程给修改了。
如果去掉了里层的 if 就变成了刚才那个典型的错误代码,加锁没有把读+修改这操作进行打包
java
public static Singleton2 getInstance() {
if (instance == null) { // 判定的是是否要加锁
synchronized (Singleton.class) {
instance = new Singleton2();
}
}
return instance;
}
1.5、懒汉模式 - 内存可见性 指令重排序
当前这个代码中还存在一个重要的问题
如果多个线程,都去调用这里的 getlnstance
就会造成大量的读 instance 内存的操作 => 可能会让编译器把这个读内存操作优化成读寄存器操作
---旦这里触发了优化,后续如果第一个线程已经完成了针对 instance 的修改,那么紧接着后面的线程都感知不到这个修改 ,仍然把 instance 当成 null
另外,还会涉及到指令重排序问题!!
instance = new Singleton();
拆分成三个步骤:
1.申请内存空间
2.调用构造方法,把这个内存空间初始化成一个合理的对象
3.把内存空间的地址赋值给 instance 引用
正常情况下,是按照 123 这个顺序来执行的
编译器还有一手操作,指令重排序:为了提高程序效率,调整代码执行顺序
123 这个顺序就可能变成 132
如果是单线程,123 和 132 没有本质区别
例如食堂阿姨打饭,1 是拿盘子,2 是装饭,3 是把盘子给我。此时,就是先把盘子给我,再装饭
但是多线程环境下,就会有问题了!!!
假设 t1 是按照 132 的步骤执行的
t1 执行到 13 之后,执行 2 之前,被切出 cpu,t2 来执行
(当 t1 执行完 3 之后,t2 看起来,此处的引用就非空了),此时此刻,t2 就相当于直接返回了 instance 引用,并且可能会尝试使用引用中的属性
但是由于 t1 中的 2(装饭) 操作还没执行完呢,t2 拿到的是非法的对象,还没构造完成的不完整的对象
解决方法:给 instance 加上 volatile 即可
java
// 这个代码是完全体的线程安全单例模式
class Singleton2 {
// 1.就不是立即就初始化实例.
private static volatile Singleton2 instance = null;
// 2.把构造方法设为 private
private Singleton2() {}
// 3.提供一个方法来获取到上述单例的实例
// 只有当真正需要用到这个实例的时候,才会真正去创建这个实例
public static Singleton2 getInstance() {
if (instance == null) { // 判定的是是否要加锁
synchronized (Singleton.class) { // 类对象作为锁对象
if (instance == null) { // 判定的是是否要创建实例
instance = new Singleton2();
}
}
}
return instance;
}
}
2、案例二:阻塞队列
2.1、生产者消费者模型
队列先进先出
阻塞队列同样也是一个符合先进先出 规则的特殊队列,相比于普通队列,阻塞队列又有一些其他方面的功能!
1、线程安全
2、产生阻塞效果
1). 如果队列为空 ,执行出队列 操作,就会出现阻塞,阻塞到另一个线程往队列里添加元素(队列不为空)为止
2). 如果队列为满 ,执行入队列 操作,也会出现阻塞,阻塞到另一个线程从队列里取走元素(队列不为满)为止
消息队列 ,也是特殊的队列,相当于是在阻塞队列的基础上,加上了个 "消息的类型",按照制定类别进行先进先出
此时咱们谈到的这个消息队列, 仍然是一个 "数据结构"
基于上述特性,就可以实现 "生产者消费者模型"
此处的阻塞队列就可以作为生产者消费者模型中的交易场所
生产者消费者模型,是实际开发中非常有用的一种多线程开发手段!尤其是在服务器开发的场景中
假设,有两个服务器, AB,A作为入口服务器直接接收用户的网络请求,B作为应用服务器,来给A提供一些数据
优点1:解耦合
实现了发送发和接受方之间的解耦
------开发中典型的场景:服务器之间的相互调用

客户端发送一个充值请求给 A 服务器,此时 A 把请求转发给 B 处理,B 处理完了把结果反馈给 A,此时就可以视为是 "A 调用了 B",
如果不使用生产者消费者模型
上述场景中,A 和 B 之间的耦合性是比较高的! A 要调用 B,A 务必要知道 B的存在,如果 B 挂了,很容易引起 A 的 bug !!!(在开发 A 代码的时候就得充分了解到 B 提供的一些接口,开发 B 代码的时候也得充分了解到 A 是怎么调用的)
另外,如果要是再加一个 C 服务器,此时也需要对 A 修改不少代码
因此就需要针对 A 重新修改代码,重新测试,重新发布,重新部署,非常麻烦了
针对上述场景,使用生产者消费者模型,就可以有效的降低耦合

对于请求:A是生产者,B是消费者
对于响应:A是消费者,B是生产者
阻塞队列都是作为交易场所,队列是不变
A 不需要认识 B,只需要关注如何和队列交互 (A 的代码中,没有任何一行代码和 B 相关)
B 不需要认识 A,也只需要关注如何和队列交互 (B 的代码中,也没有任何一行代码和 A 相关)
如果 B 挂了,对于 A 没有任何影响 ,因为队列还好着,A 仍然可以给队列插入元素,如果队列满,就先阻塞就好了,
如果 A 挂了,也对于 B 没有影响,因为队列还好着,B 仍然可以从队列取元素,如果队列空,也就先阻塞就好了
A B 任何一方挂了不会对对方造成影响!!!
新增一个 C 来作为消费者,对于 A 来说,也完全感知不到...
优点2:削峰填谷
能够对于请求进行 "削峰填谷",保证系统的稳定性
------三峡大坝,起到的效果,就是 "削峰填谷"

到了雨季,水流量就会很大,三峡大坝关闸蓄水,承担了上游的冲击,保护下游水流量不是太大,不至于出现洪灾------削峰
到了早季,水流量很小,三峡大坝就开闸放水,给下游提供更充分的水源,避免出现干旱灾害------填谷
什么时候上游涨水,真的是难以预测,防患于未然
上游,就是用户发送的请求。下游就是一些执行具体业务的服务器。
用户发多少请求?不可控的,有的时候,请求多,有的时候请求少...
------未使用生产者消费者模型:

未使用生产者消费者模型的时候,如果请求量突然暴涨 (不可控)
A暴涨 => B暴涨
A 作为入口服务器 ,计算量很轻,请求暴涨,问题不大
B 作为应用服务器 ,计算量可能很大,需要的系统资源也更多,如果请求更多了,需要的资源进一步增加,如果主机的硬件不够,可能程序就挂了
------使用生产者消费者模型:

A 请求暴涨 => 阻塞队列的请求暴涨
由于阻塞队列没啥计算量,就只是单纯的存个数据,就能抗住更大的压力
B 这边仍然按照原来的速度来消费数据,不会因为A的暴涨而引起暴涨,B就被保护的很好,就不会因为这种请求的波动而引起崩溃
"削峰":这种峰值很多时候不是持续的 ,就一阵,过去了就又恢复了
"填谷":B 仍然是按照原有的频率来处理之前积压的数据
实际开发中使用到的 "阻塞队列" 并不是一个简单的数据结构了,而是一个 / 一组专门的服务器程序 ,并且它提供的功能也不仅仅是阻塞队列的功能,还会在这基础之上提供更多的功能 (对于数据持久化存储,支持多个数据通道,支持多节点容灾冗余备份,支持管理面板,方便配置参数.......)
这样的队列又起了个新的名字,"消息队列 " (未来开发中广泛使用到的组件)
kafka 就是业界一个比较主流的消息队列,消息队列的实现,有很多种,核心功能都差不多
2.2、实现阻塞队列
学会使用 Java 标准库中的阻塞队列 ,基于这个内置的阻塞队列,实现一个简单的生产者消费者模型
再自己**实现一个简单的阻塞队列 **(为了更好地理解阻塞队列的原理,多线程,尤其是锁操作)
标准库中的阻塞队列 BlockingQueue
在 Java 标准库中内置了阻塞队列,如果我们需要在一些程序中使用阻塞队列,直接使用标准库中的即可
-
BlockingQueue 是一个接口,真正实现的类是 LinkedBlockingQueue
-
Queue 提供的方法有三个:入队列 offer。出队列 poll。取队首元素 peek
阻塞队列主要方法是两个:入队列 put,出队列 take
-
BlockingQueue 也有 offer, poll, peek 等方法,但是这些方法不带有阻塞特性

java
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;
public class demo3 {
public static void main(String[] args) throws InterruptedException {
BlockingQueue<String> blockingQueue = new LinkedBlockingQueue<>();
blockingQueue.put("hello");
String s1 = blockingQueue.take();
System.out.println(s1);
blockingQueue.take();
String s2 = blockingQueue.take();
System.out.println(s2);
}
}
取出 "hello",队列为空,此时再次取元素,就会进入阻塞,等待其他线程往队列中添加元素

生产者消费者模型
java
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;
public class ThreadDemo1 {
public static void main(String[] args) {
BlockingQueue<Integer> blockingQueue = new LinkedBlockingQueue<>();
// 创建两个线程,作为生产者和消费者
Thread customer = new Thread(() -> {
while (true) {
try {
Integer result = blockingQueue.take();
System.out.println("消费元素:" + result);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
customer.start();
Thread producer = new Thread(() -> {
int count = 0;
while (true) {
try {
blockingQueue.put(count);
System.out.println("生产元素:" + count);
count++;
Thread.sleep(500); // 每500毫秒生产一个
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
producer.start();
}
}

阻塞队列 - 单线程
要实现一个阻塞队列,需要先写一个普通的队列,再加上线程安全,再加上阻塞
队列可以基于数组 实现,也可以基于链表实现
------链表:很容易进行头删 / 尾插
链表的头删操作,时间复杂度是 O(1)
链表的尾插操作,时间复杂度是 "可以是 O(1)"
用一个额外的引用,记录当前的尾结点
------数组:循环队列

head, tail) 都指向下标为 0
**入队列** ,把新元素放到 tail 位置上,并且 tail++
**出队列** ,把 head 位置的元素返回出去,并且 head++
当 head / tail 到达数组**末尾** 之后,就需要**从头开始,重新循环**
实现循环队列的时候,有一个重要的问题,**如何区分,是空队列还是满队列?**
如果不加额外限制,此时**队列空或者满都是 head 和 tail 重合**
1. 浪费一个格子,head == tail 认为是空
head == tail+1 认为是满
2. 额外创建一个变量,size 记录元素的个数,size == 0 空
size == arr.length 满
```java
class MyBlockingQueue {
// 保存数据的本体
private int[] items = new int[1000];
// 队首下标
private int head = 0;
// 队尾下标
private int tail = 0;
// 有效元素个数
private int size = 0;
// 入队列
public void put(int value) {
// 1、
if (size == items.length) {
// 队列满了,暂时先直接返回
return;
}
// 2、把新的元素放入 tail 位置
items[tail] = value;
tail++;
// 3、处理 tail 到达数组末尾的情况
if (tail >= items.length) { // 判定 + 赋值 (虽然是两个操作,两个操作都是高效操作)
tail = 0;
}
// tail = tail % data.length; // 代码可读性差,除法速度不如比较,不利于开发效率也不利于运行效率
// 4、插入完成,修改元素个数
size++;
}
// 出队列
public Integer take() {
// 1、
if (size == 0) {
// 如果队列为空,返回一个非法值
return null;
}
// 2、取出 head 位置的元素
int ret = items[head];
head++;
// 3、head 到末尾 重新等于 0
if (head >= items.length) {
head = 0;
}
// 4、数组元素个数--
size--;
return ret;
}
}
public class TestDemo {
public static void main(String[] args) {
MyBlockingQueue queue = new MyBlockingQueue();
queue.put(1);
queue.put(2);
queue.put(3);
queue.put(4);
System.out.println(queue.take()); // 1
System.out.println(queue.take()); // 2
System.out.println(queue.take()); // 3
System.out.println(queue.take()); // 4
}
}
```
*** ** * ** ***
#### 阻塞队列 - 线程安全
当前已经完成了普通队列的实现,**加上阻塞功能** ,阻塞功能意味着,**队列要在多线程环境下使用** 。保证多线程环境下,调用这里的 put 和 take 没有问题的,
put 和 take 里面的每一行代码都是在操作公共的变量。既然如此,直接就给整个方法加锁即可
(加上` synchronized` 已经是线程安全的了)
```java
// 入队列
public void put(int value) {
// 此处是把 synchronized 包裹了方法里的所有代码,其实 synchronized 加到方法上,也是一样的效果
synchronized (this) { // 针对同一个 MyBlockingQueue,进行 put,take 操作时,会产生锁竞争
if (size == items.length) {
return;
}
items[tail] = value;
tail++;
if (tail >= items.length) {
tail = 0;
}
size++;
}
}
// 出队列
public Integer take() {
int ret = 0;
synchronized (this) {
if (size == 0) {
return null;
}
ret = items[head];
head++;
if (head >= items.length) {
head = 0;
}
size--;
}
return ret;
}
```
*** ** * ** ***
#### 阻塞队列 - 阻塞
接下来,实现**阻塞** 效果
关键要点,使用 `wait 和 notify ` 机制
对于 put 来说,阻塞条件,就是队列为满,对于 take 来说,阻塞条件,就是队列为空
**针对哪个对象加锁就使用哪个对象 wait,** 如果是针对 this 加锁,就 this.wait
put 中的 wait 要由 take 来唤醒,只要 take 成功了一个元素,就队列不满了,就可以进行唤醒了
对于 take 中的等待,条件是队列为空,队列不为空,也就是 put 成功之后,就来唤醒

当前代码中,**put 和 take 两种操作不会同时 wait (等待条件是截然不同的,一个是为空,一个是为满)**
如果有人在等待,notify 能唤醒,如果没人等待,notify 没有任何副作用
notify 只能唤醒随机的一个等待的线程,不能做到精准
要想精准,就必须使用不同的锁对象
想唤醒 t1,就 o1.notify,让 t1 进行 o1.wait。想唤醒 t2,就 o2.notify,让 t2 进行 o2.wait
当 wait 被唤醒的时候,此时 if 的条件,一定就不成立了嘛?? 具体来说,put 中的 wait 被唤醒,要求,队列不满
但是 **wait 被唤醒了之后,队列一定是不满的嘛?**
注意,咱们当前代码中,确实不会出现这种情况,当前代码一定是取元素成功才唤醒,每次取元素都会唤醒
但是稳妥起见,最好的办法,**是 wait 返回之后再次判定一下,看此时的条件是不是具备了!!**
将 if 改为 while,标准库就是建议这么写的
```java
while (size == items.length) {
// 队列满了,暂时先直接返回
// return;
this.wait();
}
while (size == 0) {
// 如果队列为空,返回一个非法值
// return null;
this.wait();
}
```

> **代码:**
```java
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;
// 自己写的阻塞队列,此处不考虑泛型,直接使用 int 来表示元素类型了
class MyBlockingQueue {
// 保存数据的本体
private int[] items = new int[1000];
// 队首下标
private int head = 0;
// 队尾下标
private int tail = 0;
// 有效元素个数
private int size = 0;
// 入队列
public void put(int value) throws InterruptedException {
synchronized (this) { // 针对同一个 MyBlockingQueue,进行 put,take 操作时,会产生锁竞争
while (size == items.length) {
// 队列满了,暂时先直接返回
// return;
this.wait();
}
// 2、把新的元素放入 tail 位置
items[tail] = value;
tail++;
// 3、处理 tail 到达数组末尾的情况
if (tail >= items.length) { // 判定 + 赋值 (虽然是两个操作,两个操作都是高效操作)
tail = 0;
}
// tail = tail % data.length; // 代码可读性差,除法速度不如比较,不利于开发效率也不利于运行效率
// 4、插入完成,修改元素个数
size++;
// 如果入队列成功,则队列非空,唤醒 take 中的 wait
this.notify();
}
}
// 出队列
public Integer take() throws InterruptedException {
int ret = 0;
synchronized (this) {
while (size == 0) {
// 如果队列为空,返回一个非法值
// return null;
this.wait();
}
// 2、取出 head 位置的元素
ret = items[head];
head++;
// 3、head 到末尾 重新等于 0
if (head >= items.length) {
head = 0;
}
// 4、数组元素个数--
size--;
// take 成后,唤醒 put 中的 wait
this.notify();
}
return ret;
}
}
public class ThreadDemo2 {
public static void main(String[] args) {
// 生产者消费者模型
BlockingQueue
针对这个问题,网上的很多说法,是不正确的!
网上一种典型的回答:假设机器有 N 核CPU,线程池的线程数目,就设为 N(CPU 的核数),N + 1,1.2N,1.5N, 2N...
只要能回答出一个具体的数字,都---定是错的!
不同的程序特点不同,此时要设置的线程数也是不同的,
考虑两个极端情况:
-
CPU 密集型
每个线程要执行的任务都是狂转 CPU (进行一系列算术运算)
此时线程池线程数,最多也不应该超过 CPU 核数
此时如果你设置的更大,也没用
CPU 密集型任务,要一直占用 CPU,搞那么多线程,但是 CPU 的坑不够了...
-
IO 密集型
每个线程干的工作就是等待 IO (读写硬盘,读写网卡,等待用户输入) ------不吃CPU
此时这样的线程处于阻塞状态,不参与 CPU 调度...
这个时候多搞一些线程都无所谓, 不再受制于 CPU 核数了
理论上来说你线程数设置成无穷大都可以 (实际上当然是不行的)
然而,我们实际开发中并没有程序符合这两种理想模型... 真实的程序,往往一部分要吃 CPU,一部分要等待 IO
具体这个程序几成工作量是吃 CPU 的,几成工作量是等待 IO,不确定...
实践中确定线程数量:通过性能测试的方式,找到合适的值
例如,写一个服务器程序,服务器里通过线程池,多线程的处理用户请求,就可以对这个服务器进行性能测试,
比如构造一些请求,发送给服务器,要测试性能,这里的请求就需要构造很多,比如每秒发送 500 / 1000 / 2000...根据实际的业务场景,构造一个合适的值
根据这里不同的线程池的线程数,来观察,程序处理任务的速度,程序持有的 CPU 的占用率,
当线程数多了,整体的速度是会变快,但是 CPU 占用率也会高
当线程数少了,整体的速度是会变慢,但是 CPU 占用率也会下降
需要找到一个让程序速度能接受,并且CPU占用也合理这样的平衡点
不同类型的程序,因为单个任务,里面 CPU 上计算的时间和阻塞的时间是分布不相同的
因此随意想出来一个数字往往是不靠谱
搞了多线程,就是为了让程序跑的更快嘛,为啥要考虑不让CPU占用率太高呢?
对于线上服务器来说,要留有一定的冗余!随时应对一些可能的突发情况! (例如请求突然暴涨)
如果本身已经把 CPU 快占完了,这时候突然来---波请求的峰值,此时服务器可能直接就挂了
Executors
ThreadPoolExecutor 这个线程池用起来更麻烦一点(提供的功能更强大),所以才提供了工厂类,让我们用着更简单
标准库中提供了一个简化版本的线程池 Executors
本质是针对 ThreadPoolExecutor
进行了封装,提供了一些默认参数
java
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class demo6 {
public static void main(String[] args) {
// 创建一个固定的线程数目的线程池,参数指定了线程的个数
ExecutorService pool = Executors.newFixedThreadPool(10);
// 创建一个自动扩扩容的线程池,线程数量动态变化,会根据任务量自动扩容
Executors.newCachedThreadPool();
// 创建一个只有一个线程的线程池
Executors.newSingleThreadExecutor();
// 创建一个带有定时器功能的线程池,类似于 Timer,只不过执行的时候不是由扫描线程自己执行,而是由单独的线程池来执行
Executors.newScheduledThreadPool(10);
}
}
------使用 Executors:
构造出一个 10 个线程的线程池
线程池提供了一个重要的方法 submit 可以给线程池提交若干个任务
把 Runnable 描述的任务提交到线程池里,此时 run 方法不是主线程调用,是由线程池中的 10 个线程中的一个调用
java
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class demo6 {
public static void main(String[] args) {
ExecutorService pool = Executors.newFixedThreadPool(10);
pool.submit(new Runnable() {
@Override
public void run() {
System.out.println("hello threadPool!");
}
});
}
}
运行结果:
java
hello threadPool!
运行程序之后发现,main 线程结束了,但是整个进程没结束,线程池中的线程都是前台线程,此时会阻止进程结束 (前面定时器 Timer 也是同理)
------循环提交 1000 个任务:
java
public class ThreadDemo2 {
public static void main(String[] args) {
ExecutorService pool = Executors.newFixedThreadPool(10);
for (int i = 0; i < 1000; i++) {
int n = i;
pool.submit(new Runnable() {
@Override
public void run() {
System.out.println("hello pool! " + n);
}
});
}
}
}

此处要注意,当前是往线程池里放了 1000 个任务
1000 个任务就是由这 10 个线程来平均分配一下,差不多是一人执行 100 个,但是注意这里并非是严格的平均,可能有的多一个有的少一个,都正常 (随机调度)
(每个线程都执行完一个任务之后,再立即取下一个任务... 由于每个任务执行时间都差不多,因此每个线程做的任务数量就差不多)
进一步的可以认为,这 1000 个任务,就在一个队列中排队呢
这 10 个线程,就依次来取队列中的任务,取一个就执行一个,执行完了之后再执行下一个
工厂模式
ExecutorService pool = Executors.newFixedThreadPool(10);
此处 new 是方法名字的一部分,不是 new 关键字
这个操作,使用某个类的某个静态方法,直接构造出一个对象来 (相当于是把 new 操作,给隐藏到这样的方法后面了)
像这样的方法,就称为"工厂方法"
提供这个工厂方法的类,也就称为"工厂类",此处这个代码就使用了"工厂模式",这种设计模式
工厂模式:---句话表示,使用普通的方法,来代替构造方法,创建对象
为啥要代替?构造方法有坑!!!
坑就体现在,只构造一种对象,好办
如果要构造多种不同情况的对象,就难搞了...
------举个栗子:
有个类,用多种方法构造平面上的一个点
java
class Point {
// 使用笛卡尔坐标系提供的坐标,来构造点
public Point(double x, double y) {}
// 使用极坐标,来构造点
public Point(double r, double a) {}
}
很明显,这个代码有问题!!! 正常来说,多个构造方法
是通过"重载"的方式来提供的
重载要求的是,方法名相同,参数的个数或者类型不相同
而上述两个方法,方法名相同,参数个数相同,参数类型相同,无法构成重载,在 Java 上无法正确编译
为了解决这个问题,就可以使用工厂模式:
java
class PointFactory {
public static Point makePointByXY(double x, double y) {}
public static Point makePointByRA(double r, double a) {}
}
java
Point p = PointFactory.makePointByXY(10,20);
普通方法,方法名字没有限制的
因此有多种方式构造,就可以直接使用不同的方法名即可,此时,方法的参数是否要区分,已经不重要了
很多时候,设计模式,是在规避编程语言语法上的坑
不同的语言,语法规则不一样,因此在不同的语言上,能够使用的设计模式,可能会不同,有的设计模式,已经被融合在语言的语法内部了...
咱们日常谈到的设计模式,主要是基于 C++/Java/C# 这样语言来展开的,这里所说的设计模式不一定适合其他语言
像工厂模式,对于 Python 来说没什么价值,Python 构造方法,不像C++/Java 的这么坑,可以直接在构造方法中通过其他手段来做出不同版本的区分
------不能直接使用 i 的原因:
Lambda 变量捕获
很明显,此处的 run 方法属于 Runnable,这个方法的执行时机,不是立刻马上
而是在未来的某个节点 (后续在线程池的队列中,排到他了,就让对应的线程去执行)
fori 循环中的 i,这是主线程里的局部变量 (在主线程的栈上),随着主线程这里的代码块执行结束就销毁了
很可能主线程这里 for 执行完了,当前 run 的任务在线程池里还没排到呢,此时 i 就已经要销毁了

为了避免作用域的差异,导致后续执行 run 的时候 i 已经销毁,
于是就有了变量捕获,也就是让 run 方法把刚才主线程的 i 给往当前 run 的栈上拷贝一份...
(在定义 run 的时候,偷偷把 i 当前的值记住
后续执行 run 的时候,就创建一个也叫做 i 的局部变量,并且把这个值赋值过去...)
在 Java 中,对于变量捕获,做了一些额外的要求
在 JDK 1.8 之前,要求变量捕获,只能捕获 final 修饰的变量,后来发现,这么搞太麻烦了
在 1.8 开始,放松了一点标准,要求不一定非得带 final 关键字,只要代码中没有修改这个变量,也可以捕获
此处,i 是有修改的,不能捕获的
而n是没有修改的,虽然没有 final 修饰,但是也能捕获了
C++, JS 也有类似的变量捕获的语法,但是没有上述限制...
4.3、实现一个线程池
线程池里面有:
- 先能够描述任务 (直接使用 Runnable)
- 需要组织任务 (直接使用 BlockingQueue)
- 能够描述工作线程
- 还需要组织这些线程
- 需要实现,往线程池里添加任务
java
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;
// 实现一个固定线程数的线程池
class MyThreadPool {
// 1、描述一个任务,不像定时器涉及"时间",直接用 Runnable,不需要额外类
// 2、使用一个数据结构(阻塞队列)来组织若干个任务
private BlockingQueue<Runnable> queue = new LinkedBlockingQueue<>();
// 在构造方法中,创建若干个线程 (n 表示线程的数量)
public MyThreadPool(int n) {
// 在这里创建线程
for (int i = 0; i < n; i++) {
Thread t = new Thread(() -> {
while (true) { // 从队列中循环地取任务
try {
// 循环地获取任务队列中的任务,然后执行
// 队列为空,直接阻塞。队列非空,就获取内容
Runnable runnable = queue.take(); // 获取任务
runnable.run(); // 执行任务
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
t.start();
}
}
// 创建一个方法,能够允许程序员放任务到线程池中
// 注册任务给线程池,由这 10 个线程执行
public void submit(Runnable runnable) {
try {
queue.put(runnable);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
public class TestDemo {
public static void main(String[] args) {
MyThreadPool pool = new MyThreadPool(10);
for (int i = 0; i < 1000; i++) {
int n = i;
pool.submit(new Runnable() {
@Override
public void run() {
System.out.println("hello " + n);
}
});
}
}
}