Loading

Java并发之合理的使用线程池

一.如何设置线程池的大小?

有时候我们使用线程池的时候对它的大小的设置没有头绪,下面的学习将对我们在使用线程池的时候如何设置其大小非常有用。
首先,线程池的理想大小取决于被提交任务的类型以及所部署系统的特性,在代码中通常不会固定线程池的大小,而应该通过某种配置机制来提供,或者根据Runtime.availableProcessors来动态计算。

1.要设置线程池的大小并不困难,只需要避免两种情况:过大过小 这两种极端情况。为什么呢? 如果线程池过大,那么大量的线程将在相对较少的CPU和内存资源上发生竞争,这不仅会导致更高的内存使用量,而且还可能耗尽资源。如果线程池较小,那么将导致许多的空闲的处理器无法执行工作,从而降低吞吐率。

2.所以要想正确的设置线程池的大小,必须分析计算环境,资源预算和任务的特性。我们需要了解:
在部署的系统中有多少个CPU? 多大的内存? 任务是计算密集型还是I/O密集型还是二者皆可?它们是否需要像JDBC连接这样的稀缺资源? 如果需要执行不同类别的任务,并且它们之间的行为相差很大,那么应该考虑使用多个线程池,从而使每个线程池可以根据各自的工作负载来调整。

下面我们根据具体的情况来计算:
1)对于计算密集型任务,在拥有N个CPU个处理器的系统上,当线程池的大小为N+1时,通常能实现最优的利用率。(当计算密集型的线程偶尔由于一些原因而暂停时,这个“额外的”线程也能确保CPU的时钟周期不会被浪费)

2)对于包含I/O操作或者其他阻塞操作的任务,由于线程并不会一直执行,因此线程池的规模应该更大。要正确的设置线程池的大小,我们必须估算出任务的等待时间与计算时间的比值。
我们还可以通过调节线程池的大小:在某个基准负载下,分别设置不同大小的线程池来运行应用程序,并观察CPU的利用率。
具体的计算如下:
给出如下定义:
Ncpu = CPU的数量
Ucpu = CPU利用率
W/C = 等待时间/计算时间
要使处理器达到期望的使用率,线程池的最优大小等于:
N = Ncpu * Ucpu*(1+W/C)

3)当然除了考虑CPU的周期外,我们还需要考虑一些其他资源:内存,文件句柄,套接字句柄,数据库连接等,计算这些资源对线程池的的约束条件是更容易的:计算每个任务对该资源的需求量,然后用资源总量/每个任务的需求量,得到的结果就是线程池大小的上限。

4)当任务需要某种通过资源池来管理的资源时,例如数据库连接,那么线程和资源池的大小将会相互影响:如果每个任务都需要一个数据库连接,那么连接池的大小就限制了线程池的大小,如果线程池中的任务是数据库连接的唯一使用者时,那么线程池的大小将限制连接池的大小。

二.配置ThreadPoolExecutor

ThreadPoolExecutor为一些线程池提供了基本的实现,这些线程池我们可以通过Executors中的newCachedThreadPool , newFixedThreadPool 和newScheduledThreadExecutor等工厂方法返回。 正常来说Java类库中提供的已经够用,但是难免会遇到不正常的情况,这时候我们要知道ThreadPoolExecutor是一个灵活的,稳定的线程池,运行进行各种定制。

如果默认的执行策略不能满足我们的需求,我们可以通过ThreadPoolExecutor的构造函数来实例化一个对象,并根据自己的需求来定制。如何定制的话我们可以先参考Executors的源代码来了解默认配置下的执行策略,然后可以在其基础上进行修改。

下面我们看一下ThreadPoolExecutor构造函数的一个常见形式:

public ThreadPoolExecutor(int corePoolSize,
                          int maxmumPoolSize,
                          long keepAliveTime,
                          TimeUnit unit,
                          BlockingQueue<Runnable> workQueue,
                          ThreadFactory threadFactory,
                          RejectedExecutionHandler handler){...}

下面我们来学习这些参数的含义:
1.线程的创建与销毁
CorePoolSize:线程池的基本大小,即在没有任务执行时线程池的大小,并且只有在工作队列满了的情况下才会创建超出这个数量的线程。
maxmumPoolSize:线程池的最大大小,表示可同时活动的线程数量的上限。
keepAliveTime:存活时间,如果某个线程的空闲时间超过了存活时间,那么将被标记为可回收的,并且当线程池的当前大小超过了基本大小时,这个线程将被终止。
unit:时间单位

