多线程案例
1、案例一:线程安全的单例模式
单例模式
单例模式是设计模式的一种
什么是设计模式?
设计模式好比象棋中的 “棋谱”,红方当头炮,黑方马来跳,针对红方的一些走法,黑方应招的时候有一些固定的套路,按照套路来走局势就不会吃亏,也就发明了一组"棋谱",称为设计模式软件开发中也有很多常见的 “问题场景”,针对一些典型的场景,给出了一些典型的解决方案
有两个设计模式是非常常见的
其一是单例模式,其二是工厂模式
单例模式 => 单个 实例 (对象)
在有些场景中,有的特定的类,只能创建出一个实例,不应该创建多个实例
单例模式能保证某个类在程序中只存在唯一一份实例, 而不会创建出多个实例,这种单例模式,在实际开发中是非常常见,也非常有用的,开发中的很多 “概念” 天然就是单例,JDBC,DataSource,这样的对象,就应该是单例的
Java 里实现单例模式的方式有很多,单例模式的两种典型实现:
- 饿汉模式
- 懒汉模式
举例:洗碗
1.中午这顿饭,使用了4个碗,吃完之后,立即把这4个碗给洗了~~[饿汉]
⒉中午这顿饭,使用了4个碗,吃完之后,先不洗。晚上这顿,只需要2个碗,然后就只洗2个即可~~[懒汉]
第二种是更加高效的操作,—般是褒义词 (在计算机中提高效率)
饿汉的单例模式,是比较着急地去进行创建实例
懒汉的单例模式,是不太着急地去创建实例,只是在用的时候,才真正创建
1.1、饿汉模式
private static Singleton instance;
注意:
-
类里面使用 static 修饰的成员,应该叫做 “类成员” => “类属性 / 类方法”,相当于这个属性对应的内存空间在类对象里面
不加 static 修饰的成员,叫做 “实例成员” => “实例属性 / 实例方法”静态变量 属于类,存储在方法区,随着的类加载而加载,
成员变量 属于对象,存储在堆中,随着对象的创建而创建-
static 是让当前 instance 属性是类属性了
-
一个类对象在一个 Java 进程中是唯一实例的 (JVM保证的),类属性是长在类对象上的,进一步的也就保证了类的 static 成员也是只有一份的
-
-
类对象 != 对象
类:就相当于实例的模板,基于模板可以创建出很多的对象来
对象(实例)- java 代码中的每个类,都会在编译完成后得到 .class文件,类对象,就是 .class 文件
JVM 运行时就会加载这个 .class 文件读取其中的二进制指令,并解析,在内存中构造出对应的类对象 (类加载),形如 Singleton.class) - 类对象里就有 .class 文件中的一切信息
包括:类名是啥,类里有哪些属性,每个属性叫啥名字,每个属性叫啥类型,每个属性是 public private…
基于这些信息,才能实现反射
- java 代码中的每个类,都会在编译完成后得到 .class文件,类对象,就是 .class 文件
// 通过 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、懒汉模式 - 单线程
class Singleton2 {// 1.就不是立即就初始化实例.private static Singleton2 instance = null;// 2.把构造方法设为 privateprivate 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 的时候都是针对同一个对象进行的加锁)
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)
class Singleton2 {// 1.就不是立即就初始化实例.private static Singleton2 instance = null;// 2.把构造方法设为 privateprivate 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 就变成了刚才那个典型的错误代码,加锁没有把读+修改这操作进行打包
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 即可
// 这个代码是完全体的线程安全单例模式
class Singleton2 {// 1.就不是立即就初始化实例.private static volatile Singleton2 instance = null;// 2.把构造方法设为 privateprivate 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 等方法,但是这些方法不带有阻塞特性
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”,队列为空,此时再次取元素,就会进入阻塞,等待其他线程往队列中添加元素
生产者消费者模型
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 重合
-
浪费一个格子,head == tail 认为是空
head == tail+1 认为是满 -
额外创建一个变量,size 记录元素的个数,size == 0 空
size == arr.length 满
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 到末尾 重新等于 0if (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()); // 1System.out.println(queue.take()); // 2System.out.println(queue.take()); // 3System.out.println(queue.take()); // 4}
}
阻塞队列 - 线程安全
当前已经完成了普通队列的实现,加上阻塞功能,阻塞功能意味着,队列要在多线程环境下使用 。保证多线程环境下,调用这里的 put 和 take 没有问题的,
put 和 take 里面的每一行代码都是在操作公共的变量。既然如此,直接就给整个方法加锁即可
(加上 synchronized
已经是线程安全的了)
// 入队列
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,标准库就是建议这么写的
while (size == items.length) {// 队列满了,暂时先直接返回// return;this.wait();
} while (size == 0) {// 如果队列为空,返回一个非法值// return null;this.wait();
}
代码:
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 中的 waitthis.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 到末尾 重新等于 0if (head >= items.length) {head = 0;}// 4、数组元素个数--size--;// take 成后,唤醒 put 中的 waitthis.notify();}return ret;}
}public class ThreadDemo2 {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();}
}
3、案例三:定时器
3.1、标准库中的定时器 Timer
定时器也是软件开发中的一个重要组件,类似于一个 “闹钟”,达到一个设定的时间之后,就唤醒并执行之前设定好的任务
生活中闹钟,有两种风格:1.指定特定时刻,提醒。2.指定特定时间段之后,提醒
这里的定时器,不是提醒,是执行一个实现准备好的方法/代码
定时器是一种实际开发中非常常用的组件
比如网络通信中,很容易出现 “连不上” 的情况,不能一直等,就可以使用定时器来进行 “止损”,如果对方 500ms 内没有返回数据,则断开连接尝试重连
比如一个 Map,希望里面的某个 key 在 3s 之后过期(自动删除)类似于这样的场景就需要用到定时器
join
(指定超时时间),sleep
(休眠指定时间,是基于系统内部的定时器,来实现的)
先介绍标准库的定时器用法,然后再看看如何自己实现一个定时器
标准库中提供了一个 Timer 类,Timer 类的核心方法为 schedule (安排),这个方法的效果是给定时器,注册一个任务,任务不会立即执行,而是在指定时间进行执行
schedule 包含两个参数,第一个参数指定即将要执行的任务代码 (Runnable),第二个参数指定多长时间之后执行 (单位为毫秒)
import java.util.Timer;
import java.util.TimerTask;public class demo5 {public static void main(String[] args) {Timer timer = new Timer();timer.schedule(new TimerTask() {@Overridepublic void run() {System.out.println("hello time");}}, 3000);timer.schedule(new TimerTask() {@Overridepublic void run() {System.out.println("hello time2");}}, 2000);System.out.println("main");}
}
运行结果:
首先打印:main
几秒后 打印:hello time2
然后打印:hello time
但是程序没有结束
Timer 内部是有专门的线程,来负责执行注册的任务的
Timer 内部都需要:
- 管理很多的任务
- 执行时间到了的任务
自己实现一个定时器:一个定时器是可以注册 N 个任务的,N 个任务会按照最初约定的时间,按顺序执行
1). 有一个扫描线程,负责判定时间到/执行任务 (单独在定时器内部,搞个线程,让这个线程周期性地扫描,判定任务是否是到时间了,如果到时间了,就执行,没到时间就再等等)
2). 还要有一个数据结构(优先级队列),来保存所有被注册的任务
3.2、描述任务
创建一个专门的类来表示一个定时器中的任务 (TimerTask)
队列中存放的任务就是 Runnable,Runnable 只是描述了任务内容,还需要描述任务什么时候被执行
// 创建一个类,表示一个任务
class MyTask {// 任务具体要做什么private Runnable runnable;// 任务什么时候执行 (任务要执行的毫秒级时间戳)private long time;public MyTask(Runnable runnable, long time) {this.runnable = runnable;this.time = time;}// 获取当前任务时间public long getTime() {return time;}// 执行任务public void run() {runnable.run();}
}
3.2、组织任务
使用一定的数据结构把一些任务给放到一起,通过一定的数据结构来组织
假设现在有多个任务过来了—个小时之后,去做作业,三个小时之后,去上课,10分钟之后,去休息—会
安排任务的时候,这些任务的顺序是无序的,但是执行任务的时候,这就不是无序的了,按照时间先后来执行!
咱们的需求就是,能够快速找到所有任务中,时间最小的任务
此时我们发现可以用堆,在标准库中,有一个专门的数据结构 PriorityQueue
咱们这里的每个任务都是带个"时间"多久之后执行,一定是时间越靠前,就先执行
按照时间小的,作为优先级高
此时队首元素,就是整个队列中,最先要执行的任务
虽然队列中的元素顺序,不能完全确定,但是可以知道,队首元素,一定是时间最靠前的
此时,扫描线程,只需要扫一下队首元素即可,不必遍历整个队列
private PriorityQueue<> queue = new PriorityQueue<>();
但是此处的优先级队列要在多线程环境下使用,要考虑到线程安全问题,可能在多个线程里进行注册任务,同时还有一个专门的线程来取任务执行,此处的队列就需要注意线程安全问题
所以我们得使用 PriorityBlockingQueue
,既带有优先级又带有阻塞队列
private PriorityBlockingQueue<> queue = new PriorityBlockingQueue<>();
// 自己写个简单的定时器
class MyTimer {// 扫描线程private Thread t = null;// 定时器内部要能够存放多个任务 阻塞优先级队列保存任务private PriorityBlockingQueue<MyTask> queue = new PriorityBlockingQueue<>();public MyTimer() {// TODO}/** 定时器提供一个 schedule 方法,注册任务* @param runnable 要执行的任务* @param after 多长时间(毫秒)之后执行*/public void schedule(Runnable runnable, long after) {MyTask task = new MyTask(runnable, System.currentTimeMillis() + after);queue.put(task); // 任务放入堆}
}
——执行时间到了的任务:
需要先执行时间最考前的任务
就需要有一个扫描线程,不停地去检查当前优先队列的队首元素,看看当前最靠前的这个任务是不是时间到了
在定时器构造方法中 创建线程进行扫描
阻塞队列,只能先把元素出队列才好判定,不满足还得放回去
这不像普通队列,可以直接取队首元素判定的
public MyTimer() {t = new Thread(() -> {while (true) {try {// 取出队首元素,再比较这个任务有没有到时间MyTask myTask = queue.take();long curTime = System.currentTimeMillis();if (curTime < (myTask).getTime()) { // 1.没到时间,任务放回堆queue.put(myTask);} else { // 2.时间到了,执行任务myTask.run();}} catch (InterruptedException e) {e.printStackTrace();}}});t.start();
}
3.3、两个缺陷
上述代码中存在两个非常严重的问题:
第—个缺陷: MyTask 没有指定比较规则
像刚才咱们实现的 MyTask 这个类的比较规则,并不是默认就存在的,这个需要咱们手动指定,按照时间大小来比较的
标准库中的集合类,很多都是有一定的约束限制的,不是随便拿个类都能放到这些集合类里面去的
——测试:
public class ThreadDemo {public static void main(String[] args) {MyTimer myTimer = new MyTimer();myTimer.schedule(new Runnable() {@Overridepublic void run() {System.out.println("任务1");}}, 1000);myTimer.schedule(new Runnable() {@Overridepublic void run() {System.out.println("任务2");}}, 2000);}
}
让 MyTask 类实现 Comparable接口,另外也可以使用 Comparator单独写个比较器
修改:
class MyTask implements Comparable<MyTask> { @Overridepublic int compareTo(MyTask o) {return (int) (this.time - o.time);}
}
第二个缺陷: 如果不加任何限制,这个循环就会执行的非常快
while (true) 转的太快了, 造成了无意义的 CPU 浪费
如果队列中的任务是空着的,就还好,这个线程就再这里阻塞了 (没问题)
就怕队列中的任务不空,并且任务时间还没到
上述操作,称为 “忙等”,等确实是等了,但是又没闲着。既没有实质性的工作产出,同时又没有进行休息
等待是要释放 CPU 资源的。让 CPU 干别的事情。但是忙等。既进行了等待。又占用着CPU资源,忙等这种操作是非常浪费 CPU 的。
既然是指定一个等待时间,为啥不直接用 sleep
,而是要再用一下 wait 呢
sleep 不能被中途唤醒的,wait 能够被中途唤醒
在等待过程中,可能要插入新的任务! 新的任务是可能出现在之前所有任务的最前面的,使用 sleep 可能会错过新任务的执行时间
可以基于 wait
这样的机制来实现
wait 有一个版本,指定等待时间 (不需要 notify,时间到了自然唤醒),计算出当前时间和任务的目标之间的时间差,就等待这么长时间即可
在 schedule
操作中,就需要加上一个 notify 操作。使用 wait 等待,每次有新任务来了 (有人调用 schedule),就 notify 一下,重新检查下时间,重新计算要等待的时间
这样扫描线程既可以指定时间等待,也可以随时唤醒。让等待不占用 CPU,同时不错过新任务
修改:
3.4、问题三:notify 空
代码写到这里,还有个很严重的问题,这个问题,还是和线程安全 / 随机调度密切相关的
考虑一个极端情况:
假设代码执行到 put 这一行,这个线程就从 cpu 调度走了…
当线程回来之后,接下来就要进行 wait 操作,此时 wait 的时间已经是算好了的
比如 curTime 是 13:00,任务 getTime 是 14:00 即将要 wait 1小时 (此时还没执行 wait,因为线程在 put 就被调走了)
此时,另一个线程调用了 schedule 添加新任务,新任务是 13:30 执行
此处调用 schedule 会执行 notify,通知 wait 唤醒
由于扫描线程 wait 还没执行呢!
所以,此处的 notify 不会产生任何的唤醒操作! 此时此刻,新的任务虽然已经插入了队列,新的任务也是在队首紧接着扫描线程回到 cpu了,此时等待时间仍然是 1小时
因此,13:30 新的任务,就被错过了!
了解了上述问题之后,就不难发现,问题出现的原因,是因为当前 take 操作,和 wait 操作,并非是原子的
如果在 take 和 wait 之间加上锁,保证在这个过程中,不会有新的任务过来,问题自然解决
(换句话说,只要保证每次 notify 时,确实都正在 wait)
修改:
此处只需要把锁的范围放大,放大之后,此时就可以保证执行 notify 的时候,wait 是确实已经执行完了
就可以预防出现 notify 的时候还没有准备好,wait这样的情况了
代码:
import java.util.concurrent.PriorityBlockingQueue;// 创建一个类,表示一个任务
class MyTask implements Comparable<MyTask> {// 任务具体要做什么private Runnable runnable;// 任务什么时候执行 (任务要执行的毫秒级时间戳)private long time;public MyTask(Runnable runnable, long time) {this.runnable = runnable;this.time = time;}// 获取当前任务时间public long getTime() {return time;}// 执行任务public void run() {runnable.run();}@Overridepublic int compareTo(MyTask o) {return (int) (this.time - o.time);}
}// 自己写个简单的定时器
class MyTimer {// 扫描线程private Thread t = null;// 定时器内部要能够存放多个任务 阻塞优先级队列保存任务private PriorityBlockingQueue<MyTask> queue = new PriorityBlockingQueue<>();// 扫描线程public MyTimer() {t = new Thread(() -> {while (true) {try {synchronized (this) {// 取出队首元素,再比较这个任务有没有到时间MyTask myTask = queue.take();long curTime = System.currentTimeMillis();if (curTime < (myTask).getTime()) { // 1.没到时间,任务放回堆queue.put(myTask);// 在 put 后 waitthis.wait(myTask.getTime() - curTime);} else { // 2.时间到了,执行任务myTask.run();}}} catch (InterruptedException e) {e.printStackTrace();}}});t.start();}/** 定时器提供一个 schedule 方法,注册任务* @param runnable 要执行的任务* @param after 多长时间(毫秒)之后执行*/public void schedule(Runnable runnable, long after) {// 注意换算,time 是一个时间戳,不是绝对的时间戳的值MyTask task = new MyTask(runnable, System.currentTimeMillis() + after);queue.put(task); // 任务放入堆// 有新任务加入 notifysynchronized (this) {this.notify();}}
}public class ThreadDemo {public static void main(String[] args) {MyTimer myTimer = new MyTimer();myTimer.schedule(new Runnable() {@Overridepublic void run() {System.out.println("任务1");}}, 1000);myTimer.schedule(new Runnable() {@Overridepublic void run() {System.out.println("任务2");}}, 2000);}
}
运行结果:
任务1
任务2
总结:
- 描述—个任务: runnable + time
- 使用优先阻塞队列来组织若干个任务,PriorityBlockingQueue
- 实现 schedule 方法来注册任务到队列中
- 创建一个扫描线程这个扫描线程不停地获取到队首元素,并且判定时间是否到达
- 注意:让 MyTask 类能够支持比较,注意解决这里的忙等问题,notity 时 wait 没有执行问题
4、案例四:线程池
4.1、用户态 / 内核态
进程,比较重,频繁创建销毁,开销大
解决方案:进程池 or 线程
线程 (轻量级进程),虽然比进程轻了,创建线程比创建进程更高效;销毁线程比销毁进程更高效;调度线程比调度进程更高效…但是如果创建销毁的频率进一步增加,仍然会发现开销还是有的
解决方案:线程池 or 协程/纤程 (还没有被加入 Java 标准库。Go 内置了协程,因此使用 Go 开发并发编程程序是有一定优势的)
使用线程池,来降低创建/销毁线程的开销
把线程提前创建好,放到池子里
1.后面需要用线程,直接从池子里取,就不必从系统这边申请了。线程用完了,也不是还给系统,而是2.放回池子里,以备下次再用
这两个动作比创建/销毁更高效的
——为森么线程放在池子里,就比从系统这边申请释放来的更快呢?
程序中的“用户态”,
用户态执行的是程序猿自己写的代码,就在最上面的应用程序这一层来运行的。这里的代码都称为 “用户态” 运行的代码。
程序中的"内核态",
内核会给程序提供一些 API,称为系统调用,有些代码,需要调用操作系统的 API,进一步的逻辑就会在内核中执行,内核态进行的操作都是在操作系统内核中完成的。
例如,调用一个 System.out.println。本质上要经过 write 系统调用,进入到内核中,内核执行—堆逻辑,控制显示器输出字符串…
在内核中运行的代码,称为 “内核态” 运行的代码。
创建/销毁线程,需要操作系统内核完成 (创建线程本质是在内核中搞个PCB,加到链表里)
调用的 Thread.start 其实归根结底,也是要进入内核态来运行。
此时你不清楚内核身上背负着多少任务 (内核不是只给你一个应用程序服务,给所有的程序都要提供服务)
因此,当使用系统调用,执行内核代码的时候,无法确定内核都要做哪些工作,整体过程 "不可控” 的
而把创建好的线程放到" 池子里",由于池子就是用户态实现的
这个放到池子 / 从池子取,这个过程不需要涉及到内核态,就是纯粹的用户态代码就能完成
一般认为,纯用户态的操作,效率要比经过内核态处理的操作,要效率更高。
例如:滑稽老铁去银行处理业务,柜员说需要省份证复印件
1、滑稽老铁,自己来到大厅的复印机这里进行复印。纯用户态的操作。(完全自己完成的,整体的过程可控)
2、滑稽老铁,把身份证给柜员,让柜员去帮他复印,这个过程就相当于交给了内核态完成一些工作。(不是自己完成的,整体不可控的)
咱们也不知道柜员身上有多少任务。可能从柜台消失之后,是给你复印去了。
但是他可能还会顺手做一些其他的事情。数一下钱 / 清点一下票据 / 上个厕所 / 回个消息…
认为内核态效率低,倒不是说—定就真的低。而是代码进入了内核态,就不可控了。
内核什么时候给你把活干完,把结果给你。(有的时候快,有的时候慢)
4.2、标准库中的线程池 ThreadPoolExecutor
ThreadPoolExecutor
先学习—下 Java 标准库中,线程池的使用,然后再自己实现一个线程池
标准库的线程池叫做 ThreadPoolExecutor
这个东西用起来有点麻烦
在 java.util.concurrent (concurrent 并发) 下,
Java 中很多和多线程相关的组件都在这个 concurrent 包里
——构造方法:
(针对 ThreadPoolExecutor 这里的构造方法参数的解释,是高频考点,重点掌握!!!)
ThreadPoolExecutor(int corePoolSize,int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue<Runnable> workQueue, ThreadFactory threadFactory, RejectedExecutionHandler handler)Creates a new ThreadPoolExecutor with the given initial parameters.// 创建一个新 ThreadPoolExecutor 给定的初始参数
int corePoolSize
核心线程数 (正式员工的数量)
int maximumPoolSize
最大线程数 (正式员工 + 临时工)
把一个线程池,想象成是一个"公司",公司里有很多员工在干活
把线程(员工)分成两类:
1、正式员工(核心线程),正式员工允许摸鱼
2、临时工,临时工不允许摸鱼
开始的时候,假设公司要完成的工作不多,正式员工完全就能搞定,就不需要临时工。
如果公司的任务突然猛增了,正式员工加班也搞不定了,就需要雇佣一批临时工 (更多的线程)
但是一个程序任务不一定始终都很多,过了一段时间,工作量又降低了,现在的活正式员工也就能搞定了,甚至还有富裕 (正式员工可以摸鱼了) 临时工就更摸鱼了,就需要对现有的线程(临时工)进行一定的淘汰
整体的策略,正式员工保底,临时工动态调节
long keepAliveTime
允许临时工摸鱼的时间
TimeUnit unit
时间的单位 (s, ms, us…)
BlockingQueue<Runnable> workQueue,
任务队列
线程池会提供一个 submit
方法让程序猿把任务注册到线程池中,加到这个任务队列中
每个工作线程都是再不停尝试 take 的,如果有任务,take 成功,没有,就阻塞。
ThreadFactory threadFactory ,
线程工厂类,用于创建线程,线程池是需要创建线程的
RejectedExecutionHandler handler
描述了线程池的 拒绝策略,也是一个特殊的对象,描述了当线程池任务队列满了,如果继续添加任务会有什么样的行为…
以下是标准库提供的四个拒绝策略:
- 直接抛异常 RejectedExecutionException
- 多出来的任务,谁加的,谁负责执行
- 直接丢弃最老的任务
- 丢弃最新的任务
比如我现在有很多任务要完成,突然有人给我来了个新的活,但是我已经非常忙,任务队列已经满了,导致我 CPU 烧了,新的活干不了 (1)
我说,我没空,你自己干吧 (2)
放下手里的工作,去做新的活 (3)
拒绝新的活,还是做原有的工作 (4)
线程池中线程的个数:
虽然线程池的参数这么多,但是使用的时候最最重要的参数,还是第一组参数,线程池中线程的个数
——有一个程序,这个程序要 并发的/多线程的 来完成一些任务,如果使用线程池的话,这里的线程数设为多少合适? [不仅仅是面试题,也是工作中需要思考的话题]
针对这个问题,网上的很多说法,是不正确的!
网上一种典型的回答:假设机器有 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
进行了封装,提供了一些默认参数
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 个线程中的一个调用
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() {@Overridepublic void run() {System.out.println("hello threadPool!");}});}
}
运行结果:
hello threadPool!
运行程序之后发现,main 线程结束了,但是整个进程没结束,线程池中的线程都是前台线程,此时会阻止进程结束 (前面定时器 Timer 也是同理)
——循环提交 1000 个任务:
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() {@Overridepublic void run() {System.out.println("hello pool! " + n);}});}}
}
此处要注意,当前是往线程池里放了 1000 个任务
1000 个任务就是由这 10 个线程来平均分配一下,差不多是一人执行 100 个,但是注意这里并非是严格的平均,可能有的多一个有的少一个,都正常 (随机调度)
(每个线程都执行完一个任务之后,再立即取下一个任务… 由于每个任务执行时间都差不多,因此每个线程做的任务数量就差不多)
进一步的可以认为,这 1000 个任务,就在一个队列中排队呢
这 10 个线程,就依次来取队列中的任务,取一个就执行一个,执行完了之后再执行下一个
工厂模式
ExecutorService pool = Executors.newFixedThreadPool(10);
此处 new 是方法名字的一部分,不是 new 关键字
这个操作,使用某个类的某个静态方法,直接构造出一个对象来 (相当于是把 new 操作,给隐藏到这样的方法后面了)
像这样的方法,就称为“工厂方法”
提供这个工厂方法的类,也就称为"工厂类",此处这个代码就使用了“工厂模式",这种设计模式
工厂模式:—句话表示,使用普通的方法,来代替构造方法,创建对象
为啥要代替?构造方法有坑!!!
坑就体现在,只构造一种对象,好办
如果要构造多种不同情况的对象,就难搞了…
——举个栗子:
有个类,用多种方法构造平面上的一个点
class Point {// 使用笛卡尔坐标系提供的坐标,来构造点public Point(double x, double y) {}// 使用极坐标,来构造点public Point(double r, double a) {}
}
很明显,这个代码有问题!!! 正常来说,多个构造方法
是通过"重载”的方式来提供的
重载要求的是,方法名相同,参数的个数或者类型不相同
而上述两个方法,方法名相同,参数个数相同,参数类型相同,无法构成重载,在 Java 上无法正确编译
为了解决这个问题,就可以使用工厂模式:
class PointFactory {public static Point makePointByXY(double x, double y) {}public static Point makePointByRA(double r, double a) {}
}
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)
- 能够描述工作线程
- 还需要组织这些线程
- 需要实现,往线程池里添加任务
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() {@Overridepublic void run() {System.out.println("hello " + n);}});}}
}