秋招Day8 - Java并发(下)- 线程池

什么是线程池?

管理一系列线程的资源池。处理完毕后线程不会立即销毁,而是等待下一次任务使用。

使用线程池的目的

复用线程降低资源消耗、使用现成的线程减少创建等待时间、提高线程的可管理性。

如何创建线程池

1. 通过ThreadPoolExecutor构造方法创建

2. 通过Executors提供的方法创建

  • FixedThreadPool:固定线程数量的线程池。若任务到达时没有空闲线程则在队列中排队等待
  • SingleThreadExecutor:创建单个线程的线程池。多余一个的线程按照FIFO排队等待
  • CachedThreadPool:根据实际情况调整线程数量的线程池。如果所有线程都在工作,又来了新任务,就会创新新的线程。线程执行完毕后放入线程池等待复用。
  • ScheduledThreadPool:特定延迟后运行任务或者定期执行任务的线程池。

为什么不推荐使用Executors内置线程池?

  • FixedThreadPool和SingleThreadPool使用的是存放线程的阻塞队列LinkedBlockingQueue。FixedThreadPool最多只能创建核心线程数的线程,SingleThreadPool只能创建一个线程。任务队列最大长度为 Integer.MAX_VALUE,如果等待队列堆积大量未处理的请求对象,就可能导致OOM。
  • CachedThreadPool使用的是同步队列SynchronousQueue(长度永远为0,线程空闲时在其上阻塞直到有一个任务来临立即执行),允许创建的线程数量为 Integer.MAX_VALUE,如果任务数量过多且执行较慢(一直没有空闲线程就会一直创建新线程),可能会创建过多的线程导致OOM
  • ScheduledThreadPoolSingleThreadScheduledExecutor使用延迟阻塞队列DelayedWorkQueue,任务队列最大长度为 Integer.MAX_VALUE,因其永远不会创建超过 corePoolSize 数量的线程来执行任务,可能堆积大量请求导致OOM

线程池常见参数有哪些

    /**
     * 用给定的初始参数创建一个新的ThreadPoolExecutor。
     */
    public ThreadPoolExecutor(int corePoolSize,//线程池的核心线程数量
                              int maximumPoolSize,//线程池的最大线程数
                              long keepAliveTime,//当线程数大于核心线程数时,多余的空闲线程存活的最长时间
                              TimeUnit unit,//时间单位
                              BlockingQueue workQueue,//任务队列,用来储存等待执行任务的队列
                              ThreadFactory threadFactory,//线程工厂,用来创建线程,一般默认即可
                              RejectedExecutionHandler handler//拒绝策略,当提交的任务过多而不能及时处理时,我们可以定制策略来处理任务
                               ) {
        if (corePoolSize < 0 ||
            maximumPoolSize <= 0 ||
            maximumPoolSize < corePoolSize ||
            keepAliveTime < 0)
            throw new IllegalArgumentException();
        if (workQueue == null || threadFactory == null || handler == null)
            throw new NullPointerException();
        this.corePoolSize = corePoolSize;
        this.maximumPoolSize = maximumPoolSize;
        this.workQueue = workQueue;
        this.keepAliveTime = unit.toNanos(keepAliveTime);
        this.threadFactory = threadFactory;
        this.handler = handler;
    }

核心的三个参数:

  • corePoolSize:任务队列未达到队列容量时,可以同时运行的最大线程数量
  • maximumPoolSize:任务队列达到队列容量时,可同时运行的线程数量变为最大线程数
  • workQueue:新任务来的时候会判断当前运行的线程数量是否达到核心线程数,如果达到,把任务放到工作队列中

其他常见参数:

  • keepAliveTime:非核心线程的空闲销毁时间
  • unit:时间单位
  • threadFactory:线程工厂,用来创建线程
  • handler:任务过多不能及时处理时的拒绝策略

线程池的核心线程会被回收吗?

一般不会,因为要减少线程创建的开销,核心线程需要保持长期活跃。但如果线程池是被用于周期性的场景,且频率不高(周期较长),可以考虑将 allowCoreThreadTimeOut(boolean value) 方法的参数设置为 true,这样就会回收超时空闲的核心线程。

核心线程空闲时处于什么状态?

  • 没有设置核心线程存活时间:处于WAITING状态。
  • 设置了核心线程存活时间:阻塞等待任务的时间未达到keepAliveTime时处于WAITING状态,达到了就会被回收,处于TERMINATED状态

