Java并发并发编程:基础构建模块

前言

Java 平台类库包含了丰富的并发基础构建模块,例如线程安全的容器类以及各种用于协调多个相互协作的线程控制流的同步工具类(Synchronizer)。本节介绍一些最有用的并发构建模块,以及在使用这些模块来构建并发应用程序时的一些常用模式。

正题

开始编写文章前,有几个问题需要思考一下:

  • 容器有哪几大类
  • 同步容器类
  • 并发容器
  • 阻塞队列和生产者消费者队列
  • 阻塞方法和中断方法
  • 同步工具类

1. 容器有哪几大类

按照并发特性来分类容器,那么可以分为以下三类:

2. 同步容器类

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

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

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

    public static Object getLast(Vector list) {
        int lastIndex = list.size() - 1;
        return list.get(lastIndex);
    }

    public static void deleteLast(Vector list) {
        int lastIndex = list.size() - 1;
        list.remove(lastIndex);
    }

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

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

    public static Object getLast(Vector list) {
        synchronized (list) {
            int lastIndex = list.size() - 1;
            return list.get(lastIndex);
        }
    }

    public static void deleteLast(Vector list) {
        synchronized (list) {
            int lastIndex = list.size() - 1;
            list.remove(lastIndex);
        }
    }

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

    for(int i = 0; i < list.size(); i++) {
        doSomething(list.get(i));
    }

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

虽然在上面清单的迭代操作中可能抛出异常,但并不意味着 Vector 就不是线程安全的。Vector 的状态仍然是有效的,而抛出的异常也与其他规范一致。然而,像在读取最后一个元素或者迭代等这样的简单操作中抛出异常显然不是人们所期待的。我们可以通过在客户端加锁来解决不可靠迭代的问题,但要牺牲一些伸缩性。通过在迭代期间持有 Vector 的锁,可以防止其他线程在迭代期间修改 Vector。然而,这样同样会导致其它线程在迭代期间无法访问它,因此降低了并发性。

 synchronized (list) {
     for (int i = 0; i < list.size(); i++) {
         doSomething(list.get(i));
     }
 }
2.1 迭代器与ConcurrentModificationException

在Java 5.0 引入的 for-each 循环语法中,对容器类进行迭代的标准方式都是使用 Iterator。然而,如果有其他线程并发地修改容器,那么即使是使用迭代器也无法避免在迭代期间对容器加锁。在设计同步容器类的迭代器时并没有考虑到并发修改的问题,并且他们表现出的行为是“及时失败”(fail-fast)的。这意味着,当他们发现容器在迭代过程中被修改时,就会抛出一个 ConcurrentModificationException 异常。

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

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

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

3. 并发容器

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,增加了可阻塞的插入和获取等操作。如果队列为空,那么获取元素的操作将一直阻塞,直到队列中出现一个可用的元素。如果队列已满(对于有界队列来说),那么插入元素的操作将一直阻塞,直到队列中出现可用的空间。在“生产者 - 消费者”这种设计模式中,阻塞队列是非常有用的。

正如 ConcurrentHashMap 用于代替基于散列的同步 Map,Java 6 也引入了 ConcurrentKipListMap 和 ConcurrentSkipListSet,分别作为同步的 SortedMap 和 SortedSet 的并发替代品

3.1 ConcurrentHashMap

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

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

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

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

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

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

    public V put(K key, V value) {
        return putVal(key, value, false);
    }

    /** Implementation for put and putIfAbsent */
    final V putVal(K key, V value, boolean onlyIfAbsent) {
        ......
            else {
                ......
                synchronized (f) {
                    ......
                    }
                }
                ......
            }
        }
        ......
    }
3.2 CopyOnWriteArrayList

CopyOnWriteArrayList 用于替代同步 List,在某些情况下它提供了更好的并发性能,并且在迭代期间不需要对容器进行加锁或复制。(类似地,CopyOnWriteArraySet 的作用替代同步 Set)。“写入时复制(Copy - On - Write)”容器的线程安全在于,只要正确地发布一个事实不可变的对象,那么在访问该对象时就不再需要进一步同步。在每次修改时,都会创建并重新发布一个新的容器副本,从而实现可变性。“写入时复制”容器返回的迭代器不会抛出 ConcurrentModificationException,并且返回的元素与迭代器创建的元素完全一致,而不必考虑之后修改操作所带来的影响。

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

3.3 串行线程封闭

在 java.util.concurrent 中实现的各种阻塞队列都包含了足够的同步机制,从而安全地将对象从生产者发布到消费者线程。

