多线程相关案例
目录
1. 单例模式
1) 饿汉模式
2) 懒汉模式
2. 阻塞队列
1) 阻塞队列的特性
2) 模拟实现阻塞队列
3. 定时器
4. 线程池
1) ThreadPoolExecutor 类
2) 模拟实现线程池
1. 单例模式
单例模式是最经典的设计模式之一。
单例模式,顾名思义,就是这个类在整个程序中只能有一个实例。
具体来说就是,约定一个类,只能有一个对象,通过编码技巧,让编译器强制进行检查,提前在类里把对象给创建好,然后将构造方法设为 private。
单例模式有两种实现方式,一种是饿汉模式,一种是懒汉模式。
懒就是,非必要,不采取行动,举个例子,在早上你可以一直睡觉,直到上学快要迟到了,你再起床,这样做的话,就能休息时间最大化,保证今天的学习效率。
而饿的话,就是截然相反,早上就非常早起床,然后马上去上学,起太早的话,可能就会睡眠不足,导致上课的时候昏昏欲睡,学习效率低。
1) 饿汉模式
我们可以具体来实现一下单例模式,用饿汉模式的方式,这个比较简单。
因为是单例模式嘛,而考虑到是饿汉的方式来实现,我们就可以不管三七二十一,先提前创建好对象,等到有人想要获取对象的时候,就直接返回创建好的对象,但是只做这些还不足以让它成为单例模式,我们还需要将它的构造方法设置成私有的,并且要将获取对象的方法设置成 public,并且用 static 修饰,这样就可以通过 类名.方法名 来调用获取实例的方法了。
最后写出来就是这个样子的:
// 期望这个类,能够有唯一实例
class Singleton {
private static final Singleton instance = new Singleton();
// 通过这个方法获取到刚才的实例
// 后续如果想使用这个类的实例,就都通过 getInstance 方法来获取
public static Singleton getInstance() {
return instance;
}
// 把构造方法设为 private,此时类外面的其他代码,就无法 new 出这个类的对象了
private Singleton() {
}
}
2) 懒汉模式
接下来再来看看单例模式如何用懒汉的方式来实现。
懒,就是非必要,不采取行动。
那我们就可以先不初始化对象,等到有人调用获取对象的方法的时候,再来创建对象,然后再返回创建好的对象,那整个代码就跟饿汉方式差不多,就是延迟了创建对象的时机。
写出来就是这样的。
这下修改完代码后,就才真正没问题了。
class SingletonLazy {
private static volatile SingletonLazy instance = null;
public static SingletonLazy getInstance() {
// 是否是首次创建对象(从而判断要不要加锁)
if (instance == null) {
// 保证串行化执行,不会 new 多个对象
synchronized (SingletonLazy.class) {
if (instance == null) {
instance = new SingletonLazy();
}
}
}
return instance;
}
private SingletonLazy() {
}
}
饿汉方式实现的单例模式会涉及到线程安全的问题,需要注意三点:
1. 加锁,保证 if 条件和 new 操作是原子的,只会创建出一个对象。
2. 两层 if,第一个 if 用来判断是否要加锁,第二个 if 用来判断是否要创建对象。
3. volatile,用来禁止指令重排序问题。
而饿汉模式天然就是线程安全的,因为它只涉及到了多个线程的读取操作。
2. 阻塞队列
阻塞队列是多线程代码中常用到的一种数据结构。
1) 阻塞队列的特性
阻塞队列的特性有两个:
1. 线程安全
2. 带有阻塞特性
a) 当队列为空时,从队列中取元素的话,就会阻塞,阻塞到其他线程往队列里添加元素为止
b) 当队列为满时,往队列中放元素的话,就会阻塞,阻塞到其他线程从队列中取走元素为止
阻塞队列最大的意义,就是用来实现 "生产者消费者模型"(是一种常见的编码方式)。
怎么理解生产者消费者模型呢?举个例子:
那么为什么要使用生产者消费者模型呢?生产者消费者模型的优点是什么?
生产者消费者模型的优点:
1. 解耦合
两个模块,联系越紧密,耦合就越高。
2. 削峰填谷
峰:短时间内请求量比较多
谷:请求量比较少
在 Java 标准库里,提供了现成的阻塞队列 BlockingQueue,来让我们使用。
标准库里,针对于阻塞队列提供了两种最主要的实现方式: 1. 基于链表 2. 基于数组
虽然 BlockingQueue 继承了 Queue,但是不建议在 BlockingQueue 里使用 Queue 的方法,因为这些方法都不具备 "阻塞" 特性。
put 方法是阻塞式的入队列,take 方法是阻塞式的出队列。
2) 模拟实现阻塞队列
了解完上面这些,我们自己也可以模拟实现一个阻塞队列,阻塞队列就是比普通队列多了线程安全以及阻塞特性,那我们就可以先实现一个队列(只用实现 put 和 take 方法),然后再加上线程安全(synchronized),再加上阻塞就好了(wait & notify)。
首先,普通队列有两种实现方式,一种是基于数组,另一种是基于链表。
基于链表实现的话,就是头插尾删,蛮简单的,
基于数组实现的话,就是要实现一个循环数组。
这里我们使用数组来实现好了,添加元素就是往 tail 下标处添加元素,删除元素就是删除 head 处元素,直接 head++ 即可。
有了上面基础,我们就可以直接开始敲代码了,只用实现 put 和 take 方法就行。
首先搭好框架,然后再来开始写代码。
// 不写作泛型了,直接让这个队列里面存储字符串
class MyBlockingQueue {
// 此处这里的最大长度,也可以指定构造方法,也可以构造方法的参数来判定
private String[] data = new String[1000];
private int head = 0;// 队列的起始位置
private int tail = 0;// 队列的结束位置的下一个位置
private int size = 0;// 记录当前队列有效元素个数
// 提供核心方法,入队列和出队列
public void put(String elem) {
}
public String take() {
}
}
大致写好了普通队列,接下来再保证线程安全,首先是涉及到要修改变量的操作,就得加锁,因为两个方法大部分都有修改操作,那我们就可以直接创建个 Object 对象,然后加锁把方法包裹起来。
然后也要注意内存可见性和指令重排序的问题,以防万一,给那几个变量加上 volatile 会比较好。
最后再加上阻塞,使用 wait & notify 来完成就好。
官方文档建议,在使用 wait 的时候,最好搭配 while 来使用(将 if 换成 while)
完整代码:
// 不写作泛型了,直接让这个队列里面存储字符串
class MyBlockingQueue {
// 此处这里的最大长度,也可以指定构造方法,也可以构造方法的参数来判定
private String[] data = new String[1000];
private volatile int head = 0;// 队列的起始位置
private volatile int tail = 0;// 队列的结束位置的下一个位置
private volatile int size = 0;// 记录当前队列有效元素个数
private Object locker = new Object();
// 提供核心方法,入队列和出队列
public void put(String elem) throws InterruptedException {
synchronized (locker) {
// 首先判断,队列满不满
while (size == data.length) {
// 阻塞, 等到有其他线程取元素的时候,再唤醒
locker.wait();
}
// 队列没满,就真正的往里面放元素
data[tail] = elem;
tail = (tail + 1) % data.length;
size++;
locker.notify();// 这个 notify 用来唤醒 take 的 wait
}
}
public String take() throws InterruptedException {
synchronized (locker) {
while (size == 0) {
// 队列为空,阻塞,等到后面有其他线程添加元素后再唤醒
locker.wait();
}
// 队列不空,就需要把 head 位置的元素给删除掉,并且返回
String ret = data[head];
head = (head + 1) % data.length;
size--;
// 这个 notify 用来唤醒 put 的 wait
locker.notify();
return ret;
}
}
}
借助这个阻塞队列,我们就可以实现一个简单的生产者消费者模型,就是一个线程往里面添加元素,另一个线程从里面消费元素。
// 不写作泛型了,直接让这个队列里面存储字符串
class MyBlockingQueue {
// 此处这里的最大长度,也可以指定构造方法,也可以构造方法的参数来判定
private String[] data = new String[1000];
private volatile int head = 0;// 队列的起始位置
private volatile int tail = 0;// 队列的结束位置的下一个位置
private volatile int size = 0;// 记录当前队列有效元素个数
private Object locker = new Object();
// 提供核心方法,入队列和出队列
public void put(String elem) throws InterruptedException {
synchronized (locker) {
// 首先判断,队列满不满
while (size == data.length) {
// 阻塞, 等到有其他线程取元素的时候,再唤醒
locker.wait();
}
data[tail] = elem;
tail = (tail + 1) % data.length;
size++;
locker.notify();
}
}
public String take() throws InterruptedException {
synchronized (locker) {
while (size == 0) {
// 队列为空,阻塞,等到后面有其他线程添加元素后再唤醒
locker.wait();
}
String ret = data[head];
head = (head + 1) % data.length;
size--;
locker.notify();
return ret;
}
}
}
public class Demo24 {
public static void main(String[] args) {
// 生产者,消费者,分别使用一个线程表示。(也可以使用多个线程)
MyBlockingQueue queue = new MyBlockingQueue();
// 消费者
Thread t1 = new Thread(() -> {
while (true) {
try {
String num = queue.take();
System.out.println("消费元素:" + num);
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
});
// 生产者
Thread t2 = new Thread(() -> {
int num = 1;
while (true) {
try {
queue.put(num + "");
System.out.println("生产元素:" + num);
num++;
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
});
t1.start();
t2.start();
}
}
运行结果,发现生产者一下子把队列生产满了,后面就是消费者消费一个元素,生产者生产一个元素。
3. 定时器
定时器是一个日常开发的常见组件。
约定一个时间,等时间到了之后,就会执行某个代码逻辑。
这个就跟闹钟差不多。
定时器非常常见,尤其是在进行网络通信的时候。
举个例子:
而 Java 标准库也给我们提供现成的定时器 Timer 类,来让我们使用。
// 定时器
public class Demo25 {
public static void main(String[] args) {
Timer timer = new Timer();
// 给定时器安排了一个任务,预定在 xxx 时间去执行。
timer.schedule(new TimerTask() {
@Override
public void run() {
System.out.println("3000");
}
}, 3000);
timer.schedule(new TimerTask() {
@Override
public void run() {
System.out.println("2000");
}
}, 2000);
timer.schedule(new TimerTask() {
@Override
public void run() {
System.out.println("1000");
}
}, 1000);
System.out.println("程序启动!");
}
}
了解上述内容后,我们自己也可以模拟实现一个定时器。
刚刚也说了,定时器内部包含了一个扫描线程,以及一个任务对象的类,来专门表示任务和时间。
那我们也可以根据这两点来实现定时器:
1. 定时器内部需要有一个线程,负责扫描时间
2. 定时器可以添加多个任务,所以需要用一个数据结构来存放任务对象
3. 需要创建一个类,来描述任务(必须包括时间和任务)
那该用哪个数据结构来组织任务对象呢?
定时器跟时间有关,并且时间短的任务先执行,那我们就可以使用优先级队列来组织任务对象。
根据这些,我们就可以敲代码了,首先创建出两个类 MyTimer 和 MyTimerTask,分别用来表示定时器和任务,MyTimer 里需要存放任务列表,所以还需要添加一个优先级队列 queue(记得要传比较器),而 MyTimerTask 里包含了时间和任务,并为它们提供构造方法,MyTimer 构造方法就需要创建出一个线程,用来扫描任务,还有一个 schedule 方法,用来添加任务。
// 模拟实现一个简单的定时器
class MyTimer {
private PriorityQueue<MyTimerTask> queue = new PriorityQueue<>(new Comparator<MyTimerTask>() {
@Override
public int compare(MyTimerTask o1, MyTimerTask o2) {
// 时间小的先执行,建立小根堆
return (int) (o1.getTime() - o2.getTime());
}
});
public MyTimer() {
Thread thread = new Thread(() -> {
// 写扫描线程的逻辑
});
// 不要忘记启动线程
thread.start();
}
public void schedule(MyTimerTask task) {
queue.offer(task);
}
}
class MyTimerTask {
private long time;
private Runnable runnable;
public MyTimerTask(long time, Runnable runnable) {
// 这里记录绝对时间方便我们计算
this.time = time + System.currentTimeMillis();
this.runnable = runnable;
}
// 提供 getter 方法
public long getTime() {
return time;
}
public Runnable getRunnable() {
return runnable;
}
}
然后再来实现扫描线程的工作。
这个其实也很简单,
先判断队列是否为空,如果队列为空,就需要阻塞等待,直到有线程调用 schedule 方法,
不空的话,只要不停地看一下任务队列里的队首元素,看一下该元素的时间到没到,到了的话就执行任务,执行完任务后将任务从队列里弹出,
没到的话,就等时间到了再来执行任务,(也可以看做是懒,非必要,不采取行动)
而前面也说了,要让线程阻塞等待,那就可以使用 wait,而使用 wait 就必须得先加锁,此时,我们发现其实要进行阻塞的这几处,都有修改操作,schedule 是要往队列里面添加新任务,而扫描线程扫描任务列表,当时间到了,就得执行任务,执行完任务后就得将任务出队,那这样的话,我们加锁和使用 wait 就是顺理成章的事情了。
写完后就是这样的:
// 模拟实现一个简单的定时器
class MyTimer {
private PriorityQueue<MyTimerTask> queue = new PriorityQueue<>(new Comparator<MyTimerTask>() {
@Override
public int compare(MyTimerTask o1, MyTimerTask o2) {
// 时间小的先执行,建立小根堆
return (int) (o1.getTime() - o2.getTime());
}
});
public MyTimer() {
Thread thread = new Thread(() -> {
synchronized (this) {
// 写扫描线程的逻辑
while (true) {
// 首先判断队列为不为空,空的话,就阻塞等待
// 直到有线程调用 schedule 方法为止
while (queue.isEmpty()) {
// 要加锁
// 阻塞等待
try {
this.wait();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
MyTimerTask task = queue.peek();
if (System.currentTimeMillis() >= task.getTime()) {
// 时间到了,需要执行任务, 然后出队列
task.getRunnable().run();
queue.poll();
} else {
// 没到时间的话,就进行等待
try {
// 如果添加了新的任务,也需要将线程唤醒
// 重新更新一下,最早的任务是什么,以及更新等待时间
this.wait(task.getTime() - System.currentTimeMillis());
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
}
});
// 最后要记得启动线程
thread.start();
}
public void schedule(Runnable runnable, long time) {
synchronized (this) {
queue.offer(new MyTimerTask(time, runnable));
this.notify();
}
}
}
class MyTimerTask {
private long time;
private Runnable runnable;
public MyTimerTask(long time, Runnable runnable) {
// 这里记录绝对时间方便我们计算
this.time = time + System.currentTimeMillis();
this.runnable = runnable;
}
// 提供 getter 方法
public long getTime() {
return time;
}
public Runnable getRunnable() {
return runnable;
}
}
写完之后,我们就可以来测试一下,看看代码是否正确,就写个例子,调用下方法就行。
没什么问题,一个简单的定时器就写好了
4. 线程池
线程诞生的意义,是因为进程太重量。
而线程因为创建/销毁的时候不需要申请/释放资源,所以比进程快,线程也叫做轻量级进程。
但是,如果频繁的创建销毁线程,此时这个开销也是不容忽视的。
此时,就有两种提高效率的方法,第一种:协程。第二种:线程池。
协程也被叫做轻量级线程,它相比于线程,把系统调度的过程,给省去了,就是让我们自己来调度
但是很遗憾,在 Java 协程的圈子里,很少有人会用协程。
主要有两个原因:第一,Java 官方没有实现协程,虽然有第三方库实现了,但是不够权威也不够靠谱。 第二,Java 有线程池,有线程池兜着底,让线程也不至于太慢。
那到底什么是线程池呢?
可以先看看,这里面的池是什么意思。
池其实是计算机中一种重要的思想方法,很多地方都会涉及到(如线程池、进程池、内存池、连接池)。
举个例子来说明吧:
那么线程池,就是在使用第一个线程的时候,提前把线程 2、3、4、5....给创建出来,
如果后续想要使用新的线程,不用重新创建,而是直接从线程池里面拿就好,就能降低创建线程的开销。
Java 标准库提供了写好的线程池,来让我们使用。
1) ThreadPoolExecutor 类
ThreadPoolExecutor 类的功能非常丰富,提供了很多参数,上述标准库的几个方法,就是给这个类填写了不同的参数用来构造线程池。
我们也可以来学学这个类(面试会考)。
ThreadPoolExecutor 的核心方法就两个:1. 构造(构造方法参数很多) 2. 注册任务(添加任务)
我们来看看它的构造方法,直接看最下面的构造方法就行,因为这个的参数涵盖了上面的参数。
(有一说一,第一次看到这么多参数的构造方法,天都要塌了,但是理解之后其实还好)
2) 模拟实现线程池
了解以上这些后,我们可以自己来实现一个线程池。
线程池:写一个固定线程数目的线程池(暂时不考虑线程的增加和减少)
(1) 提供构造方法,指定创建多少个线程
(2) 在构造方法中,把这些线程都创建好
(3) 有一个阻塞队列,能够用来存放要执行的任务
(4) 提供 submit 方法,用来添加任务
有了以上思路,就很好写代码了。
// 模拟实现一个简单的线程池
class MyThreadPool {
// 将创建好的线程放在数组里面,等到需要使用的时候就拿出来用
private List<Thread> threadList = new ArrayList<>();
// 拒绝策略就是阻塞等待,直到别的线程使用 submit 方法添加任务为止
BlockingQueue<Runnable> queue = new ArrayBlockingQueue<>(1000);// 任务队列
// 通过这个方法,把任务添加到队列中
public void submit(Runnable runnable) throws InterruptedException {
// 此处我们的拒绝策略,相当于第五种拒绝策略了,阻塞等待(这是下策)
queue.put(runnable);
}
public MyThreadPool(int n) {
// 创建出 n 个线程,负责执行上述队列中的任务
for (int i = 0; i < n; i++) {
Thread t = new Thread(() -> {
// 让每个线程不停的从队列中消费任务,如果没有任务了,
// 那此时线程就会阻塞等待,直到有其他线程调用 submit 方法为止
while (true) {
// 让这个线程,从队列中消费任务,并进行执行
try {
Runnable runnable = queue.take();
runnable.run();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
});
t.start();
threadList.add(t);
}
}
}
写个简单的代码来测试一下:
没啥问题。