1)前面三个参数共同负责线程的创建和销毁。
2)我们可以通过调节线程池的基本大小和存活时间帮助线程池回收空闲线程占有的资源,从而可以让这些资源用于其他的工作。(这显然是一种折衷:回收空闲线程也会产生额外的延迟,因为当需求增加时,必须创建新的线程来满足)

3)例如:newFixedThreadPool工厂方法将线程池的基本大小和最大大小设置为参数中指定的值,而且创建的线程不会超时。
newCachedThreadPool工厂方法将线程池的最大大小设置为Integer.MAX_NALUE ,而将基本大小设置为0,并将存活时间设置为1分钟,这种方法创建出来的线程池可以被无限扩展,并且当需求降低时会自动收缩。

2.管理任务队列
BlockingQueue< Runnable > workQueue:任务队列,用于存放未被执行的任务

1)在有限的线程池中会限制可并发执行的任务数量(单例线程池例外:它能确保不会有任务并发执行,因为它们通过线程封闭来实现线程安全性。 )

2)前面我们学习到如果无限制的创建线程将会导致不稳定性,并通过采用固定大小的线程池来解决这个问题。 然而,这个方案并不完整。固定大小在高负载的情况下,应用程序仍可能耗尽资源,只是出现的情况较小。如果新请求的到达速率超过了线程池的处理速率,那么新到来的请求将会积累起来。
在线程池中,这些请求会在一个由Executor管理的Runnable队列中等待,而不会像线程那样去竞争CPU 这样当然比使用线程来表示的开销低,但是如果客户提交给服务器请求的速率超过了服务器的处理速率,那么仍可能会耗尽资源。

3)尽管队列有助于缓解任务突增的问题,但如果任务持续高速的到来,那么最终还是会抑制请求的到达速率以避免耗尽内存,甚至在耗尽内存之前,响应性能也随着任务队列的增长而变得越来越遭。

4)ThreadPoolExecutor允许提供一个BlockingQueue来保存等待执行的任务。 基本任务的排列方式有三种:无界队列,有界队列以及同步移交(Synchronous Handoff).至于如何设置这个队列与其他的参数有关,例如线程池的大小

5).例如newFixedThreadPool和newSingleThreadExecutor在默认情况下将使用一个无界的LinkedBlockingQueue 。如果所有工作者线程都处于忙碌状态,那么任务将在队列中等候,如果任务持续快速的到达,并且超过了线程池的处理速度,那么队列将会无限制的增加。

6).一种更稳妥的资源管理策略是使用有界队列,例如ArrayBlockingQueue,有界的LingkedBlockingQueue,PriorityBlockingQueue。有界队列有助于避免资源耗尽的情况发生,同时对于队列填满后新任务怎么办?的问题,也有对应的饱和策略来处理。 在使用有界的工作队列时,队列的大小与线程池的大小必须一起调节。
如果线程池较小而队列较大,那么有助于减少内存使用量,降低CPU的使用率,同时还可以减少上下文切换,但付出的代价是可能会限制吞吐量。

7)我们对于非常大的或者无界的线程池,可以通过使用SynchronousQueue来避免任务排队,以及直接将任务从生产者移交给工作者线程。(我们前面没学习到SynchronousQueue不是一个真正的队列,而是一种在线程之间进行移交的机制)。

注意:只有当线程池是无界或者可以拒绝任务时,同步移交才有实用价值。

对于Executor,newCachedThreadPool工厂方法是一种很好的默认选择,它能提供比固定大小的线程池更好的排队性能。当需要限制当前任务的数量以满足资源管理求时,那么可以选择固定大小的线程池,就像在接受网络客户请求的服务器应用程序中,如果不进行限制,那么很容易发生过载问题。

8)只有当任务相互独立时,为线程池或者工作队列设置界限才是合理的,如果任务之间存在依赖性,那么有界的线程池或者队列就可能导致饥饿死锁,此时我们应该使用无界的线程池。例如:newCachedThreadPool。

