同步容器与并发容器 Java并发编程实战总结

同步容器与并发容器 Java并发编程实战总结_第1张图片

同步容器类

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

同步容器类的问题

        同步容器类都是线程安全的, 但在某些情况下可能需要额外的客户端加锁来保护复合操作。 容器上常见的复合操作包括:迭代(反复访问元素, 直到遍历完容器中所有元素)、 跳转(根据指定顺序找到当前元素的下一个元素)以及条件运算, 例如 “若没有则添加” (检查在 Map 中是否存在键值 K, 如果没有, 就加入二元组 (K,V))。在同步容器类中, 这些复合操作在 没有客户端加锁的情况下仍然是线程安全的, 但当其他线程并发地修改容器时, 它们可能会表现出意料之外的行为。

        程序清单 5-1 给出了在 Vector 中定义的两个方法: getLast和 deleteLast, 它们都会执行 “先检查再运行 ” 操作。 每个方法首先都获得数组的大小, 然后通过结果来获取或删除最后一个元素。

同步容器与并发容器 Java并发编程实战总结_第2张图片

        这些方法看似没有任何问题, 从某种程度上来看也确实如此一一无论多少个线程同时调用它们, 也不破坏Vector。但从这些方法的调用者角度来看, 情况就不同了。如果线程A 在包含10 个元素的Vector 上调用getLast, 同时线程B 在同一个Vector 上调用deleteLast, 这些操作的交替执行如图5-1 所示, getLast 将抛出ArraylndexOutOffioundsException 异常。在调用size与调用getLast 这两个操作之间, Vector 变小了, 因此在调用size 时得到的索引值将不再有效。这种情况很好地遵循了Vector 的规范一一如果请求一个不存在的元素, 那么将抛出一个异常。但这并不是getLast 的调用者所希望得到的结果(即使在并发修改的情况下也不希望看到),除非Vector 从一开始就是空的。


同步容器与并发容器 Java并发编程实战总结_第3张图片

        由于同步容器类要遵守同步策略, 即支持客户端加锁, 因此可能会创建一些新的操作,只要我们知道应该使用哪一个锁, 那么这些新操作就与容器的其他操作一样都是原子操作。同步容器类通过其自身的锁来保护它的每个方法。通过获得容器类的锁, 我们可以使getLast 和delete Last 成为原子操作, 并确保Vector 的大小在调用size 和get 之间不会岁生变化, 如程序清单5-2 所示。

同步容器与并发容器 Java并发编程实战总结_第4张图片

        在调用size和相应的get之间,Vector的长度可能会发生变化, 这种风险在对Vector中的元素进行迭代时仍然会出现.

        这种迭代操作的正确性要依赖于运气, 即在调用size和get之间没有线程会修改Vector。在单线程环境中, 这种假设完全成立, 但在有其他线程并发地修改Vector时, 则可能导致麻烦。与getLast 一样,如果在对Vector进行迭代时, 另一个线程删除了一个元素, 并且这两个操作交替执行, 那么这种迭代方法将抛出ArrayindexOutOfBoundsException异常。

        虽然在程序清单5-3的迭代操作中可能抛出异常, 但并不意味着Vector就不是线程安全的。Vector的状态仍然是有效的, 而抛出的异常也与其规范保持一致。然而,像在读取最后一个元素或者迭代等这样的简单操作中抛出异常显然不是人们所期望的。

        我们可以通过在客户端加锁来解决不可靠迭代的问题, 但要牺牲一些伸缩性。通过在迭代期间持有Vector的锁, 可以防止其他线程在迭代期间修改Vector, 如程序清单5-4所示。然而,这同样会导致其他线程在迭代期间无法访问它, 因此降低了并发性。


同步容器与并发容器 Java并发编程实战总结_第5张图片

迭代器与ConcurrentModificationException

        为了将问题阐述清楚, 我们使用了Vector, 虽然这是一个“ 古老” 的容器类。然而, 许多“现代” 的容器类也并没有消除复合操作中的问题。无论在直接迭代还是在Java5.0引入的for-each循环语法中,对容器类进行迭代的标准方式都是使用Iterator, 然而, 如果有其他线程并发地修改容器, 那么即使是使用迭代器也无法避免在迭代期间对容器加锁。在设计同步容器类的迭代器时并没有考虑到并发修改的问题, 并且它们表现出的行为是“ 及时失败" (fail-fast)的。这意味着, 当它们发现容器在迭代过程中被修改时, 就会抛出一个ConcurrentModificationException异常。

        这种“ 及时失败” 的迭代器井不是一种完备的处理机制,而只是“ 善意地” 捕获并发错误,因此只能作为并发问题的预警指示器。它们采用的实现方式是,将计数器的变化与容器关联起来: 如果在迭代期间计数器被修改, 那么hasNext或next将抛出ConcurrentModificationException。然而, 这种检查是在没有同步的情况下进行的, 因此可能会看到失效的计数值, 而迭代器可能并没有意识到已经发生了修改。这是一种设计上的权衡, 从而降低并发修改操作的检测代码对程序性能带来的影响。 与迭代Vector 一样, 要想避免出现ConcurrentModificationException , 就必须在迭代过程持有容器的锁。

        然而, 有时候开发人员并不希望在迭代期间对容器加锁。例如, 某些线程在可以访问容器之前, 必须等待迭代过程结束, 如果容器的规模很大, 或者在每个元素上执行操作的时间很长, 那么这些线程将长时间等待。 长时间地对容器加锁也会降低程序的可伸缩性。持有锁的时间越长, 那么在锁上的竞争就可能越激烈, 如果许多线程都在等待锁被释放,那么将极大地降低吞吐址和CPU的利用

        如果不希望在迭代期间对容器加锁, 那么一种替代方法就是“ 克隆” 容器, 并在副本上进行迭代。由于副本被封闭在线程内, 因此其他线程不会在选代期间对其进行修改,这样就避免了抛出ConcurrentModificationException (在克隆过程中仍然需要对容器加锁)。在克隆容器时存在显著的性能开销。这种方式的好坏取决千多个因素, 包括容器的大小,在每个元素上执行的工作, 迭代操作相对于容器其他操作的调用频率,以及在响应时间和吞吐量等方面的需求。

