《java并发编程实战》 第八章 线程池的使用

第八章 线程池的使用

  本章介绍线程池的配置与调优的一些高级选项,并分析在任务执行框架时需要注意的各种危险,以及一些使用Executor的高级用法。

与执行策略之间存在隐形耦合的任务(不可轻易更改线程池)

  虽然Executor框架为制定、修改执行策略都提供了相当大的灵活性,但是并不是所有的任务都能适用于一般的执行策略,有些任务与执行策略之间存在隐形耦合,需要明确指定执行策略。包括:
依赖性任务:当线程池中的任务都是独立执行时,那么更改线程池的大小和配置只会对执行性能产生影响,但是如果提交给线程池的任务与其他任务之间有依赖关系,那么必须小心维持这些执行策略以避免产生活跃性问题。
  例如ThreadDeadlock,书中举了个RenderPageTask中提交了两个任务来获取页眉页脚例子无法看到运行效果,自己重写了个饥饿(严格意义上讲是资源死锁)。向只有单线程的Executor框架提交多个任务没有问题,在PrintTaskCallable 提交的第一个任务向同一个ExecutorService 再次提交两个任务也没有问题,但是在PrintTaskCallable 要等着提交的两个任务的结果那就会发生死锁了。此处若将PrintTaskCallable改成实现Runnable返回,不用等两个任务的返回值,则不会有问题。

public class PrintTaskCallable implements Callable<String>{
   public String message;	
   public PrintTaskCallable(String message) {
   	super();
   	this.message = message;
   }
   @Override
   public String call() {
   	// TODO Auto-generated method stub
   	System.out.println(message +"finished");
   	return message+"return";
   }
}
public class ThreadDeadLock {
   ExecutorService exec = Executors.newSingleThreadExecutor();
   public final TwoPrintTask twoPrintTask = new TwoPrintTask();
   public class TwoPrintTask implements Callable<String>{
   	public String call() throws Exception{
   		Future<String> print1Result,print2Result;
   		print1Result = exec.submit(new PrintTaskCallable("print1 task"));
   		print2Result = exec.submit(new PrintTaskCallable("print2 task"));
   		return print1Result.get()+print2Result.get()+"success"; //TwoPrintTask中等待同一个Executor下其他任务的执行结果,Executor只有一个线程
   	}
   }
   public static void main(String[] args) {
   	ThreadDeadLock threadDeadLock = new ThreadDeadLock();
   	Future<String> twoPrintTaskResult = threadDeadLock.exec.submit(threadDeadLock.twoPrintTask);
   	try {
   		System.out.println(twoPrintTaskResult.get());
   	} catch (InterruptedException e) {
   		e.printStackTrace();
   	} catch (ExecutionException e) {
   		e.printStackTrace();
   	}		
   }
}

