九、线程池

113 阅读11分钟

1、使用线程池的好处

(1)降低资源消耗。通过重复利用已创建的线程降低线程创建和销毁造成的消耗。

(2)提高响应速度。当任务到达时,任务可以不需要等到线程创建就能立即执行。

(3)提高线程的可管理性。线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控。

2、如何创建线程池?

第八章 并发编程-线程池

2.1 通过 Executors 创建

  1. newFixedThreadPool // 创建一个大小固定的线程池,可控制并发的线程数,超出的线程会在队列中等待
  2. newCachedThreadPool // 创建一个可缓存的线程池,若线程数超过处理所需,缓存一段时间后会回收,若线程数不够,则新建线程
  3. newSingleThreadExecutor // 创建单个线程的线程池,可以保证先进先出的执行顺序
  4. newScheduledThreadPool // 创建一个可以执行延迟任务的线程池
  5. newSingleThreadScheduledExecutor // 创建一个单线程的可以执行延迟任务的线程池
  6. newWorkStealingPool // 创建一个抢占式执行的线程池 // 需要注意的是此方法是 JDK 1.8 版本新增的,所以 1.8 版本之前的程序中不能使用。

2.1.1 Executor 框架结构

  1. 任务(Runnable /Callable)执行任务需要实现的 Runnable 接口 或 Callable接口。

Runnable 接口或 Callable 接口 实现类都可以被 ThreadPoolExecutor 或 ScheduledThreadPoolExecutor 执行。

  1. 任务的执行Executor,包括任务执行机制的核心接口 Executor ,以及继承自 Executor 接口的 ExecutorService 接口。

ThreadPoolExecutor 和 ScheduledThreadPoolExecutor 这两个关键类实现了 ExecutorService 接口。

  1. 异步计算的结果(Future)Future 接口以及 Future 接口的实现类 FutureTask 类都可以代表异步计算的结果。

当我们把 Runnable接口 或 Callable 接口 的实现类提交给 ThreadPoolExecutor 或 ScheduledThreadPoolExecutor 执行。(调用 submit() 方法时会返回一个 FutureTask 对象)

2.1.2 线程池必须手动通过 ThreadPoolExecutor 的构造函数来声明。为什么不允许使用 Executors 去创建?

避免使用Executors 类的 newFixedThreadPool 和 newCachedThreadPool ,因为可能会有 OOM 的风险。

  1. FixedThreadPool 和 SingleThreadExecutor : 允许请求的队列长度为 Integer.MAX_VALUE ,可能堆积大量的请求,从而导致 OOM。
  2. CachedThreadPool 和 ScheduledThreadPool : 允许创建的线程数量为 Integer.MAX_VALUE ,可能会创建大量线程,从而导致 OOM。

除了避免 OOM 的原因之外,不推荐使用 Executors提供的两种快捷的线程池的原因还有:

  1. 实际使用中需要根据自己机器的性能、业务场景来手动配置线程池的参数比如核心线程数、使用的任务队列、饱和策略等等。
  2. 我们应该显示地给我们的线程池命名,这样有助于我们定位问题。

2.2 通过 ThreadPoolExecutor 创建

image.png

2.2.1 线程池参数

(1)corePoolSize : 核心线程数定义了最小可以同时运行的线程数量。

(2)maximumPoolSize : 当队列中存放的任务达到队列容量的时候,当前可以同时运行的线程数量变为最大线程数。

(3)workQueue: 当新任务来的时候会先判断当前运行的线程数量是否达到核心线程数,如果达到的话,新任务就会被存放在队列中。

(4)keepAliveTime: 当线程池中的线程数量大于 corePoolSize 的时候,如果这时没有新的任务提交,核心线程外的线程不会立即销毁,而是会等待,直到等待的时间超过了 keepAliveTime才会被回收销毁;

(5)unit : keepAliveTime 参数的时间单位。

(6)threadFactory : executor 创建新线程的时候会用到。

(7)handler : 饱和策略。

2.2.2 线程池的饱和策略

