Java中的多线程与线程池——线程池原理与实战详解

Java
237
0
0
2023-06-02
标签   Java多线程

线程池

线程池是什么?

简单来说, 线程 池是指提前创建若干个线程,当有任务需要处理时,线程池里的线程就会处理任务,处理完成后的线程并不会被销毁,而是继续等待下一个任务。由于创建和销毁线程都是消耗系统资源的,所以,当某个业务需要频繁进行线程的创建和销毁时,就可以考虑使用线程池来提高系统的性能啦。

线程池可以做什么?

借由《 Java 并发编程的艺术》,使用线程池能够帮助 :

  • 降低资源消耗。通过重复利用已经创建的线程,能够降低线程创建和销毁造成的消耗。
  • 提高响应速度。当任务到达时,任务可以不需要等待线程的创建就能立即执行。
  • 提高线程的可管理性。线程是稀缺资源,如果无限制地创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控。

如何创建一个线程池

首先创建一个 Runnable 接口实现类。

 package demo;

import java.util.Date;

/**
 * @author yuanyiwen
 * @create 2020-02-28 16:05
 * @description
 */
public class DemoThread implements Runnable {

    private String command;

    public Demo Thread (String command) {
        this.command = command;
    }

    @ Override 
    public void run() {
        System.out.println(Thread.currentThread().getName() + " 开始时间 : " + new Date());
        processCommand();
        System.out.println(Thread.currentThread().getName() + " 结束时间 : " + new Date());
    }

