【Java并发编实战】基础构建模块与任务执行

来源:互联网 发布:yy协议软件手机版 编辑:程序博客网 时间:2024/05/23 13:10
  【基础构建模块】

Java平台类库包含了丰富的并发基础构建模块,例如线程安全的容器类以及各种用于协调多个相互协作的线程控制流得同步工具类(Synchronizer)

1.同步容器类

同步容器类包括Vector和HashTable,二者是早起JDK的一部分,此外还包括同步的封装器类,由Collections.synchronizedXxx等工厂方法创建的,这些类实现线程安全的方式是:将它们的状态封装起来,并对每个公有方法都进行同步,使得每次只有一个线程能访问容器的状态。

1.1 同步容器类的问题

同步容器累都是线程安全的,但在某些情况下可能需要额外的客户端加锁来保护复合操作。
比如,在Vector中定义的两个方法:getLast和deleteLast,它们都会执行“先检查再运行”操作,每个方法首先都获得数组的大小,然后通过结果来获取或删除最后一个元素。
如果线程A在包含10个元素的Vector上调用getLast,同时线程B在同一个Vector上调用deleteLast,这些操作的交替执行,getLast将抛出ArrayIndexOutOfBoundsException异常。
由于同步容器类要遵循同步策略,即支持客户端加锁,因此可能要创建一些新的操作,只要我们知道应该使用哪一个锁,那么这些新操作就与容器的其他操作一样都是原子操作。
在调用size的相应的get之间,Vector的长度可能会发生变化,这种风险在对Vector中的元素进行迭代时仍然会出现。
这种迭代操作的正确性要依赖于运气,即在调用size和get之间没有线程会修改Vector。在单线程环境下,这种假设完全成立,但在其他线程并发地修改Vector时,则可能导致麻烦。与getLast一样,如果在对Vector尽心迭代时,另一个线程删除了一个元素,并且这两个操作交替执行,那么这种迭代方法将抛出ArrayIndexOutOfBoundsException异常。
我们可以通过在客户端加锁来解决不可靠迭代的问题,但要牺牲一些伸缩性。通过在迭代期间持有Vector的锁,可以防止其他线程在迭代期间修改Vector。

1.2 迭代器与ConcurrentModificationException

无论在直接迭代还是Java5.0引入的for-each循环语法中,对容器类进行迭代的标准方式都是使用Iterator。在设计同步容器类的迭代器时并没有考虑并发修改的问题,并且他们表现出的行为是“及时失败”(fail-fast)的。这意味着,当它们发现容器在迭代过程中被修改时,就会抛出一个ConcurrentModificationException异常。
这种“及时失败”的迭代器并不是一种完备的处理机制,而只是“善意地”捕获并发错误,因此只能作为并发问题的预警指示器。
与迭代Vector一样,要想避免出ConcurrentModificationException,就必须在迭代过程中有容器的锁。
即使不存在饥饿或者死锁的风险,长时间地对容器加锁也会降低程序的可伸缩性。持有锁的是时间越长,那么在锁上的竞争就可能越激烈,如果许多线程都在等待锁释放,那么将极大地降低吞吐量和CPU的利用率。
如果不希望在迭代期间内对容器加锁,那么一种替代方法就是“克隆”容器,并在副本上进行迭代。由于副本被封闭在线程中,因此其他线程不会在迭代期间对其进行修改,这样就避免了抛出ConcurrentModificationException(在克隆过程中仍然需要对容器加锁)

1.3 隐藏迭代器

虽然加锁可以防止迭代器抛出ConcurrentModificationException,但是在所有对共享容器进行迭代的地方都需要加锁。在单线程代码中也可能抛出ConcurrentModificationException异常,当对象直接从容器中删除而不是通过Iterator.remove来删除时,就会抛出这个异常。
如果状态与保护它的同步代码之间相隔越远,那么开发人员就越容易忘记在访问状态时使用正确的的同步。
正如封装对象的状态有助于维持不变性条件一样,封装对象的同步机制同样有助于确保实施同步策略。
容器的hashCode和equals等方法也会间接地执行迭代操作,当容器作为另一个容器的元素或健值时,就会出现这个情况。

