并发编程实战(7)线程池相关

线程池的使用

一、线程池概念

线程池通过重用现有的线程而不是创建新线程,可以减少线程创建和销毁过程带来的巨大开销。另外一个好处是,当请求到达时工作线程通常已经存在,不会由于等待创建线程而延迟任务的执行,从而提高的响应性。

通过适当调整线程池的大小,可以创建足够多的线程以便使处理器保持忙碌状态,同时还可以防止过多线程相互竞争资源而是应用程序耗尽内存或失败。

创建线程池

Executors中的静态工厂方法提供多种线程池创建方式:

  • newFixedThreadPool: 创建一个固定长度的线程池,每当提交一个任务时就创建一个线程,直到达到线程池最大数量。此时线程池规模不再发生变化,如果某个线程挂掉,线程池会补充一个新的线程。
  • newCachedThreadPool: 创建一个可缓存的线程池,如果线程池的当前规模超过了处理需求时,将回收空闲的线程,而当需求增加时则可以添加新的线程,线程池的规模不存在任何限制。默认 coreThread为0,maxThreadNum为$2^{32} -1 $
  • newSingleThreadExecutor: 是一个单线程的Executor,如果这个线程异常结束会创建另一个线程来替代。能够确保依照任务在队列中的顺序来串行执行。
  • newScheduledThreadPool: 创建一个固定长度的线程池,而且以延迟火定时的方式来执行任务,类似于Timer。
基本参数详解

线程池的一些基本参数负责线程的创建与销毁。

  • 基本大小(Core Pool Size): 线程池的目标大小,即在没有任务执行时线程池的大小,并且只有在工作队列满了的情况下才会创建超出这个数量的线程。
  • 最大大小(Maximum Pool Size): 表示可同时活动的线程数量的上限。如果某个线程的空闲时间超出了存活时间,那么将被标记为可回收的,当线程池的当前大小超出了基本大小时,这个线程将被终止。

二、任务与执行策略

ThreadLocal使每个线程都可以拥有某个变量的一个私有的“版本”。只有当线程本地值的生命周期受限于任务的生命周期时,在线程池中使用ThreadLocal才有意义,而在线程池的线程中不应该使用ThreadLocal在人物之间传递值。

对于标准的Executor实现来说,当执行需要较低时将回收空闲线程,当需求增加时将添加新的线程。如果从任务中跑出一个未检查异常,那么江永一个新的工作者线程来替代抛出异常的线程。

只有当任务都是同类型的并且相互独立时,线程池的性能才能达到最佳。如果将运行时间较长的与运行时间较短的任务混在一起,除非线程池很大,否则将可能造成阻塞。如果提交的任务依赖于其他任务,那么除非线程池无限大,否则可能造成死锁。

线程饥饿死锁

在单线程的Executor中,如果一个任务将另一个任务提交到同一个Executor,并且等待这个被提交任务的结果,通常会引发死锁。第二个任务停留在工作队列中,并等待第一个任务完成,而第一个任务又无法完成,因为它在等待第二个任务的完成。在更大的线程池中如果所有正在执行的线程都由于等待其他仍处于工作队列中的任务而阻塞,会发生同样的问题。这种现象被称为“饥饿线程死锁”。

运行时间较长的任务

任务阻塞时间过长,响应性会变得很糟糕。解决办法是限定任务等待资源的时间,而不是无限制地等待。大多数可阻塞方法中都同时提供了限时版本和无限时版本。如果等待时间超时可以把任务标识为失败,然后终止任务或者将任务放回队列以便随后执行。

三、线程池的大小

正确设置线程池大小,需要分析计算环境、资源预算和任务的特性。一般考虑如下问题:

  • 在部署的系统中有多少个CPU?多大的内存?
  • 任务是计算密集型、IO密集型还是二者皆可?
  • 是否需要像JDBC连接这样的稀缺资源?
  • 任务类别是否不同?之间的行为是否相差很大?此时应该考虑多个线程池

