任务的执行与线程池(下)

自定义线程池

如果由Executors的几个工具方法创建的线程池提供的执行策略不合你的胃口,你也可以自己动手设计一个定制版线程池。当然,这个定制不需要我们从头开始编写,设计java的大叔们已经为我们提供了一个ThreadPoolExecutor类,它实现了ExecutorService接口,代表着一个线程池,我们可以通过不同的构造方法参数来自定义的配置我们需要的执行策略,看一下这个类的构造方法:

  1. public ThreadPoolExecutor(int corePoolSize,
  2.                           int maximumPoolSize,
  3.                           long keepAliveTime,
  4.                           TimeUnit unit,
  5.                           BlockingQueue<Runnable> workQueue,
  6.                           ThreadFactory threadFactory,
  7.                           RejectedExecutionHandler handler) {
  8.     // ... 省略具体实现                      
  9. }                              

下边来详细看一下这些参数都是什么意思,然后我们就可以随心所欲的配置自定义线程池喽~

线程的创建与销毁

相关的参数及描述如下:

初始状态线线程池里并没有线程,之后每提交一个任务就会分配一个线程,直到线程数到达corePoolSize指定的基本大小值。之后即使没有新任务到达,这些线程也不会被销毁。

如果此时线程处理任务的速度足够快,那么将会复用这些线程去处理任务,但是如果任务添加的速度超过了处理速度的话,线程池里的线程数量可以继续增加到maximumPoolSize指定的最大线程数量值时,之后便不再增加。如果线程数量已经到达最大值,但是任务的提交速度还是超过了处理速度,那么这些任务将会被暂时放到任务队列中,等待线程个执行完任务之后从任务队列中取走。

如果某个线程在指定的keepAliveTime时间(单位是unit)内都处于空闲状态,也就是说没有任务可执行,那这个线程将被标记为可回收的,但是此时并没有被终止,仅当当前线程池的线程数量超过了corePoolSize值时,该线程将被终止。

我们可以通过这4个参数来控制线程池中线程的创建与销毁。我们之前用到的Executors.newCachedThreadPool方法创建的线程池基本大小为0,最大大小为最大的int值,空闲存活时间为1分钟;Executors.newFixedThreadPool方法创建的线程池基本大小和最大大小都是指定的参数值,空闲存活时间为0,表示线程不会因为长期空闲而终止。

管理任务队列

相关的参数及描述如下:

线程池内部维护了一个阻塞队列,这个队列是用来存储任务的,线程池的基本运行过程就是:线程调用阻塞队列take方法,如果当前阻塞队列中没有任务的话,线程将一直阻塞,如果有任务提交到线程池的话,会调用该阻塞队列put方法,并且唤醒阻塞的线程来执行任务。