使用线程封闭机制的任务线程封闭机制在第一二章提过,即单个线程访问数据,不共享数据。在与线程池相比,单线程的Executor能确保任务不会并发执行,使你能放宽代码对线程安全的要求,对象封闭在任务新城中,使在执行的任务再访问对象时不需要同步,即使这些资源不是线程安全的也没有问题。但是,当Executor框架从单线程环境改成线程池环境,那么将失去线程安全性
使用ThreadLocal的任务:ThreadLocal使每个线程都可以拥有某个变量的一个私有"版本",而线程池中的线程是重复使用的,即一次使用完后,会被重新放回线程池,可被重新分配使用。因此,ThreadLocal线程变量,如果保存的信息只是针对一次请求的,放回线程池之前需要清空这些Threadlocal变量的值(或者取得线程之后,首先清空这些Threadlocal变量的值。
运行时间长的任务:线程池的大小应该超过有较长执行时间的任务数量,否则可能造成线程池中线程均服务于长时间任务导致其它短时间任务也阻塞导致性能下降。此种情形,执行策略可以采用缓解策略:限定任务等待资源的时间,如果等待超时,那么可以把任务标示为失败,然后中止任务或者将任务重新返回队列中以便随后执行。这样,无论任务的最终结果是否成功,这种方法都能确保任务总能继续执行下去,并将线程释放出来以执行一些能更快完成的任务。例如Thread.join、BlockingQueue.put、CountDownLatch.await以及Selector.select等。
  总之,要充分发挥出线程池的性能,要保证任务都是同类型并且独立。

设置线程池的大小

  线程池的理想大小取决于被提交任务的类型及所部署系统的特性,代码中通常不会固定线程池的大小,应该通过某种配置机制提供,或者根据Runtime.availableProcessor来计算。
  线程池过大,那么大量的线程将在相对很少的CPU和内存资源上发生竞争,这不仅会导致更高的内存使用量,而且还可能耗尽资源
如果线程池过小,那么将导致许多空闲的处理器无法执行工作,从而降低吞吐量
  对于计算密集型的任务,在拥有Ncpu个处理器的系统上,当线程池的大小为Ncpu+1时,通常能实现最优的利用率;对于包含I/O操作或者其他阻塞操作的任务,由于线程并不会一直执行,因此线程池的规模应该更大。
线程池最优大小为:N(threads)=N(cpu)U(cpu)(1+W/C)
其中: N(cpu)=CPU的数量=Runtime.getRuntime().availableProcessors();
其中:U(cpu)= 期望CPU的使用率,
其中:W/C=等待时间与运行时间的比率
范围:0<=U(cpu)<=1
  当然,除此之外,内存、文件句柄、套接字句柄、数据库连接、CPU周期都是影响线程池大小的资源。

配置ThreadPoolExecutor

AbstractExecutorService实现了ExecutorService 、Executor,而ThreadPoolExecutor实现了AbstractExecutorService。ThreadPoolExecutor是一个灵活的、稳定的线程池,允许进行各种定制。

public ThreadPoolExecutor(int corePoolSize,
                          int maximumPoolSize,
                          long keepAliveTime,
                          TimeUnit unit,
                          BlockingQueue<Runnable> workQueue)
参数:
    corePoolSize - 池中所保存的线程数,包括空闲线程。
    maximumPoolSize - 池中允许的最大线程数。
    keepAliveTime - 当线程数大于核心时,此为终止前多余的空闲线程等待新任务的最长时间。
    unit - keepAliveTime 参数的时间单位。
    workQueue - 执行前用于保持任务的队列。此队列仅保持由 execute 方法提交的 Runnable 任务。 
抛出: 
	IllegalArgumentException - 如果 corePoolSize 或 keepAliveTime 小于 0,或者 maximumPoolSize 小于等于 0,或者 corePoolSize 大于 maximumPoolSize。 
	NullPointerException - 如果 workQueue 为 null

线程的创建配置

ThreadPoolExecutor类需要关注的参数:
CorePoolSize: 线程池基本大小,在创建ThreadPoolExecutor初期,线程并不会立即启动,而是等到有任务提交时才会启动,除非调用prestartAllCoreThreads,并且只有在工作队列满了的情况下才会创建超出这个数量的线程。
MaxmumPooSize: 线程池最大大小表示可同时活动的线程数量的上限。若某个线程的空闲时间超过了keepAliveTime, 则被标记为可回收的。
同时,也可通过Executors类提供的newFixedThreadPool(int nThreads) 、newCachedThreadPool() 、newSingleThreadScheduledExecutor() 等工厂方法创建线程池。具体采用何种方法,查手册配置参数。
例如Executors类提供的newFixedThreadPool工厂方法:
public static ExecutorService newFixedThreadPool(int nThreads)

任务队列的管理

ThreadPoolExecutor类的任务队列:
  BlockingQueue workQueue:执行前用于保持任务的队列。此队列用于传输和保存由 execute 方法提交的 Runnable 任务。所有 BlockingQueue 都可用于传输和保持提交的任务。可以使用此队列与池大小进行交互:
  如果运行的线程少于 corePoolSize,则 Executor 始终首选添加新的线程,而不进行排队。
  如果运行的线程等于或多于 corePoolSize,则 Executor 始终首选将新增的任务请求加入任务队列,而不添加新的线程。
  如果无法将请求加入队列,则创建新的线程,除非创建此线程超出 maximumPoolSize,在这种情况下,任务将被拒绝。
可见,任务队列在线程池中的重要性,与线程数量、任务数量超出线程数时的保存有很大连系。
任务队列通常有三种通用策略:
无任务队列。工作队列的默认选项是 SynchronousQueue,它将任务直接提交给线程而不将任务保存在任务队列中。在此,如果不存在可用于立即运行任务的线程,则试图把任务加入队列将失败,因此会构造一个新的线程。此策略可以避免在处理可能具有内部依赖性的请求集时出现锁。直接提交通常要求无界 maximumPoolSizes 以避免拒绝新提交的任务。当命令以超过队列所能处理的平均数连续到达时,此策略允许无界线程具有增长的可能性。
无界队列。使用无界队列(例如,不具有预定义容量的 LinkedBlockingQueue)将导致在所有 corePoolSize 线程都忙时新任务在队列中等待。这样,创建的线程就不会超过 corePoolSize。(因此,maximumPoolSize 的值也就无效了。)当每个任务完全独立于其他任务,即任务执行互不影响时,适合于使用无界队列;例如,在 Web 页服务器中。这种排队可用于处理瞬态突发请求,当命令以超过队列所能处理的平均数连续到达时,此策略允许无界线程具有增长的可能性。
有界队列。当使用有限的 maximumPoolSizes 时,有界队列(如 ArrayBlockingQueue)有助于防止资源耗尽,但是可能较难调整和控制。队列大小和最大池大小可能需要相互折衷:使用大型队列和小型池可以最大限度地降低 CPU 使用率、操作系统资源和上下文切换开销,但是可能导致人工降低吞吐量。如果任务频繁阻塞(例如,如果它们是 I/O 边界),则系统可能为超过您许可的更多线程安排时间。使用小型队列通常要求较大的池大小,CPU 使用率较高,但是可能遇到不可接受的调度开销,这样也会降低吞吐量。(摘自jdk api文档)

Executors类的工厂方法中的任务队列:
  newFixedThreadPool和newSingleThreadPool在默认情况下将使用一个无界的LinkedBlockingQueue,无界的任务队列能保证更好的性能
  使用有界队列有助于避免资源耗尽的情况发生,为了避免当队列填满后,在使用有界的工作队列时,队列的大小与线程池的大小必须一起调节,能防止过载。
  对于非常大的或者无界的线程池,可以通过使用SynchronousQueue来避免任务排队,要将一个元素放入SynchronousQueue中,必须有另一个线程正在等待接受这个元素,任务会直接移交给执行它的线程,否则将拒绝任务newCachedThreadPool工厂方法中就使用了SynchronousQueue。使用优先队列PriorityBlockingQueue可以控制任务被执行的顺序。

饱和策略(任务有界队列)

  当任务的有界队列被填满后,饱和策略开始发挥作用,饱和策略就是制定了该舍弃哪个任务的规则。
AbortPolicy(中止策略),默认的饱和策略。该策略会抛出未检查的RejectedExecutionException异常。当新提交的任务无法保存到队列中等待执行时,中止策略分普通抛弃和抛弃下一个(抛弃最旧),普通抛弃偷偷抛弃这个任务。抛弃下一个,如果队列是优先策略,优先级最高的任务反而会抛弃。因此人家说最好不要将“抛弃最旧”饱和策略和优先级队列一起使用。
调用者运行策略:既不抛弃任务,也不抛出异常,而是将某些任务回退至调用者。不会再线程池的某个线程执行新提交的任务,而是在一个调用了execute的线程中执行该任务。
例如:当我们的WebServer采用有界队列和“调用者运行”饱和策略,当线程池中所有的线程都被占用,并且任务队列也被填满,下一个任务在调用execute时在主线程中执行。由于执行任务需要一定时间,因此主线程至少在一段时间内无法提交任务,因此到达的请求最终保存在TCP层的队列中,而不是应用程序的队列中。如果持续过载,那么TCP层最终发现请求队列被填满,会抛异常。

线程工厂(线程池中创建线程方式)

  当线程池需要创建一个线程时,都是通过线程工厂方式。默认的线程工厂方法可以创建一个新的、非守护的线程,并且不包含特殊的配置信息。在ThreadFactory中只定义了一个方法newThread,每当线程池需要创建一个新线程都会调用这个方法。

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

  很多时候,我们都需要自己定制线程工厂方法,例如希望为线程池中的线程指定一个UncaughtExceptionHandler,或者实例化一个定制的Thread类用于执行调试信息的记录。

public class MyThreadFactory implements ThreadFactory {
 private final String poolName;
     public MyThreadFactory(String poolName) {
       super();
      this.poolName = poolName;
    }
    @Override
    public Thread newThread(Runnable r) {
       return new MyAppThread(r);
    }
}

  MyAppThread中定制行为,指定名字,设置自定义UncaughtExceptionHandler向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 r, String name) {
        super(r, name+ "-" + created.incrementAndGet());
        setUncaughtExceptionHandler( //设置未捕获的异常发生时的处理器
                new Thread.UncaughtExceptionHandler() {
                    @Override
                    public void uncaughtException(Thread t, Throwable e) {
                        log.log(Level.SEVERE, "UNCAUGHT in thread " + t.getName(), e);
                    }
                });
    }
    @Override
    public void run() {
        boolean debug = debugLifecycle;
        if (debug) 
            log.log(Level.FINE, "running thread " + getName());
        try {
            alive.incrementAndGet();
            super.run();
        } finally {
           alive.decrementAndGet();
            if (debug) 
                log.log(Level.FINE, "existing thread " + getName());
        }
    }
} 