3.饱和策略
当有界队列被填满后,饱和策略开始发挥作用,另外,如果某个任务被提交到一个已被关闭的Executor时,也会用到饱和策略,ThreadPoolExecutor的饱和策略可以通过调用setRejectedExecutionHandler来修改。

JDK提供了几种不同的RejectedExecutionHandler实现,每种实现都包含有不同的饱和策略:AbortPolicy , CallerRunsPolicy , DiscardPolicy 和 DiscardOldestPolicy.
1) “Abort中止” 策略是默认的饱和策略,该策略将抛出未检查的RejectedExecutionException 。调用者可以捕获这个异常,然后根据需求编写自己的处理代码。

2)当新提交的任务无法保存到队列中等待执行时, “ 抛弃(Discard)”策略会悄悄抛弃该任务。

3)“抛弃最旧的(DiscardOldest)” 策略将导致抛弃下一个将被执行的任务,然后尝试重新提交新的任务。(不要将这个策略和优先级队列一起使用)

4)“调用者运行(Caller-Runs) ” 策略实现了一种调节机制,该策略即不会抛弃任务,也不会抛出异常,而是将某些任务回退到调用者,从而降低新任务的流量。 它不会在线程池的某个线程中执行新提交的任务,而是在一个调用了execute的线程中执行该任务。

当创建Executor时,可以选择饱和策略或者对执行策略进行修改。如下:

ThreadPoolExecutor executor =
 new ThreadPoolExecutor(N_THREADS , 
                        N_THREADS,
                        0L,
                        TimeUnit.MILLISECONDS,
                        new LinkedBlockingQueue<Runnable>(CAPACITY));
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
)

上面代码创建一个固定大小的线程池,并采用有界队列以及 调用者运行 饱和策略。

下面代码使用信号量控制任务的提交速率:

public class BoundedExecutor{
  private final Executor exec;//声明线程池
  private final Semaphore semaphore;//声明信号量
  
  //构造方法初始化
  public BoundedExecutor(Executor exec , int bound){
    this.exec = exec;
    this.semaphore = new Semaphore(bound);
  }
  
  public void submitTask(final Runnable command) throws InterruptedException{
   semaphore.acquire();//获取信号量
   try{
    exec.execute(new Runnable(){
      public void run(){
        try{
         command.run();
        }finally{
          semaphore.release();//释放信号量
        }
      }
    });
   }catch(RejectedExecutionException e){
     semaphore.release();//释放信号量
   }
  }
}

4.线程工厂
每当线程需要创建一个线程时,都是通过一个线程工厂方法来完成的。
默认的线程工厂方法将创建一个新的,非守护的线程,并且不包含特殊的配置信息。

我们可以通过指定一个线程工厂方法,可以定制线程池的配置信息,在ThreadFactory中只定义了一个方法newThread,每当线程池需要创建一个新线程时都会调用这个方法。

public interface ThreadFactory{
   Thread newThread(Runnable r);
}

在许多情况下我们都需要使用定制的线程工厂方法。 例如:我们希望为线程池中的线程指定一个UncaughtExceptionHandler , 或者实例化一个定制的Thread类用于执行调试信息的记录。

下面给出了一个自定义的线程工厂:

public class MyThreadFactory implements ThreadFactory{
  private final String poolName;

  public MyThreadFactory(String poolName){
    this.poolName = poolName;
  }
  public Thread newThread(Runnable runnable){
     return new MyAppThread(runnable,poolName);
  }
}

其中MyAppThread可以自己写:

public class MyAppThread extends Thread{
  public static final String DEFAULT_NAME = "MyAppThread";//线程名称
  private static volatile boolean debugLifecycle = false;//debug标志
  private static final AtomicInteger created = new AtomicInteger();//创建的数量
  private static final AtomicInteger alive = new AtomicInteger();//存活的数量
  private static final Logger log = Logger.getAnonymousLogger();//日志对象

  public MyAppThread(Runnable runnable , String name){
    this(r,DEFAULT_NAME);
 }
 