2.并发容器

Java5.0提供了多种并发容器类来改进同步容器的性能。同步容器将所有对容器状态的访问串行化,以实现它们的线程安全性。这种方法的代价是严重降低并发性,当多个线程竞争容器的锁时,吞吐量将严重减低。
并发容器是针对多个线程并发访问设计的。在Java5.0中增加了ConcurrentHashMap,用来替代同步且基于散列的Map,以及CopyOnWriteArratList,用于在遍历操作为主要操作的情况下代替原来的List。在新的ConcurrentMap接口中增加了对一些常见复合操作的支持,例如“若没有则添加”、替代以及有条件删除等。
通过并发容器来代替同步容器,可以极大地提高伸缩性并降低风险。
Java5.0增加了两种新的容器类型:Queue和BlockingQueue。Queue用来临时保存一些待处理的元素,它提供了几种实现,包括:ConcurrentLinkedQueue,这是一个传统的先进先出队列,以及PriorityQueue,这是一个(非并发的)优先队列。
Queue上的操作不会阻塞,如果队列为空,那么获取元素的操作将返回空值。事实上,正是通过LinkedList来实现的Queue的LinkedList来实现Queue的,但还需要一个Queue的类,因为它能去掉List的速记访问需求,从而实现更高效的并发。
在java5.0中新增加了java.util.Queue接口,用以支持队列的常见操作。该接口扩展了java.util.Collection接口。Queue使用时要尽量避免Collection的add()和remove()方法,而是要使用offer()来加入元素,使用poll()来获取并移出元素。它们的优点是通过返回值可以判断成功与否,add()和remove()方法在失败的时候会抛出异常。 如果要使用前端而不移出该元素,使用element()或者peek()方法。
BlockingQueue扩展了Queue,增加了可阻塞的插入和获取等操作。在“生产者-消费者”这种设计模式中,阻塞队列是非常有用的。
正如ConcurrentHashMap用于代替基于散列的同步Map,Java 6 也引入了ConcurrentSkipMap和ConcurrentSkipListSet,分别作为同步的SortedMap和SortedSet的并发替代品(例如用SynchronizedMap包装的TreeMap或TreeSet)

2.1 ConcurrentHashMap

同步容器类在执行每个操作期间都持有一个锁。在一些操作中,例如HashMap.get或List.contains,可能包含大量的工作:当遍历散列桶或链表来查找某个特定的对象时,必须在许多元素上调用equals(而equals本身还包含一定的计算量)。
与HashMap一样,ConcurrentHashMap也是一个基于散列的Map,但它使用了一种完全不同的加锁策略来提供更高的并发性和伸缩性。ConcurrentHashMap并不是将每个方法都在同一个锁上同步并是得每次只能由一个线程访问容器,而是使用一种粒度更细的加锁机制来实现更大程度的共享,这种机制成为分段锁(Lock Stripping)ConcurrentHashMap带来的结果是,在并发访问环境下将实现更高的吞吐量,而在单线程环境中值损失非常小的性能。
ConcurrentHashMap与其他并发容器一起增强了同步容器类:它们提供的迭代器不会抛出ConcurrentModificationException,因此不需要在迭代过程中对容器加锁。**ConcurrentHashMap反悔的迭代器具有弱一致性(Weakly Consistent)而并非“及时失败”。
尽管有这些改进,但仍然有一些需要权衡的因素,对于一些需要在整个Map上进行计算方法,例如size和isEmpty,这些方法的语义被略微减弱了以反映容器的并发特性。 但是事实上,size和isEmpty这样的方法在并发环境下的用处很小,因为它们的返回值总在不断变化。因此,这些操作的需求被弱化了,以换取对其他更重要操作的性能优化,包括get、put、containsKey和remove等。
在ConcurrentHashMap中没有实现对Map加锁以提供独占访问。与HashTable和synchronizedMap相比,ConcurrentHashMap有着更多的优势以及更少的劣势,因此在大多数情况下,用ConcurrentHashMap来代替同步Map能进一步提高代码的可伸缩性,只有当应用程序需要加锁Map以进行独占访问时,才应该放弃使用ConcurrentHashMap。

