一、线程池
背景
- 线程属于一个系统资源,在高并发场景下,每一个任务都创建一个线程的话,对于内存的占用是相当大的。
- 线程不一定越多越好,一般情况下CPU核心并不多,如果线程过多,CPU只能让大部分线程陷入阻塞,引起线程上下文频繁的切换。上下文切换越频繁,对CPU性能影响就越大
解决方案:线程池
利用享元模式的思想,充分利用已有线程的资源,来处理不同的任务。从而减少线程的数量,避免频繁的上下文切换
二、自定义线程池实现
该实现是跟着黑马程序员的《全面深入学习java并发编程,java基础进阶中级必会教程》实现的。自己简单做了一个总结,看官们如果有什么建议,也可以尽情提出。
- 按照上面的图来实现自定义线程池
- 根据生产者消费者模式的思想创建一个
BlockingQueue
阻塞队列来平衡生产者创建任务和消费者处理任务速度之间的差异,生产者不断的创建新的任务,消费者(线程池)不断来消费。 - 如果生产者没有创建新的任务,那么任务队列就是空的,消费者就需要等待生产者创建任务。
- 如果消费者来不及消费任务,那么任务队列可能就满了,生产者就需要等待任务的消费。
实现
1. 任务队列BlockingQueue
Tips:
- 任务队列的数据结构使用双向链表
ArrayDeque
实现,ArrayDeque
和LinkedList
都是双向链表的实现,大多情况下ArrayDeque
性能优于LinkedList
。 - 为了通用给
BlockingQueue
加了一个范型 - 运行过程中可能存在多个线程来队列中消费任务或者生产任务,所以必须用锁保护任务队列队头和队尾,锁可以使用较为灵活的
ReentrantLock
- 任务队列是有容量限制的,不可能无限制的向任务队列中推送任务,那么任务队列满了的时候,生产者就必须等待。同时消费者把任务队列消费完了以后,消费者就必须等待生产者推送任务,可以使用
ReentrantLock
的条件变量。
/**
* 阻塞队列
* @param
*/
@Slf4j(topic = "c.BlockingQueue") // logback日志
class BlockingQueue {
// 任务队列(双向链表)
// ArrayDeque性能优于LinkedList
private Deque queue = new ArrayDeque<>();
// 队列容量
private int capacity;
// 锁
private ReentrantLock lock = new ReentrantLock();
// 锁的条件变量(生产者)
// 任务队列满了的时候 生产者要等待
private Condition prodWaitSet = lock.newCondition();
// 锁的条件变量(消费者)
// 任务队列空时,消费者要等待
private Condition consWaitSet = lock.newCondition();
public BlockingQueue(int capacity) { this.capacity = capacity; }
/**
* 生产者向任务队列推送任务
* 队列满时等待推送任务无超时时间限制
* @param t 任务
*/
public void put(T t) {
lock.lock();
// 加锁
try {
// 判断队列是否满了
// log.debug("queue.size(): {},capacity: {}", queue.size(), capacity);
while (queue.size() == capacity) {
// 队列满了需要让线程进入等待
// 任务数超过了任务队列容量,生产者生产的任务就会一直处于阻塞状态
log.debug("任务队列满了,生产者还不能向任务队列里推送任务!");
prodWaitSet.await();
}
// 队列没有满,推送任务
log.debug("任务队列没有满,生产者向任务队列推送任务: {}", t);
queue.addLast(t);
// 唤醒消费者等待中的线程
consWaitSet.signalAll();
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}
/**
* 消费者消费任务
* 队列为空时,无限制等待
*/
public T take() {
lock.lock();
try {
// 判断生产者队列是否为空
while (queue.isEmpty()) {
// 消费者线程进入等待
try {
consWaitSet.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
// 唤醒生产者等待的线程
prodWaitSet.signalAll();
// 队列不为空,移除队列第一个元素
return queue.removeFirst();
} finally {
lock.unlock();
}
}
/**
* 获取任务队列容量
*/
public int size() {
lock.lock();
try {
return this.capacity;
} finally {
lock.unlock();
}
}
}
改进BlockingQueue
的take
方法
消费者消费任务时,如果任务队列为空,那么take
方法将无限制的等待下去,可以设计poll
方法,该方法设置超时时间,让消费者不会在任务队列为空的情况下无限等待。
/**
* 消费者消费任务(有超时时间的等待)
*
* @return
*/
public T poll(long timeout, TimeUnit timeUnit) {
lock.lock();
try {
// 判断生产者队列是否为空
long nanos = timeUnit.toNanos(timeout); // 将超时时间单位转换为纳秒
while (queue.isEmpty()) {
try {
// awaitNanos方法在等待过程中被唤醒,返回值是等待的剩余时间
if (nanos <= 0) {
// 避免虚假唤醒的超时时间重置
// 如果等待的剩余时间小于等于0,说明等待超时了,直接返回null
log.debug("消费超时了!");
return null;
}
// 消费者线程进入等待
nanos = consWaitSet.awaitNanos(nanos);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
// 唤醒生产者等待的线程
prodWaitSet.signalAll();
// 队列不为空,移除队头元素
log.debug("消费完成!从任务队列中移除该任务");
return queue.removeFirst();
} finally {
lock.unlock();
}
}
2. 线程池ThreadPool
tips
- 线程池集合
HashSet
范型不使用Thread
而是Worker
类,方便任务的处理 - 线程池处理任务时,要判断线程集合的容量有没有超过核心线程数
- 线程集合容量超过核心线程数,需要将任务放入任务队列中等待消费者消费
- 线程集合容量未超过核心线程数,将当前任务交给新建的Worker执行即可
/**
* 线程池
*/
@Slf4j(topic = "c.ThreadPool")
class ThreadPool {
/**
* 任务队列
*/
private BlockingQueue queue;
/**
* 线程集合
*/
private HashSet workers = new HashSet<>();
/**
* 核心线程数
* 线程池中的线程数量
*/
private int coreSize;
/**
* 消费等待的超时时间
*/
private long timeout;
/**
* 超时时间单位
*/
private TimeUnit timeUnit;
public ThreadPool(int coreSize, long timeout, TimeUnit timeUnit, int queueSize) {
this.coreSize = coreSize;
this.timeout = timeout;
this.timeUnit = timeUnit;
this.queue = new BlockingQueue<>(queueSize); // 构建任务队列
}
/**
* 执行任务
*
* @param task 任务
*/
public void executeTask(Runnable task) {
if (workers.size() < coreSize) {
// 核心线程数未超过线程集合的容量
// 将当前任务交给Worker执行
Worker worker = new Worker(task);
// 将worker加入线集合
log.debug("核心线程数未满,剩余:{},新增Worker执行任务", coreSize - workers.size());
workers.add(worker);
// 启动线程
worker.start();
} else {
// 核心线程数超过线程集合的容量,将当前任务加入任务队列等待消费
queue.put(task);
}
}
/**
* 线程包装类
*/
class Worker extends Thread {
private Runnable task; // 任务
public Worker(Runnable task) {
this.task = task;
}
/**
* 执行任务
*/
@Override
public void run() {
// 执行任务
// 1)当前task不为空,直接执行任务即可 (该情况出现于核心线程数还没有超过线程集合的容量,新建的线程的task肯定不为null)
// 2)当前task为空,去任务队列中看有没有待执行的任务(该情况出现于核心线程数已超过线程集合的容量)
while (task != null || (task = queue.take()) != null) {
try {
log.debug("worker执行任务");
task.run();
} catch (Exception e) {
e.printStackTrace();
} finally {
task = null;
}
}
// 退出循环意味着没有任务处理,从workers中移除该Worker
synchronized (workers) {
log.debug("任务处理完毕,移除该worker");
workers.remove(this);
}
}
}
}
测试
任务数未超过任务队列的最大容量
@Slf4j(topic = "c.TestThreadPool")
public class TestThreadPool {
public static void main(String[] args) throws InterruptedException {
// 测试:
// 创建一个线程池
// 核心线程数 3, 超时时间 5000ms,时间单位 ms,任务队列容量 10
ThreadPool threadPool = new ThreadPool(3, 5000, TimeUnit.MILLISECONDS, 10);
// 任务数没有超过任务队列容量的情况
log.debug("--------------------任务数:5---------------------");
for (int i = 0; i < 5; i++) {
int tmpI = i;
threadPool.executeTask(() -> {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
log.debug("-------执行任务了!-----,{}", tmpI);
});
}
}
}
结果输出:
12:09:17 [main] c.TestThreadPool - --------------------任务数:5---------------------
12:09:17 [main] c.ThreadPool - 核心线程数未满,剩余:3,新增Worker执行任务
12:09:17 [main] c.ThreadPool - 核心线程数未满,剩余:2,新增Worker执行任务
12:09:17 [Thread-0] c.ThreadPool - worker执行任务
12:09:17 [main] c.ThreadPool - 核心线程数未满,剩余:1,新增Worker执行任务
12:09:17 [Thread-1] c.ThreadPool - worker执行任务
12:09:17 [main] c.BlockingQueue - 任务队列没有满,生产者开始推送任务: com.example.pool.TestThreadPool$$Lambda$2/1537358694@56cbfb61
12:09:17 [Thread-2] c.ThreadPool - worker执行任务
12:09:17 [main] c.BlockingQueue - 任务队列没有满,生产者开始推送任务: com.example.pool.TestThreadPool$$Lambda$2/1537358694@1134affc
12:09:18 [Thread-0] c.TestThreadPool - -------执行任务了!-----,0
12:09:18 [Thread-0] c.ThreadPool - worker执行任务
12:09:18 [Thread-2] c.TestThreadPool - -------执行任务了!-----,2
12:09:18 [Thread-1] c.TestThreadPool - -------执行任务了!-----,1
12:09:18 [Thread-2] c.ThreadPool - worker执行任务
12:09:19 [Thread-0] c.TestThreadPool - -------执行任务了!-----,3
12:09:19 [Thread-2] c.TestThreadPool - -------执行任务了!-----,4
核心线程数 3,任务数 5,前三个任务不会放入任务队列,而是交给线程池里的线程立即执行了,剩余的两个任务放入任务队列等待线程池中的线程空闲后再消费。
所以可以看见日志:09:18
时执行了任务0,1,2,09:19
时执行了任务3,4
同时我们可以注意到:Worker调用的是BlockingQueue
的take
方法,take
方法在任务队列为空时仍然在无限等待。所以如果想要优化,我们可以改成poll
方法,即有超时时间的等待。
任务队列空闲5s后,退出循环,并移除线程池中的worker
任务数超过任务队列的最大容量
@Slf4j(topic = "c.TestThreadPool")
public class TestThreadPool {
public static void main(String[] args) throws InterruptedException {
// 测试:
// 创建一个线程池
// 核心线程数 3, 超时时间 5000ms,时间单位 ms,任务队列容量 5
ThreadPool threadPool = new ThreadPool(3, 5000, TimeUnit.MILLISECONDS, 5);
// 任务数超过任务队列容量的情况
// Thread.sleep(1000);
log.debug("--------------------任务数:9---------------------");
for (int i = 0; i < 9; i++) {
int tmpI = i;
threadPool.executeTask(() -> {
try {
// 让任务执行时间延长
Thread.sleep(200000L);
} catch (InterruptedException e) {
e.printStackTrace();
}
log.debug("-------执行任务了!-----,{}", tmpI);
});
}
}
}
测试中,我们让任务执行的时间延长足够长时间
结果:
13:45:33 [main] c.TestThreadPool - --------------------任务数:9---------------------
13:45:33 [main] c.ThreadPool - 核心线程数未满,剩余:3,新增Worker执行任务
13:45:33 [main] c.ThreadPool - 核心线程数未满,剩余:2,新增Worker执行任务
13:45:33 [Thread-0] c.ThreadPool - worker执行任务
13:45:33 [main] c.ThreadPool - 核心线程数未满,剩余:1,新增Worker执行任务
13:45:33 [Thread-1] c.ThreadPool - worker执行任务
13:45:33 [main] c.BlockingQueue - 任务队列没有满,生产者开始推送任务: com.example.pool.TestThreadPool$$Lambda$2/1537358694@56cbfb61
13:45:33 [Thread-2] c.ThreadPool - worker执行任务
13:45:33 [main] c.BlockingQueue - 任务队列没有满,生产者开始推送任务: com.example.pool.TestThreadPool$$Lambda$2/1537358694@1134affc
13:45:33 [main] c.BlockingQueue - 任务队列没有满,生产者开始推送任务: com.example.pool.TestThreadPool$$Lambda$2/1537358694@d041cf
13:45:33 [main] c.BlockingQueue - 任务队列没有满,生产者开始推送任务: com.example.pool.TestThreadPool$$Lambda$2/1537358694@129a8472
13:45:33 [main] c.BlockingQueue - 任务队列没有满,生产者开始推送任务: com.example.pool.TestThreadPool$$Lambda$2/1537358694@1b0375b3
13:45:33 [main] c.BlockingQueue - queue.size(): 5,capacity: 5
13:45:33 [main] c.BlockingQueue - 任务队列满了,生产者还不能向任务队列里推送任务!
此时,由于消费者迟迟没有将任务执行完,所以生产者一直向任务队列中添加任务,导致任务队列被填满,其余的任务无法再添加到任务队列中,导致生产者线程也阻塞住了。这样对生产者线程不够友好。应该给生产者线程提供选择,让它选择是继续等待下去呢,还是选择做其他操作。
优化
1. 给生产者添加一个等待超时的选择
类似于poll()
/**
* 带超时时间的阻塞添加
*
* @param t 任务
* @param timeout 超时时间
* @param timeUnit 时间单位
* @return false 添加超时 true 添加成功
*/
public boolean offer(T t, long timeout, TimeUnit timeUnit) {
lock.lock();
// 加锁
try {
// 判断队列是否满了
long nanos = timeUnit.toNanos(timeout);
while (queue.size() == capacity) {
// 队列满了需要让线程进入等待
// 任务数超过了任务队列容量,生产者生产的任务就会一直处于阻塞状态
try {
log.debug("任务队列满了,生产者还不能向任务队列里推送任务!");
if (nanos <= 0) {
// 生产者添加任务超时了
return false;
}
nanos = prodWaitSet.awaitNanos(nanos);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
// 队列没有满,推送任务
log.debug("任务队列没有满,生产者开始推送任务: {}", t);
queue.addLast(t);
// 唤醒消费者等待中的线程
consWaitSet.signalAll();
return true;
} finally {
lock.unlock();
}
}
2. 生产者还有其他选择
- 死等
- 超时放弃
- 直接放弃
- 抛出异常
- 生产者自行处理
- 其他
我们不可能有需求是就往线程池的executeTask()
方法中添加逻辑分支,这样我们的代码可扩展性就非常差。我们可以利用策略模式把选择的权利下放给生产者。
实现
将处理方法抽象为一个接口的方法
/**
* 利用策略模式,让生产者自行决定如果任务队列满了的时候 该选择何种策略来处理该情况
* 1)死等
* 2)超时拒绝
* 3)直接拒绝
* 4)抛出异常
* 5)自行执行
* 6)...其他
* 方便扩展
*/
@FunctionalInterface
interface RejectPolicy {
/**
* 拒绝策略
*
* @param queue 任务队列
* @param task 任务
*/
void reject(BlockingQueue queue, T task);
}
修改任务队列类
- 添加
tryPut()
方法,在任务队列容量等于最大容量, 将执行策略下放给调用方
public void tryPut(RejectPolicy rejectPolicy, T t) {
lock.lock();
try {
if (capacity == queue.size()) {
// 任务队列容量等于最大容量, 执行策略下放给调用方
rejectPolicy.reject(this, t);
} else {
// 队列没有满,推送任务
log.debug("任务队列没有满,生产者开始推送任务: {}", t);
queue.addLast(t);
// 唤醒消费者等待中的线程
consWaitSet.signalAll();
}
} finally {
lock.unlock();
}
}
修改线程池类
- 添加拒绝策略的成员变量
private RejectPolicy rejectPolicy;
- 构造方法
public ThreadPool(int coreSize, long timeout, TimeUnit timeUnit, int queueSize, RejectPolicy rejectPolicy) {
this.coreSize = coreSize;
this.timeout = timeout;
this.timeUnit = timeUnit;
this.queue = new BlockingQueue<>(queueSize); // 构建任务队列
this.rejectPolicy = rejectPolicy; // 拒绝策略
}
-
executeTask()
在核心线程数超过线程集合的容量时,调用BlockingQueue
的tryPut()
public void executeTask(Runnable task) {
if (workers.size() < coreSize) {
// 核心线程数未超过线程集合的容量
// 将当前任务交给Worker执行
Worker worker = new Worker(task);
// 将worker加入线集合
log.debug("核心线程数未满,剩余:{},新增Worker执行任务", coreSize - workers.size());
workers.add(worker);
// 启动线程
worker.start();
} else {
// 核心线程数超过线程集合的容量
// 选择策略模式,让调用者自行选择超时策略
queue.tryPut(rejectPolicy, task);
}
}
再来测一哈
public static void main(String[] args) throws InterruptedException {
// 测试:
// 创建一个线程池
// 核心线程数 3, 超时时间 5000ms,时间单位 ms,任务队列容量 5
ThreadPool threadPool = new ThreadPool(3,
5000,
TimeUnit.MILLISECONDS,
5,
(queue, task) -> {
// 1) 死等
// queue.put(task);
// 2) 超时放弃
// boolean timeoutFlag = queue.offer(task, 1500, TimeUnit.MILLISECONDS);
// if (!timeoutFlag) {
// log.debug("生产者推送任务超时!");
// }
// 3) 直接放弃
// log.debug("放弃:{}", task);
// 4) 抛出异常
// throw new RuntimeException("任务推送失败! 任务: " + task);
});
// 任务数超过任务队列容量的情况
log.debug("--------------------任务数:9---------------------");
for (int i = 0; i < 10; i++) {
int tmpI = i;
threadPool.executeTask(() -> {
try {
Thread.sleep(200000L);
} catch (InterruptedException e) {
e.printStackTrace();
}
log.debug("-------执行任务了!-----,{}", tmpI);
});
}
}
死等
测试结果见上面的日志输出
超时放弃
14:17:46 [main] c.TestThreadPool - 生产者推送任务超时!
直接放弃
14:19:15 [main] c.TestThreadPool - 放弃:com.example.pool.TestThreadPool$$Lambda$2/1537358694@2f7c7260
抛出异常
Exception in thread "main" java.lang.RuntimeException: 任务推送失败! 任务: com.example.pool.TestThreadPool$$Lambda$2/1537358694@2f7c7260
at com.example.pool.TestThreadPool.lambda$main$0(TestThreadPool.java:39)
at com.example.pool.BlockingQueue.tryPut(TestThreadPool.java:386)
at com.example.pool.ThreadPool.executeTask(TestThreadPool.java:163)
at com.example.pool.TestThreadPool.main(TestThreadPool.java:60)
【注意】如果后面还有任务要推送,也会推送失败
三、总结
- 利用生产者消费者模式平衡了线程池消费任务和任务生产速度不一致的问题。
- 利用享元模式充分利用已有线程的资源,来处理不同的任务。从而减少线程的数量,避免频繁的发生上下文切换
- 利用策略模式让生产者自行决定如果任务队列满了的时候 该选择何种策略处理该情况。增加了线程池的可扩展性
- 总而言之,言而总之,收获颇丰。如果实现过程中有明显的硬伤,希望各位看官指出,粉肠感谢!