若有可用任务,会从WAITING变为RUNNABLE。

线程池内部实现原理:

线程在线程池内部被抽象为了Worker,当Worker被启动后会不断从任务队列中获取任务。

获取任务时,会通过一个timed的布尔值来判断从任务队列获取任务的行为。

如果设置了「核心线程的存活时间」或「当前线程数量超过核心线程数量」,则将timed标记为true,表示获取任务时需要使用poll()来指定最大阻塞等待时间。

  • timed == true :使用 poll() 来获取任务。使用 poll() 方法获取任务超时的话,则当前线程会退出执行( TERMINATED ),该线程从线程池中被移除。
  • timed == false :使用 take() 来获取任务。使用 take() 方法获取任务会让当前线程一直阻塞等待(WAITING)。
// ThreadPoolExecutor
private Runnable getTask() {
    boolean timedOut = false;
    for (;;) {
        // ...

        // 1、如果「设置了核心线程的存活时间」或者是「线程数量超过了核心线程数量」,则 timed 为 true。
        boolean timed = allowCoreThreadTimeOut || wc > corePoolSize;
        // 2、扣减线程数量。
        // wc > maximuimPoolSize:线程池中的线程数量超过最大线程数量。其中 wc 为线程池中的线程数量。
        // timed && timeOut:timeOut 表示获取任务超时。
        // 分为两种情况:核心线程设置了存活时间 && 获取任务超时,则扣减线程数量;线程数量超过了核心线程数量 && 获取任务超时,则扣减线程数量。
        if ((wc > maximumPoolSize || (timed && timedOut))
            && (wc > 1 || workQueue.isEmpty())) {
            if (compareAndDecrementWorkerCount(c))
                return null;
            continue;
        }
        try {
            // 3、如果 timed 为 true,则使用 poll() 获取任务;否则,使用 take() 获取任务。
            Runnable r = timed ?
                workQueue.poll(keepAliveTime, TimeUnit.NANOSECONDS) :
                workQueue.take();
            // 4、获取任务之后返回。
            if (r != null)
                return r;
            timedOut = true;
        } catch (InterruptedException retry) {
            timedOut = false;
        }
    }
}

线程池的拒绝策略

如果当前等待任务队列和存活线程数量都达到最大值,ThreadPoolExecutor 定义一些策略:

  • ThreadPoolExecutor.AbortPolicy:抛出 RejectedExecutionException来拒绝新任务的处理。这个是默认的拒绝策略。
  • ThreadPoolCallerRunsPolicy:由提交任务的线程(即调用 execute() 或 submit() 方法的那个线程)来直接执行这个被拒绝的任务。可能会使提交任务的线程阻塞从而降低新任务提交速度。
  • ThreadPoolExecutor.DiscardPolicy:不处理新任务也不抛异常,直接丢弃掉。
  • ThreadPoolExecutor.DiscardOldestPolicy:此策略将丢弃workQueue中最早的未处理的任务请求,然后将新任务添加到工作队列中。

CallerRunsPolicy有哪些风险?如何解决?

如果要处理的是一个非常耗时的任务,而提交任务的又是主线程,就会导致主线程长时间阻塞,影响后续的任务提交,影响程序的正常运行,导致数据在源头堆积造成OOM。

为了不使提交任务的线程阻塞,我们可以增加阻塞队列BlockingQueue的大小并调整堆内存以容纳更多的任务,确保任务能够被准确执行。也可以增加调整线程池的maximumPoolSize。

但是,如果服务器资源以达到可利用的极限,无法增加最大线程数量和阻塞队列的大小,我们的思路需要转为任务持久化。方法包括但不限于:

  1. 设计一张任务表将任务存储到 MySQL 数据库中。
  2. Redis 缓存任务。
  3. 将任务提交到消息队列中。

线程池处理任务的流程

秋招Day8 - Java并发(下)- 线程池_第1张图片

线程池在提交任务前,可以提前创建线程吗?

答案是可以的。ThreadPoolExecutor 提供了两个方法帮助我们在提交任务之前,完成核心线程的创建,从而实现线程池预热的效果:

  • prestartCoreThread():启动一个核心线程,等待任务,如果已达到核心线程数,这个方法返回 false,否则返回 true;
  • prestartAllCoreThreads():启动所有的核心线程,并返回启动成功的核心线程数。