2.2 额外的原子Map操作

由于ConcurrentHashMap不能被加锁来执行独占访问,因此我们无法使用客户端加锁来创建新的原子操作。比如“若没有则添加”、“若没有则移除”、“若相当则替换”等,都已经实现了原子操作并且在ConcurrentMap的接口中声明,如果你需要在现在的同步Map中添加这样的功能,那么很可能就意味着应该考虑ConcurrentMap了。

2.3 CopyOnWriteArrayList

CopyOnWriteArrayList用户替代同步List,在某些情况下它提供了更好的并发性能,在迭代期间不需要对容器进行加锁或复制。
“写入时复制(Copy-on-Write)”容器的线程安全性在于,只要正确地发布一个事实不可变的对象,那么在访问该对象时就不需要进一步的同步,在每次修改时,都会创建并重新发布一个新的容器副本,从而实现可变性。
“写入时复制”容器返回的迭代器不会抛出ConcurrentModificationException,并且返回的元素与迭代器创建时的元素完全一致,而不必考虑之后修改操作所带来的影响。
显然,每当修改容器时都会复制底层数组,这需要一定的开销,特别是当容器的规模较大时。仅当迭代操作远远多于修改操作时,才应该使用“写入时复制”容器。

3.阻塞队列和生产者-消费者模式

阻塞队列提供了可阻塞的put和take方法,以及支持定时的offer和poll方法。队列可以是有界的也可以是无界的,无界队列永远都不会充满,因此无界队列上的put方法也永远不会阻塞。
阻塞队列支持生产者-消费者这种设计模式。生产者-消费者模式能简化开发过程,因为它消除了生产者类和消费者类之间的代码依赖性,此外,该模式还将生产数据的过程和使用数据的过程解耦开来以简化工作负载的管理,因为这两个过程在处理数据的速率上有所不同。
在基于阻塞队列构建的生产者-消费者设计中,当数据生成时,生产者把数据放入队列,而当消费者准备处理数据时,将从队列中获取数据。
BlockingQueue简化了生产者-消费者设计的实现过程,它支持任意数量的生产者和消费者。一种最常见的生产者-消费者设计模式就是线程池和工作队列的组合,在Executor任务执行框架中就体现了这种模式。
以两个人洗盘子为例,盘架相当于队列,如果盘架上没有盘子,那么消费者会一直等待,直到有盘子需要烘干。如果盘架放满了,那么生产者会停止清晰直到盘架上有更多的空间。
阻塞队列简化了消费者程序的编码,因为take操作会一直阻塞直到有可用的数据。同样,put方法的阻塞特性也极大地简化了生产者的编码。如果使用有界队列,那么当队列充满时,生产者将阻塞并且不能继续生成工作,而消费者就有时间赶上工作处理速度。
阻塞队列同样提供了一个offer方法,如果数据项不能被添加到队列中个,那么将返回一个失败状态。这样你就能够创建更多灵活的策略来处理负荷过载的情况,例如减轻负载,将多余的工作项序列化并写入磁盘,减少生产者线程的数量,或者通过某种方式来抑制生产者线程。
在构建高可靠的应用程序时,有界队列是一种强大的资源管理工具:它们能抑制并防止产生过多的工作项,使应用程序在负荷过载的情况下变得更加健壮。
虽然生产者-消费者模式能够见生产者-消费者的代码彼此解耦开来,但它们的行为仍然会通过共享工作队列间接地耦合在一起。应该今早地通过阻塞队列在设计中构建资源管理机制——这件事情做得越早,就越容易。在许多情况下,阻塞队列能使这项工作更加简单,如果阻塞队列并不完全符合设计需求,那么还可以通过信号量(Semaphore)来创建其他的阻塞数据结构。
在类库中包含了BlockingQueue的多种实现,其中,LinkedBlockingQueue和ArrayBlockingQueue是FIFO队列,二者分别与LinkedList和ArrayList类似,但比同步list拥有更好的并发性能。PriorityBlockingQueue是一个按优先级排序的队列,当你希望按照某种顺序而不是FIFO类处理元素时,这个队列就非常有用。
最后一个BlockingQueue实现是SynchronousQueue,实际上它不是一个真正的队列,因为它不会为队列中元素维护存储空间。与其他队列不同的是,它维护一组线程,这些线程在等待着把元素加入或移出队列。因为SynchronousQueue没有存储功能,因此put和take会一直阻塞,直到有另一个线程已经准备好参与到交付过程中。仅当有足够多的消费者,并且总是有一个消费者准备好获取交付的工作时,才适合使用同步队列。