(1)ThreadPoolExecutor.AbortPolicy: 抛出 RejectedExecutionException来拒绝新任务的处理。

(2)ThreadPoolExecutor.CallerRunsPolicy: 调用执行自己的线程运行任务,也就是直接在调用execute方法的线程中运行(run)被拒绝的任务,如果执行程序已关闭,则会丢弃该任务。因此这种策略会降低对于新任务提交速度,影响程序的整体性能。如果您的应用程序可以承受此延迟并且你要求任何一个任务请求都要被执行的话,你可以选择这个策略。

(3)ThreadPoolExecutor.DiscardPolicy: 不处理新任务,直接丢弃掉。

(4)ThreadPoolExecutor.DiscardOldestPolicy: 此策略将丢弃最早的未处理的任务请求。

2.2.3 如何设定线程池的大小?

(1)CPU 密集型任务(N+1): 这种任务消耗的主要是 CPU 资源,可以将线程数设置为 N(CPU 核心数)+1,比 CPU 核心数多出来的一个线程是为了防止线程偶发的缺页中断,或者其它原因导致的任务暂停而带来的影响。一旦任务暂停,CPU 就会处于空闲状态,而在这种情况下多出来的一个线程就可以充分利用 CPU 的空闲时间。

(2)I/O 密集型任务(2N): 这种任务应用起来,系统会用大部分的时间来处理 I/O 交互,而线程在处理 I/O 的时间段内不会占用 CPU 来处理,这时就可以将 CPU 交出给其它线程使用。因此在 I/O 密集型任务的应用中,我们可以多配置一些线程,具体的计算方法是 2N。

如何判断是 CPU 密集任务还是 IO 密集任务?
  CPU 密集型简单理解就是利用 CPU 计算能力的任务比如你在内存中对大量数据进行排序。但凡涉及到网络读取,文件读取这类都是 IO 密集型,这类任务的特点是 CPU 计算耗费时间相比于等待 IO 操作完成的时间来说很少,大部分时间都花在了等待 IO 操作完成上。

2.2.4 一般建议是不同的业务使用不同的线程池

假如我们线程池的核心线程数为 n,父任务(扣费任务)数量为 n,父任务下面有两个子任务(扣费任务下的子任务),其中一个已经执行完成,另外一个被放在了任务队列中。由于父任务把线程池核心线程资源用完,所以子任务因为无法获取到线程资源无法正常执行,一直被阻塞在队列中。父任务等待子任务执行完成,而子任务等待父任务释放线程池资源,这也就造成了 "死锁"。

3、AQS

AQS 的全称为 AbstractQueuedSynchronizer ,翻译过来的意思就是抽象队列同步器。这个类在 java.util.concurrent.locks 包下面。 AQS 为构建锁和同步器提供了一些通用功能的是实现,因此,使用 AQS 能简单且高效地构造出应用广泛的大量的同步器,比如我们提到的 ReentrantLock,Semaphore,其他的诸如 ReentrantReadWriteLock,SynchronousQueue等等皆是基于 AQS 的。

3.1 AQS组件总结

1、Semaphore(信号量)-允许多个线程同时访问: synchronized 和 ReentrantLock 都是一次只允许一个线程访问某个资源,Semaphore(信号量)可以指定多个线程同时访问某个资源。

2、CountDownLatch (倒计时器): CountDownLatch是一个同步工具类,用来协调多个线程之间的同步。这个工具通常用来控制线程等待,它可以让某一个线程等待直到倒计时结束,再开始执行。

3、CyclicBarrier(循环栅栏): CyclicBarrier 和 CountDownLatch 非常类似,它也可以实现线程间的技术等待,但是它的功能比 CountDownLatch 更加复杂和强大。主要应用场景和 CountDownLatch 类似。CyclicBarrier 的字面意思是可循环使用(Cyclic)的屏障(Barrier)。它要做的事情是,让一组线程到达一个屏障(也可以叫同步点)时被阻塞,直到最后一个线程到达屏障时,屏障才会开门,所有被屏障拦截的线程才会继续干活。CyclicBarrier默认的构造方法是 CyclicBarrier(int parties),其参数表示屏障拦截的线程数量,每个线程调用await()方法告诉 CyclicBarrier 我已经到达了屏障,然后当前线程被阻塞。