对于可变对象,生产者 - 消费者这种设计与阻塞队列一起,促进了串行线程封闭,从而将对象所有权从生产者交付给消费者。线程封闭对象只能由单个线程拥有,但可以通过安全地发布对象来“转移”所有权。在转移所有权后,也只有另一个线程能获得这个对象的访问权限,并且发布对象的线程不能在访问它。这种安全地发布确保了对象状态对于新的所有者来说是可见的,并且由于最初的所有者不会再访问它,因此对象将被封闭在新的线程中。新的所有者可以对该对象做任意膝盖,因为它具有独占的访问权。

对象池利用了串行线程封闭,将对象“借给”一个请求线程。只要对象池包含足够的内部来安全地发布池中的对象,或者在将对象返回给对象池后就不再使用它,那么就可以安全地在线程之间传递所有权。

我们也可以使用其他机制来传递可变对象的所有权,但必须确保只有一个线程能接受被转移的对象。阻塞队列简化了这项工作。除此之外,还可以通过 ConcurrentMap 的原子方法 remove 或者 AtomicReference 的原子方法 compareAndSet 来完成这项工作。

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

阻塞队列提供了可阻塞的 put 和 take 方法,以及支持定时的 offer 和 poll 方法。如果队列已经满了,那么 put 方法将阻塞直到有空间可用;如果队列为空,那么 take 方法将会阻塞直到有元素可用。队列可以是有界的也可以是无界的,无界队列永远都不会充满,因此无界队列上的 put 方法也永远不会阻塞。

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

在基于阻塞队列构建的生产者 - 消费者设计中,当数据生成时,生产者把数据放入队列,而当消费者准备处理数据时,将从队列中获取数据。生产者不需要知道消费者的标识或数量,或者它们是唯一的生产者,而值需要将数据放入队列即可。同样,消费者也不需要知道生产者是谁,或者工作来自何处。BlockingQueue 简化了生产者 - 消费者设计的实现过程,它支持任意数量的生产者和消费者。一种最常见的生产者 - 消费者设计模式就是线程池与工作队列的组合,在 Executor 任务执行框架中就体现了这种模式。

阻塞队列简化了消费者程序的编码,因为 take 操作一直阻塞知道有可用的数据。如果生产者不能尽快地产生工作项使消费者保持忙碌,那么消费者就只能一直等待,直到有工作可做。在某些情况下,这种方式是非常适合的(例如,在服务器应用程序中,没有任何客户请求服务),而在其他一些情况下,这也表示需要调整生产者线程数量和消费者线程数量之间的比率,从而实现更高的资源利用率。

如果生产者生成工作的速率比消费者处理工作的速率快,那么工作项会在队列中累积起来,最终耗尽内存。同样,put 方法的阻塞特性极大地简化了生产者的编码。如果使用有界队列,那么当队列充满时,生产者将阻塞并且不能继续生成工作,而消费者就有时间来赶上工作处理进度。

阻塞队列同样提供了一个 offer 方法,如果数据项不能被添加到队列中,那么将返回一个失败状态。这样你就能够创建更多灵活的策略来处理负荷过载的情况,例如减轻负载,将多余的工作项序列化并写入磁盘,减少生产者线程的数量,或者通过某种方式来抑制生产者线程。

虽然生产者 - 消费者模式能够将生产者和消费者的代码彼此解耦开来,但它们的行为仍然会通过共享工作队列间接的耦合在一起。开发人员总会假设消费者处理工作的速率能赶上生产者生成工作项的速率,因此通常不会为工作队列的大小设置边界,但将导致在之后需要重新设计系统架构。因此,应该尽早地通过阻塞队列在设计中构建资源管理机制 —— 这件事情做得越早,就越容易。在许多情况下,阻塞队列能使这项工作更加简单,如果阻塞队列并不完全符合设计需求,那么还可以通过信号量来创建其他的阻塞数据结构。

在类库中包含了 BlockingQueue 的多种实现,其中,LinkedBlockingQueue 和 ArrayBlockingQueue 是 FIFO 队列,二者分别与 LinkedList 和 ArrayList 类似,但比同步 List 拥有更好的并发性能。PriorityBlockingQueue 是一个按优先级排序的队列,当你希望按照某种顺序而不是 FIFO 来处理元素时,这个队列将非常有用。正如其他有序的容器一样,PriorityBlockingQueue 既可以根据元素的自然顺序来比较元素(如果它们实现了 Comparable)方法,也可以使用 Comparator 来比较。