    private void processCommand() {
        try {
            Thread.sleep(5000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

    @Override
    public String toString() {
        return "DemoThread{" +
                "command='" + command + ''' +
                '}';
    }
}  

这里让我们使用 ThreadPoolExecutor 来创建一个线程池进行测试:

 package demo;

import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;

/**
 * @author yuanyiwen
 * @create 2020-02-28 16:19
 * @description
 */
public class DemoThreadPoolExecutor {

    private static final int CORE_POOL_SIZE = 5;
    private static final int MAX_POOL_SIZE  = 10;
    private static final int QUEUE_CAPACITY = 100;
    private static final Long KEEP_ALIVE_TIME = 1L;

    public static void main(String[] args) {
        // 使用线程池来创建线程
        ThreadPoolExecutor threadPoolExecutor = new ThreadPool Executor (
                // 核心 线程数 为 :5
                CORE_POOL_SIZE,
                // 最大线程数 :10
                MAX_POOL_SIZE,
                // 等待时间 :1L
                KEEP_ALIVE_TIME,
                // 等待时间的单位 :秒
                TimeUnit.SECONDS,
                // 任务队列为 ArrayBlockingQueue,且容量为 100
                new ArrayBlockingQueue<>(QUEUE_CAPACITY),
                // 饱和策略为 CallerRunsPolicy
                new ThreadPoolExecutor.CallerRunsPolicy()
        );

        for(int i = 0; i < 15; i++) {
            // 创建WorkerThread对象,该对象需要实现Runnable接口
            Runnable worker = new DemoThread("任务" + i);
            // 通过线程池执行Runnable
            threadPoolExecutor.execute(worker);
        }
        // 终止线程池
        threadPoolExecutor.shutdown();
        while (!threadPoolExecutor.isTerminated()) {

        }
        System.out.println("全部线程已终止");
    }
}  

最后让我们来看一下运行结果 :

Java中的多线程与线程池——线程池原理与实战详解

可以看到,当核心线程数为 5 时,即使总共要运行的线程有 15 个,每次也只会同时执行 5 个任务,剩下的任务则会被放入等待队列,等待核心线程空闲后执行。总的来说步骤如下 :

Java中的多线程与线程池——线程池原理与实战详解


Executor框架

Executor 框架是 Java5 之后引进的。在 Java5 之后,通过 Executor 来启动线程比使用 Thread 的 start 方法更好。除了更易管理,效率更好(用线程池实现,节约开销)外,还有关键的一点 :有助于避免 this 逃逸问题。

this 逃逸

引发 this 逃逸通常需要满足两个条件 :一个是在构造函数中创建内部类,另一个就是在构造函数中将这个内部类发布了出去。

由于发布出去的内部类对象自带对外部类 this 的访问权限,这就导致在通过内部类对象访问外部类 this 时,外部类可能并未构造完成,从而导致一些意想不到的问题。

典型的 this 逃逸情景如下 :

 public class DemoThisEscape {

    private int a = 10;

    public DemoThisEscape() {
        // 在外部类的构造函数中调用内部类
        new Thread(new InnerClass()).start();
    }

    private class InnerClass implements Runnable {
        @Override
        public void run() {
            // 在这里通过 DemoThisEscape.this 引用尚未构造完毕的对象,比如这样 :
            System.out.println(DemoThisEscape.this.a);
        }
    }
}  

通过使用线程池进行统一的线程调度,省去了在程序中手动启动线程的步骤,从而避免了在构造器中启动一个线程的情况,因此能够有效规避 this 逃逸。

ThreadPoolExecutor常用参数

1. corePoolSize :核心线程线程数

定义了最小可以同时运行的线程数量。

2. maximumPoolSize :最大线程数

当队列中存放的任务达到队列容量时,当前可以同时运行的线程数量会扩大到最大线程数。

3. keepAliveTime :等待时间

当线程数大于核心线程数时,多余的空闲线程存活的最长时间。

4. unit :时间单位。

keepAliveTime 参数的时间单位,包括 TimeUnit.SECONDS、TimeUnit.MINUTES、TimeUnit.HOURS、TimeUnit.DAYS 等等。

5. workQueue :任务队列

任务队列,用来储存等待执行任务的队列。

6. threadFactory :线程工厂

线程工厂,用来创建线程,一般默认即可。

7. handler :拒绝策略

也称饱和策略;当提交的任务过多而不能及时处理时,可以通过定制策略来处理任务。

ThreadPoolExecutor 饱和策略 : 指当前同时运行的线程数量达到最大线程数量并且队列也已经被放满时,ThreadPoolTaskExecutor 所执行的策略。

常用的拒绝策略包括 :

  • ThreadPoolExecutor.AbortPolicy: 抛出 RejectedExecutionException 来拒绝新任务的处理,是 Spring 中使用的默认拒绝策略。
  • ThreadPoolExecutor.CallerRunsPolicy: 线程调用运行该任务的 execute 本身,也就是直接在调用 execute 方法的线程中运行 (run) 被拒绝的任务,如果执行程序已关闭,则会丢弃该任务。此策略提供简单的反馈控制机制,能够减缓新任务的提交速度,但可能造成延迟。若应用程序可以承受此延迟且不能丢弃任何一个任务请求,可以选择这个策略。
  • ThreadPoolExecutor.DiscardPolicy: 不处理新任务,直接丢弃掉。
  • ThreadPoolExecutor.DiscardOldestPolicy: 此策略将丢弃最早的未处理的任务请求。

为什么推荐使用 ThreadPoolExecutor 来创建线程?

规约一 :线程资源必须通过线程池提供,不允许在应用中自行显示创建线程。

使用线程池的好处是减少在创建和销毁线程上所消耗的时间以及系统资源开销,解决资源不足的问题。如果不使用线程池,有可能会造成系统创建大量同类线程而导致消耗完内存或者“过度切换”的问题。

规约二 :强制线程池不允许使用 Executors 去创建,而是通过 ThreadPoolExecutor 构造函数的方式,这样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险。

Executors 返回线程池对象的弊端如下:
FixedThreadPool 和 SingleThreadExecutor : 允许请求的 队列长度 为 Integer.MAX_VALUE,可能会 堆积大量请求 ,从而导致 OOM。
CachedThreadPool 和 ScheduledThreadPool : 允许创建的 线程数量 为 Integer.MAX_VALUE,可能会 创建大量线程 ,从而导致 OOM。

几种常见的线程池

FixThreadPool 固定线程池

FixThreadPool :可重用固定线程数的线程池。

 public static ExecutorService newFixedThreadPool(int nThreads, ThreadFactory threadFactory) {
    return new ThreadPoolExecutor(
            nThreads, nThreads,
            0L, TimeUnit.MILLISECONDS,
            new LinkedBlockingQueue<Runnable>(),
            threadFactory);
    }  

执行机制 :

  • 若当前运行的线程数小于 corePoolSize,来新任务时,就创建新的线程来执行任务;
  • 当前运行的线程数等于 corePoolSize 后,如果再来新任务的话,会将任务加到 LinkedBlockingQueue;
  • 线程池中的线程执行完手头的工作后,会在循环中反复从 LinkedBlockingQueue 中获取任务来执行。

FixThreadPool 使用的是无界队列 LinkedBlockingQueue(队列容量为 Integer.MAX_VALUE),而它会给线程池带来如下 影响 :

  • 当线程池中的线程数达到 corePoolSize 后,新任务将在无界队列中等待,因此线程池中的线程数不会超过 corePoolSize;
  • 由于使用的是一个无界队列,所以 maximumPoolSize 将是一个无效参数,因为不可能存在任务队列满的情况,所以 FixedThreadPool 的 corePoolSize、maximumPoolSize 被设置为同一个值,且 keepAliveTime 将是一个无效参数;
  • 运行中的 FixedThreadPool(指未执行 shutdown() 或 shutdownNow() 的)不会拒绝任务,因此在任务较多的时候可能会导致 OOM。

SingleThreadExecutor 单一线程池

SingleThreadExecutor 是只有一个线程的线程池。

 public static ExecutorService newSingleThreadExecutor(ThreadFactory threadFactory) {
    return new FinalizableDelegatedExecutorService
            (new ThreadPoolExecutor(
                    1, 1,
                    0L, TimeUnit.MILLISECONDS,
                    new LinkedBlockingQueue<Runnable>(),
                    threadFactory));
}  

除了池中只有一个线程外,其他和 FixThreadPool 是基本一致的。

CachedThreadPool 缓存线程池

CachedThreadPool 是一个会根据需要创建新线程的线程池,但会在先前构建的线程可用时重用它。

 public static ExecutorService newCachedThreadPool(ThreadFactory threadFactory) {
        return new ThreadPoolExecutor(
            0, Integer.MAX_VALUE,
            60L, TimeUnit.SECONDS,
            new SynchronousQueue<Runnable>(),
            threadFactory);
}  

其 corePoolSize 被设置为 0,maximumPoolSize 被设置为 Integer.MAX.VALUE,也就是无界的。虽然是无界,但由于该线程池还存在一个销毁机制,即如果一个线程 60 秒内未被使用过,则该线程就会被销毁,这样就节省了很多资源。

但是,如果主线程提交任务的速度高于 maximunPool 中线程处理任务的速度,CachedThreadPool 将会源源不断地创建新的线程,从而依然可能导致 CPU 耗尽或内存溢出。

执行机制 :

  • 首先执行 offer 操作,提交任务到任务队列。若当前 maximumPool 中有空闲线程正在执行 poll 操作,且主线程的 offer 与空闲线程的 poll 配对成功时,主线程将把任务交给空闲线程执行,此时视作 execute() 方法执行完成;否则,将执行下面的步骤。
  • 当初始 maximum 为空,或 maximumPool 中没有空闲线程时,将没有线程执行 poll 操作。此时,CachedThreadPool 会创建新线程执行任务,execute() 方法执行完成。

如何拟定线程池的大小?

上下文切换

多线程 变编程中一般线程的个数都大于 CPU 核心的个数,而一个 CPU 核心在任意时刻只能被一个线程使用。为了让这些线程都能得到有效执行,CPU 采取的策略是为每个线程分配时间片并轮转的形式。当一个线程的时间片用完的时候就会重新处于就绪状态让给其他线程使用,这个过程就属于一次上下文切换。

概括来说就是,当前任务在执行完 CPU 时间片切换到另一个任务之前,会先保存自己的状态,以便下次再切换回这个任务时,可以直接加载到上次的状态。任务从保存到再加载的过程就是一次上下文切换。

上下文切换通常是计算密集型的。也就是说,它需要相当可观的处理器时间,在每秒几十上百次的切换中,每次切换都需要纳秒量级的时间。所以,上下文切换对系统来说意味着消耗大量的 CPU 时间,事实上,可能是操作系统中时间消耗最大的操作。

Linux 相比与其他操作系统(包括其他类 Unix 系统)有许多,其中有一项就是,其上下文切换和模式切换的时间消耗非常少。

简单的拟定判断

CPU 密集型任务(N+1):

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

I/O 密集型任务(2N):

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