目录
1、线程池的好处
- 降低资源消耗。通过复用已经创建的线程,避免频繁的线程创建、销毁带来的资源消耗
- 提高响应速度。任务到达时,直接取一个线程就能执行,不用等待线程初始化和创建,提高系统并发能力
- 提高线程的可管理性。线程是稀缺的系统资源,不能无限制创建,应该由线程池统一管理、分配和监控
2、手写一个线程池
分析:
- 需要实现一个阻塞队列,用于管理任务
- 实现一个线程池,管理若干个线程,向外提供获取、归还线程的方法
- 如果阻塞队列,需要执行用户指定的拒绝策略
1、实现阻塞队列
功能:若干个生产者线程可以往队列中添加任务。当线程池有空时就挑选一个任务执行,所有线程都繁忙时就阻塞。
- 需要上锁,保证只有一个线程能执行该任务,防止任务重复执行
- 需要条件变量。当任务队列满时,生产者线程阻塞。当任务队列空时,消费者线程阻塞
- 可以设定一个超时时间
public class MyBlockingQueue<T> { //任务队列 private Deque<T> queue = new ArrayDeque<>(); //锁 private ReentrantLock lock = new ReentrantLock(); //生产者条件变量 private Condition fullWaitSet = lock.newCondition(); //消费者条件变量 private Condition emptyWaitSet = lock.newCondition(); //容量 private int capacity; public MyBlockingQueue(int capacity) { this.capacity = capacity; } //阻塞获取 public T take(){ lock.lock(); try { //当队列中没有任务就阻塞 while (queue.isEmpty()){ try { emptyWaitSet.await(); } catch (InterruptedException e) { e.printStackTrace(); } } //获取队列头部元素 T t = queue.removeFirst(); //唤醒等待的生产者线程 fullWaitSet.signal(); return t; }finally { lock.unlock(); } } //带超时的阻塞获取 public T tackInTime(long timeout, TimeUnit unit){ lock.lock(); try { //将timeout统一转换为纳秒 long nanos = unit.toNanos(timeout); //当队列中没有任务就阻塞 while (queue.isEmpty()){ try { if (nanos <= 0){ return null; } //等待nanos纳秒 nanos = emptyWaitSet.awaitNanos(nanos); } catch (InterruptedException e) { e.printStackTrace(); } } //获取队列头部元素 T t = queue.removeFirst(); //唤醒等待的生产者线程 fullWaitSet.signal(); return t; }finally { lock.unlock(); } } //阻塞添加 public void put(T element){ lock.lock(); try { //如果队列已满,就阻塞 while (queue.size() == capacity){ try { fullWaitSet.await(); } catch (InterruptedException e) { e.printStackTrace(); } } //把任务添加到队列尾部 queue.addLast(element); //唤醒等待的消费者线程 emptyWaitSet.signal(); }finally { lock.unlock(); } } //获取队列中任务的数量 public int size(){ lock.lock(); try { return queue.size(); }finally { lock.unlock(); } } }
2、实现线程池
需要设计一个线程集合,存放若干线程。还需要包含一个阻塞队列
public class MyThreadPool { //阻塞队列 private MyBlockingQueue<Runnable> taskQueue; //线程集合 private HashSet<Worker> workers; //核心线程数 private int coreSize; //任务队列的容量上限 private int queueCapacity; //超时时间,时间内没有任务就结束线程 private long timeout; //时间单位 private TimeUnit timeUnit; public MyThreadPool(int coreSize, int queueCapacity, long timeout, TimeUnit timeUnit) { this.coreSize = coreSize; this.queueCapacity = queueCapacity; this.timeout = timeout; this.timeUnit = timeUnit; taskQueue = new MyBlockingQueue<>(queueCapacity); workers = new HashSet<>(); } //执行任务 public void execute(Runnable task) { synchronized (workers) { //如果任务数小于核心数,直接创建一个worker对象执行 if (workers.size() < coreSize) { //创建线程对象 Worker worker = new Worker(task); System.out.println("新增一个worker线程,立即执行任务,线程编号:" + worker.getName()); //将线程对象加入线程集合 workers.add(worker); //启动线程 worker.start(); } //如果任务数大于核心数,就把它放入任务队列 else { System.out.println("线程池已满,任务被放入任务队列"); taskQueue.put(task); } } } //线程的包装类,包含一些额外信息 class Worker extends Thread { //任务对象 private Runnable task; public Worker(Runnable task) { this.task = task; } //执行任务 @Override public void run() { //如果task不为null,说明创建线程对象时就指定了任务,直接执行 //如果task为null,说明之前的任务已经执行完成。查看任务队列,如果任务队列不为空,就取出一个任务执行 while (task != null || (task = taskQueue.tackInTime(timeout, timeUnit)) != null) { try { System.out.println(this.getName() + " 正在执行任务:" + task); task.run(); } catch (Exception e) { e.printStackTrace(); } finally { //任务执行完毕 System.out.println(task + "执行完毕:"); task = null; } } //上面调用的是任务队列的超时等待方法,如果它返回null,说明已经等够时间了,直接销毁线程 synchronized (workers) { System.out.println("没有任务了,worker被移除" + this.getName()); workers.remove(this); } } } }
3、测试
public static void main(String[] args) { //创建线程池对象 MyThreadPool pool = new MyThreadPool(2, 2, 1000, TimeUnit.MICROSECONDS); //创建一堆任务对象,用线程池执行 for (int i = 0; i < 5; i++){ int num = i; pool.execute(() -> { System.out.println(num + "号任务正在执行..."); }); } }
4、增加功能:自定义拒绝策略
之前的实现中,如果线程都在执行任务,而且任务队列已满,那么任务会一直等待。
public static void main(String[] args) { //创建线程池对象 MyThreadPool pool = new MyThreadPool(2, 5, 1000, TimeUnit.MICROSECONDS); //创建一堆任务对象,用线程池执行 for (int i = 0; i < 15; i++){ int num = i; pool.execute(() -> { try { Thread.sleep(10000); System.out.println(num + "号任务正在执行..."); } catch (InterruptedException e) { e.printStackTrace(); } }); } }
如果任务队列已满,应该让调用者自己决定策略,而不是只能一直等待其他任务结束。这个策略是作用于整个线程池的。
编写一个拒绝策略接口:
@FunctionalInterface public interface RejectPolicy<T> { //把队列和任务传给调用者 void reject(MyBlockingQueue<T> queue, T task); }
在线程池中定义拒绝策略的属性,并在构造方法中接收这个参数
//拒绝策略 private RejectPolicy<Runnable> rejectPolicy; public MyThreadPool(int coreSize, int queueCapacity, long timeout, TimeUnit timeUnit, RejectPolicy<Runnable> rejectPolicy) { this.coreSize = coreSize; this.queueCapacity = queueCapacity; this.timeout = timeout; this.timeUnit = timeUnit; this.rejectPolicy = rejectPolicy; taskQueue = new MyBlockingQueue<>(queueCapacity); workers = new HashSet<>(); }
当线程池已满时,就调用任务队列的tryPut方法
//执行任务 public void execute(Runnable task) { synchronized (workers) { //如果任务数小于核心数,直接创建一个worker对象执行 if (workers.size() < coreSize) { //创建线程对象 Worker worker = new Worker(task); System.out.println("新增一个worker线程,立即执行任务,线程编号:" + worker.getName()); //将线程对象加入线程集合 workers.add(worker); //启动线程 worker.start(); } //如果任务数大于核心数,就调用tryPut else { taskQueue.tryPut(rejectPolicy, task); } } }
任务队列的tryPut方法
//完成拒绝策略
public void tryPut(RejectPolicy<T> rejectPolicy, T task) {
lock.lock();
try {
//如果队列没有满,就加入队列
if (queue.size() == capacity){
queue.addLast(task);
System.out.println(task + "被成功放入任务队列!");
emptyWaitSet.signal();
}
//如果队列满了,就执行拒绝策略
else {
rejectPolicy.reject(this, task);
}
}finally {
lock.unlock();
}
}
测试,定义拒绝策略为“死等”
MyThreadPool pool = new MyThreadPool(2, 5, 1000, TimeUnit.MICROSECONDS,
(queue, task)->{
queue.put(task);
});
定义拒绝策略为“抛出异常”
MyThreadPool pool = new MyThreadPool(2, 5, 1000, TimeUnit.MICROSECONDS,
(queue, task)->{
throw new RuntimeException("队列已满");
});
3、ThreadPoolExecutor
JDK提供的线程池实现:
- ExecutorService:定义最基本的线程池功能,比如关闭线程池、执行任务等
- ScheduledExecutorService:扩展接口,新增了任务调度功能
- ThreadPoolExecutor:最基本的实现类
- ScheduledThreadPoolExecutor:实现了任务调度功能
1、线程池状态
ThreadPoolExecutor 使用 int 的高 3 位来表示线程池状态,低 29 位表示线程数量
- 需要记录两个参数:线程池状态、线程池中活跃线程的数量
- 这两个参数是共享的,因此存在线程安全问题。而且,它们是相关的,所以必须保证对它们赋值时的原子性。
- 如果放在两个原子变量中,那么无法保证两次CAS操作之间的原子性。
- 把它们都放在一个int中,就可以使用一个原子变量,通过一次CAS操作进行赋值,很巧妙。
源码的做法:
// c 为旧值, ctlOf 返回结果为新值
ctl.compareAndSet(c, ctlOf(targetState, workerCountOf(c))));
// rs 为高 3 位代表线程池状态, wc 为低 29 位代表线程个数,ctl 是合并它们
private static int ctlOf(int rs, int wc) { return rs | wc; }
2、构造方法
public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler)
- corePoolSize:核心线程数
- maximumPoolSize:线程池能容纳的最大线程数量(核心线程+救急线程)
- keepAliveTime:救急线程的空闲时长。如果超过该时长,救急线程就会被回收。
- unit:指定keepAliveTime的时间单位
- workQueue:任务队列
- threadFactory:(可选),指定创建新线程的方式
- handler:(可选),指定当任务队列已满时的拒绝策略。
根据这个构造方法,JDK Executors 类中提供了众多工厂方法来创建各种用途的线程池。但是阿里强制要求,线程池必须自己定义。
合理的设置策略
- corePoolSize:
- 依据任务的处理时间和每秒产生的任务数量来确定
- 按照百分之80的情况设计核心线程数,剩下的百分之20可以利用最大线程数处理
- maximumPoolSize:
- 最大线程数=(最大任务数 - 任务队列长度)* 单个任务执行时间
- workQueue:
- 任务队列长度一般设计为:核心线程数 / 单个任务执行时间 * 2
3、线程池的工作方式
线程池中分为两类线程:核心线程、救急线程
- 线程池中刚开始没有线程。当一个任务提交给线程池后,线程池会创建一个新线程来执行任务。
- 当线程数达到 corePoolSize 并没有线程空闲,这时再加入任务,新来的任务会被加入任务队列中排队等待,直到有空闲的线程。
- 如果选择了有界队列来充当任务队列,当队列已满,就创建maximumPoolSize - corePoolSize 数目的线程来救急。
- 如果线程数到达 maximumPoolSize ,但仍然有新任务,这时会执行拒绝策略。
- 超过corePoolSize 的救急线程如果在设定的空闲时间内没有任务做,就被销毁。这个时间由 keepAliveTime 和 unit 来控制。
- 默认情况下,核心线程会一直存活,但是当将 allowCoreThreadTimeout 设置为 true 时,核心线程也会超时回收。
4、构造参数详解
1、workQueue
作为任务队列,是基于阻塞队列实现的,需要实现 BlockingQueue 接口。
可以自己实现,也可以选择使用JDK自带的,常用的有下列这些:
-
ArrayBlockingQueue:一个由数组结构组成的有界阻塞队列。
-
LinkedBlockingQueue: 一个由链表结构组成的有界阻塞队列。
- 在未指明容量时,容量默认为 Integer.MAX_VALUE,可以看做无界队列。
-
PriorityBlockingQueue: 一个支持优先级排序的无界阻塞队列。
- 对元素没有要求,可以实现 Comparable 接口,也可以提供 Comparator 来对队列中的元素进行比较。
-
DelayQueue:二叉堆实现的无界优先级阻塞队列。
- 要求元素都实现 Delayed 接口,通过执行时延从队列中提取任务,时间没到任务取不出来。
-
SynchronousQueue: 一个不存储元素的阻塞队列。
- 消费者线程调用 take() 方法的时候就会发生阻塞,直到有一个生产者线程生产了一个元素,消费者线程就可以拿到这个元素并返回。
- 生产者线程调用 put() 方法的时候也会发生阻塞,直到有一个消费者线程消费了一个元素,生产者才会返回。
-
LinkedBlockingDeque: 使用双向队列实现的有界双端阻塞队列。
- 双端意味着可以像普通队列一样 FIFO(先进先出),也可以像栈一样 FILO(先进后出)。
-
LinkedTransferQueue: 它是ConcurrentLinkedQueue、LinkedBlockingQueue 和 SynchronousQueue 的结合体。
有界队列与无界队列的区别
- 如果使用有界队列,当队列饱和时并超过最大线程数时就会执行拒绝策略
- 而如果使用无界队列,因为任务队列永远都可以添加任务,所以设置 maximumPoolSize 没有任何意义。不过需要考虑OOM。
任务队列泛型使用Runnable和Callable的区别
- 使用Runnable,不能返回结果和抛出异常,但Callable可以。
- 所以,如果任务都不需要返回值,也不需要抛出异常,就可以使用Runnable,更加简洁。
工具类 Executors 可以实现 Runnable 对象和 Callable 对象之间的相互转换:
- Executors.callable(Runnable task)
- Executors.callable(Runnable task, Object resule)
2、threadFactory
线程工厂,指定创建线程的方式。需要实现 ThreadFactory 接口,并实现 newThread(Runnable r) 方法。
该参数可以不用指定,Executors 框架已经实现了一个默认的线程工厂
但推荐执行。线程尽量自己指定一个名字,这样在dump时看到线程的名称就可以快速知道是哪个业务的线程出了问题,如果都是默认名称,那就不好分辨。
3、handler
JDK提供了4种拒绝策略的实现:
- AbortPolicy:让调用者抛出 RejectedExecutionException 异常,这是默认策略。(abort:流产)
- CallerRunsPolicy:让调用者自己运行任务
- DiscardPolicy:直接放弃本次任务
- DiscardOldestPolicy:放弃队列中最早的任务,然后重新尝试提交被拒绝的任务
其他框架的实现方式:
- Dubbo:在抛出 RejectedExecutionException 异常之前会记录日志,并 dump 线程栈信息,方便定位问题
- Netty:创建一个新线程来执行任务,有可能引发OOM
- ActiveMQ:带超时等待的尝试放入队列。默认60s
- PinPoint:使用了一个拒绝策略链,会逐一尝试策略链中的每种拒绝策略
5、不推荐使用Executors线程池
Executors提供了四种线程池,但不推荐使用,推荐自己定义每个线程池参数,避免资源耗尽的风险。
- FixedThreadPool:创建一个固定线程数量的线程池,如果没有空闲线程,任务被加入任务队列
- SingleThreadExecutor:创建一个只有一个线程的线程池,如果没有空闲线程,任务被加入任务队列
- CachedThreadPool:创建一个根据实际情况调整线程数量的线程池,如果没有空闲线程,会创建新的线程来执行任务
Executors存在的问题:
- FixedThreadPool 和 SingleThreadExecutor:
- 主要问题是,任务队列均采用 LinkedBlockingQueue,没有任务队列的容量限制
- 可能会耗费非常大的内存,甚至 OOM。
- CachedThreadPool 和 ScheduledThreadPool:
- 主要问题是,线程数最大数是 Integer.MAX_VALUE,可能会创建数量非常多的线程,甚至 OOM。
6、execute和submit的区别
- void execute(Runnable command) :执行任务,没有返回值,一般用来执行Runnable
- Future submit(Callable task):执行任务,有返回值,一般用来执行Callable
- 可以通过Future 类型的返回值,判断任务是否执行成功。
- 可以通过Future的实例方法get(),获取方法的返回值。
- get()会阻塞调用线程,直到返回结果。
- get(long timeout, TimeUnit unit)会阻塞调用线程一段时间,到时间后立即返回当前结果,此时可能任务还没执行完
7、线程池应该设置多少个线程
- 如果线程太少,程序就不能充分利用系统资源,而且容易饥饿
- 如果线程太多,那么效率也没有得到提升,额外的线程上下文切换开销很大,而且占用很多内存
程序一般都是CPU计算和IO操作交叉进行的,如果大部分情况下,程序的大量时间都在执行IO操作,就称为IO密集型。
1、CPU密集型计算
CPU密集型,通常采用CPU核数+1个线程
,来实现最佳的CPU利用率。
+1是为了保证,当发生缺页中断时或者其他原因导致阻塞时,这个额外的线程能够顶上去运行,不浪费CPU资源。
这是理想状态下,在某一时刻,CPU的所有核心都在运行线程池中的线程 的情况。
2、IO密集型运算
IO密集的程序,CPU不总是处于繁忙状态,因为执行IO操作时CPU就闲下来了,因此可以多开几个线程,让CPU一直工作。
1、如何考虑
对于IO密集型,IO耗时和CPU耗时的比值是一个关键参数,但是这个参数是未知的,而且是动态的。
所以只能去估计这个参数,然后针对不同场景去进行压力测试,重点关注CPU和IO设备的综合利用率和性能指标之间的关系,去不断调整这个参数。
可以使用apm工具来测试IO耗时和CPU耗时。
2、对于单核CPU
最佳线程数 = 1 + ( IO耗时 / CPU耗时)
原理:
-
如果CPU耗时和IO耗时是1:1,那么创建2个线程是最合适的。
- 因为在一个线程进行IO操作阻塞时,另一个线程可以利用CPU进行运算。这样CPU和IO都能达到100%利用率
-
如果CPU耗时和IO耗时是1:2,那么创建3个线程是最合适的。
- 第一个线程执行IO操作,阻塞,还剩2个时间
- 第二个线程执行IO操作,阻塞,它还剩2个时间,第一个线程还剩1个时间
- 第三个线程执行CPU运算,花费1个时间。当它执行IO操作阻塞时,第一个线程刚好执行完IO操作,把CPU接过来
- 这样CPU和IO都能达到100%利用率
3、对于多核CPU
最佳线程数 = 核数 * [1 + ( IO耗时 / CPU耗时)]
原理很简单,只需要将单核的线程数等比扩大即可。
注意:
-
这个公式仅适用于服务器上部署一个服务的场景
-
前提条件是IO没有达到瓶颈,即增加线程数量后,同时请求IO的线程数也增加了,但IO时间不变。
IO遇到瓶颈之后,CPU使用率就上不去了,因为线程都卡在了IO这块,此时增加更多的线程也没有用,可以考虑升级硬件。
4、超线程技术
现在很多处理器都是类似4核8线程,超线程技术属于硬件层面上的并发,从cpu硬件来看是每个物理核心有两个逻辑核心。
但因为缓存、执行资源等存在共享和竞争,所以两个核心并不能并行工作。
超线程技术虽然多了一个逻辑核心,但性能提升大概是30%左右,并不是性能翻倍。
操作系统层面,是按照逻辑核心的数量来调度的,计算线程数时也是按照逻辑核心的数量来计算的。
但计算出参数后,具体的性能表现还是要依靠压力测试,如果发现线程数设置得不合理,那么调整一下,再测试即可。
3、具体场景
- 高并发、任务执行时间短的业务:属于计算密集型,线程池线程数可以设置为CPU核数+1,减少线程上下文的切换
- 并发不高、任务执行时间长的业务:
- 如果任务执行时间长是因为有长时间的IO操作,那么属于IO密集型的任务,可以适当增大线程池的线程数量,提高CPU利用率
- 如果任务执行时间长是因为计算比较耗时,那就没办法了,线程设置得少一点,减少线程上下文的切换
- 高并发、业务执行时间长的业务:优化这种业务的关键不在于线程池,而是在于整体架构的设计,比如考虑增加缓存,使用中间件,增加服务器。
8、线程池的使用
不同业务最好使用不同的线程池。一方面可以根据业务的特点来配置线程池,另一方面,多个存在关联的业务使用同一个线程池可能发生死锁。
比如,父任务和子任务使用同一个线程池,父任务执行过程中会调用子任务。
假设线程池中全是父任务,子任务得不到执行,但父任务一直在等待子任务返回结果,就出现了死锁。