3.1 串行线程封闭

在java.util.concurrent中实现的各种阻塞队列都包含了足够的内部同步机制,从而安全地将对象从生产者线程发布到消费者线程。
对于可变对象,生产者-消费者这种设计与阻塞队列一起,促进了串行线程封闭,将对象所有权从生产者交付给消费者。
对象池利用了串行线程封闭,将对象“借给”一个请求线程。我们也可以使用其他发布机制来传递可变对象的所有权,但必须确保只有一个线程能接受被转移的对象。

3.2 双端队列与工作密取

Java 6新增了两种容器类型,Deque(发音为“deck”)和BlockingDeque,它们分别对Queue和BlockingQueue进行了扩展。Deque是一个双端队列,实现了在队列头和队列尾的高效插入和移除。
正如阻塞队列适用于生产者-消费者模式,双端队列同样适用于另一种相关模式,即工作密取(Work Stealing)。工作密取设计中,每个消费者都有各自的双端队列,如果一个消费者完成了自己双端队列中的全部工作,那么它可以从其他消费者双端队列末尾秘密地获取工作。密取工作模式比传统的生产者-消费者模式具有更高的可伸缩性,这是因为工作者线程不会再耽搁共享的任务队列上发生竞争。

4. 阻塞方法和中断方法

线程可能会阻塞或暂停执行,原因有多种:等待I/O操作结束,等待获取一个锁,等待从Thread.sleep方法中醒来,或是等待另一个线程的计算结果。
BlockingQueue的put和take等方法会抛出受检查异常(Checked Exception)Interrupted Exception,这与类库中其他一些方法的做法相同,例如Thread.sleep。当某方法抛出Interrupted Exception时,表示该方法是个阻塞方法,如果这个方法被中断,那么它将努力提前结束阻塞状态。
中断时一种协作机制。一个线程不能强制其他线程停止在正在执行的操作而去执行其他的操作。
当在代码中调用了一个将抛出InterruptedException异常的方法时,你自己的方法也就变成了一个阻塞方法,并且必须要处理对中断的响应。
传递InterruptException 。避开这个异常通常是最明智的策略——只需把InterruptedException传递给方法的调用者。传递InterruptedException的方法,根本不捕获该异常,然后执行某种简单的清理工作后再次抛出异常。
恢复中断。有时候不能抛出InterruptedException,例如代码是Runnable的一部分时。在这种情况下,必须捕获InterruptedException,并通过调用当前线程上的Interrupt方法恢复中断状态,这样在调用栈中更高层的代码将看到引发了一个中断。

5.同步工具类

在容器类中,阻塞队列是一种独特的类:它们不仅能作为保存对象的容器,还能协调生产者和消费者等线程之间的控制流,因为take和put等方法将阻塞,直到队列达到期望的状态(队列既非空,也非满)。
阻塞队列可以作为同步工具类,其他类型的同步工具类还包括信号量(Semaphore)、栅栏(Barrier)以及闭锁(Latch)

5.1 闭锁