最后一个 BlockingQueue 实现是 SynchronousQueue,实际上它不是一个真正的队列,因为它不会为队列中元素维护储存空间。与其他队列不同的是,它维护一组线程,这些线程在等待着把元素加入或移出队列。如果以洗盘子的比喻为例,那么这就相当于没有盘架,而是将洗好的盘子直接放入下一个空闲的烘干机中。这种实现队列的方式看似很奇怪,但由于可以直接交付工作,从而降低了将数据从生产者移动到消费者的延迟(在传统的队列中,在一个工作单元可以交付之前,必须通过串行方式首先完成入列或者出列等操作)。直接交付方式还会将更多关于任务状态的信息反馈给生产者。当交付被接受时,它就知道消费者已经得到了任务,而不是简单的把任务放入一个队列 —— 这种区别就好比将文件直接交给同事,还是将文件放到她的邮箱并希望她能尽快拿到文件。因为 SynchronousQueue 没有储存功能,因此 put 和 take 会一直阻塞,并直到有另一个线程已经准备好参与到交付过程。仅当有足够多的消费者,并且总是有一个消费者准备好获取交付的工作时,才适合使用同步队列。

5. 阻塞方法与中断方法

线程可能会阻塞或暂停执行,原因有多种:等待 I/O 操作结束,等待获得一个锁,等待从 Thread.sleep 方法中醒来,或者等待另一个线程的计算结果。当线程阻塞时,它通常被挂起,并处于某种阻塞状态(BLOCK、WAITING 或 TIMED_WAITING)。阻塞操作与执行时间很长的普通操作的差别在于,被阻塞的线程必须等待某个不受它控制的事件发生后才能继续执行,例如等待 I/O 操作完成,等待某个锁变成可用,或者等待外部计算的结束。当某个外部事件发生时,线程被置回 RUNNABLE 状态,并可以再次被调度执行。

BlockingQueue 的 put 和 take 等方法会抛出受检查异常(Checked Exception)InterruptedException,这与类库中其他一些方法的做相同,例如 Thread.sleep。当某方法抛出 InterruptedException 时,表示该方法是一个阻塞方法,如果这个方法被中断,那么它将努力提前结束阻塞状态。

Thread 提供了 interrupt 方法,用于中断线程或者查询线程是否已经被中断。每个线程都有一个布尔类型的属性,表示线程的中断状态,当中断线程时设置这个状态。

中断是一种协作机制。一个线程不能强制其他线程停止正在执行的操作而去执行其他的操作。当线程 A 中断 B 时,A 仅仅是要求 B 在执行到某个可以暂停的地方停止正在执行的操作 —— 前提是如果线程 B 愿意停止下来。虽然在 API 或者语言规范中并没有为中断定义任何特定应用级别的语义,但最常使用中断的情况就是取消某个操作。方法对中断的响应度越高,就越容易及时取消那些执行时间很长的操作。

当在代码中调用一个将抛出 InterruptedException 异常的方法时,你自己的方法也就变成了一个阻塞方法,并且必须要处理对中断的响应。对于库代码来说,有两种基本选择:

  • 传递 InterruptedException。避开这个异常通常是最明智的策略 —— 只需把 InterruptedException 传递给方法的调用者。传递 InterruptedException 的方法包括:根本不捕获该异常,或者捕获该异常,然后在执行某种简单的清理工作之后再次抛出这个异常。
  • 恢复中断。有时候不能抛出 InterruptedException,例如当代码是 Runnable 的一部分时。在这些情况下,必须捕获 InterruptedException,并通过调用当前线程上的 interrupt 方法恢复中断状态,这样在调用栈中更高层的代码将看到引发一个中断。
public class TaskRunnable implements Runnable {
    BlockingQueue<Task> queue;