对于计算密集型的任务,在拥有N个cpu的系统上,当线程池大小为N+1时,通常实现最优利用率。额外的线程是为了处理计算机密集型的线程偶尔由于缺页故障或者其他原因而暂停的问题,CPU的时钟周期不会被浪费。要正确设置线程池的大小,必须估算出任务的等待时间与计算时间的比值。估算不需要很精确,可以通过一些分析或监控工具来获得。或者使用一种启发式的方法,在某个基准负载下,分别设置不同大小的线程池来运行应用程序,观察CPU的利用率的水平。一般来说,线程池的大小如下所示:

									$$ N_{threads} =N_{cpu} * U_{cpu} * (1 + \frac{W}{C})  $$   

W C \frac{W}{C} CW 代表等待时间和计算时间的比值, U c p u U_{cpu} Ucpu 代表CPU利用率, N c p u N_{cpu} Ncpu代表cpu核数。Java中可以通过如下代码获取CPU数目

int n_cpus = Runtime.getRuntime().availableProcessors();

除此之外,内存、文件句柄、套接字句柄和数据库连接等都会影响线程池大小。

四、ThreadPoolExecutor的配置

根据阿里巴巴编程规范,线程池不允许使用Executors去创建,而是通过ThreadPoolExecutor的方式,这样的处理方式让写代码的人更加明确线程池的运行规则,规避资源耗尽的风险。 说明:Executors各个方法的弊端:

  • newFixedThreadPool 和 newSingleThreadExecutor: 主要问题是堆积的请求处理队列可能会耗费非常大的内存,甚至OOM;
  • newCachedThreadPool 和 newScheduledThreadPool: 主要问题是线程数最大数是Integer.MAX_VALUE,可能会创建数量非常多的线程,甚至OOM。
4.1 管理队列任务

如果新请求的到达率超过了线程池的处理速率,那么信道来的请求将累积起来。在线程池中,这些请求会在一个由Executor管理的Runnable队列中等待。即使请求的平均到达速率很稳定,也仍然会出现请求突增的情况。尽管队列有助于缓解任务的突增问题。但如果任务持续高速地到来,那么最终还是会抑制请求的到达率以避免耗尽内存。甚至在耗尽内存之前,响应性能也随着任务队列的增长而变得越来越糟。

ThreadPoolExecutor允许提供一个BlockingQueue来保存等待执行的任务。基本的任务排队方法有3种:无界队列、有界队列和同步移交(Synchronous Handoff)。队列的选择与其他配置参数有关,例如线程池的大小。

newFixedThreadPool和我newSingleThreadExecutor默认使用无界队列LinkedBlockingQueue。如果所有工作线程都处于忙碌状态,那么任务将在队列中等候。如果任务持续快速地到达,并且超过了线程池处理他们的速度,队列将无限制地增加。

有界队列包括:ArrayBlockingQueue、有界的LinkedBlockingQueue、PriorityBlockingQueue。在使用有界队列时,队列的大小与线程池的大小必须一起调节。如果线程池较小而队列比较大,那么有助于减少内存使用量,降低CPU使用率,同时还可以减少上下文切换,但代价是限制吞吐量。

对于非常大的或者无界的线程池,可以通过使用SynchronousQueue来避免任务排队,以及直接将任务从生产者移交给工作者线程。SynchronousQueue不是一个真正的队列,而是一种在线程之间进行移交的机制。要将一个元素放入SynchronousQueue中,必须有另一个线程正在等待接受这个元素。如果没用线程正在等待,并且线程池的当前大小小于最大值,那么ThreadPoolExecutor将创建一个新的线程,否则根据饱和策略,这个任务将被拒绝。使用直接移交更加高效,因为任务会直接移交给执行它的线程而不是被首先放在任务队列中,然后由工作者线程从队列中提取该任务。只有当线程池是无界的或者可以拒绝任务时,SynchronousQueue才有实际价值。newCached ThreadPool工厂方法默认使用SynchronousQueue。

使用LinkedBlockingQueue或ArrayBlocking这样的FIFO队列时,任务的执行顺序与到达顺序相同。如果想控制任务执行顺序,可以使用PriorityBlockingQueue,根据优先级来安排任务。

当任务相互独立时,使用有界队列何时。如果任务之间有依赖,有界队列可能导致线程“饥饿”死锁问题,此时应该使用无界队列。

4.2 饱和策略