 public MyAppThread(Runnable runnable , String name){
    super(runnable, name + "-"+created.incrementAndGet());//创建一个线程且created+1
    setUncaughtExceptionHandler(
               new Thread.UncaughtExceptionHandler(){
                   public void uncaughtException(Thread t,Throwable e){
                     log.log(Level.SEVERE,"UNCAUGHT in thread "+ t.getName((),e);
                   }
               }
             );//设置当发生不可捕获异常时该怎么做
  }
  
  public void run(){
    //复制debug标志以确保一致的值
    boolean debug = debugLifecycle;
    if(debug) log.log(Level.FINE,"Create "+ getName());//如果出现了debug,记录日志
    try{
      alive.incrementAndGet();//存活数加一
      super.run();//运行之
    }finally{
      alive.decrementAndGet();//存活数建减一
      if(debug) log.log(Level.FINE,"Exiting "+ getName());//记录日志
    }
  }
  public static int getThreadsCreated(){
   return created.get();
  }
  
  pulbic static int getThreadAlive(){
    return alive.get();
  }

 public static boolean getDebug(){
    return debugLifecycle;
 }

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

了解:如果应用程序中需要利用安全策略来控制对某些特殊的代码库的访问权限,那么可以通过Executor中的privilegedThreadFactory工厂来定制自己的线程工厂。 通过这种方式创建出来的线程,将与创建privilegedThreadFactory的线程拥有相同的访问权限。

5.在调用构造函数后再定制ThreadPoolExecutor
例如:

ExecutorService exec = Executors.newCachedThreadPool();
(ThreadPoolExecutor)exec.setCorePoolSize(10);
...

就是通过一系列的set方法来进行设置。

注意:在Executors中包含一个unconfigurableExecutorService工厂方法,该方法对一个现有的ExecutorService进行包装,使其只暴露出ExecutorService的方法,因此不能对它进行配置。
例如newSingleThreadExecutor返回按这种方式封装的ExecutorService,而不是最初的ThreadPoolExecutor.

我们可以在自己的Executor中使用这项技术以防止执行策略被修改。 如果将ExecutorService暴露给不信任的代码,又不希望对其进行修改,就可以通过unconfigurableExecutorService来包装它。

三.扩展ThreadPoolExecutor

ThreadPoolExecutor是可扩展的,它提供了几个可以在子类中改写的方法:beforeExecute,afterExecute和terminated,这些方法可以用于扩展ThreadPoolExecutor的行为。

1)在执行任务的过程中将调用beforeExecute和afterExecute等方法,在这些方法中还可以添加日志,计时,监视或统计信息收集的功能。 无论任务是从run中正常返回还是抛出一个异常而返回,afterExecute都会被调用。但是,如果任务在完成后带有一个Error,那么就不会调用afterExecute,如果beforeExecute抛出一个RuntimeException ,那么任务将不被执行,并且afterExecute也不会被调用。

2)在关闭线程池完成关闭操作时调用terminated,也就是在所有任务都已经完成并且所有工作者线程也已经关闭后。terminated可以用来释放Executor在其生命周期里分配的各种资源,此外还可以执行发送通知,记录日志或者收集finalize统计信息等操作。

示例:给线程池添加统计信息

public class TimingThreadPool extends ThreadPoolExecutor{
  private final ThreadLocal<Long> startTime = new ThreadLocal<Long>();
  private final Logger log = Logger.getLogger("TimingThreadPool");
  private final AtomicLong numTasks = new AtomicLong();
  private final AtomicLong totalTime = new AtomicLong();
  
  protected void beforeExecute(Thread t , Runnable r){
      super.beforeExecute(t,r);
      log.fine(String.fomat("Thread %s:start %s ",t,r));
      startTime.set(System.nanoTime());//设置当前时间
  }
  
  protected void afterExecute(Runnable r , Throwable t){
    try{
      long endTime = System.nanoTime();//得到任务完成后的时间
      long taskTime = endTime - startTime.get();
      numTasks.incrementAndGet();
      totalTime.addAndGet(taskTime);
      long.fine(String.format("Thread %s: end %s, time = %dns",t,r,taskTime));
    }finally{
      super.afterExecute(r,t);
    }
  }
  protected void terminated(){
    try{
      log.info(String.format("Terminated:avg time=%dns"
                               ,totalTime.get()/numTasks.get()));
    }finally{
       super.terminated();
    }
  }
}

总结:对于线程池的使用,如果现有的线程池执行策略符合我们的需要,我们就用现有的,如果没有,我们再考虑自己设置执行策略。

posted @ 2020-11-28 09:37  文牧之  阅读(43)  评论(0)    收藏  举报  来源