    public void run() {
        try {
            processTask(queue.take());
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }

    void processTask(Task task) { }

    interface Task {}
}

还可以采用一些更复杂的中断处理方法,但上述两种方法已经可以应付大多数情况了。然而在出现 InterruptedException 时不应该做的事情是:捕获它但不做出任何响应。这将使调用栈上更高层的代码无法对中断采取处理措施,因为线程被中断的证据已经丢失。只有在一种特殊的情况中才能屏蔽中断,即对 Thread 进行扩展,并且能控制调用栈上所有更高层的代码。

6. 同步工具类

在容器类中,阻塞队列是一种独特的类:它们不仅能作为保存对象的容器,还能协调生产者和消费者等线程之间的控制流,因为 take 和 put 等方法将阻塞,直到队列达到期望的状态(队列既非空,也非满)。

同步工具类可以是任何一个对象,只要它根据其自身的状态来协调线程的控制流。阻塞队列可以作为同步工具类,其他类型的同步工具类还包括信号量(Semaphore)、栅栏(Barrier)以及闭锁(Latch)。在平台类库中还包含其他一些同步工具类的类,如果这些类还无法满足需要还可以创建自己的同步工具类。

所有的同步工具类都包含一些特定的结构化属性:它们封装了一些状态,这些状态将决定执行同步工具类的线程是继续执行还是等待,此外还提供了一些方法对状态进行操作,以及另一些方法用来高效的等待同步工具类进入到预期状态。

6.1 闭锁

闭锁是一种同步工具类,可以延迟线程的进度直到其到达终止状态。闭锁的作用相当于一扇门:在闭锁到达结束状态之前,这扇门一直是关闭的,并且没有任何线程能通过,当到达结束状态时,这扇门会打开并允许所有的线程通过。当闭锁到达结束状态后,将不会再改变状态,因此这扇门将永远保持打开状态。闭锁可以用来确保某些活动直到其他活动都完成后才继续执行:

  • 确保某个计算在其需要的所有资源都被初始化之后才继续执行。二元闭锁(包括两个状态)可以用来表示“资源 R 已经被初始化”,而所有需要 R 的操作都必须现在这个闭锁上等待。
  • 确保某个服务在其依赖的所有其他服务都已经启动之后才启动。每个服务都有一个相关的二元闭锁。当启动服务 S 时,将首先在 S 依赖的其他服务的闭锁上等待,在所有依赖的服务都启动后会释放闭锁 S,这样其他依赖的服务才能继续执行。
  • 等待直到某个操作的所有参与者(例如,在多玩家游戏中的所有玩家)都就绪再继续执行。在这种情况中,当所有玩家都准备就绪时,闭锁将到达结束状态。

CountDownLatch 是一种灵活的闭锁实现,可以在上述各种情况中使用,它可以使一个或多个线程等待一组事件发生。闭锁状态包括一个计数器,该计数器被初始化为一个正数,表示需要等待的事件数量。countDown 方法递减计数器,表示有一个事件已经发生了,而 await 方法等待计数器达到零,这表示所有需要等待的事件都已经发生。如果计数器的值非零,那么 await 会一直阻塞直到计数器为零,或者等待中的线程中断,或者等待超时。

在程序清单 TestHarness 中给出了闭锁的两种常见用法。TestHarness 创建一定数量的线程,利用它们并发地执行指定的任务。它使用两个闭锁,分别表示“起始门(Starting Gate)”和“结束门(Ending Gate)”。起始门计数器的初始值为 1,而结束门计数器的初始值为工作线程的数量。每个工作线程首先要做的就是在启动门上等待,从而确保所有线程都就绪后才开始执行。而每个线程要做的最后一件事情是将调用结束门的 countDown 方法减 1,这能使主线程高效地等待直到所有工作线程都执行完成,因此可以统计所消耗的时间。

public class TestHarness {
    public long timeTasks(int nThreads, final Runnable task)
            throws InterruptedException {
        final CountDownLatch startGate = new CountDownLatch(1);
        final CountDownLatch endGate = new CountDownLatch(nThreads);

        for (int i = 0; i < nThreads; i++) {
            Thread t = new Thread() {
                public void run() {
                    try {
                        startGate.await();
                        try {
                            task.run();
                        } finally {
                            endGate.countDown();
                        }
                    } catch (InterruptedException ignored) {
                    }
                }
            };
            t.start();
        }

        long start = System.nanoTime();
        startGate.countDown();
        endGate.await();
        long end = System.nanoTime();
        return end - start;
    }
}
6.2 栅栏

我们已经看到通过闭锁来启动一组相关的操作,或者等待一组相关操作结束。闭锁是一次性对象,一旦进入终止状态,就不能被重置。

栅栏(Barrier)类似于闭锁,它能阻塞一组线程直到某个事件发生。栅栏与闭锁的关键区别在于,所有线程必须同时到达栅栏位置,才能继续执行。闭锁用于等待事件,而栅栏用于等待其他线程

CyclicBrrier 可以使一定数量的参与方反复地在栅栏位置汇集,它在并行迭代算法中非常有用:这种算法通常将一个问题拆分成一系列相互独立地子问题。当线程到达栅栏位置时将调用 await 方法,这个方法将阻塞直到所有线程都到达栅栏位置。如果所有线程都到达了栅栏位置,那么栅栏将打开,此时所有线程都被释放,而栅栏将被重置以便下次使用。如果对 await 调用都将终止并抛出 BrokenBarrierException。如果成功地通过栅栏,那么 await 将为每个线程返回一个唯一的到达索引号,我们可以利用这些索引来“选举”产生一个领导线程,并在下一次迭代中由该领导线程执行一些特殊的工作。CyclicBarrier 还可以使你将一个栅栏操作传递给构造函数,这是一个 Runnable,当成功通过栅栏时会(在一个子任务线程中)执行它,但在阻塞线程被释放之前是不能执行的。