闭锁是一种同步工具类,可以延迟线程的进度直到其到达终止状态。闭锁的作用相当于一扇门:在闭锁到达结束之前,这扇门一直是关闭的,并且没有任何线程能通过,当到达结束状态时,这扇门会打开并允许所有的线程通过。
CountDownLatch是一种灵活的闭锁实现,可以在上述各种情况中使用,它可以使一个或多个线程等待一组事件发生。闭锁状态包括一个计数器,该计数器被初始化一个正数,表示需要等待的事件数量。countDown方法递减计数器,表示一个事件已经发生了,而await方法等待计算器达到零,这表示所有需要等待的事件都已经发生。如果计数器的值非零,那么await会一直阻塞直到计算器为零,或者等待中的线程中断,或者等待超时。

5.2 FutureTask

FutureTask也可以用做闭锁。(FutureTask实现了Future语义,表示一种抽象的可生产结果的计算。)FutureTask表示的计算是通过Callable来实现的,相当于一种可生成结果的Runnable,并且可以处于3种状态:等待运行(Waiting to run),正在运行(Running)和运行完成(Completed)。“执行完成”表示计算的所有可能结束方式,包括正常结束、由于取消而结束和由于异常而结束等。
Future.get的行为取决于任务的状态。如果任务已完成,那么get会立即返回结果,否则get将阻塞直到任务进入完成状态,然后返回结果将抛出异常。

5.3 信号量

计数信号量(Counting Semaphore)用户控制同时访问某个特定资源的操作数量,或者同时执行某个执行操作的数量。
Semaphore中管理着一组虚拟的许可(permit),许可的初始数量可通过构造函数来指定。在执行操作时可以首先获取许可(只要还有剩余的许可),并在使用释放许可。(如果没有许可,那么acquire将阻塞直到有许可(或者直到被中断或者操作超时)。release方法将返回一个许可给信号量。)
Semaphore可以用于实现资源池,例如数据库连接池。我们可以构造一个固定长度的资源池,当池为空时,请求资源将会失败,但你真正希望看到的行为是阻塞而不是失败,并且当池非空时解除阻塞。在构造阻塞对象池时,一种更简单的方法时使用BlockingQueue来保持池的资源。同样,你也可以使用Semaphore将任何一种容器变成有界阻塞容器。

5.4 栅栏

通过闭锁来启动一组相关的操作,或者等待一组相关的操作结束。闭锁是一次性对象,一旦进入终止状态,就不能被重置。
栅栏(barrier)类似闭锁,它能阻塞一组线程直到某个事件发生。栅栏与闭锁的关键区别在于,所有线程必须同时到达栅栏位置,才能继续进行。闭锁用于等待时间,而栅栏用来等待其他线程。
CyclicBarrier可以使一定数量的参与方反复地在栅栏位置汇集,它在并行迭代算法中非常有用:这种算法通常将一个问题拆分成一系列相互独立的子问题。当线程到达栅栏位置时将调用await方法,这个方法将阻塞直到所有线程都到达栅栏位置。*如果所有线程都到达了栅栏位置,那么栅栏将打开,此时所有线程都被释放,而栅栏将被重置以便下次使用*。

第一部分小结:
【线程安全性、对象的共享、对象的组合和基础构建模块】**

可变状态时至关重要的(It’s the mutable state stupid)
所有的并发问题都可以归结为如何协调对并发状态的访问。可变状态越少,就越容易确保线程安全性。
尽量将域声明为final类型,除非需要它们是可变的。
不可变对象一定是线程安全的。
不可变对象能极大地降低并发编程的复杂性。它们更为简单并且安全,可以任意分享而无须使用加锁或保护性复制等机制。
封装有助于管理复杂性
在编写线程安全的程序时,虽然可以将所有数据都保存在全局变量中,但为什么要这样做?将数据封装在对象中,更易于维持不变性条件:将同步机制封装在对象中,更易于遵循同步策略。
用锁来保护每个可变变量
当保护同一个不变性条件中的所有变量时,要使用同一个锁。
在执行复合操作期间,要持有锁。
如果从多个线程中访问同一个可变变量时没有同步机制,那么程序会出现问题。
不要故作聪明地推断出不需要使用同步。
在设计过程中考虑线程安全,或者在文档中明确地指出它不是线程安全的。
将同步策略文档化。

         第二部分 结构化并发应用程序   【任务执行】