扩展ThreadPoolExecutor

  在调用完ThreadPoolExecutor的构造函数后,仍然可以通过许多设置函数(setter)来修改传递给它的构造函数参数,例如线程池基本大小、最大最小、存活时间、线程工厂以及拒绝执行处理器(Rejected Excetion Handler)。
  ThreadPoolExecutor是可扩展的,提供了几个可以在子类只呢个改写的方法:beforeExecute、afterExecute、和terminated,这些方法可以扩展ThreadPoolExecutor的行为。
    a、线程执行前调用beforeExecute(如果beforeExecute抛出了一个RuntimeException,那么任务将不会被执行)
    b、线程执行后调用afterExecute(抛出异常也会调用,如果任务在完成后带有一个Error,那么就不会调用afterExecute)
    c、在线程池完成关闭操作时调用terminated,也就是所有任务都已经完成并且所有工作者线程也已经关闭后
  例如TimingThreadPoolExecutor,可以看出由于要测量任务的运行时间,startTime以ThreadLocal方式保存以便afterExecute可以访问,并且numTask、totalTime采用AtomicLong变量用于记录已处理的任务数和总的处理时间。

public class TimingThreadPoolExecutor extends ThreadPoolExecutor {
   private final ThreadLocal<Long> startTime = new ThreadLocal<Long>();//任务执行开始时间
   private final Logger log = Logger.getAnonymousLogger();
   private final AtomicLong numTasks = new AtomicLong(); //统计任务数
   private final AtomicLong totalTime = new AtomicLong(); //线程池运行总时间

