4.1 同步容器类
同步容器类包括Vector和Hashtable,两者是早期JDK的一部分,这些同步的封装器类是由Collection.synchronizedXxx等工厂方法创建的。这些类实现线程安全的方式是:将它们的状态封装起来,并对每个公有方法都进行同步,使每次都只有一个线程能访问容器的状态。
4.1.1 问题
同步容器类都是线程安全的,但是在某些情况下可能需要额外的客户端加锁来保护复合操作。容器上常见的复合操作包括:迭代、跳转以及条件运算。在同步容器类中,这些复合操作在没有客户端加锁的情况下仍然是线程安全的,但当其他线程并发地修改容器时,它们可能会表现出意料之外的行为。
4.1.2 迭代器与ConcurrentModificationException
无论是直接迭代还是for-each循环语法中,对容器类进行迭代的标准方式都是使用Iterator。然而,如果有其他线程并发地修改容器,那么即使使用迭代器也无法避免在迭代期间对容器加锁。在设计同步容器类的迭代器时并没有考虑到并发修改的问题,并且它们表现出的行为是fail-fast。这意味着,当它们发现容器在迭代过程中被修改时,就会抛出ConcurrentModificationException。
这种fail-fast的迭代器并不是一种完备的处理机制,而只是善意地捕获并发错误,因此只能作为并发问题的预警指示器。它们采用的实现方式是,将计数器的变化与容器关联起来:如果在迭代期间计数器被修改,那么hasNext火next将抛出ConcurrentModificationException。然而这种检查是在没有同步的情况下进行的,因此可能会看到失效的计数值,而迭代器可能没有意识到已经发生了修改。这是一种设计上的权衡,从而降低并发修改操作的检测代码对程序性能带来的影响。
4.1.3 隐藏迭代器
虽然加锁可以防止迭代器抛出ConcurrentModificationException,但必须记住在所有对共享容器进行迭代的地方都需要加锁。实际情况更加复杂,因为在某些情况下,迭代器会隐藏起来。
4.2 并发容器
有多种并发容器类来改进同步容器的性能。同步容器是将所有对容器状态的访问都串行化,以实现它们的线程安全性。这种方法的代价是严重降低并发性,当多个线程竞争容器的锁时,吞吐量将严重降低。
另一方面,并发容器是针对多个线程并发访问设计的。ConcurrentHashMap,用来替代同步且基于散列的Map,以及CopyOnWriteArrayList,用于遍历操作为主要情况下代替同步的List。在新的ConcurrentMap接口中增加了一些对常见符合操作的支持。
Queue用来保存一组待处理的元素。它提供了几组实现,包括:ConcurrentLinkedQueue,这是一个传统的先进先出队列,以及PriorityQueue,这是一个(非并发)优先队列。Queue上的操作不会阻塞,如果队列为空,那么获取元素的操作将返回空值。虽然可以用List来模拟Queue的行为——事实上,正是通过LinkedList来实现Queue的,但还需要一个Queue类,因为它能去掉List的随机访问需求,从而实现更高效的并发。
BlookingQueue扩展了Queue,增加了可阻塞的插入和获取操作。如果队列为空,那么获取元素的操作将一直阻塞,直到队列中出现了一个可用的元素。如果队列已满,那么插入元素的操作将是非常有用的,直到队列中出现可用的空间。在“生产者-消费者”这种设计模式中,阻塞队列非常有用。
4.2.1 ConcurrentHashMap
也是一个基于散列的Map,但它使用了一种完全不同的加锁策略来提供更高的并发性和伸缩性。ConcurrentHashMap并不是将每个方法都在同一个锁上同步并使得每次只能有一个线程访问容器,而且使用一种粒度更细的加锁机制来实现更大程度的共享,这种机制称为分段锁。在这种机制中,任意数量的读取线程可以并发地访问Map,执行读取操作的线程和执行写入操作的线程可以并发访问Map,并且一定数量的写入线程可以并发地修改Map。ConcurrentHashMap带来的结果是在并发访问环境下将实现更高的吞吐量,而在单线程环境中只损失非常小的性能。
ConcurrentHashMap与其他并发容器一起增强了同步容器类:它们提供的迭代器不会抛出ConcurrentModificationException,因此不需要在迭代过程中对容器加锁。ConcurrentHashMap返回的迭代器具有弱一致性,而并非“及时失败”。弱一致性的迭代器被构造后将修改操作反映给容器。
4.2.2 额外的原子Map操作
由于ConcurrentHashMap不能被加锁来执行独占访问,因此我们无法使用客户端加锁来建新的原子操作。但是,一些常见的复合操作,如若没有则添加、若相等则移除和若相等则替换等,都已经实现为原子操作并且在ConcurrentMap的接口中声明。如果你需要在现有的同步Map中添加这样的功能,那么很有可能意味着应该使用ConcurrentMap了。
4.2.3 CopyOnWriteArrayList
用于代替同步List,在某些情况下他提供了更好的并发性能,并在迭代期间不需要对容器进行加锁或复制。
“Copy-On-Write”容器的线程安全性在于,只要正确发布一个事实不变的对象,那么在访问该对象时就不再需要进一步的同步。在每次修改时,都会创建并重新发布一个新的容器副本,从而实现可变性。“写入时复制”容器的迭代器保留一个指向底层基础数组的引用,这个数组当前位于迭代器的初始位置,由于它不会被修改,因此在对其进行同步时只需确保数组内容的可见性。因此,多个线程可以同时对这个容器进行迭代,而不会彼此干扰或者与修改容器的线程相互干扰。“写入时复制”容器返回的迭代器不会抛出ConcurrentModificationException,并且返回的元素与迭代器创建时的元素完全一致,而不必考虑之后修改操作所带来的影响。
每当修改容器时都会复制底层数组,这需要一定的开销,特别是当容器规模较大时。仅当迭代操作远远多于修改操作时,才应该使用“写入时复制”容器。这个准则很好得描述了许多事件通知系统:在分发通知时需要迭代已注册监听链表,并调用每一个监听器,在大多数情况下,注册和注销事件监听器的操作远少于接收事件通知的操作。
4.3 阻塞队列和生产者-消费者模式
阻塞队列提供了可阻塞的put和take方法,以及支持定时的offer和poll方法。如果队列已经满了,那么put方法将阻塞到有空间可用;如果队列为空,那么take方法将阻塞到有元素可用。队列可以是有界的也可以是无界的,无界队列永远不会满,因此无界队列上的put方法也永远不会阻塞。
阻塞队列支持生产者-消费者这种设计模式。该模式将“找出需要完成的工作”与“执行工作”这两个过程分离开,并把工作放入一个“待完成”列表中以便在随后处理,而不是找出后立即处理。生产者-消费者模式能简化开发过程,因为它消除了生产者类与消费者类之间的代码依赖性,此外,该模式还将生产数据的过程与使用数据的过程解耦以简化对工资负载的管理,因为这两个过程在处理数据的速率上有所不同。
在基于阻塞队列构建的生产者-消费者设计中,当数据生成时,生产者把数据放入队列,而当消费者准备处理数据时,将从队列中获取数据。生产者不需要知道消费者的标识或数量,或者它们是否唯一的生产者,而只需将数据放入队列即可。同样,采访中也不需要知道生产者是谁,或者工作来自何处。BlockingQueue简化了生产者-消费者设计的实现过程,它支持任意数量生产者和消费者。一种常见的生产者-消费者设计模式就是线程池与工作队列的组合,在Executor任务执行框架中就体现了这种模式。
4.3.1 串行线程封闭
对于可变对象,生产者-消费者这种设计与阻塞队列一起,促进了串行线程封闭,从而将对象所有权从生产者交付消费者。线程封闭对象只能由单个线程拥有,但可以通过安全发布该对象来转移所有权。在转移所有权后,也只有另一个线程能获得这个对象的访问权,并且发布对象的线程不会再访问它。这种安全发布确保了对象状态对于新的所有者来说是可见的,并且由于最初的所有者不会再访问它,因此对象将被封闭在新的线程中。新的所有者线程可以对该对象做任意修改,因此它具有独占的访问权。
4.3.2 双端队列与工作密取
Deque和BlockingDeque,他们分别对Queue和BlockingQueue进行了扩展。Deque是一个双端队列,实现了在队列头和队列尾的高效插入和移除。具体实现包括ArrayDeque和LinkedBlockingDeque。
正如阻塞队列适用于生产者-消费者模式,双端队列同样适用于另一种相关模式,即工作密取。在生产者-消费者设计中,所有消费者有一个共享的工作队列,而在工作密取设计中,每个消费者都有各自的双端队列。如果一个消费者完成了自己双端队列中的全部工作,那么它可以从其他消费者双端队列末尾秘密获取工作。密取工作模式比传统的生产者-消费者模式具有更高的可伸缩性,这是因为工作者线程不会再单个共享的任务队列上发生竞争。在大多数时候,它们都只是访问自己的双端队列,从而极大地减少竞争。当工作者线程需要访问另一个队列时,它会从队列的尾部而不是从头部获取工作,因此进一步降低了队列上的竞争程度。
4.4 阻塞方法与中断方法
线程可能会阻塞或暂停执行,原因有多种:等待I/O操作结束,等待获得一个锁,等待从Thread.sleep方法中醒来,或是等待另一个线程的计算结果。当线程阻塞时,它通常被挂起,并处于某种阻塞状态(BLOCKED,WAITING,TIMED_WAITING)。阻塞操作与执行时间很长的普通操作的差别在于,被阻塞的线程必须等待某个不受它控制的事件发生后才能继续执行,等待某个锁变成可用,或者等待外部计算的结束。当某个外部时间发生时,线程被置回RUNNABLE状态,不可以再次被调度执行。
Thread提供了interrupt方法,用于中断线程或者查询线程是否已经被中断。每个线程都有一个布尔类型的属性,表示线程的中断状态,当中断线程时将设置这个状态。
中断是一种协作机制。一个线程不能强制其他线程停止正在执行的操作而去执行其他操作。当线程A中断B时,A仅仅是要求B在执行到某个可以暂停的地方停止正在执行的操作,前提是如果线程B愿意停止下来。虽然在API或者语言规范中并没有为中断定义任何特定应用级别的语义,但最常使用中断的情况就是取消某个操作。方法对中断请求的响应度越高,就越容易及时取消那些执行时间很长的操作。
4.5 同步工具类
在容器中,阻塞队列是一种独特的类:它们不仅能作为保存对象的容器,还能协调生产者和消费者等线程之间的控制流,因为take和put等方法将阻塞,直到队列到期望的状态。
同步工具类可以是任何一个对象,只要它根据其自身的状态来协调线程的控制流。阻塞队列可以作为同步工具类,其他类型的同步工具类还包括信号量、栅栏以及闭锁。
所有的同步工具类都包含一些特定的结构化属性:它们封装了一些状态,这些状态将决定执行同步工具类的线程是继续执行还是等待,此外还提供了一些方法对状态进行操作,以及另一些方法用于高效地等待同步工具类进入到预期状态。
4.5.1 闭锁
闭锁是一种同步工具类,可以延迟线程的进度直到其到达终止状态。闭锁的作用相当于一扇门:在闭锁到达结束状态之前,这扇门一直是关闭的,并且没有任何线程能通过,当到达结束状态时,这扇门将永远保持打开状态。闭锁可以用来确保某些活动直到前天活动都完成后才继续执行。
4.5.2 FutureTask
也可以用做闭锁。它表示的计算是通过Callable来实现的,相当于一只可生成结果的Runnable,并且可以处于三种状态:等待运行,正在运行和运行完成。“执行完成”表示计算的所有可能结束方式,包括正常结束、由于取消结束和由于异常而结束等。当FutureTask进入完成状态后,它会永远停止在这个状态上。
4.5.3 信号量
计数信号量用来控制同时访问某个特定资源的操作数量,或者同时执行某个指定操作的数量。计数信号量还可以用来实现某种资源池,或者对容器施加边界。
4.5.4 栅栏
栅栏类似于闭锁,它能阻塞一组线程直到某个事件发生。栅栏与闭锁的关键区别在于,所有线程必须同时到达栅栏位置,才能继续执行。闭锁用于等待事件,而栅栏用于等待其他线程。