线程池详解

目录

1、线程池的好处

2、手写一个线程池

1、实现阻塞队列

2、实现线程池

3、测试

4、增加功能:自定义拒绝策略

3、ThreadPoolExecutor

1、线程池状态

2、构造方法

3、线程池的工作方式

4、构造参数详解

1、workQueue

2、threadFactory

3、handler

5、不推荐使用Executors线程池

6、execute和submit的区别

7、线程池应该设置多少个线程

1、CPU密集型计算

2、IO密集型运算

1、如何考虑

2、对于单核CPU

3、对于多核CPU

4、超线程技术

8、线程池的使用


1、线程池的好处

  1. 降低资源消耗。通过复用已经创建的线程,避免频繁的线程创建、销毁带来的资源消耗
  2. 提高响应速度。任务到达时,直接取一个线程就能执行,不用等待线程初始化和创建,提高系统并发能力
  3. 提高线程的可管理性。线程是稀缺的系统资源,不能无限制创建,应该由线程池统一管理、分配和监控

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、线程池的使用

不同业务最好使用不同的线程池。一方面可以根据业务的特点来配置线程池,另一方面,多个存在关联的业务使用同一个线程池可能发生死锁。

比如,父任务和子任务使用同一个线程池,父任务执行过程中会调用子任务。

假设线程池中全是父任务,子任务得不到执行,但父任务一直在等待子任务返回结果,就出现了死锁。

  • 2
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值