隐藏迭代器

        虽然加锁可以防止迭代器抛出ConcurrentModificationException, 但你必须要记住在所有对共享容器进行迭代的地方都需要加锁。实际情况要更加复杂, 因为在某些情况下, 迭代器会隐藏起来, 如程序清单5-6中的Hiddenlterator 所示。在Hiddenlterator中没有显式的迭代操作,但在粗体标出的代码中将执行迭代操作。编译器将字符串的连接操作转换为调用StringBuilder.append(Object), 而这个方法又会调用容器的toString方法,标淮容器的toString方法将迭代容器, 并在每个元素上调用toString来生成容器内容的格式化表示。


同步容器与并发容器 Java并发编程实战总结_第6张图片


        addTenThings方法可能会抛出ConcurrentModificationException, 因为在生成调试消息的过程中, toString 对容器进行迭代。当然,真正的问题在于Hiddenlteracor不是线程安全的。在用println 中的set 之前必须首先获取Hiddenlterator 的锁,但在调试代码和日志代码中通常会忽视这个要求。

        这里得到的教训是,如果状态与保护它的同步代码之间相际越远,那么开发人员就越容易忘记在访问状态时使用正确的同步。如果Hiddenlterator 用synchronizedSet 来包装HashSet,并且对同步代码进行封装,那么就不会发生这种错误。

        正如封装对象的状态有助于维持不变性条件一样,封装对象的同步机制同样有助于确保实施同步策略。

        容器的hashCode和equals等方法也会间接地执行迭代操作,当容器作为另一个容器的元素或键值时,就会出现这种情况。同样, containsAll、removeAll 和retainAll 等方法,以及把容器作为参数的构造函数,都会对容器进行迭代。所有这些间接的迭代操作都可能抛出ConcurrentModificationException。


并发容器

        Java 5.0 提供了多种并发容器类来改进同步容器的性能。同步容器将所有对容器状态的访问都串行化,以实现它们的线程安全性。这种方法的代价是严重降低并发性,当多个线程竞争容器的锁时,吞吐量将严重减低。

        另一方面,并发容器是针对多个线程并发访问设计的。在Java 5.0 中增加了ConcurrentHashMap,用来替代同步且基于散列的Map, 以及CopyOnWriteArrayList, 用于在遍历操作为主要操作的情况下代替同步的List。在新的ConcurrentMap 接口中增加了对一些常见复合操作的支持,例如“ 若没有则添加”、替换以及有条件删除等。

        通过并发容器来代替同步容器,可以极大地提高伸缩性并降低风险。

        Java 5.0 增加了两种新的容器类型: Queue 和BlockingQueue Queue 用来临时保存一组等待处理的元素。它提供了几种实现, 包括: ConcurrentLinkedQueue, 这是一个传统的先进先出队列, 以及PriorityQueue, 这是一个(非并发的)优先队列。Queue上的操作不会阻塞, 如果队列为空,那么获取元素的操作将返回空值。虽然可以用List来模拟Queue的行为一一事实, 正是通过LinkedList来实现Queue的,但还需要一个Queue的类, 因为它能去掉List的随机访问需求, 从而实现更高效的井发。

        BlockingQueue扩展了Queue, 增加了可阻塞的插入和获取等操作如果队列为空,那么获取元素的操作将一直阻塞, 直到队列中出现一个可用的元素。如果队列已满( 对于有界队列来说) ,那么插入元素的操作将一直阻塞, 直到队列中出现可用的空间。在“ 生产者- 消费者”这种设计模式中,阻塞队列是非常有用的,5.3节将会详细介绍。正如ConcurrentHashMap 用于代替基于散列的同步Map, Java 6也引入了ConcurrentSkipListMap和ConcurrentSkipListSet, 分别作为同步的SortedMap和SortedSet的并发替代(例如用synchronizedMap包装的TreeMap或TreeSet)。

