Java并发编程学习笔记7

117 阅读6分钟

系统并发类型

迭代器和 ConcurrentModificationException

同步集合返回的迭代器不是为处理并发修改而设计的,它们是快速失败的——这意味着如果它们检测到集合自迭代开始后发生了变化,它们就会抛出未经检查的 ConcurrentModificationException。

有几个原因可能不希望在迭代期间锁定集合。其他需要访问集合的线程会阻塞,直到迭代完成;如果集合很大或为每个元素执行的任务很长,他们可能会等待很长时间。

在迭代期间锁定集合的另一种方法是克隆集合并迭代副本。由于克隆是线程受限的,因此在迭代期间没有其他线程可以修改它,从而消除了并发修改异常的可能性。 (在克隆操作过程中,集合仍然必须被锁定。)克隆集合有明显的性能成本;这是否是一个有利的折衷取决于许多因素,包括集合的大小、每个元素完成的工作量、迭代与其他集合操作相比的相对频率,以及响应能力和吞吐量要求。

迭代也由集合的 hashCode 和 equals 方法间接调用,如果集合用作另一个集合的元素或键,则可以调用这些方法。类似地,containsAll、removeAll 和 retainAll 方法,以及将集合作为参数的构造函数,也会迭代集合。所有这些间接使用迭代都可能导致 ConcurrentModificationException。

ConcurrentHashmap

ConcurrentHashMap 和 HashMap 一样是基于哈希的 Map,但它使用完全不同的锁定策略,提供更好的并发性和可扩展性。它不是在公共锁上同步每个方法,一次限制对单个线程的访问,而是使用称为锁条带化(finer-grained locking mechanism called lock striping)的更细粒度的锁定机制来允许更大程度的共享访问。任意多个读取线程可以并发访问map,读取者可以与写入者并发访问map,有限数量的写入者可以并发修改map。结果是并发访问下的吞吐量高得多,单线程访问的性能损失很小。

ConcurrentHashMap 以及其他并发集合通过提供不抛出 ConcurrentModificationException 的迭代器进一步改进了同步集合类,从而消除了在迭代期间锁定集合的需要。 ConcurrentHashMap 返回的迭代器是弱一致性的而不是快速失败的。弱一致性迭代器可以容忍并发修改,遍历构造迭代器时存在的元素,并且可以(但不保证)反映迭代器构造后对集合的修改。

ConcurrentHashMap 以及其他并发集合的tradeoff:

对整个 Map 进行操作的方法的语义,例如 size 和 isEmpty被弱化。由于 size 的结果在计算时可能已经过时,它实际上只是一个估计值,因此允许 size 返回一个近似值而不是精确值。

由于无法锁定 ConcurrentHashMap 以进行独占访问,因此我们无法使用客户端锁定来创建新的原子操作,例如 put-if-absent。相反,一些常见的复合操作,如 put-if-absent、remove-if-equal 和 replace-if-equal 被实现为原子操作并由 ConcurrentMap 接口提供。

CopyOnWriteArrayList

CopyOnWriteArrayList 是同步列表的并发替代品,它在某些常见情况下提供更好的并发性,并且无需在迭代期间锁定或复制集合。

写入时复制集合的线程安全性源于这样一个事实,即只要一个有效的不可变对象被正确发布,访问它时就不需要进一步的同步。他们通过在每次修改集合时创建和重新发布集合的新副本来实现可变性。 

写入时复制集合返回的迭代器不会抛出 ConcurrentModificationException 并返回与创建迭代器时完全相同的元素,无论后续修改如何。

当迭代比修改更频繁时,写入时复制集合才更适合使用。这个标准恰好描述了许多事件通知系统:传递通知需要迭代注册监听器列表并调用它们中的每一个,并且在大多数情况下注册或注销事件监听器远不如接收事件通知常见。

Blocking Queues

阻塞队列提供阻塞的 put 和 take 方法以及定时等价物 offer 和 poll。如果队列已满,put方法将会阻塞直到有空间可用;如果队列为空,则take方法将会阻塞直到有元素可用。队列可以是有界的或无界的;无界队列永远不会满,所以放入无界队列永远不会阻塞。

阻塞队列支持生产者-消费者设计模式。该模式将“找出需要完成的工作”与“执行工作”这两个过程分离开。并把工作项放在“待办事项”列表中以便以后处理,而不是在找出后立即处理。生产者-消费者模式简化了开发,因为它消除了生产者和消费者类之间的代码依赖性。此外,该模式还将生产数据的过程与使用数据的过程解耦开以简化工作负载的管理,因为这两个过程在处理数据的速率上有所不同。

在基于阻塞队列构建的生产者-消费者设计中,生产者在数据可用时将其放入队列,而消费者在在准备处理数据时从队列中获取数据。生产者不需要知道任何关于消费者的身份或数量,甚至他们是否是唯一的生产者——他们所要做的就是将数据项放入队列中。同样,消费者不需要知道生产者是谁或者工作来自何处。

阻塞队列还提供了一种offer方法,如果数据项无法入队,该方法会返回失败状态。

有界队列是一种强大的资源管理工具,用于构建可靠的应用程序:它们通过限制可能产生超出处理能力的工作量的活动,使您的程序更健壮,不会过载。

双端队列和工作窃取

Java 6 还添加了另外两种集合类型,Deque(发音为“deck”)和 BlockingDeque,它们扩展了 Queue 和 BlockingQueue。 Deque 是一个双端队列,允许从头部和尾部高效地插入和移除。实现包括 ArrayDeque 和 LinkedBlockingDeque。

正如阻塞队列适用于生产者-消费者模式一样,双端队列适用于称为工作窃取的相关模式。生产者-消费者设计为所有消费者共享一个工作队列;在窃取工作的设计中,每个消费者都有自己的双端队列。如果一个消费者耗尽了自己的双端队列中的工作,它可以从其他人的双端队列中窃取工作。工作窃取比传统的生产者-消费者设计更具可扩展性,因为工作人员不争用共享工作队列;大多数时候他们只访问自己的双端队列,减少争用。当一个工作人员必须访问另一个队列时,它会从尾部而不是头部进行访问,从而进一步减少争用。

工作窃取非常适用于消费者同时也是生产者的问题——执行一个工作单元很可能会导致识别出更多的工作。