任务通常是一些抽象的且离散的工作单元。通过把应用程序的工作分解到多个任务中,可以简化程序的组织结构,提供一种自然的事务边界来优化错误恢复过程,以及提供一种自然的并行工作结构来提升并发性。

1.线程中执行任务

独立性有助于实现并发,因为如果存在足够多的处理资源,那么这些独立的任务都可以并行执行。

在正常的负载下,服务器应用程序应该同时表现出良好的吞吐量和快速的响应性。而且,当负载过载时,应用程序的性能应该是逐渐降低,而不是直接失败。要实现上述目标,应该选择清晰的任务边界以及明确的任务执行策略。

1.1 串行地执行任务

在应用程序中可以通过多种策略来调度任务,而其中一些策略能够更好地利用潜在并发性。最简单的策略就是在单个线程中串行地执行各项任务。
在Web请求的处理中包含了一组不同的运算与I/O操作。服务器必须处理套接字I/O以读取请求和写回响应,这些操作通常会由于网络拥堵或连通性问题而被阻塞。此外,服务器还可能处理文件I/O或者数据库请求,这些操作同样会阻塞。

1.2 显示地为任务创建线程

  • 任务过程过程从主线程中分离处理,使得主循环能够更快地重新等待下一个到来的连接。这使得程序在完成前面的请求之前可以接受新的请求,从而提高响应性。
  • 任务可以并行处理,从而能同时服务多个请求。如果有多个处理器,或者任务由于某种原因被阻塞,例如等待I/O完成、获取锁或者资源可用性等,程序的吞吐量将得到提高。
  • 任务处理代码必须是线程安全的,因为当有多个任务时并发地调用这段代码。
    在正常负载情况下,“为每个任务分配一个线程”的方法能提升串行执行的性能。只要请求的到达速率不超出服务器的请求处理能力,那么这种方法可以同时带来更快的响应性和更高的吞吐率。

1.3 无限制创建线程的不足

在生产环境中,“为每个任务分配一个线程”这种方法存在一些缺陷,尤其是当需要创建大量的线程。

线程生命周期的开销非常高。线程的创建与销毁并不是没有代价的。
资源消耗。活跃的线程会消耗系统资源,尤其是内存。
稳定性。在可创建线程的数量上存在一个限制。这个限制值将随着平台的不同而不同,并且受多个因素制约,包括JVM的启动参数、Thread构造函数中请求的栈大小,以及底层操作系统对线程的限制等。

2. Executor框架

任务是一组逻辑工作单元,而线程则是使任务异步执行的机制。串行执行的问题在于其糟糕的响应性和吞吐量,而“为每个任务分配一个线程”的问题在于资源管理的复杂性。
之前提到过,通过有界队列来防止高负荷的应用程序耗尽内存。线程池简化了线程的管理工作,并且java.util.concurrent提供了一种灵活的线程池实现作为Executor框架的一部分。
**虽然Executor是个简单的接口,但它却为灵活且强大的异步任务执行框架提供了基础,该框架支持多种不同类型的任务执行策略。它提供了一种标准的方法将任务的提交过程和执行过程解耦开来,并用Runnable来表示任务。**Executor的实现还提供了对生命周期的支持,以及统计信息收集、应用程序管理机制和性能监视等机制。
Executor基于生产者-消费者模式,提交任务的操作相当于生产者(生成待完成的工作单元),执行任务的线程相当于消费者(执行完这些工作单元)。

2.1 执行策略