ConcurrentHashMap

        同步容器类在执行每个操作期间都持有一个锁。在一些操作中,例如HashMap.get或List.contains, 可能包含大最的工作: 当遍历散列桶或链表来查找某个特定的对象时,必须在许多元素上调用equals (而equals本身还包含一定的计算量)。在基于散列的容器中, 如果hashCode不能很均匀地分布散列值,那么容器中的元素就不会均匀地分布在整个容器中。某些情况下,某个糟糕的散列函数还会把一个散列表变成线性链表。当遍历很长的链表并且在某些或者全部元素上调用equals方法时,会花费很长的时间,而其他线程在这段时间内都不能访问该容器。

        与HashMap 一样,ConcurrentHashMap也是一个基于散列的Map, 但它使用了一种完全不同的加锁策略来提供更高的并发性和伸缩性。ConcurrentHashMap 并不是将每个方法都在同一个锁上同步并使得每次只能有一个线程访问容器, 而是使用一种粒度更细的加锁机制来实现更大程度的共享,这种机制称为分段锁(Lock Striping)。在这种机制中,任意数量的读取线程可以并发地访问Map, 执行读取操作的线程和执行写入操作的线程可以并发地访问Map, 并且一定数量的写入线程可以并发地修改Map。ConcurrentHashMap带来的结果是,在并发访问环境下将实现更高的吞吐址, 而在单线程环境中只损失非常小的性能。

        ConcurrentHashMap与其他并发容器一起增强了同步容器类: 它们提供的迭代器不会抛出ConcurrentModificationException, 因此不需要在迭代过程中对容器加锁。ConcurrentHashMap返回的迭代器具有弱一致性(Weakly Consistent), 而并非“ 及时失败"。弱一致性的迭代器可以容忍并发的修改,当创建迭代器时会遍历已有的元素, 并可以(但是不保证) 在迭代器被构造后将修改操作反映给容器。

        尽管有这些改进, 但仍然有一些需要权衡的因素。对于一些需要在整个Map上进行计算的方法, 例如size和isEmpty, 这些方法的语义被略微减弱了以反映容器的并发特性。由于size返回的结果在计算时可能已经过期了,它实际上只是一个估计值,因此允许size返回一个近似值而不是一个精确值。虽然这看上去有些令人不安, 但事实上size和isEmpty这样的方法在并发环境下的用处很小, 因为它们的返回值总在不断变化。因此, 这些操作的需求被弱化了, 以换取对其他更重要操作的性能优化, 包括get、put、containsKey 和remove等。

        在ConcurrentHashMap 中没有实现对Map 加锁以提供独占访问。在Hashtable 和synchronizedMap中, 获得Map 的锁能防止其他线程访问这个Map。在一些不常见的情况中需要这种功能,例如通过原子方式添加一些映射, 或者对Map 迭代若干次并在此期间保持元素顺序相同。然而, 总体来说这种权衡还是合理的, 因为并发容器的内容会持续变化。

        与Hashtable 和synchronizedMap 相比, ConcurrentHashMap 有着更多的优势以及更少的劣势,因此在大多数情况下, 用ConcurrentHashMap 来代替同步Map 能进一步提高代码的可伸缩性。只有当应用程序需要加锁Map 以进行独占访问时, 才应该放弃使用ConcurrntHashMap。

CopyOnWriteArrayList

         CopyOnWriteArrayList 用于替代同步List, 在某些情况下它提供了更好的并发性能,并且在迭代期间不需要对容器进行加锁或复制。(类似地, CopyOnWriteArraySet 的作用是替代同步Set。)

        “ 写入时复制(Copy-On-Write)" 容器的线程安全性在于, 只要正确地发布一个事实不可变的对象, 那么在访问该对象时就不再需要进一步的同步。在每次修改时, 都会创建并重新发布一个新的容器副本 从而实现可变性。 “ 写入时复制 ” 容器的迭代器保留一个指向底层基础数组的引用, 这个数组当前位于迭代器的起始位置, 由于它不会被修改, 因此在对其进行同步时只需确保数组内容的可见性。因此, 多个线程可以同时对这个容器进行迭代, 而不会彼此干扰或者与修改容器的线程相互干扰。 “ 写入时复制 ” 容器返回的迭代器不会抛出ConcurrentModificationException, 并且返回的元素与迭代器创建时的元素完全一致, 而不必考虑之后修改操作所带来的影响。

        显然, 每当修改容器时都会复制底层数组, 这需要一定的开销, 特别是当容器的规模较大时。 仅当迭代操作远远多于修改操作时, 才应该使用 “ 写入时复制” 容器。 这个准则很好地描述了许多事件通知系统:在分发通知时需要迭代已注册监听器链表, 并调用每一个监听器, 在 大多数情况下, 注册和注销事件监听器的操作远少于接收事件通知的操作。

你可能感兴趣的:(同步容器与并发容器 Java并发编程实战总结)