饱和策略作用于:使用了有界队列,并且队列被填满以后。Thread’PoolExecutor的饱和策略可以使用setRejectedExecutionHandler()函数来修改。常见的JDK提供的饱和策略包括以下几种:

  • AbortPolicy:抛出异常,默认的饱和策略。该策略抛出未检查的RejectedExecutionExeception异常。调用者可以捕获此异常,然后做出处理。
  • CallerRunsPolicy:实现了一种调节机制,既不抛弃任务,也不会抛出异常,而是将某些任务回退到调用者,从而降低新任务的流量。此策略不会在线程池的某个线程中执行新提交的任务,而是在一个调用了execute的主线程中执行该任务。主线程执行任务,需要一定时间,此时主线程一定时间内无法提交任何任务。主线程不会调用accept,因此新到达的请求会被保存在TCP层的队列中而不是应用程序的队列中。线程池->工作队列->应用程序->TCP层->客户端,层层蔓延,导致服务器在高负载下实现一种平缓的性能降低。
  • DiscardPolicy:抛弃策略,如果新提交的任务无法保存到队列中等待执行,抛弃该任务
  • DiscardOldestPolicy:抛弃最旧的策略,会抛弃下一个将被执行的任务,然后尝试重新提交新的任务。如果工作队列是一个优先队列,那么“抛弃最旧的”策略将导致抛弃优先级最高的任务,因此两者最好不要一起使用。
4.3 线程工厂

每当线程池需要创建一个线程时,都是通过线程工厂方法来完成的,默认的线程工厂是创建一个新的、非守护的线程。可以自定义线程工程方法,定制线程池的线程的配置信息,比如指定线程名字、设置自定义未捕获异常向Logger中写入信息,维护一些统计信息(有多少线程被创建和销毁),在线程被创建或终止时把消息写入日志等。

自定义线程工厂需要实现ThreadFactory接口,实现有参构造函数和重写newThread函数。下面是一个例子:

public class MyThreadFactory implements ThreadFactory {
    private final String poolName;


    public MyThreadFactory(String poolName) {
        this.poolName = poolName;
    }

    @Override
    public Thread newThread(Runnable r) {
        return new Thread(r, poolName);  // 此处可以对Thread进行定制
        // return new MyAppThread(r, poolName);
    }
}

可以定制线程的其他行为,包括名字、自定义Logger、维护统计信息等。代码如下所示:

public class MyAppThread extends Thread {

    public static final String DEFAULT_NAME = "MyAppThread";
    private static volatile boolean debugLifecycle = false;
    private static final AtomicInteger created = new AtomicInteger();
    private static final AtomicInteger alive = new AtomicInteger();
    private static final Logger log = Logger.getAnonymousLogger();

    public MyAppThread(Runnable r) {
        this(r, DEFAULT_NAME);
    }

    public MyAppThread(Runnable runnable, String name) {
        super(runnable, name + "-" + created .incrementAndGet());
        setUncaughtExceptionHandler(
                new Thread.UncaughtExceptionHandler() {
                    @Override
                    public void uncaughtException(Thread t, Throwable e) {
                        log.log(Level.SEVERE, "UNCAUHT in thread" + t.getName(), e);
                    }
                }
        );
    }

    @Override
    public void run() {
        boolean debug = debugLifecycle;
        if (debug) {
            log.log(Level.FINE, "Created " + getName());
        }
        try {
            alive.incrementAndGet();
            super.run();
        } finally {
            alive.decrementAndGet();
            if (debug) {
                log.log(Level.FINE, "Exiting " + getName());
            }
        }
    }

    public static int getThreadCreated() {
        return created.get();
    }

    public static int getThreadsAlive() {
        return alive.get();
    }

    public static boolean getDebug() {
        return debugLifecycle;
    }

    public static void setDebug(boolean b) {
        debugLifecycle = b;
    }
}

五、扩展ThreadPoolExecutor

ThreadPoolExecutor支持扩展,提供了beforeExecutre、afterExecute和terminated这些方法扩展原有的行为。

  • beforeExecute: 任务执行之前执行,用来做一些初始化行为,比如做时间统计的话记录开始时间。beforeExecute抛出运行时异常后面的任务不会被执行。
  • afterExecute:任务从run正常返回或抛出异常都会调用afterExecute,如果是error不会调用。
  • terminated:线程池完成关闭操作时调用,也就是所有任务都已经完成并且所有工作线程都已经关闭后。可以用来释放Executor在生命周期内分配的各种资源,也可以发送通知、记录日志、收集统计信息等。
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值