通过将任务的提交与执行解耦开来,从而无须太大的困难就可以为某种类型的任务制定和修改执行策略。在执行策略中定义了任务执行的”What、Where、When、How”等方面,包括:

  • 在什么(What)线程中执行任务?
  • 任务按照什么(What)顺序执行(FIFO、LIFO、优先级)?
  • 有多少个(How Many)任务能并发执行?
  • 在队列中有多少个(How Many)任务在等待执行?
  • 如果系统由于过载而需要拒绝一个任务,那么应该选择哪一个(Which)任务?另外,如何(How)通知应用程序有任务拒绝?
  • 在执行一个任务之前或之后,应该进行哪些(What)动作?
    各种执行策略都是一种资源管理工具,最佳策略取决于可用的计算资源以及对服务质量的需求。

2.2 线程池

线程池,从字面含义来看,是指管理一组同构工作线程的资源池。线程池是与工作队列(Work Queue)密切相关的,其中在工作队列中保存了所有等待执行的任务。工作者线程(Work Thread)的任务很简单:从工作队列中获取一个任务,执行任务,然后返回线程池并等待下一个任务。
“在线程池中执行任务”比“为每个任务分配一个线程”优势更大。通过重用现有的线程而不是创建新线程,可以在处理多个请求时分摊在线程和销毁过程中产生的巨大开销。
通过调用Executor中的静态工厂方法之一来创建一个线程池:
newFixedThreadPool。newFixedThreadPool将创建一个固定长度的线程池,每当提交一个任务时就创建一个线程,直到达到线程池的最大数量,这时线程池的规模将不再变化(如果线程由于发生了未预期的Exception而结束,那么线程池会补充一个新的线程)。
newCachedThreadPool。newCachedThreadPool将创建一个可缓存的线程池,如果线程池的当前规模超过了处理需求时,那么将回收空闲的线程,而当需求增加时,则可以添加新的线程,线程池的规模不存在任何限制。
newSingleThreadExecutor。newSingleThreadExecutor是一个单线程的Executor,它创建单个线程供作者线程来执行任务,如果这个线程异常结束,会创建另一个线程来替代。newSingleThreadExecutor能确保依照任务在队列中的顺序来串行执行(例如FIFO、LIFO、优先级)。
newScheduleThreadPool。newScheduleThreadPool创建了一个固定长度的线程池,而且延迟或定时的方式来执行任务,类似于Timer。
由于服务器不会创建数千个线程来争夺有限的CPU和内存资源,因此服务器的性能将平缓地降低。通过使用Executor,可以实现各种调优、管理、监视、记录日志、错误报告和其他功能,如果不使用任务执行框架,那么要增加这些功能是非常困难的。

2.3 Executor的生命周期

Executor的实现通常会创建线程来执行任务。但JVM只有在所有(非守护)线程全部终止后才会退出。因此,如果无法正确地关闭Executor,那么JVM就无法结束。
当关闭应用程序时,可能采用最平缓的关闭形式(完成所有已经启动的任务,并且其他不再接受任何新的任务),也可能采用最粗暴的关闭方式(直接关闭机房的电源),以及其他各种可能的形式。
为了解决执行服务的生命周期问题,Executor扩展了ExecutorService接口,添加了一些用户生命周期管理的方法(同时还有一些用户任务提交的便利方法)。
ExecutorService的声明周期有3种状态:运行、关闭和已终止。ExecutorService在初始化创建时处于运行状态,shutdown方法将执行平缓的关闭过程:不再接受新的任务,同时等待已经提交的任务执行完成——包括那些还未开始执行的任务。shutdownNow方法将执行粗暴的关闭过程:它将尝试取消所有运行中的任务,并且不再启动队列中尚未开始执行的任务。

2.4 延迟任务与周期任务

Timer类负责延迟任务(“在100ms后执行该任务”)以及周期任务(“每10ms执行一次任务”)。Timer在执行所有定时任务时只会创建一个线程。如果某个任务的执行时间最长,那么将破坏其他TimerTask的定时精确性。
Timer的另一个问题是,如果TimerTask抛出了一个未检查的异常,那么Timer将表现出糟糕的行为。Timer线程并不捕获异常,因此当TimerTask抛出未检查的异常时将终止定时线程。
Timer支持基于绝对时间而不是相对时间的调度机制,因此任务的执行对系统时钟变化很敏感,而ScheduledThreadPoolExecutor只支持基于相对时间的调度。

