深入理解线程池

概述

在程序中,我们会用各种池化技术来缓存昂贵的对象,比如线程池、连接池、内存池。一般是通过预先创建一些对象放入池中,使用的时候直接取出,用完归还以便复用,还会通过一定的策略调整池中的缓存数量,实现池的动态伸缩。

由于线程比较昂贵,随意、没有控制的创建大量的线程就会造成性能问题,因此短平快的任务一般考虑使用线程池来处理,而不是直接创建线程。

线程池

虽然在java语言中创建线程看上去就像创建一个对象一样简单,只需要new Thread()就可以了,但是实际上创建线程原不仅仅是创建一个对象那么简单。

创建对象仅仅是在JVM的堆里分配了一块内存而已;而创建了一个线程,却需要调用操作系统内核的API,然后操作系统要为线程分配一些列的资源,这个成本就很高了,所以线程是一个重量级别的对象,应该避重复创建和销毁,一般就是使用线程池来避免频繁的创建和销毁线程。

线程池原理

Java通过用户线程与内核线程结合的1:1线程模型来实现,Java将线程的调度和管理设置在了用户态。在HOTSpot VM的线程模型中,Java线程被一对一映射为内核线程。Java在使用线程执行程序时,需要创建一个内核线程;当该Java线程被终止时,这个内核线程会被回收。因此Java线程的创建与销毁将会消耗一定的计算机资源,从而增加性能的开销。

除此之外,大量创建线程同样会给系统带来性能问题,因为内存和CPU资源都将被线程抢占,如果处理不当,就会发生内存溢出,CPU使用率超负荷等问题。

为了解决上述两种问题,Java提供了线程池概念,对于频繁创建线程业务的场景,线程池可以创建固定的线程数量,并且在操作系统底层,轻量级进程会把这些线程映射到内核。

线程池可以提高线程服用,又可以固定最大线程使用量,防止无限制的创建线程。当程序提交一个任务需要的线程时,回去线程池查找是否有空闲的线程,若有,则直接使用空闲的线程,若没有,会去判断当前已创建线程的数量是否超过最大线程数量,如未超过,则创建新的线程,如已经超过,则进行排队等待或者直接抛出异常。

线程池是一种生产者----消费者模式

线程池的设计,普遍采用的都是生产者--消费者的模式。线程池的使用方是生产者,线程池本身是消费者。

原理实现大概如下:

package com.lyyzoo.test.concurrent.executor;

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;

/**
 * @author bojiangzhou 2020/02/12
 */
public class CustomThreadPool {

    public static void main(String[] args) {
        // 使用有界阻塞队列 创建线程池
        CustomThreadPool pool = new CustomThreadPool(2, new LinkedBlockingQueue<>(10));
        pool.execute(() -> {
            System.out.println("提交了一个任务");
        });
    }

    // 利用阻塞队列实现生产者-消费者模式
    final BlockingQueue<Runnable> workQueue;
    // 保存内部工作线程
    final List<Thread> threads = new ArrayList<>();

    public CustomThreadPool(int coreSize, BlockingQueue<Runnable> workQueue) {
        this.workQueue = workQueue;
        // 创建工作线程
        for (int i = 0; i < coreSize; i++) {
            WorkerThread work = new WorkerThread();
            work.start();
            threads.add(work);
        }
    }