    public static void main(String[] args) {
        ExecutorService service = Executors.newCachedThreadPool();
        final  CyclicBarrier cb = new CyclicBarrier(3);//创建CyclicBarrier对象并设置3个公共屏障点
        for(int i = 0; i < 3; i++){
            Runnable runnable = new Runnable(){
                public void run(){
                    try {
                        Thread.sleep((long)(Math.random()*10000));
                        System.out.println("线程" + Thread.currentThread().getName() +
                                "即将到达集合地点1,当前已有" + cb.getNumberWaiting() + "个已经到达,正在等候");
                        cb.await();//到此如果没有达到公共屏障点,则该线程处于等待状态,如果达到公共屏障点则所有处于等待的线程都继续往下运行

                        Thread.sleep((long)(Math.random()*10000));
                        System.out.println("线程" + Thread.currentThread().getName() +
                                "即将到达集合地点2,当前已有" + cb.getNumberWaiting() + "个已经到达,正在等候");
                        cb.await();
                        Thread.sleep((long)(Math.random()*10000));
                        System.out.println("线程" + Thread.currentThread().getName() +
                                "即将到达集合地点3,当前已有" + cb.getNumberWaiting() + "个已经到达,正在等候");
                        cb.await();
                    } catch (Exception e) {
                        e.printStackTrace();
                    }
                }
            };
            service.execute(runnable);
        }
        service.shutdown();
    }

运行结果:

线程pool-1-thread-3即将到达集合地点1,当前已有0个已经到达,正在等候
线程pool-1-thread-1即将到达集合地点1,当前已有1个已经到达,正在等候
线程pool-1-thread-2即将到达集合地点1,当前已有2个已经到达,正在等候
线程pool-1-thread-3即将到达集合地点2,当前已有0个已经到达,正在等候
线程pool-1-thread-2即将到达集合地点2,当前已有1个已经到达,正在等候
线程pool-1-thread-1即将到达集合地点2,当前已有2个已经到达,正在等候
线程pool-1-thread-1即将到达集合地点3,当前已有0个已经到达,正在等候
线程pool-1-thread-3即将到达集合地点3,当前已有1个已经到达,正在等候
线程pool-1-thread-2即将到达集合地点3,当前已有2个已经到达,正在等候

总结

基本容器集合:


同步容器:

在 Java 中,同步容器主要包括 2 类:

Vector、Stack、hashTable


接下来看看 Vector容器:

public class Vector<E> extends AbstractList<E> implements List<E>, RandomAccess, Cloneable, java.io.Serializable
{
    ......
   public synchronized boolean add(E e) {
        modCount++;
        ensureCapacityHelper(elementCount + 1);
        elementData[elementCount++] = e;
        return true;
    }

   public synchronized E get(int index) {
        if (index >= elementCount)
            throw new ArrayIndexOutOfBoundsException(index);

        return elementData(index);
    }
    ......
}

Collections 类中提供的静态工厂方法创建的类:

Collections 类是一个工具提供类。在 Collections 类中提供了大量的方法,比如对集合或者容器进行排序、查找等操作。更重要的是,在它里面提供了几个静态工厂方法来创建同步容器类:

内置的静态类:

每个静态类对应创建静态容器方法:

    static class SynchronizedCollection<E> implements Collection<E>, Serializable {
        ......

        final Collection<E> c;  // Backing Collection
        final Object mutex;     // Object on which to synchronize

        SynchronizedCollection(Collection<E> c) {
            this.c = Objects.requireNonNull(c);
            mutex = this;
        }

        SynchronizedCollection(Collection<E> c, Object mutex) {
            this.c = Objects.requireNonNull(c);
            this.mutex = Objects.requireNonNull(mutex);
        }

        public boolean add(E e) {
            synchronized (mutex) {return c.add(e);}
        }
      
        public boolean addAll(Collection<? extends E> coll) {
            synchronized (mutex) {return c.addAll(coll);}
        }
        ......
 }

猜你喜欢

转载自blog.csdn.net/dilixinxixitong2009/article/details/79059143