3.找出可利用的并行性

Executor框架帮助制定执行策略,但如果要使用Executor,必须将任务表述为一个Runnable。在大多数服务器应用程序中,都存在一个明显的任务边界:单个客户请求。但即使是服务器应用程序,在单个客户请求中仍可能存在可发掘的并行性,例如数据库服务器。

3.1 示例:串行的页面渲染器

最简单的方法就是对HTML文档进行串行处理。当遇到文本标签时,将其绘制到图像缓存中。当遇到图像引用时,先通过网络获取它,然后再将其绘制到图像缓存中。另一种串行执行方法更好一些,它先绘制文本元素,同时为图像预留出矩形的占位空间,在处理完第一遍文本后,程序在开始下载图像,并将它们绘制到相应的占位空间中。
图像下载过程的大部分时间都是在等待I/O操作执行完成,在这期间CPU几乎不做任何工作。因此,这种串行执行方法没有充分地利用CPU,使得用户在看到最终页面之前要等待过长的时间。通过将问题分解成独立的任务并发执行,能够获得更高的CPU利用率和相应灵敏度。

3.2 携带结果的任务Callable与Future

Executor框架使用Runnable作为其基本的任务表现形式。许多任务实际上都是存在迟延的计算——执行数据库查询,从网络上获取资源,或者计算某个复杂的功能。对于这些任务,Callable是一种更好的抽象:它认为主入口点(即call)将返回一个值,并可能抛出一个异常。
Runnable和Callable描述的都是抽象的计算任务。这些任务通常是有范围的,即都有一个明确的起始点,并且最终会结束。Executor执行的任务有4个生命周期阶段 :创建、提交、开始和完成。
Future表示一个任务的生命周期,并提供了相应的方法来判断是否已经完成或取消,以及获取任务的结果和取消任务等。
在Future规范中包含的隐含意义是:任务的生命周期只能前进,不能后退,就像ExecutorService的生命周期一样。当某个任务完成后,它就永远停留在“完成”状态上。
get方法的行为取决于任务的状态(尚未开始、正在运行、已完成)。如果任务已经完成,那么get 会立即返回或者抛出一个Exception,如果任务没有完成,那么get将阻塞并直到任务完成。

3.3 示例:使用Future实现页面渲染器

为了使页面渲染器实现更高的并发性,首先将渲染过程分解成两个任务:一个是渲染所有的文本,另一个是下载所有的图像。(因为其中一个任务时CPU密集型,而另一个任务时I/O密集型,因此这种方法即使在单CPU系统上也能提升性能。)
get 方法拥有“状态依赖”的内在特性,因而调用者不需要直到任务的状态,此外在任务提交和获取结果中包含的安全发布属性也确保了这个方法时线程安全的。Future.get的异常处理代码将处理这两个可能的问题:任务遇到一个Exception,或者调用get的线程在获得结果之前被中断。

3.4 在异构任务并行化中存在的局限

当在多个工人之间分配异构的任务时,还有一个问题就是各个任务的大小可能完全不同。如果将两个任务A和B分配给两个工人,但A的执行时间是B的10倍,那么整个过程也只能加速9%。最后,当在多个工人之间分解任务时,还需要一定的任务协调开销:为了使任务分解能提高性能,这种开销不能高于并行性实现的提升。
只有当大量相互独立且同构的任务可以并发进行处理时,才能体现出程序的工作负载分配到多个任务中带来的真正性能提升。

小结:
通过围绕任务执行来设计应用程序,可以简化开发过程,并有助于实现并发。Executor框架将任务提交与执行策略解耦开来,同时还支持多种不同类型的执行策略。当需要创建线程来执行任务时,可以考虑使用Executor。要想将应用程序分解成不同的任务时获取最大的好处,必须定义清晰的任务边界。某些应用程序中存在着比较明显的任务边界,而在其他一些程序中则需要进一步分析才能揭示出粒度更细的并发性。

阅读全文
0 0
原创粉丝点击