美文网首页
JAVA并发编程实战-结构化并发应用程序

JAVA并发编程实战-结构化并发应用程序

作者: 端木轩 | 来源:发表于2016-12-04 22:11 被阅读77次

    一、在线程中执行任务

    1.1 无限制创建线程的不足

    • 线程生命周期的开销非常高

    线程的创建与销毁并不是没有代价的。根据平台的不同,实际的开销也有所不同,但线程的创建过程都需要实际,延迟处理的请求,并且需要JVM和操作系统提供一些辅助操作。如果请求的到达率非常高且请求的处理过程是轻量级的,那么为每个请求创建一个新线程将消耗大量的计算资源。

    • 资源消耗

    活跃的小船会消耗系统的资源,尤其是内存。如果可运行的线程数量多于可用处理器的数量,那么有些线程将闲置。大量空闲的线程会占用许多内存,给垃圾回收器带来压力,而且大量线程在竞争CPU资源时还将产生其他的性能开销。如果你已经拥有足够多的线程使所有CPU保持忙碌状态,那么再创建更多的线程反而会降低性能。

    • 稳定性

    在可创建线程的数量上存在一个限制。这个限制值将随着平台的不同而不同,并且受多个因素制约,包括JVM的启动参数、Thread构造函数中请求的栈大小,以及底层操作系统对线程的限制等。如果破坏了这些限制,那么很可能抛出OutOfMemoryError异常,要想从这个错误中恢复过来是非常危险的,更简单的办法是通过构造程序来避免超出这些限制。

    二、Executor框架

    Executor基于生产者-消费者模式,提交任务的操作相当于生产者(生成待完成的工作单元),执行任务的线程则相当于消费者(执行完这些工作单元)。如果要在程序中实现一个生产者-消费者的设计,那么最简单的方式通常就是使用Executor。

    2.1 线程池

    线程池是与工作队列(Work Queue)密切相关的,其中在工作队列中保存了所有等待执行的任务。工作者想成的任务很简单:从工作队列中获取一个任务,执行任务,然后返回线程池并等待下一个任务。

    “在线程池中执行任务”比“为每个任务分配一个线程”优势更多。通过重用现有的线程而不是创建新线程,可以在处理多个请求时分摊在线程创建和销毁过程中产生的巨大开销。另一个额外的好处是,当请求到达时,工作线程通常已经存在,因此不会由于等待创建线程而延迟任务的执行,从而提高了响应性。通过适当调整线程池的大小,可以创建足够多的线程以便使处理器保持忙碌状态,同时还可以防止过多线程相互竞争资源而使应用程序耗尽内存或失败。

    类库提供了一个灵活的线程池以及一些有用的默认配置。可以通过调用Executors中的静态工厂方法之一来创建一个线程池:

    • newFixedThreadPool。创建一个固定长度的线程池,每当提交一个任务时就创建一个线程,直到达到线程池的最大数量,这时线程池的规模将不再变化(如果某个线程由于发生了未预期的Exception而结束,那么线程池会补充一个新的线程)。
    • newCachedThreadPool。创建一个可缓存的线程池,如果线程池的当前规模超过了处理需求时,将会回收空闲的线程,而当需求增加时,则可以添加新的线程,线程池的规模不存在任何限制。
    • newSingleThreadExecutor。一个单线程的Executor,他创建单个工作者线程来执行任务,如果这个线程异常结束,会创建另一个线程来替代。
    • newScheduledThreadPool。创建一个固定长度的线程池,而且以延迟或定时的方式来执行任务。

    三、在任务与执行策略之间的隐性耦合

    虽然Executor框架为制定和修改执行策略都提供了相当大的灵活性,但并非所有的任务都能适用所有的执行策略。有些类型的任务需要明确的指定执行策略,包括:

    • 依赖性任务
    • 使用线程封闭机制的任务
    • 对响应时间敏感的任务
    • 使用ThreadLocal的任务:只有当线程本地值得生命周期受限于任务的生命周期时,在线程池的线程中使用ThreadLocal才有意义,而在线程池的线程中不应该使用ThreadLocal在任务之间传递值。

    3.1 线程饥饿死锁

    在线程池中,如果任务依赖于其他任务,那么可能产生死锁。在单线程的Executor中,如果一个任务将另一个任务提交到同一个Executor,并且等待这个被提交任务的结果,那么通常会引发死锁。第二个任务停留在工作队列中,并等待第一个任务完成,而第一个任务又无法完成,因为它在等待第二个任务的完成。在更大的线程池中,如果所有正在执行任务的线程都由于等待其他仍处于工作队列中的任务而阻塞,那么会发生同样的问题。zhe'zhong'xian'c这种现象被称为线程饥饿死锁(Thread Starvation Deadlock),只要线程池中的任务需要无限期的等待一些必须由池中其中任务才能提供的资源或条件,那么除非线程池足够大,否则将发生线程饥饿死锁。

    public class ThreadDeadlock {
        ExecutorService exec = Executors.newSingleThreadExecutor();
        
        public class RenderPageTask implememts Callable<String> {
            public String call() throws Exception {
                Future<String> header, footer;
                header = exec.submit(new LoadFileTask("header.html"));
                footer = exec.submit(new LoadFileTask("footer.html"));
                String page = renderBody();
                //将发生死锁--由于任务在等待子任务的结果
                return header.get() + page + footer.get();
            }
        }
    }
    

    四、设置线程池的大小

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

    对于计算密集型的任务,在拥有N个处理器的系统上,当线程池的大小为N+1时,通常能实现最优的使用率。对于包含I/O操作或者其他阻塞操作的任务,由于线程并不会一直执行,因此线程池的规模应该更大。要正确的设置线程池的大小,你必须估算出任务的等待时间和计算时间的比值。这种估算不需要很精确,并且可以通过一些分析或监控工具来获得。你还可以通过另一种方法来调节线程池的大小:在某个基准负载下,分别设置不同大小的线程池来运行应用程序,并观察CPU利用率的水平。

    给定下列定义:

    N = number of CPUs
    U = target CPU utilization,0<= U <=1
    W/C = ratio of wait time to compute time

    要使处理器达到期望的使用率,线程池的最优大小等于:
    Nthreads = N * U * (1+W/C);

    可以通过Runtime来获得CPU的数目:

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

    当然,CPU周期并不是唯一影响线程池大小的资源,还包括内存、文件句柄、套接字句柄和数据库连接等。

    五、配置ThreadPoolExecitor

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

    5.1 线程的创建与销毁

    线程池的基本大小、最大大小以及存活时间等因素共同负责线程的创建于销毁。基本大小就是线程池的目标大小,即在没有任务执行时线程池的大小,并且只有在工作队列满了的情况下才会创建超出这个数量的线程。小成成的最大大小表示可同时活动的线程数量的上限。如果某个线程的空闲时间超过了存活时间,那么将被标记为可回收的,并且当线程池的当前大小超过了基本大小时,这个线程将被终止。

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

    5.2 管理队列任务

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

    ThreadPoolExecutor允许提供一个BlockingQueue来保存等待执行的任务。基本的任务排队方法有3种:无界队列、有界队列和同步移交(Synchronized Handoff)。

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

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

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

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

    5.3 饱和策略

    当有界队列被填满后,饱和策略开始发挥作用。ThreadPoolExecutor的饱和策略可以通过调用setRejectedExecutionHandler来修改。JDK提供了几种不同的RejectedExecutionHandler实现,每种实现饱和不同的饱和策略:AbortPolicy、CallerRunsPolicy、DiscardPolicy和DiscardOldestPolicy。

    “中止(Abort)”策略是默认的饱和策略,该策略会抛出未检查的RejectedExecutionException。当新提交的任务无法保存到队列中等待执行时,“抛弃(Discard)”策略会悄悄抛弃该任务。“抛弃最旧的(Discard-Oldest)”策略则会抛弃下一句将被执行的任务,然后尝试重新提交新的任务。

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

    六、扩展ThreadPoolExecutor

    ThreadPoolExecutor提供了几个可以在子类化中改写的方法:beforeExecutor、afterExecutor和terminated,这些方法可以用于扩展ThreadPoolExecutor的行为。

    相关文章

      网友评论

          本文标题:JAVA并发编程实战-结构化并发应用程序

          本文链接:https://www.haomeiwen.com/subject/wbxkmttx.html