深入了解线程池
1、线程池
线程池介绍
- 线程池,顾名思义就是一个线程缓存,线程是稀缺资源,如果被无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,因此Java中提供线程池对线程进行统一分配、调优和监控
- 作用:线程池做的工作主要是控制运行的线程数量,处理过程中将任务放入队列,然后在线程创建后启动这些任务,如果线程数量超过了最大数量,超出数量的线程排队等候,等其他线程执行完毕,再从队列中取出任务来执行。
- 优点:
- 线程复用。减少线程创建,消亡的开销,提高性能;
- 提高响应速度。当任务到达时,任务可以不需要的等到线程创建就能立即执行。
- 管理线程。线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控。
线程池核心参数
- corePoolSize
- 线程池中的核心线程数。当提交一个任务时,线程池创建一个新线程执行任务,直到当前线程数等于corePoolSize;如果当前线程数为corePoolSize,继续提交的任务被保存到阻塞队列中,等待被执行;如果执行了线程池的prestartAllCoreThreads()方法,线程池会提前创建并启动所有核心线程。
- 参数设置:
- 每个任务需要tasktime秒处理,则每个线程每秒可以处理1/tasktime个任务。系统每秒有tasks
个任务,所以需要的线程数为tasks/(1/tasktime),即taskstasktime个线程数。 - 假设系统每秒任务数为100-1000,每个任务耗时0.1秒,那么需要10-100个线程, 核心线程数应设
置为大于10,具体数字最好根据八二原则,即80%情况下系统每秒任务数,若系统80%情况下每
秒任务数小于200,最多时为1000,那么可以设置为20。
- 每个任务需要tasktime秒处理,则每个线程每秒可以处理1/tasktime个任务。系统每秒有tasks
- maximumPoolSize
- 线程池中允许的最大线程数。如果当前阻塞队列满了,且继续提交任务,则创建新的线程执行任务,前提是当前线程数小于maximumPoolSize;
- 参数设置:
- (最大任务数 - queueCapacity )*(任务耗时)
- CPU密集型程序:线程数等于 CPU 核心数 + 1;
- IO密集型程序:线程数等于 CPU 核心数 * 2;
- keepAliveTime
线程池维护线程所允许的空闲时间。当线程池中的线程数量大于corePoolSize的时候,如果这时没有新的任务提交,核心线程外的线程不会立即销毁,而是会等待,直到等待的时间超过了keepAliveTime; - unit
- keepAliveTime的单位;
- workQueue
- 用来保存等待被执行的任务的阻塞队列。任务必须实现Runable接口,在JDK中提供了如下阻塞队列:
- ArrayBlockingQueue:基于数组结构的有界阻塞队列,按FIFO排序任务;
- LinkedBlockingQuene:基于链表结构的阻塞队列,按FIFO排序任务,吞吐量通常要高于ArrayBlockingQuene;
- SynchronousQuene:一个不存储元素的阻塞队列,每个插入操作必须等到另一个线程调用移除操作,否则插入操作一直处于阻塞状态,吞吐量通常要高于LinkedBlockingQuene;
- priorityBlockingQuene:具有优先级的无界阻塞队列;
- 用来保存等待被执行的任务的阻塞队列。任务必须实现Runable接口,在JDK中提供了如下阻塞队列:
- threadFactory
- 用来创建新线程。默认使用Executors.defaultThreadFactory() 来创建线程。使用默认的ThreadFactory来创建线程时,会使新创建的线程具有相同的NORM_PRIORITY优先级并且是非守护线程,同时也设置了线程的名称。
- handler
- 线程池的饱和拒绝策略。当阻塞队列满了,且没有空闲的工作线程,如果继续提交任务,必须采取一种策略处理该任务,线程池提供了4种策略:
- AbortPolicy:丢弃任务并抛出RejectedExecutionException异常,默认策略;
- CallerRunsPolicy:用调用者所在的线程来执行任务;
- DiscardOldestPolicy:丢弃队列最前面的任务,然后重新提交被拒绝的任务;
- DiscardPolicy:丢弃任务,但是不抛出异常;
- 也可以根据应用场景实现RejectedExecutionHandler接口,自定义饱和策略,如记录日志或持久化存储不能处理的任务。
- 线程池的饱和拒绝策略。当阻塞队列满了,且没有空闲的工作线程,如果继续提交任务,必须采取一种策略处理该任务,线程池提供了4种策略:
线程池五大状态
-
RUNNING
- 状态说明:线程池处在RUNNING状态时,能够接收新任务,以及对已添加的任务进行处理。
- 状态切换:线程池的初始化状态是RUNNING。换句话说,线程池被一旦被创建,就处于RUNNING状态,并且线程池中的任务数为0!
-
SHUTDOWN
- 状态说明:线程池处在SHUTDOWN状态时,不接收新任务,但能处理已添加的任务。
- 状态切换:调用线程池的shutdown()接口时,线程池由RUNNING -> SHUTDOWN。
-
STOP
- 状态说明:线程池处在STOP状态时,不接收新任务,不处理已添加的任务,并且会中断正在处理的任务。
- 状态切换:调用线程池的shutdownNow()接口时,线程池由(RUNNING or SHUTDOWN ) -> STOP。
-
TIDYING
- 状态说明:当所有的任务已终止,ctl记录的”任务数量”为0,线程池会变为TIDYING状态。当线程池变为TIDYING状态时,会执行钩子函数terminated()。terminated()在ThreadPoolExecutor类中是空的,若用户想在线程池变为TIDYING时,进行相应的处理;可以通过重载terminated()函数来实现。
- 状态切换:当线程池在SHUTDOWN状态下,阻塞队列为空并且线程池中执行的任务也为空时,就会由 SHUTDOWN -> TIDYING。 当线程池在STOP状态下,线程池中执行的任务为空时,就会由STOP -> TIDYING。
-
TERMINATED
- 状态说明:线程池彻底终止,就变成TERMINATED状态。
- 状态切换:线程池处在TIDYING状态时,执行完terminated()之后,就会由 TIDYING -> TERMINATED。
- 进入TERMINATED的条件如下:
- 线程池不是RUNNING状态;
- 线程池状态不是TIDYING状态或TERMINATED状态;
- 如果线程池状态是SHUTDOWN并且workerQueue为空;
- workerCount为0;
- 设置TIDYING状态成功。
线程池底层工作原理
- 在创建了线程池后,开始等待请求。
- 当调用execute()方法添加一个请求任务时,线程池会做出如下判断:
- 如果正在运行的线程数量小于corePoolSize,那么马上创建线程运行这个任务;
- 如果正在运行的线程数量大于或等于corePoolSize,那么将这个任务放入队列;
- 如果这个时候队列满了且正在运行的线程数量还小于maximumPoolSize,那么还是要创建非核心
线程立刻运行这个任务; - 如果队列满了且正在运行的线程数量大于或等于maximumPoolSize,那么线程池会启动饱和拒绝
策略来执行。
- 当一个线程完成任务时,它会从队列中取下一个任务来执行。
- 当一个线程无事可做超过一定的时间(keepAliveTime)时,线程会判断:
如果当前运行的线程数大于corePoolSize,那么这个线程就被停掉。 - 线程池的所有任务完成后,它最终会收缩到corePoolSize的大小。
线程池四大线程池
- FixedThreadPool : 该⽅法返回⼀个固定线程数量的线程池。适合任务量比较固定但耗时长的任
务。 - SingleThreadExecutor: ⽅法返回⼀个只有⼀个线程的线程池。这类线程池适用于多个任务顺序
执行的场景。 - CachedThreadPool: 该⽅法返回⼀个可根据实际情况调整线程数量的线程池。适合任务量大但
耗时少的任务。 - ScheduledThreadPool :该方法返回一个能实现定时、周期性任务的线程池。适合执行定时或周
期性的任务。核心线程数量固定,非核心线程数量无限,执行完闲置10ms后回收,任务队列为延
时阻塞队列。
缺点:
- FixedThreadPool和SingleThreadExecutor允许请求队列长度为Integer.MAX_VALUE,可能会堆积大量请求,从而导致OOM;
- CachedThreadPool和ScheduledThreadPool 允许创建线程数量为Integer.MAX_VALUE,可能会创建大量线程,从而导致OOM;
阿里巴巴开发手册推荐自定义线程池
线程池如何证线程一直运行的
- 在当前线程执行的runWorker方法中有个while循环,while循环的第一个判断条件是执行当前线程关联的Worker对象中的任务,执行一轮后进入while循环的第二个判断条件getTask(),从任务队列中取任务,取这个任务的过程要么是一直阻塞的,要么是阻塞一定时间直到超时才结束的,超时到了的时候这个线程也就走到了生命的尽头。
线程池execute()方法和submit()方法
- execute() ⽅法⽤于提交不需要返回值的任务,所以⽆法判断任务是否被线程池执⾏成功与否;
- submit() ⽅法⽤于提交需要返回值的任务。线程池会返回⼀个 Future 类型的对象,通过这个Future 对象可以判断任务是否执⾏成功;
2、线程池核心方法、类
execute方法
- 执行execute()方法时执行过程:
- 如果workerCount < corePoolSize,则创建并启动一个线程来执行新提交的任务;
- 如果workerCount >= corePoolSize,且线程池内的阻塞队列未满,则将任务添加到该阻塞队列中;
- 如果workerCount >= corePoolSize && workerCount < maximumPoolSize,且线程池内的阻塞队列已满,则创建并启动一个线程来执行新提交的任务;
- 如果workerCount >= maximumPoolSize,并且线程池内的阻塞队列已满, 则根据拒绝策略来处理该任务, 默认的处理方式是直接抛异常。
- 要注意一下addWorker(null, false); 也就是创建一个线程,但并没有传入任务,因为任务已经被添加到workQueue中了,所以worker在执行的时候,会直接从workQueue中获取任务。所以,在workerCountOf(recheck) == 0 时执行 addWorker(null, false); 也是为了保证线程池在RUNNING状态下必须要有一个线程来执行任务。
addWorker方法
- addWorker方法的主要工作:在线程池中创建一个新的线程并执行。firstTask参数 用于指定新增的线程执行的第一个任务,core参数为true表示在新增线程时会判断当前活动线程数是否少于corePoolSize,false表示新增线程前需要判断当前活动线程数是否少于maximumPoolSize;
Worker类
- 线程池中的每一个线程被封装成一个Worker对象,ThreadPool维护的其实就是一组Worker对象;
- Worker类继承了AQS,并实现了Runnable接口,注意 firstTask 和 thread 属性:firstTask用它来保存传入的任务;thread是在调用构造方法时通过ThreadFactory来创建的线程,是用来处理任务的线程。
- 在调用构造方法时,需要把任务传入,这里通过 getThreadFactory().newThread(this); 来新建一个线程,newThread 方法传入的参数是this,因为Worker本身继承了Runnable接口,也就是一个线程,所以一个Worker对象在启动的时候会调用Worker类中的run方法。
- Worker继承了AQS,使用AQS来实现独占锁的功能。
- lock方法一旦获取了独占锁,表示当前线程正在执行任务中;
- 如果正在执行任务,则不应该中断线程;
- 如果该线程现在不是独占锁的状态(state = 0,即空闲的状态),说明它没有在处理任务,这时可以对该线程进行中断;
- 线程池在执行shutdown方法或tryTerminate方法时会调用interruptIdleWorkers方法来中断空闲的线程,interruptIdleWorkers方法会使用tryLock方法来判断线程池中的线程是否是空闲状态;
- 之所以不可重入,是因为不希望任务在调用像 setCorePoolSize 这样的线程池控制方法时重新获取锁。如果使用ReentrantLock,它是可重入的,这样如果在任务中调用了如setCorePoolSize这类线程池控制的方法,会中断正在运行的线程。所以,Worker继承自AQS,用于判断线程是否空闲以及是否可以被中断。
runWorker方法
- runWorker方法的执行过程:
- while循环不断地通过getTask()方法获取任务;
- getTask()方法从阻塞队列中取任务;
- 如果线程池正在停止,那么要保证当前线程是中断状态,否则要保证当前线程不是中断状态;
- 调用task.run()执行任务;
- 如果task为null则跳出循环,执行processWorkerExit()方法;
- 该方法会根据 allowCoreThreadTimeOut 属性判断是否删除核心线程,至少保留一个;
- runWorker方法执行完毕,也代表着Worker中的run方法执行完毕,销毁线程。
工作线程的生命周期
- execute方法开始;
- Worker使用ThreadFactory创建新的工作线程;
- runWorker通过getTask获取任务,然后执行任务;
- 如果getTask返回null,进入processWorkerExit方法,整个线程结束;
3、CPU密集型与IO密集型
CPU密集型(CPU-bound)
-
CPU密集型也叫计算密集型,指的是系统的硬盘、内存性能相对CPU要好很多,此时,系统运作大部分的状况是CPU Loading 100%,CPU要读/写I/O(硬盘/内存),I/O在很短的时间就可以完成,而CPU还有许多运算要处理,CPU Loading很高。
-
在多重程序系统中,大部份时间用来做计算、逻辑判断等CPU动作的程序称之CPU bound。例如一个计算圆周率至小数点一千位以下的程序,在执行的过程当中绝大部份时间用在三角函数和开根号的计算,便是属于CPU bound的程序。
-
CPU bound的程序一般而言CPU占用率相当高。这可能是因为任务本身不太需要访问I/O设备,也可能是因为程序是多线程实现因此屏蔽掉了等待I/O的时间。
-
线程数一般设置为:线程数 = CPU核数 + 1
IO密集型(I/O bound)
-
IO密集型指的是系统的CPU性能相对硬盘、内存要好很多,此时,系统运作,大部分的状况是CPU在等I/O (硬盘/内存) 的读/写操作,此时CPU Loading并不高。
-
I/O bound的程序一般在达到性能极限时,CPU占用率仍然较低。这可能是因为任务本身需要大量I/O操作,而pipeline做得不是很好,没有充分利用处理器能力。
-
线程数一般设置为:线程数 = ((线程等待时间+线程CPU时间)/ 线程CPU时间 )* CPU数目
CPU密集型 vs IO密集型
- 可以把任务分为计算密集型和IO密集型。
- 计算密集型任务的特点是要进行大量的计算,消耗CPU资源,比如计算圆周率、对视频进行高清解码等等,全靠CPU的运算能力。这种计算密集型任务虽然也可以用多任务完成,但是任务越多,花在任务切换的时间就越多,CPU执行任务的效率就越低,所以,要最高效地利用CPU,计算密集型任务同时进行的数量应当等于CPU的核心数。
- 计算密集型任务由于主要消耗CPU资源,因此,代码运行效率至关重要。Python这样的脚本语言运行效率很低,完全不适合计算密集型任务。对于计算密集型任务,最好用C语言编写。
- 第二种任务的类型是IO密集型,涉及到网络、磁盘IO的任务都是IO密集型任务,这类任务的特点是CPU消耗很少,任务的大部分时间都在等待IO操作完成(因为IO的速度远远低于CPU和内存的速度)。对于IO密集型任务,任务越多,CPU效率越高,但也有一个限度。
- 常见的大部分任务都是IO密集型任务,比如Web应用。IO密集型任务执行期间,99%的时间都花在IO上,花在CPU上的时间很少,因此,用运行速度极快的C语言替换用Python这样运行速度极低的脚本语言,完全无法提升运行效率。
- 对于IO密集型任务,最合适的语言就是开发效率最高(代码量最少)的语言,脚本语言是首选,C语言最差。