我们前边详细说过各种阻塞队列的详细用法,那我们在自定义线程池的时候该使用哪一种阻塞队列呢?这取决于我们实际的应用场景,各种阻塞队列其实大致可以分为3类:

  1. 无界队列

    <p>其实<code>无界</code>在实际操作中的意思就是队列容量很大很大,比如有界队列<code>LinkedBlockingQueue</code>的默认容量就是最大的int值,也就是<code>2147483647</code>,这个大小已经超级大了,所以也可以被看作是<code>无界</code>的。</p>
    
    <p>如果在线程池中使用<code>无界</code>队列,而且任务的提交速度大于处理速度时,将不断的往队列里塞任务,但是内存是有限的,在队列大到一定层度的时候,内存将被用光,抛出<code>OutOfMemoryError</code>的错误(注意,是错误,不是异常)。</p>
    
    <p>所以你应该对当前任务的执行速度和提交速度有所了解,在任务不至于积压严重的情况下才使用无界队列。</p>
    </li>
    <li>
    <p>有界队列</p>
    
    <p>正是因为无界队列可能导致内存用光,所以有界队列看上去是一个不错的选择。但是它也有自己的问题,如果有界队列已经被塞满了,那后续提交的任务该怎么办呢?我们可以选择直接把任务舍弃,或者在提交任务的线程中抛出异常,或者别的什么处理方式,这种针对队列已满的情况下的反应措施被称为<code>饱和策略</code>,<code>ThreadPoolExecutor</code>构造方法中的<code>handler</code>参数就是用来干这个,我们稍后会详细说明各种策略采取的应对措施。</p>
    
    <p>所以<code>有界队列 + 饱和策略</code>的配置是我们常用的一种方案。</p>
    </li>
    <li>
    <p>同步移交队列</p>
    
    <p>你还记得在唠叨阻塞队列的时候提到过一种叫<code>SynchronousQueue</code>的队列么,它名义上是个队列,但底层并不维护链表也没有维护数组,在一个线程调用它的<code>put</code>方法时会立即将塞入的元素转交给调用<code>take</code>的线程,如果没有调用<code>take</code>的线程则<code>put</code>方法会阻塞。</p>
    
    <p>使用这种阻塞队列的线程池肯定不能堆积任务,在提交任务后必须立即被一个线程执行,否则的话,后续的任务提交将失败。所以这种队列适用于非常大或者说无界的线程池,因为任务会被直接移交给执行它的线程,而不用先放到底层的数组或链表中,线程再从底层数组或链表中获取,所以这种阻塞队列性能更好。<code>Executors.newCachedThreadPool()</code>就是采用<code>SynchronousQueue</code>作为底层的阻塞队列的。</p>
    </li>
    

我们普通使用的LinkedBlockingQueue或者ArrayBlockingQueue这样的队列都是先到达的任务会先被执行,如果你的任务有优先级的话,可以考虑使用PriorityBlockingQueue作为阻塞队列。

  1. 小贴士:
  2. 有没有注意到`workQueue`的类型是`BlockingQueue<Runnable>`,那`Callable`任务怎么办呢?实际上`Callable`任务在底层也会被转化成`Runnable`来交给线程执行,这个转换的过程被设计java的大叔们给封装起来了了,如果你有兴趣的话,也可以设计一个`Executor`框架,来定义自己的带返回值的任务。

线程工厂

相关的参数及描述如下:

如果我们不指定该参数的话,线程池将为我们创建新的非守护线程,如果你觉得默认的线程不太好,比如你希望给线程池中的线程指定名字、设置线程的优先级(不推荐)、修改守护状态(不推荐)或者为线程指定异常处理器,你可以通过指定threadFactory参数来定制自己的线程。先看一下ThreadFactory的定义:

  1. public interface ThreadFactory {
  2.     Thread newThread(Runnable r);
  3. }

也就是说如果我们指定了自己定义的ThreadFactory,线程池会调用我们自定义的ThreadFactorynewThread方法来创建线程,比如我们定义一个自己的ThreadFactory

  1. import java.util.concurrent.ThreadFactory;
  2. public class MyThreadFactory implements ThreadFactory {
  3.     private static int COUNTER = 0;
  4.     private static String THREAD_PREFIX = "myThread";
  5.     @Override
  6.     public Thread newThread(Runnable r) {
  7.         int i = COUNTER++;
  8.         return new Thread(r, THREAD_PREFIX + i);
  9.     }
  10. }

我们自定义了一个ThreadFactory的子类MyThreadFactory,每次调用newThread获取的线程的名称都会加1。

饱和策略

相关的参数及描述如下:

这个参数规定了当有界队列被任务填满之后,应该采取的措施。在ThreadPoolExecutor里定义了四个实现了RejectedExecutionHandler接口的静态内部类以表示不同的应对措施,我们看一下都有哪些:

为了演示一下这些饱和策略的用途,我们先定义一个耗时任务:

  1. public class LongTimeTask implements Runnable {
  2.     private int num;
  3.     public LongTimeTask(int num) {
  4.         this.num = num;
  5.     }
  6.     @Override
  7.     public void run() {
  8.         try {
  9.             System.out.println(Thread.currentThread().getName() + "线程正在执行第" + num + "个任务");
  10.             Thread.sleep(1000000L);     //模拟耗时操作
  11.         } catch (InterruptedException e) {
  12.             throw new RuntimeException(e);
  13.         }
  14.     }
  15. }

