本篇将详细介绍阻塞队列和线程池
目录
一、阻塞队列
在了解什么是阻塞队列之前,我们先来了解一个在多线程环境中常见的模型,生产者与消费者模型。
生产者与消费者模型
生产者与消费者模型是指在多线程环境中,有一个共享的数据缓存区,其中一个线程负责往这个缓冲区写数据,而另一个线程负责从这个缓冲区中读取数据。
这样的模型有什么好处呢?
- 降低线程之间的耦合关系。线程读和写都是直接通过这个共享的缓冲区来完成的,因此线程之间不需要去关心对方的状态,而只需要关心这个缓冲区就行了,从而也就降低耦合度了。
- 解决线程之间速度执行差异的问题,因为线程是直接与缓冲区进行交互的,线程无论是执行的快还是执行的慢都不会对其他线程造成影响,从而也就解决了速度差异的问题
阻塞队列就是对生产者与消费者模型的一个具体实现。
阻塞队列的概念与使用
阻塞队列实际上也是一种队列,不过他在普通的队列上引入了两个新的特性
- 在执行出队的时候,如果此时队列为空,就会进行阻塞,直到队列不为空时才能继续出队
- 在执行入队的时候,如果此时队列满了,就会进行阻塞,直到队列不为满的时候才能入队
下面我们来看一下阻塞队列在Java中的具体使用
在java中使用阻塞队列主要是通过BlockingQueue接口,他的具体实现类有以下几种
ArrayBlockingQueue | 一个由数组结构组成的有界阻塞队列 | |
LinkedBlockingQueue | 一个由链表结构组成的有界阻塞队列 | |
PriorityBlockingQueue | 一个支持优先级排序的无界阻塞队列 | |
LinkedTransferQueue | 一个由链表结构组成的无界阻塞队列 | |
LinkedBlockingDeque | 一个由链表结构组成的双向阻塞队列 | |
SynchronousQueue | 一个不存储元素的阻塞队列 |
主要方法
方法 | 作用 |
void put() | 入队(队列满时阻塞) |
E take() | 出队(队列空时阻塞 |
boolean contains(Object o) | 判断队列是否包含某个元素 |
下面我们来使用阻塞队列来实现一个简单的生产者消费者模型
public void test01(){
//创建一个容量为5的阻塞队列
BlockingQueue<Integer> queue = new ArrayBlockingQueue<>(5);
//创建生产者,产出数字0-10
Thread produce = new Thread(() -> {
for (int i = 0; i <= 10; i++) {
try {
//put的内部实现中使用了await方法,所以抛出了一个中断异常
queue.put(i);
System.out.println("生产者生产了数字:" + i);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
//创建消费者,消费队列的数字
Thread cost = new Thread(() -> {
for (int i = 0; i <= 10; i++) {
try {
//take的内部实现中同样使用了await方法,所以也抛出了一个中断异常
int j = queue.take();
System.out.println("消费者消费了数字:" + j);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
produce.start();
cost.start();
}
运行结果
通过运行结果可以法现,生产者生产的数字全部都被消费者拿到了
实现简化版阻塞队列
我们可不可以自己动手实现一个阻塞队列的,当然是可以的,通过上篇中所介绍的wait和notify方法就能够很轻易的实现一个简易版的阻塞队列,具体的实现如下
public class MyBlockingQueue {
//创建一个容量为1000的数组,实现一个基于循环队列的容量固定为1000的阻塞队列
private int[] queue = new int[1000];
//队首元素
private int head = 0;
//队尾元素
private int tail = -1;
//元素个数
private int size = 0;
//取元素
public int take() {
int result = 0;
synchronized (this) {
//队列为空时调用wait
while (size == 0) {
try {
this.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
result = queue[head];
head++;
if (head == queue.length) head = 0;
size--;
//唤醒被阻塞的执行放元素的线程。如果没有符合的线程则可视为无事发生
this.notify();
}
return result;
}
//存元素
public void put(int val) {
synchronized (this) {
//队列满时调用wait
while (size == queue.length) {
try {
this.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
tail++;
if (tail == queue.length) tail = 0;
queue[tail] = val;
size++;
//唤醒被阻塞的执行取元素的线程。如果没有符合的线程则可视为无事发生
this.notify();
}
}
}
在判断是否为空或者是否为满时,采用了循环而不是直接使用if,这主要是因为在被唤醒后并不一定队列就不为空或者不为满了,可能有别的线程抢先一步取出或者放入了,这时就算被唤醒,队列仍然是空的或者满的,通过阅读源码也能发现,官方在这里也是使用循环
下面我们来拿上面生产者与消费者模型的代码来测试下我们自己实现的阻塞队列吧
public void test01(){
//创建一个容量为5的阻塞队列
//BlockingQueue<Integer> queue = new ArrayBlockingQueue<>(5);
MyBlockingQueue queue = new MyBlockingQueue();
//创建生产者,产出数字0-10
Thread produce = new Thread(() -> {
for (int i = 0; i <= 10; i++) {
//put的内部实现中使用了await方法,所以抛出了一个中断异常
queue.put(i);
System.out.println("生产者生产了数字:" + i);
}
});
//创建消费者,消费队列的数字
Thread cost = new Thread(() -> {
for (int i = 0; i <= 10; i++) {
//take的内部实现中同样使用了await方法,所以也抛出了一个中断异常
int j = queue.take();
System.out.println("消费者消费了数字:" + j);
}
});
produce.start();
cost.start();
try {
produce.join();
cost.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
运行结果
通过运行结果可以发现,我们实现的阻塞队列也是能正常运行的
二、线程池
众所周知,线程是为了提高并发编程而诞生的一种更为轻量的进程,但随者业务的不断发展,人们需求的不断提升,线程带来的性能提升也显得有点不够了。为此,程序员们想出两种更为高效的优化方案,一种是协程,一个比线程更为轻量的存在,还有一种就是我们一会儿要介绍的线程池
线程池的概念和使用
线程池是一种基于池化思想形成的管理和使用线程的机制,他会自动创建线程,并将创建的线程储存起来,开发人员可以向线程提交要执行的任务,线程池会让事先创建好的线程来执行这些任务。
线程池的出现,大大提高了使用了多线程进行并发编程的效率,因为它省去了自己创建线程这一步,而是直接使用事先已经创建好的线程,要知道的是创建线程的过程也是比较消耗资源的。
在Java中,我们通常有两种方法来创建线程池
其中一种是使用ExecutorService,具体代码如下
ExecutorService pool = Executors.newCachedThreadPool();
通过这串代码可以发现我们在创建线程池并没有使用new,而是直接通过一个方法就能获得线程池对象
其实这里将new隐藏在了方法里了,像这种直接从方法里获得实例对象的思想模式,我们称之为工厂模式,而这种反法就称为工厂方法,这个调用工厂方法的类就叫工厂类
在上图中我们发现工厂方法还有很多,他们能够创建的线程池,例如newFixedThreadPool方法创建的就是指定线程数量的线程池,而newSingleThreadPool方法创建的是单一线程的线程池
另一种创建线程池的方法则是使用ExecutorService的实现类ThreadPoolExecutor。
通过上图可以发现,ThreadPoolExecutor的构造方法中有很多参数,下面我们来一个一个解释这些参数的含义
- corePoolSize:核心线程的数量,核心线程就相当于是公司的合伙人,整个线程池就相当于是整个公司。
- maximumPoolSize:最大线程数,线程池中能同时存在的线程的最大数量
- keepAliveTime:不执行任务的线程的最大存活时间,过了这个时间就会销毁
- unit:最大存活时间的单位
- BlockingQueue:指定所提交任务存放的队列
- ThreadFactory:创建线程的方式
- RejectedExecutionHandler:存放任务的队列是有大小的,有大小就会被存满,当任务队列存满时该做什么,由这个参数指代的拒绝策略决定
下面我们来看一下拒绝策略都有哪些
- ThreadPoolExecutor.AbortPolicy:直接抛出异常
- ThreadPoolExecutor.CallerRunPolicy:让添加任务的线程自己执行该任务
- ThreadPoolExecutor.DiscardOldestPolicy:让最早加入的任务出队,然后再加入这个新任务
- ThreadPoolExecutor.DiscardPolicy:让最新加入的任务出队,如后再加入这个新任务
使用ThreadPoolExecutot就能够自定义创建符合我们需求的线程池
ThreadPoolExecutor pool1 = new ThreadPoolExecutor(5,10,10, TimeUnit.DAYS,new
ArrayBlockingQueue<>(5),Executors.defaultThreadFactory(),new ThreadPoolExecutor.AbortPolicy());
说完线程池的创建我们再来看一下线程池的主要方法
方法 | 作用 |
Future<?> submit(Runnable task) | 往线程池中提交任务 |
实现简易版线程池
实现线程池主要是要注意两个东西,一个是用来储存任务的任务队列,还有一个就是要提前创建好的线程,下面我们来看一下具体实现
public class MyThreadPool {
//用阻塞队列存放待执行任务
private BlockingDeque<Runnable> queue = new LinkedBlockingDeque<>();
public MyThreadPool(int size){
//创建size个线程来循化的从队列中获取任务并执行
for (int i = 0 ; i < size ; i++){
int j = i;
Thread t = new Thread(() -> {
while (!Thread.interrupted()){
try {
//从任务队列中取出任务
Runnable runnable = queue.take();
System.out.println("线程"+ j +"执行任务");
//执行任务
runnable.run();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
t.start();
}
}
//存放任务
public void submit(Runnable runnable){
try {
//将任务存入队列
queue.put(runnable);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
然后我们再来具体使用一下
public static void main(String[] args) {
MyThreadPool myThreadPool = new MyThreadPool(8);
for (int i = 1 ; i < 10 ;i++){
int j = i;
myThreadPool.submit(new Runnable() {
@Override
public void run() {
System.out.println(j);
}
});
}
}
运行结果
顺利完成了打印任务
在前面的代码中还有一个需要注意的地方,就是用变量j重新保存一下i的值,这里涉及了变量捕获的问题,为什么不能直接使用i呢,是因为提交的这些任务是异步执行的,而变量i的作用范围只是这个for循环,当任务真正被线程执行时,循环可能早就结束了,而变量i也跟着被销货了,所以i是捕获不到的,所以要重新创建一个变量来保存一下i的值。还需要注意的是在JDK1.8中变量捕获只能捕获到全程未修改的变量,而j每循环一次都会创建一个新的,每次创建的这个j的值都是没有修改的,而i随者循环的进行一直在变化,这也是能捕获到j而不能捕获到i的一个原因。