目录以前,我总觉得的买一件东西,做一件事,或者从某一个时间节点开始,我的生命就会发生转折,一切就会无比顺利,立马变厉害。但是,事实上并不是如此。我不可能马上变厉害,也不可能一口吃成一个胖子。看一篇文章也不能让你从此走上人生巅峰,越来越相信,这是一个长期的过程,只有量变引起质变,纵使缓慢,驰而不息。
这是一个常见的问题,如果在比较熟悉线程池运作原理的情况下,这个问题并不难。设计实现一个东西,三步走:是什么?为什么?怎么做?
线程池使用了池化技术,将线程存储起来放在一个 "池子"(容器)里面,来了任务可以用已有的空闲的线程进行处理, 处理完成之后,归还到容器,可以复用。如果线程不够,还可以根据规则动态增加,线程多余的时候,亦可以让多余的线程死亡。
实现线程池有什么好处呢?
那线程池设计需要考虑的点:
线程池状态:
线程
任务
如果从任务的阶段来看,分为以下几个阶段:
状态可以设置为以下几种:
各种状态之间是不一样的,他们的状态之间变化如下:
而维护状态的话,可以用一个变量单独存储,并且需要保证修改时的原子性,在底层操作系统中,对int的修改是原子的,而在32位的操作系统里面,对double
,long
这种64位数值的操作不是原子的。除此之外,实际上JDK里面实现的状态和线程池的线程数是同一个变量,高3位表示线程池的状态,而低29位则表示线程的数量。
这样设计的好处是节省空间,并且同时更新的时候有优势。
线程,即是实现了Runnable
接口,执行的时候,调用的是start()
方法,但是start()
方法内部编译后调用的是 run()
方法,这个方法只能调用一次,调用多次会报错。因此线程池里面的线程跑起来之后,不可能终止再启动,只能一直运行着。既然不可以停止,那么执行完任务之后,没有任务过来,只能是轮询取出任务的过程
线程可以运行任务,因此封装线程的时候,假设封装成为 Worker
, Worker
里面必定是包含一个 Thread
,表示当前线程,除了当前线程之外,封装的线程类还应该持有任务,初始化可能直接给予任务,当前的任务是null的时候才需要去获取任务。
可以考虑使用 HashSet
来存储线程,也就是充当线程池的角色,当然,HashSet
会有线程安全的问题需要考虑,那么我们可以考虑使用一个可重入锁比如 ReentrantLock
,凡是增删线程池的线程,都需要锁住。
private final ReentrantLock mainLock = new ReentrantLock();
(1)初始化线程的时候可以直接指定任务,譬如Runnable firstTask
,将任务封装到 worker
中,然后获取 worker
里面的 thread
,thread.run()
的时候,其实就是 跑的是 worker
本身的 run()
方法,因为 worker
本身就是实现了 Runnable
接口,里面的线程其实就是其本身。因此也可以实现对 ThreadFactory
线程工厂的定制化。
private final class Worker extends AbstractQueuedSynchronizer implements Runnable { final Thread thread; Runnable firstTask; ... Worker(Runnable firstTask) { setState(-1); // inhibit interrupts until runWorker this.firstTask = firstTask; // 从线程池创建线程,传入的是其本身 this.thread = getThreadFactory().newThread(this); } }
(2)运行完任务的线程,应该继续取任务,取任务肯定需要从任务队列里面取,要是任务队列里面没有任务,由于是阻塞队列,那么可以等待,如果等待若干时间后,仍没有任务,倘若该线程池的线程数已经超过核心线程数,并且允许线程消亡的话,应该将该线程从线程池中移除,并结束掉该线程。
取任务和执行任务,对于线程池里面的线程而言,就是一个周而复始的工作,除非它会消亡。
现在我们所说的是Java
中的线程Thread
,一个线程在一个给定的时间点,只能处于一种状态,这些状态都是虚拟机的状态,不能反映任何操作系统的线程状态,一共有六种/七种状态:
NEW
:创建了线程对象,但是还没有调用Start()
方法,还没有启动的线程处于这种状态。
Running
:运行状态,其实包含了两种状态,但是Java
线程将就绪和运行中统称为可运行
Runnable
:就绪状态:创建对象后,调用了start()
方法,该状态的线程还位于可运行线程池中,等待调度,获取CPU
的使用权
start()
之后进入就绪状态,sleep()
结束或者join()
结束,线程获得对象锁等都会进入该状态。CPU
时间片结束或者主动调用yield()
方法,也会进入该状态Running
:获取到CPU
的使用权(获得CPU时间片),变成运行中BLOCKED
:阻塞,线程阻塞于锁,等待监视器锁,一般是Synchronize
关键字修饰的方法或者代码块
WAITING
:进入该状态,需要等待其他线程通知(notify
)或者中断,一个线程无限期地等待另一个线程。
TIMED_WAITING
:超时等待,在指定时间后自动唤醒,返回,不会一直等待
TERMINATED
:线程执行完毕,已经退出。如果已终止再调用start(),将会抛出java.lang.IllegalThreadStateException
异常。
线程池本身,就是为了限制和充分使用线程资的,因此有了两个概念:核心线程数,最大线程数。
要想让线程数根据任务数量动态变化,那么我们可以考虑以下设计(假设不断有任务):
由上面可以看出,主要控制伸缩的参数是核心线程数
,最大线程数
,任务队列
,拒绝策略
。
线程不能被重新调用多次start()
,因此只能调用一次,也就是线程不可能停下来,再启动。那么就说明线程复用只是在不断的循环罢了。
消亡只是结束了它的run()
方法,当线程池数量需要自动缩容的,就会让一部分空闲的线程结束。
而重复利用,其实是执行完任务之后,再去去任务队列取任务,取不到任务会等待,任务队列是一个阻塞队列,这是一个不断循环
的过程。
任务少的时候,来了直接创建,赋予线程初始化任务,就可开始执行,任务多的时候,把它放进队列里面,先进先出。
任务队列满了,会继续增加线程,直到达到最大的线程数。
一般的队列,只是一个有限长度的缓冲区,要是满了,就不能保存当前的任务,阻塞队列可以通过阻塞,保留出当前需要入队的任务,只是会阻塞等待。同样的,阻塞队列也可以保证任务队列没有任务的时候,阻塞当前获取任务的线程,让它进入wait
状态,释放cpu
的资源。因此在线程池的场景下,阻塞队列其实是比较有必要的。
【作者简介】:
秦怀,公众号【秦怀杂货店】作者,技术之路不在一时,山高水长,纵使缓慢,驰而不息。这个世界希望一切都很快,更快,但是我希望自己能走好每一步,写好每一篇文章,期待和你们一起交流。如果有帮助,顺手点个赞,对我,是莫大的鼓励和认可。