线程池中线程异常后,销毁还是复用?

分两种情况:
使用execute()提交任务:如果任务的异常未被捕获并抛出,那么该异常会导致线程终止,并且异常会被打印到控制台或日志文件中。线程池会检测到线程终止,然后创建一个新的线程替换它

使用submit()提交任务:如果在任务执行中发生异常,这个异常不会直接打印出来。异常会被封装到submit()返回的Future对象中,调用Future.get()方法可以捕获到一个ExecuteException。这种情况下线程不会异常终止,会继续存在于线程池中等待复用。 

如何给线程池命名?

默认名字是pool-1-thread-n

1. 使用guava的ThreadFactoryBuilder:

ThreadFactory threadFactory = new ThreadFactoryBuilder()
                        .setNameFormat(threadNamePrefix + "-%d")
                        .setDaemon(true).build();
ExecutorService threadPool = new ThreadPoolExecutor(corePoolSize, maximumPoolSize, keepAliveTime, TimeUnit.MINUTES, workQueue, threadFactory);

2. 自定义ThreadFactory

import java.util.concurrent.ThreadFactory;
import java.util.concurrent.atomic.AtomicInteger;

/**
 * 线程工厂,它设置线程名称,有利于我们定位问题。
 */
public final class NamingThreadFactory implements ThreadFactory {

    private final AtomicInteger threadNum = new AtomicInteger();
    private final String name;

    /**
     * 创建一个带名字的线程池生产工厂
     */
    public NamingThreadFactory(String name) {
        this.name = name;
    }

    @Override
    public Thread newThread(Runnable r) {
        Thread t = new Thread(r);
        t.setName(name + " [#" + threadNum.incrementAndGet() + "]");
        return t;
    }
}

如何设定线程池的大小?

  • CPU密集型任务:设置线程池大小为N + 1(N为CPU核心数量,+1是为了应对偶尔的页缺失少量的短暂等待)。如果线程数远超过 CPU 核心数,多余的线程并不能获得更多的 CPU 时间,反而会因为操作系统需要频繁地在这些线程之间进行上下文切换而带来额外的开销,降低整体效率。
  • I/O密集型任务:设置线程池大小为2N,因为当一个 I/O 密集型线程因为等待 I/O 而阻塞时,它会释放 CPU。此时,如果线程池中有其他线程处于可运行状态,操作系统就可以将 CPU 切换给这些线程,从而避免 CPU 空闲。为了在大量线程等待 I/O 时仍然有足够的线程来“填补”CPU 的空闲时间,需要更多的线程。

如何动态修改线程池的参数?

主要是对corePoolSize、maximumPoolSize和workQueue进行动态修改,通过ThreadPoolExecutor的set方法。

程序运行期间的时候,我们调用 setCorePoolSize()这个方法的话,线程池会首先判断当前工作线程数是否大于newCorePoolSize,如果大于的话就会对超出数量的线程设置超时时间逐渐回收工作线程

ThreadPoolExecutor并没有动态指定工作队列长度的方法,美团的方式是自定义了一个ResizableCapacityLinkedBlockIngQueue 的队列(主要就是把LinkedBlockingQueue的 capacity 字段的 final 关键字修饰给去掉了,让它变为可变的)。

如何设计一个能够根据任务优先级执行的线程池?

使用PriorityBlockingQueue优先级阻塞队列,值最小的元素优先出队。作为参数传入ThreadPoolExecutor中。

队列中的任务必须有排序能力,可以让任务本身实现Comparable接口或者新建一个Comparator对象以供创建PriorityBlockingQueue时传入。

但是会有一些风险和问题:

  • PriorityBlockingQueue是无界的,挤压过多任务可能会导致OOM
  • 由于需要对队列中的元素进行排序操作以及保证线程安全(并发控制采用的是可重入锁 ReentrantLock),因此会降低性能。
  • 可能会导致饥饿问题,优先级低的任务可能永远都得不到执行

OOM的问题很好解决,就在PriorityBlockingQueue的实现中重写一下offer()方法,插入元素数量超过指定值就返回false。

排序和线程安全问题无法避免。

饥饿问题:等待时间过长的任务会被移除并重新添加到队列中,但是优先级会被提升。

你可能感兴趣的:(java)