然后定义一个自定义线程池,只持有1个线程并且阻塞队列的大小为1。我们可以使用这个来执行LongTimeTask

  1. public class Test {
  2.     public static void main(String[] args) {
  3.         ExecutorService service = new ThreadPoolExecutor(
  4.                 1,  //基本大小为1
  5.                 1,  //最大大小为1
  6.                 0,  //表示线程不会因为长时间空闲而被停止
  7.                 TimeUnit.SECONDS,
  8.                 new LinkedBlockingQueue<>(1),   //大小为1的阻塞队列
  9.                 new MyThreadFactory(),  //自定义线程工厂
  10.                 new ThreadPoolExecutor.AbortPolicy());  //饱和策略
  11.         try {
  12.             service.submit(new LongTimeTask(1));    //该任务会被线程立即执行
  13.             service.submit(new LongTimeTask(2));    //该任务会被塞到阻塞队列中
  14.             service.submit(new LongTimeTask(3));    //该任务会根据不同的饱和策略而产生不同的反应
  15.         } catch (Exception e) {
  16.             e.printStackTrace();
  17.         }
  18.     }
  19. }

我们看到,第1个任务会被线程池里唯一的线程立即执行,第2个任务会被塞到阻塞队列中,之后阻塞队列就满了,所以在提交第3个任务的时候将会根据饱和策略来产相应的应对措施措施,当前使用的是AbortPolicy,所以执行后会抛出异常:

  1. myThread0线程正在执行第1个任务
  2. java.util.concurrent.RejectedExecutionExceptionTask java.util.concurrent.FutureTask@266474c2 rejected from java.util.concurrent.ThreadPoolExecutor@6f94fa3e[Running, pool size = 1, active threads = 1, queued tasks = 1, completed tasks = 0]
  3.     at java.util.concurrent.ThreadPoolExecutor$AbortPolicy.rejectedExecution(ThreadPoolExecutor.java:2047)
  4.     at java.util.concurrent.ThreadPoolExecutor.reject(ThreadPoolExecutor.java:823)
  5.     at java.util.concurrent.ThreadPoolExecutor.execute(ThreadPoolExecutor.java:1369)
  6.     at java.util.concurrent.AbstractExecutorService.submit(AbstractExecutorService.java:112)
  7.     at concurrency.MyThreadFactory.main(MyThreadFactory.java:30)

我们把上边自定义线程池的AbortPolicy饱和策略换成CallerRunsPolicy试试(为省略篇幅就不重复写代码了),执行结果是:

  1. myThread0线程正在执行第1个任务
  2. main线程正在执行第3个任务

CallerRunsPolicy饱和策略的意思是谁提交的任务谁执行,由于是main线程提交的任务,所以该任务由main线程去处理,由于该任务实在是太耗时了,所以main线程一直在执行该任务而无法执行后边的代码了~

剩下的两个饱和策略DiscardPolicyDiscardOldestPolicy就不多唠叨了,就是直接丢弃的不同方式而已,大家伙可以试一试效果。

线程池使用注意事项

线程池是个好东西,每当我们看到new Thread(r).start()这种代码的时候最好把它修改为使用线程池提交任务的形式,这样的话我们就可以方便的修改任务的执行策略。

线程池的线程数量

线程池中的线程数量既不能太多,也不能太少。太多了的话将有大量线程在处理器和内存资源上发生竞争,太少了的话处理器资源又不能充分利用,所以在设置线程数量的时候核心原则就是:尽量使提高各种资源的利用率,而不会在线程切换上浪费过多时间,也不会因为线程过使内存溢出。

