单例模式是设计模式的一种
什么是设计模式?
设计模式好比象棋中的 “棋谱”,红方当头炮,黑方马来跳,针对红方的一些走法,黑方应招的时候有一些固定的套路,按照套路来走局势就不会吃亏,也就发明了一组"棋谱",称为设计模式软件开发中也有很多常见的 “问题场景”,针对一些典型的场景,给出了一些典型的解决方案
有两个设计模式是非常常见的
其一是单例模式,其二是工厂模式
单例模式 => 单个 实例 (对象)
在有些场景中,有的特定的类,只能创建出一个实例,不应该创建多个实例
单例模式能保证某个类在程序中只存在唯一一份实例, 而不会创建出多个实例,这种单例模式,在实际开发中是非常常见,也非常有用的,开发中的很多 “概念” 天然就是单例,JDBC,DataSource,这样的对象,就应该是单例的
Java 里实现单例模式的方式有很多,单例模式的两种典型实现:
- 饿汉模式
- 懒汉模式
举例:洗碗
1.中午这顿饭,使用了4个碗,吃完之后,立即把这4个碗给洗了~~[饿汉]
⒉中午这顿饭,使用了4个碗,吃完之后,先不洗。晚上这顿,只需要2个碗,然后就只洗2个即可~~[懒汉]
第二种是更加高效的操作,—般是褒义词 (在计算机中提高效率)
饿汉的单例模式,是比较着急地去进行创建实例
懒汉的单例模式,是不太着急地去创建实例,只是在用的时候,才真正创建
private static Singleton instance;
注意:
类里面使用 static 修饰的成员,应该叫做 “类成员” => “类属性 / 类方法”,相当于这个属性对应的内存空间在类对象里面
不加 static 修饰的成员,叫做 “实例成员” => “实例属性 / 实例方法”
静态变量 属于类,存储在方法区,随着的类加载而加载,
成员变量 属于对象,存储在堆中,随着对象的创建而创建
static 是让当前 instance 属性是类属性了
一个类对象在一个 Java 进程中是唯一实例的 (JVM保证的),类属性是长在类对象上的,进一步的也就保证了类的 static 成员也是只有一份的
类对象 != 对象
类:就相当于实例的模板,基于模板可以创建出很多的对象来
对象(实例)
// 通过 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,仅仅是读取了变量的内容
如果多个线程只是读同一个变量,不修改,此时仍然是线程安全的
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 的文件,但是只先加载这—个屏幕中能显示出来的部分) [懒汉]
真正要解决的问题,是实现一个线程安全的单例模式
线程安全不安全,具体指的是多线程环境下,并发的调用 getInstance 方法,是否可能存在 bug
——懒汉模式 与 饿汉模式 在多线程环境下,是否线程安全?
饿汉模式这里,多线程调用,只是涉及到"读操作"
懒汉模式中,包含读操作和修改操作,存在线程安全问题
上述罗列出了一种可能的排序情况,实际情况是有很多种
通过上述分析,就可以看出,当前这个代码中是存在bug,可能导致实例被创建出多份来
如何保证懒汉模式的线程安全呢?加锁!
可不是说,代码中有了 synchronized 就—定线程安全,synchronized 加的位置也得正确,不能随便写
本质是读,比较,写,这三个操作不是原子的。这就导致了 t2 读到的值可能是 t1 还没来得及写的(脏读),导致多次 new;所以要把锁加在外面,此时才能保证 读操作 和 修改操作 是一个整体
使用这里的类对象作为锁对象
(类对象在一个程序中只有唯——份,就能保证多个线程调用 getInstance 的时候都是针对同一个对象进行的加锁)
public static Singleton2 getInstance() {
synchronized (Singleton.class) { // 类对象作为锁对象
if (instance == null) {
instance = new Singleton2();
}
}
return instance;
}
当前虽然加锁之后,线程安全问题得到解决了,但是又有了新的问题:
对于刚才这个懒汉模式的代码来说, 线程不安全,是发生在 instance 被初始化之前的,未初始化的时候,多线程调用 getinstance,就可能同时涉及到读和修改,但是一旦 instance 被初始化之后,后续调用 getlnstance,此时 instance 的值一定是非空的,if 判断不成立,也就线程安全了,因此就会直接触发 return,getlnstance 就只剩下两个读操作,相当于一个是比较操作,一个是返回操作,这两个都是读操作
而按照上述的加锁方式,无论代码是初始化之后,还是初始化之前。加锁是有开销的,每次调用 getinstance 都会进行加锁,也就意味着即使是初始化之后 (已经线程安全了),但是仍然存在大量的锁竞争 加锁确实能让代码保证线程安全,也付出了代价 (程序的速度就慢了)
所以为啥不推荐使用 vector hashtable ?? 就是因为这俩类里面就是在无脑加锁
改进方案,在加锁这里再加上一层条件判定即可,对象还没创建,才进行加锁;对象创建过了,就不再加锁了,
条件就是当前是否已经初始化完成 (instance == null)
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 就变成了刚才那个典型的错误代码,加锁没有把读+修改这操作进行打包
public static Singleton2 getInstance() {
if (instance == null) { // 判定的是是否要加锁
synchronized (Singleton.class) {
instance = new Singleton2();
}
}
return instance;
}
当前这个代码中还存在一个重要的问题
如果多个线程,都去调用这里的 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.把构造方法设为 private
private Singleton2() {}
// 3.提供一个方法来获取到上述单例的实例
// 只有当真正需要用到这个实例的时候,才会真正去创建这个实例
public static Singleton2 getInstance() {
if (instance == null) { // 判定的是是否要加锁
synchronized (Singleton.class) { // 类对象作为锁对象
if (instance == null) { // 判定的是是否要创建实例
instance = new Singleton2();
}
}
}
return instance;
}
}
队列先进先出
阻塞队列同样也是一个符合先进先出规则的特殊队列,相比于普通队列,阻塞队列又有一些其他方面的功能!
1、线程安全
2、产生阻塞效果
1). 如果队列为空,执行出队列操作,就会出现阻塞,阻塞到另一个线程往队列里添加元素(队列不为空)为止
2). 如果队列为满,执行入队列操作,也会出现阻塞,阻塞到另一个线程从队列里取走元素(队列不为满)为止
消息队列,也是特殊的队列,相当于是在阻塞队列的基础上,加上了个 "消息的类型”,按照制定类别进行先进先出
此时咱们谈到的这个消息队列, 仍然是一个 “数据结构”
基于上述特性,就可以实现 “生产者消费者模型”
生产者消费者模型,是实际开发中非常有用的一种多线程开发手段!尤其是在服务器开发的场景中
假设,有两个服务器, AB,A作为入口服务器直接接收用户的网络请求,B作为应用服务器,来给A提供一些数据
实现了发送发和接受方之间的解耦
——开发中典型的场景:服务器之间的相互调用
客户端发送一个充值请求给 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 来说,也完全感知不到…
能够对于请求进行 “削峰填谷”,保证系统的稳定性
——三峡大坝,起到的效果,就是 “削峰填谷”
到了雨季,水流量就会很大,三峡大坝关闸蓄水,承担了上游的冲击,保护下游水流量不是太大,不至于出现洪灾——削峰
到了早季,水流量很小,三峡大坝就开闸放水,给下游提供更充分的水源,避免出现干旱灾害——填谷
什么时候上游涨水,真的是难以预测,防患于未然
上游,就是用户发送的请求。下游就是一些执行具体业务的服务器。
用户发多少请求?不可控的,有的时候,请求多,有的时候请求少…
——未使用生产者消费者模型:
未使用生产者消费者模型的时候,如果请求量突然暴涨 (不可控)
A暴涨 => B暴涨
A 作为入口服务器,计算量很轻,请求暴涨,问题不大
B 作为应用服务器,计算量可能很大,需要的系统资源也更多,如果请求更多了,需要的资源进一步增加,如果主机的硬件不够,可能程序就挂了
——使用生产者消费者模型:
由于阻塞队列没啥计算量,就只是单纯的存个数据,就能抗住更大的压力
B 这边仍然按照原来的速度来消费数据,不会因为A的暴涨而引起暴涨,B就被保护的很好,就不会因为这种请求的波动而引起崩溃
“削峰”:这种峰值很多时候不是持续的,就一阵,过去了就又恢复了
“填谷”:B 仍然是按照原有的频率来处理之前积压的数据
实际开发中使用到的 “阻塞队列” 并不是一个简单的数据结构了,而是一个 / 一组专门的服务器程序,并且它提供的功能也不仅仅是阻塞队列的功能,还会在这基础之上提供更多的功能 (对于数据持久化存储,支持多个数据通道,支持多节点容灾冗余备份,支持管理面板,方便配置参数.……)
这样的队列又起了个新的名字,"消息队列” (未来开发中广泛使用到的组件)
kafka 就是业界一个比较主流的消息队列,消息队列的实现,有很多种,核心功能都差不多
学会使用 Java 标准库中的阻塞队列,基于这个内置的阻塞队列,实现一个简单的生产者消费者模型
再自己**实现一个简单的阻塞队列 **(为了更好地理解阻塞队列的原理,多线程,尤其是锁操作)
在 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 到末尾 重新等于 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
已经是线程安全的了)
// 入队列
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 中的 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<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();
}
}
定时器也是软件开发中的一个重要组件,类似于一个 “闹钟”,达到一个设定的时间之后,就唤醒并执行之前设定好的任务
生活中闹钟,有两种风格: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() {
@Override
public void run() {
System.out.println("hello time");
}
}, 3000);
timer.schedule(new TimerTask() {
@Override
public void run() {
System.out.println("hello time2");
}
}, 2000);
System.out.println("main");
}
}
运行结果:
首先打印:main
几秒后 打印:hello time2
然后打印:hello time
但是程序没有结束
Timer 内部是有专门的线程,来负责执行注册的任务的
Timer 内部都需要:
- 管理很多的任务
- 执行时间到了的任务
自己实现一个定时器:一个定时器是可以注册 N 个任务的,N 个任务会按照最初约定的时间,按顺序执行
1). 有一个扫描线程,负责判定时间到/执行任务 (单独在定时器内部,搞个线程,让这个线程周期性地扫描,判定任务是否是到时间了,如果到时间了,就执行,没到时间就再等等)
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();
}
}
使用一定的数据结构把一些任务给放到一起,通过一定的数据结构来组织
假设现在有多个任务过来了—个小时之后,去做作业,三个小时之后,去上课,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();
}
上述代码中存在两个非常严重的问题:
第—个缺陷: MyTask 没有指定比较规则
像刚才咱们实现的 MyTask 这个类的比较规则,并不是默认就存在的,这个需要咱们手动指定,按照时间大小来比较的
标准库中的集合类,很多都是有一定的约束限制的,不是随便拿个类都能放到这些集合类里面去的
——测试:
public class ThreadDemo {
public static void main(String[] args) {
MyTimer myTimer = new MyTimer();
myTimer.schedule(new Runnable() {
@Override
public void run() {
System.out.println("任务1");
}
}, 1000);
myTimer.schedule(new Runnable() {
@Override
public void run() {
System.out.println("任务2");
}
}, 2000);
}
}
让 MyTask 类实现 Comparable接口,另外也可以使用 Comparator单独写个比较器
修改:
class MyTask implements Comparable<MyTask> {
@Override
public 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,同时不错过新任务
修改:
代码写到这里,还有个很严重的问题,这个问题,还是和线程安全 / 随机调度密切相关的
考虑一个极端情况:
假设代码执行到 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();
}
@Override
public 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 后 wait
this.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); // 任务放入堆
// 有新任务加入 notify
synchronized (this) {
this.notify();
}
}
}
public class ThreadDemo {
public static void main(String[] args) {
MyTimer myTimer = new MyTimer();
myTimer.schedule(new Runnable() {
@Override
public void run() {
System.out.println("任务1");
}
}, 1000);
myTimer.schedule(new Runnable() {
@Override
public void run() {
System.out.println("任务2");
}
}, 2000);
}
}
运行结果:
任务1
任务2
总结:
- 描述—个任务: runnable + time
- 使用优先阻塞队列来组织若干个任务,PriorityBlockingQueue
- 实现 schedule 方法来注册任务到队列中
- 创建一个扫描线程这个扫描线程不停地获取到队首元素,并且判定时间是否到达
- 注意:让 MyTask 类能够支持比较,注意解决这里的忙等问题,notity 时 wait 没有执行问题
进程,比较重,频繁创建销毁,开销大
解决方案:进程池 or 线程
线程 (轻量级进程),虽然比进程轻了,创建线程比创建进程更高效;销毁线程比销毁进程更高效;调度线程比调度进程更高效…但是如果创建销毁的频率进一步增加,仍然会发现开销还是有的
解决方案:线程池 or 协程/纤程 (还没有被加入 Java 标准库。Go 内置了协程,因此使用 Go 开发并发编程程序是有一定优势的)
使用线程池,来降低创建/销毁线程的开销
把线程提前创建好,放到池子里
1.后面需要用线程,直接从池子里取,就不必从系统这边申请了。线程用完了,也不是还给系统,而是2.放回池子里,以备下次再用
这两个动作比创建/销毁更高效的
——为森么线程放在池子里,就比从系统这边申请释放来的更快呢?
程序中的“用户态”,
用户态执行的是程序猿自己写的代码,就在最上面的应用程序这一层来运行的。这里的代码都称为 “用户态” 运行的代码。
程序中的"内核态",
内核会给程序提供一些 API,称为系统调用,有些代码,需要调用操作系统的 API,进一步的逻辑就会在内核中执行,内核态进行的操作都是在操作系统内核中完成的。
例如,调用一个 System.out.println。本质上要经过 write 系统调用,进入到内核中,内核执行—堆逻辑,控制显示器输出字符串…
在内核中运行的代码,称为 “内核态” 运行的代码。
创建/销毁线程,需要操作系统内核完成 (创建线程本质是在内核中搞个PCB,加到链表里)
调用的 Thread.start 其实归根结底,也是要进入内核态来运行。
此时你不清楚内核身上背负着多少任务 (内核不是只给你一个应用程序服务,给所有的程序都要提供服务)
因此,当使用系统调用,执行内核代码的时候,无法确定内核都要做哪些工作,整体过程 "不可控” 的
而把创建好的线程放到" 池子里",由于池子就是用户态实现的
这个放到池子 / 从池子取,这个过程不需要涉及到内核态,就是纯粹的用户态代码就能完成
一般认为,纯用户态的操作,效率要比经过内核态处理的操作,要效率更高。
例如:滑稽老铁去银行处理业务,柜员说需要省份证复印件
1、滑稽老铁,自己来到大厅的复印机这里进行复印。纯用户态的操作。(完全自己完成的,整体的过程可控)
2、滑稽老铁,把身份证给柜员,让柜员去帮他复印,这个过程就相当于交给了内核态完成一些工作。(不是自己完成的,整体不可控的)
咱们也不知道柜员身上有多少任务。可能从柜台消失之后,是给你复印去了。
但是他可能还会顺手做一些其他的事情。数一下钱 / 清点一下票据 / 上个厕所 / 回个消息…
认为内核态效率低,倒不是说—定就真的低。而是代码进入了内核态,就不可控了。
内核什么时候给你把活干完,把结果给你。(有的时候快,有的时候慢)
先学习—下 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
任务队列
线程池会提供一个 submit
方法让程序猿把任务注册到线程池中,加到这个任务队列中
每个工作线程都是再不停尝试 take 的,如果有任务,take 成功,没有,就阻塞。
ThreadFactory threadFactory ,
线程工厂类,用于创建线程,线程池是需要创建线程的
RejectedExecutionHandler handler
描述了线程池的 拒绝策略,也是一个特殊的对象,描述了当线程池任务队列满了,如果继续添加任务会有什么样的行为…
以下是标准库提供的四个拒绝策略:
比如我现在有很多任务要完成,突然有人给我来了个新的活,但是我已经非常忙,任务队列已经满了,导致我 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 快占完了,这时候突然来—波请求的峰值,此时服务器可能直接就挂了
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() {
@Override
public 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() {
@Override
public 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 也有类似的变量捕获的语法,但是没有上述限制…
线程池里面有:
- 先能够描述任务 (直接使用 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() {
@Override
public void run() {
System.out.println("hello " + n);
}
});
}
}
}