   public TimingThreadPoolExecutor(int corePoolSize, int maximumPoolSize,
           long keepAliveTime, TimeUnit unit, BlockingQueue<Runnable> workQueue) {
       super(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue);
   }

   @Override
   protected void beforeExecute(Thread t, Runnable r) {
       super.beforeExecute(t, r);
       log.fine(String.format("Thread %s: start %s", t, r));
       startTime.set(System.nanoTime());
   }

   @Override
   protected void afterExecute(Runnable r, Throwable t) {
       try{
           long endTime = System.nanoTime();
           long taskTime = endTime - startTime.get();
           numTasks.incrementAndGet();
           totalTime.addAndGet(taskTime);
           log.fine(String.format("Thread %s: end %s, time=%dns", t, r, taskTime));
       } finally{
           super.afterExecute(r, t);
       }
   }

   @Override
   protected void terminated() {
       try{
           //任务执行平均时间
           log.info(String.format("Terminated: average time=%dns", totalTime.get() / numTasks.get()));
       }finally{
           super.terminated();
       }
   }
}

算法的并行化

顺序循环----并行化:

  如果一个循环中的每次事件处理都是独立的,彼此没有影响,那可以将一个顺序的循环变成一个并行的循环。顺序执行时,只有当循环中的所有任务全部执行完毕后才会返回。而在并行执行中,只要将任务添加到了Executor执行队列中就可以返回了,任务之后会并发的执行。节省了等待时间。当有任务集时,可以使用ComplectionService。

//顺序执行
   void processSequentially(List<Element> elements){
       for (Element e:elements) {
           process(e);
       }
   }
   //并行执行
   void processInparallel(Executor executor,List<Element> elements){
       for (final Element e:elements) {
           executor.execute(new Runnable() {
               @Override
               public void run() {
                   process(e);
               }
           });
       }
   }

递归算法----并行化:

//顺序递归
   void sequentialRecursive(List<Node<Integer>> nodes, Collection<Integer> results){
       for (Node<Integer> node:nodes) {
           results.add(node.compute);//任务计算
           sequentialRecursive(nodes.getChildren(),results);
       }
   }
   //并行递归
   void parallelRecursive(final Executor executor,List<Node<Integer>> nodes, Collection<Integer> results){
       for (Node<Integer> node:nodes) {
           executor.execute(new Runnable() {
               @Override
               public void run() {
                   results.add(node.compute);//任务计算
               }
           });
           sequentialRecursive(nodes.getChildren(),results);
       }
   }

遍历的过程仍然是顺序的,但是对遍历过程中出现的可能会等待的任务进行了并行执行。可通过以下方法获取计算结果。

       ExecutorService executorService = Executors.newCachedThreadPool();
       executorService.shutdown();
       executorService.awaitTermination(Long.MAX_VALUE, TimeUnit.SECONDS);

小结:Executor框架是一个强大且灵活的框架,它提供了大量可调节的选项,例如创建线程和关闭线程的策略,处理队列任务的策略,处理多任务的策略,并且提供了几个钩子方法来扩展它的行为。然而,与大多数的功能强大的框架一样,其中有些设置参数不能很好的工作,某些任务需要特定的执行策略,一些参数组合可能会产生奇怪的结果。

猜你喜欢

转载自blog.csdn.net/weixin_41262453/article/details/86707393