3.2 AQS 的原理是什么?

AQS 核心思想是,如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并且将共享资源设置为锁定状态。如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制 AQS 是用 CLH 队列锁 实现的,即将暂时获取不到锁的线程加入到队列中。

CLH 队列结构如下图所示:

40cb932a64694262993907ebda6a0bfe~tplv-k3u1fbpfcp-zoom-1.image.png CLH(Craig,Landin,and Hagersten) 队列是一个虚拟的双向队列(虚拟的双向队列即不存在队列实例,仅存在结点之间的关联关系)。AQS 是将每条请求共享资源的线程封装成一个CLH锁队列的一个结点(Node)来实现锁的分配。在CLH同步队列中,一个节点表示一个线程,它保存着线程的引用(thread)、 当前节点在队列中的状态(waitStatus)、前驱节点(prev)、后继节点(next)。

AQS(AbstractQueuedSynchronizer)的核心原理图:

CLH.png

AQS 使用 int 成员变量 state 表示同步状态,通过内置的线程等待队列来完成获取资源线程的排队工作。 state 变量由 volatile 修饰,用于展示当前临界资源的获锁情况。

另外,状态信息 state 可以通过 protected 类型的getState()、setState()和compareAndSetState() 进行操作。并且,这几个方法都是 final 修饰的,在子类中无法被重写。

以 ReentrantLock 为例,state 初始值为 0,表示未锁定状态。A 线程 lock() 时,会调用 tryAcquire() 独占该锁并将 state+1 。此后,其他线程再 tryAcquire() 时就会失败,直到 A 线程 unlock() 到 state=0(即释放锁)为止,其它线程才有机会获取该锁。当然,释放锁之前,A 线程自己是可以重复获取此锁的(state 会累加),这就是可重入的概念。 但要注意,获取多少次就要释放多少次,这样才能保证 state 是能回到零态的。

3.3 CountDownLatch 和 CyclicBarrier的区别

CountDownLatch 的计数器是大于或等于线程数的,而CyclicBarrier是一定等于线程数 CountDownLatch 放行由其他线程控制而CyclicBarrier是由本身来控制的

CountDownLatch

说明: 一个线程等待其他线程执行完之后再执行,相当于加强版的join,在初始化CountDownLatch是需要设定计数器的数值(计数器数据不一定跟线程数相同,但是一定计数器的值一定是要大于等于线程数,一个线程中可以进行多次扣减。当计数器扣减至0时才可继续向下执行)

举例说明: 比如LOL在游戏开始时需要玩家全部准备完毕之后才开始,开始游戏可以理解为“主线程”,玩家准备理解为“其他线程”,在“其他线程”没有准备完毕之前,“主线程时等待状态”,当“其他线程”准备完毕之后“主线程”就会执行下一步开始游戏的动作

CyclicBarrier

说明: 让一组线程到达某个屏障,然后被阻塞,一直到最后一个线程到达屏障,然后屏障开放,所有被阻塞的线程继续执行,计数器与线程数相等。 CyclicBarrier(int parties, Runnable barrierAction) 当时使用这个构造函数时,barrierAction定义的任务会执行

举例说明: 假设有一家公司要全体员工进行团建活动,活动内容为翻越三个障碍物,每一个人翻越障碍物所用的时间是不一样的。但是公司要求所有人在翻越当前障碍物之后再开始翻越下一个障碍物,也就是所有人翻越第一个障碍物之后,才开始翻越第二个,以此类推比如跨栏比赛,我们修改一下规则,当所有选手都跨过第一个栏杆是,才去跨第二个,以此类推,每一个员工都是一个“其他线程”。当所有人都翻越的所有的障碍物之后,程序才结束。而主线程可能早就结束了,这里我们不用管主线程。