    // 生产者 提交任务
    public void execute(Runnable command) {
        try {
            // 队列已满,put 会一直等待
            workQueue.put(command);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

    /**
     * 工作线程负责消费任务,并执行任务
     */
    class WorkerThread extends Thread {
        @Override
        public void run() {
            // 循环取任务并执行,take 取不到任务会一直等待
            while (true) {
                try {
                    Runnable runnable = workQueue.take();
                    runnable.run();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }
}

ThreadPoolExecutor

线程池参数说明

Java提供的线程池相关的工具类中,最核心的是ThreadPoolExecutor,通过这个名字也可以看的出来,它强调的是Executor,而不是一般意义上的池化资源。

ThreadPoolExecutor 的构造函数非常复杂,这个最完备的构造函数有 7 个参数:

各个参数的含义如下:

  • corePoolSize:表示线程池包邮的最小线程数。
  • maxinmumPoolSize:表示线程池创建的最大线程数。
  • keepAliveTime&unit:如果一个线程空闲了keepAlive&unit这么久,而且线程池的线程数量大于corePoolSize,那么这个空闲的线程就要被回收了。
  • workQueue:工作队列,一般定义有界阻塞队列。
  • threadFactory:通过这个参数你可以自定义如何创建线程,例如你可以给线程指定一个有意义的名字。
  • handler:通过这个参数可以自定义任务的拒绝策略。如果线程池中所有的线程都在忙碌,并且工作队列也满了(前提是工作队列是有界队列),那么此时提交任务,线程池就会拒绝接收。ThreadPoolExecutor已经提供了一下4种策略。
  1.  CallerRunsPolicy:提交任务的线程自己去执行该任务。
  2.  AbortPolicy:默认的拒绝策略,会 throws RejectedExecutionException。
  3.  DiscardPolicy:直接丢弃任务,没有任何异常抛出。
  4.  DiscardOldestPolicy:丢弃最老的任务,其实就是把最早进入工作队列的任务丢弃,然后把新任务加入到工作队列。

ThreadPoolExecutor 构造完成后,还可以通过如下方法定制默认行为:

  • executor.allowCoreThreadTimeOut(true):将包括“核心线程”在内的,没有任务分配的所有线程,在等待 keepAliveTime 时间后回收掉。
  • executor.prestartAllCoreThreads():创建线程池后,立即创建核心数个工作线程;线程池默认是在任务来时才创建工作线程。

创建线程池示例:

public void test() throws InterruptedException {
    ThreadPoolExecutor poolExecutor = new ThreadPoolExecutor(
            // 核心线程数
            2,
            // 最大线程数
            16,
            // 线程空闲时间
            60, TimeUnit.SECONDS,
            // 使用有界阻塞队列
            new LinkedBlockingQueue<>(1024),
            // 定义线程创建方式,可自定线程名称
            new ThreadFactoryBuilder().setNameFormat("executor-%d").build(),
            // 自定义拒绝策略,一般和降级策略配合使用
            (r, executor) -> {
                // 队列已满,拒绝执行
                throw new RejectedExecutionException("Task " + r.toString() +
                        " rejected from " + executor.toString());
            }
    );

    poolExecutor.submit(() -> {
        LOGGER.info("submit task");
    });
}

线程池任务提交的方式

提交任务可以通过execute和submit方法提交任务

submit方法签名:

execute 方法签名:

使用execute提交任务

使用 execute 提交任务,线程池内抛出异常会导致线程退出,线程池只能重新创建一个线程。如果每个异步任务都以异常结束,那么线程池可能完全起不到线程重用的作用。

而且主线程无法捕获(catch)到线程池内抛出的异常。因为没有手动捕获异常进行处理,ThreadGroup 帮我们进行了未捕获异常的默认处理,向标准错误输出打印了出现异常的线程名称和异常信息。显然,这种没有以统一的错误日志格式记录错误信息打印出来的形式,对生产级代码是不合适的。

如下,execute 提交任务,抛出异常后,从线程名称可以看出,老线程退出,创建了新的线程。

ThreadGroup 处理未捕获异常:直接输出到 System.err

解决方式:

  • 以 execute 方法提交到线程池的异步任务,最好在任务内部做好异常处理;
  • 设置自定义的异常处理程序作为保底,比如在声明线程池时自定义线程池的未捕获异常处理程序。或者设置全局的默认未捕获异常处理程序。
// 自定义线程池的未捕获异常处理程序
ThreadPoolExecutor executor = new ThreadPoolExecutor(8, 8,
        30, TimeUnit.MINUTES,
        new LinkedBlockingQueue<>(),
        new ThreadFactoryBuilder()
                .setNameFormat("pool-%d")
                .setUncaughtExceptionHandler((Thread t, Throwable e) -> {
                    log.error("pool happen exception, thread is {}", t, e);
                })
                .build());

// 设置全局的默认未捕获异常处理程序
static {
    Thread.setDefaultUncaughtExceptionHandler((thread, throwable)-> {
        log.error("Thread {} got exception", thread, throwable)
    });
}

定义的异常处理程序将未捕获的异常信息打印到标准日志中了,老线程同样会退出。如果要避免这个问题,就需要使用 submit 方法提交任务。

使用 submit 提交任务

使用 submit,线程不会退出,但是异常不会记录,会被生吞掉。查看 FutureTask 源码可以发现,在执行任务出现异常之后,异常存到了一个 outcome 字段中,只有在调用 get 方法获取 FutureTask 结果的时候,才会以 ExecutionException 的形式重新抛出异常。所以我们可以通过捕获 get 方法抛出的异常来判断线程的任务是否抛出了异常。

submit 提交任务,可以通过 Future 获取返回结果,如果抛出异常,可以捕获 ExecutionException 得到异常栈信息。通过线程名称可以看出,老线程也没有退出。

需要注意的是,使用 submit 时,setUncaughtExceptionHandler 设置的异常处理器不会生效。

submit 与 execute 的区别

execute提交的是Runnable类型的任务,而submit提交的是Callable或者Runnable类型的任务;

execute的提交没有返回值,而submit的提交会返回一个Future类型的对象;

execute提交的时候,如果有异常,就会直接抛出异常,而submit在遇到异常的时候,通常不会立马抛出异常,而是会将异常暂时存储起来,等待你调用Future.get()方法的时候,才会抛出异常;

execute 提交的任务抛出异常,老线程会退出,线程池会立即创建一个新的线程。submit 提交的任务抛出异常,老线程不会退出;

线程池设置的 UncaughtExceptionHandler 对 execute 提交的任务生效,对 submit 提交的任务不生效。

线程数设置多少合适

创建多少线程合适,要看多线程具体的应用场景。我们的程序一般都是 CPU 计算和 I/O 操作交叉执行的,由于 I/O 设备的速度相对于 CPU 来说都很慢,所以大部分情况下,I/O 操作执行的时间相对于 CPU 计算来说都非常长,这种场景我们一般都称为 I/O 密集型计算;和 I/O 密集型计算相对的就是 CPU 密集型计算了,CPU 密集型计算大部分场景下都是纯 CPU 计算。I/O 密集型程序和 CPU 密集型程序,计算最佳线程数的方法是不同的。

CPU 密集型计算

多线程本质上是提升多核 CPU 的利用率,所以对于一个 4 核的 CPU,每个核一个线程,理论上创建 4 个线程就可以了,再多创建线程也只是增加线程切换的成本。所以,对于 CPU 密集型的计算场景,理论上“线程的数量 = CPU 核数”就是最合适的。不过在工程上,线程的数量一般会设置为“CPU 核数 +1”,这样的话,当线程因为偶尔的内存页失效或其他原因导致阻塞时,这个额外的线程可以顶上,从而保证 CPU 的利用率。

I/O 密集型的计算场景

如果 CPU 计算和 I/O 操作的耗时是 1:1,那么 2 个线程是最合适的。如果 CPU 计算和 I/O 操作的耗时是 1:2,那设置 3 个线程是合适的,如下图所示:CPU 在 A、B、C 三个线程之间切换,对于线程 A,当 CPU 从 B、C 切换回来时,线程 A 正好执行完 I/O 操作。这样 CPU 和 I/O 设备的利用率都达到了 100%。

会发现,对于 I/O 密集型计算场景,最佳的线程数是与程序中 CPU 计算和 I/O 操作的耗时比相关的,可以总结出这样一个公式:最佳线程数 =1 +(I/O 耗时 / CPU 耗时)

对于多核 CPU,需要等比扩大,计算公式如下:最佳线程数 =CPU 核数 * [ 1 +(I/O 耗时 / CPU 耗时)]

线程池线程数设置 

可通过如下方式获取CPU核数:

/**
 * 获取返回CPU核数
 *
 * @return 返回CPU核数,默认为8
 */
public static int getCpuProcessors() {
    return Runtime.getRuntime() != null && Runtime.getRuntime().availableProcessors() > 0 ?
            Runtime.getRuntime().availableProcessors() : 8;
}

在一些非核心业务中,我们可以将核心线程数设置小一些,最大线程数量设置为CPU核心数量,阻塞队列大小根据具体场景设置;不要过大,防止大量任务进入等待队列而超时,应尽快创建非核心线程执行任务;也不要过小,避免队列满了任务被拒绝丢弃。

1 public ThreadPoolExecutor executor() {
 2     int coreSize = getCpuProcessors();
 3     ThreadPoolExecutor executor = new ThreadPoolExecutor(
 4             2, coreSize,
 5             10, TimeUnit.MINUTES,
 6             new LinkedBlockingQueue<>(512),
 7             new ThreadFactoryBuilder().setNameFormat("executor-%d").build(),
10             new ThreadPoolExecutor.AbortPolicy()
11     );14 
15     return executor;
16 }

2021.0824

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值