在设置之前我们必须分析程序是因为什么受限而不能更快的运行,如果是CPU密集型的程序,我们添加过多线程并不会起到什么效果,因为CPU的利用率一直很高,所以一般将线程数设置成:处理器数量 + 1(这个1是为了防止某个线程因为某些原因而暂停,这个线程立即替换调被暂停的线程,从而最大限度的提升处理器利用率)。在java中,我们可以通过Runtime对象来获取当前计算机的处理器数量:

int numberOfCPUs = Runtime.getRuntime().availableProcessors(); //获取当前计算机处理器数量

对于别的密集型程序,我们通常能通过常见更多的线程来提升处理器利用率。但是线程数量也受限于依赖资源的数量,比如内存一共有有20M,每个线程需要1M的内存去运行任务,这样我们创建多于20个线程也没有用,因为超过的线程会因为分配不到内存而被迫终止。所以最优的线程数量会使得各种资源的利用率处于最高水平。

需要特别注意的使用情况

在一些情况下使用线程池可能造成一些风险,比如下边这几种情况:

有依赖性任务的情况

一个任务在执行过程中依赖另一个任务的执行结果才能继续往下执行,在线程池中可能会造成该任务永远僵死在那:

  1. import java.util.concurrent.Callable;
  2. import java.util.concurrent.ExecutorService;
  3. import java.util.concurrent.Executors;
  4. import java.util.concurrent.Future;
  5. public class StarvationDeadlockDemo {
  6.     private static ExecutorService service = Executors.newSingleThreadExecutor();
  7.     private static class Task1 implements Callable<String{
  8.         @Override
  9.         public String call() throws Exception {
  10.             System.out.println("开始执行task1");
  11.             Future<String> future = service.submit(new Task2());
  12.             System.out.println("task2的执行结果是:" + future.get());
  13.             return "task1";
  14.         }
  15.     }
  16.     private static class Task2 implements Callable<String{
  17.         @Override
  18.         public String call() throws Exception {
  19.             System.out.println("开始执行task2");
  20.             return "task2";
  21.         }
  22.     }
  23.     public static void main(String[] args) {
  24.         service.submit(new Task1());
  25.     }
  26. }

由于线程池中只有一个线程,所以该线程在执行Task1任务的时候就无法再执行其他任务。而Task1任务的内容却是提交另一个任务,并阻塞等待该任务的执行结果,这样程序就卡在这里动不了了,看一下执行结果:

开始执行task1

这是一个单线程线程池的极端案例,当然在线程池不够大的时候,这样的任务依赖导致程序僵死情况仍然可能发生,所以在有任务依赖的情况下最好不要使用线程池来执行这些任务,应该显式的去创建线程或者分散在不同的线程池中执行任务。

任务运行处理时间差异较大,某些任务运行时间太长的情况

如果不同的任务的执行时间有长有短,它们被提交到了同一个线程池,一个线程中需要时间短的任务很快被执行完,可能该线程接着就获取到一个时间长的任务,久而久之,线程池的所有线程都可能运行着需要时间长的任务,哪些需要时间短的任务反而都被堵在阻塞队列中无法执行。如果出现这样的情况,最好把需要时间长的任务和需要时间短的任务分开来处理。

任务中使用ThreadLocal的情况。

我们知道ThreadLocal是针对线程提出的概念,起到的效果就是对于同一个变量,每个线程看起来都好像有一个私有的值。而在线程池中的一个线程可以执行多个任务,如果在一个线程某个任务中使用了ThreadLocal变量,那当该任务执行完之后,这个线程又开始执行别的任务,上一个任务遗留下的ThreadLocal变量对这个任务是没有意义的。除非该 ThreadLocal 变量的生命周期受限于任务的生命周期,也就是在任务执行过程中创建,在任务执行完成前销毁。

如果你从本文中学到的知识有助于你解决眼前的工作、学习问题,或者对你的升职加薪起到了作用,可以点击下方喜欢作者,互惠互利谢谢~ 

                                </div>
posted @ 2019-07-15 21:57  星朝  阅读(444)  评论(0编辑  收藏  举报