[Java Concurrency in Practice]第五章 基础构建模块

基础构建模块

委托时创建线程安全类的一个最有效的策略,只需让现有的线程安全类管理所有的状态即可。
平台类库中包含了一个并发构建块的丰富集合,如线程安全的容器与同步工具。

5.1 同步容器类

分两部分,一是JDK1.0的Vector与Hashtable,另一个是JDK1.2才被加入的同步包装类Collections.synchronizedXxx工厂方法创建的。Collections.synchronizedXxx工厂方法构造出的容器返回的List与Set的iterator()与listIterator()(List集合)没有使用同步。
这些类实现线程安全的方式是:将它们的状态封装起来,并对每个公有方法都进行同步,使得每次只有一个线程能访问容器的状态。

5.1.1 同步容器类的问题

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

操作Vector(同步容器)的复合操作可能导致混乱的结果:

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

在多线程的环境下可能会抛出ArrayIndexOutOfBoundsException,因为其他线程可能会在size()与get中修改Vector,但单线程下是不会有问题的。

由于同步容器要遵守同步策略,即支持客户端加锁,因此可能会创建一些新的操作,只要我们知道应该使用哪一个锁,那么这些新操作就与容器的其他操作一样都是原子操作。同步容器类通过其自身的锁来保护它的每个方法。使用客户端加锁,对Vector进行复合操作:

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

在调用size和相应的get之间,Vector的长度可能会发生变化,这种风险在对Vector中的元素进行迭代时仍然会出现,多线程环境下迭代过程中也可能抛出ArrayIndexOutOfBoundsException:

for (int i = 0; i < vector.size(); i++)
    doSomething(vector.get(i));

迭代过程中可能抛出异常,但并不意味着Vector就不是线程安全。Vector的状态仍然是有效的,事实上异常恰好使它保持规范的一致性。然而,在正常或迭代读过程中抛出异常的确不是人们所期望的。

造成迭代不可靠的问题同样可以通过在客户端加锁来完成,这要增加一些开销,像下那样,通过在迭代期间持有Vector的锁,我们防止其他线程在迭代期间修改Vector,这样完全阻止了其他线程在这期间访问它,如果集合很大或者对每个元素执行的任务耗时比较长,这会削弱并发性。

synchronized (vector) {
    for (int i = 0; i < vector.size(); i++)
        doSomething(vector.get(i));//还要持有另一个锁,这是一个产生死锁风险的因素
}

5.1.2 迭代器与ConcurrentModificationException

尽管上面讨论的Vector是“遗留”下来的容器类,这只是说明同步容器有这样的问题。其实,“现代”的容器类也并没有消除复合操作产生的问题,比如迭代复合操作,当其他线程并发修改容器时,使用迭代器仍然避免不了在使用的地方加锁,在设计同步容器返回迭代器时,并没有使用同步(注,这里讲的是说返回的迭代器不是线程安全,而不是指返回迭代器的方法iterator() 没有使用同步,它本身就是经过同步了的。),因为他们是“及时失败”——只要有其他线程修改容器结果,立马就会抛出未检查性异常ConcurrentModificationException。

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

注:ConcurrentModificationException也可能出现在单线程的代码中,如果对象不是调用Iterator.remove,而是直接从容器中删除就会出现这种情况。

1.5中的for-each循环语法对容器进行迭代时,也是隐式地用到了Iterator,从内部来看,javac将生成使用Iterator的代码,反复调用hasNext和next来迭代List对象,与迭代Vector一样,想要避免ConcurrentModificationException,就必须在迭代过程持有容器的锁:

List<Widget> widgetList = Collections.synchronizedList(new ArrayList<Widget>());
...
// May throw ConcurrentModificationException
for (Widget w : widgetList)
    doSomething(w);

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

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

5.1.3 隐藏迭代器

在一个可能发生迭代的共享容器中,各处都需要锁,这是一个棘手的问题,因为迭代器有时是隐藏的,就像下面代码一样,容器的toString方法的实现是通过迭代容器中的每个元素。编译器将字符串的连接操作转换为调用StringBuilder.append(Object),而这个方法又会调用容器的toString方法,标准容器的toString方法将迭代容器,并在每个元素上调用toString来生成容器内容的格式化表示。

public class HiddenIterator {
    private final Set<Integer> set = new HashSet<Integer>();
    public synchronized void add(Integer i) { set.add(i); }
    public void addTenThings() {
        Random r = new Random();
        for (int i = 0; i < 10; i++)
            add(r.nextInt());
        System.out.println("DEBUG: added ten elements to " + set);//这里会隐式地使用迭代
   }
}

toString对容器进行迭代。当然真正的问题是HiddenIterator不是线程安全的。在使用println的set之前必须首先获取HiddenIterator的锁,但在调试代码和日志代码中通常会忽视这个要求。

如果状态与保护它的同步代码之间相隔越远,那么开发人员就越容易忘记在访问状态时使用正确的同步。如果将HashSet包装为synchronizedSet,并且对同步代码进行封装,就不会出现ConcurrentModificationException异常了。

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

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

5.2 并发容器

1.5提供了几个并发的容器类来改进同步容器。同步容器通过对容器的进行串行访问,从而实现了它们的线程安全。这样做虽然是绝对的安全,但代价是削弱了并发性,当多个线程共同竞争容器级的锁时,吞吐量会降低。

并发容器是为多线程并发访问而设计的。1.5添加了ConcurrentHashMap,来替代同步的哈希Map实现;当大多数的操作是读操作时(因为如果有很多写操作会引起内部对原来集合进行复制,从而带来开销),CopyOnWriteArrayList是List相应的同步实现,同样CopyOnWriteArraySet是Set相应的同步实现(内部是以CopyOnWriteArrayList来实现的)。并且在ConcurrentMap接口还加入了对常见复合操作的支持,如“缺少即加入 put-if-absent”、替换和条件删除。

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

1.5同时增加了两个新的容器类型:Queue和BlockingQueue(Queue接口继承了Collection接口)。有几种实现,一个传统意义上(入队与出队不会被阻塞,是相对阻塞队列来说的)的FIFO队列ConcurrentLinkedQueue,底层是基于链表结构;一个是有优先级顺序的队列PriorityQueue(注,它不支持非并发)。Queue的操作不会阻塞,如果队列是空,那么从队列中获取时返回null。尽管可以使用List来模拟Queue的类——事实上,LinkedList就已实现了Queue(如果我们只需要一个单纯的或者是传统意义上的队列时,我们应该使用LinkedList,如果我们需要在并发环境下,则使用ConcurrentLinkedQueue来代替它)——但我们还是需要Queue的类,因为如果忽略掉List的随机访问需求的话,使用Queue能得到高效的并发实现。

Queue接口:

BlockingQueue接口:
这里写图片描述

BlockingQueue扩展了Queue,增加了可阻塞的插入(put)和获取操作(take)。如果队列为空,则take阻塞;如果队列满(对于有限队列:LinkedBlockingQueue—可以不指定,不指定时容量为最大的Integer.MAX_VALUE、ArrayBlockingQueue构造时则一定要指定大小),put操作会阻塞直到有空间,而对于无界队列(PriorityBlockingQueue、DelayQueue),放入时不会被阻塞,直到OutOfMemoryError。阻塞队列在生产者——消费者设计中非常有用。

正如ConcurrentHashMap作为同步的哈希Map的一个替代,1.6加入了ConcurrentSkipListMap和ConcurrentSkipListSet,用来作为同步的SortedMap和SortedSet的并发替代品(用synchronizedMap包装的TreeMap或TreeSet)。

5.2.1 ConcurrentHashMap

同步容器类在每个操作的执行期间都持有一个锁。比如HashMap.get或者List.contains操作,可能包含大量的工作:当遍历散列桶或列表来查找某个特定对象时,必须在许多元素上调用equals(而equals本身还包含一定的计算量)。在基于散列的容器中,如果hashCode不能很均匀地分布散列值,那么容器中的元素就不会均匀地分布在整个容器中。再调用它们的过程中可能需要很长一段时间,并且在这段时间内,其他线程都不能访问这个容器。

ConcurrentHashMap和HashMap一样是一个哈希表,但是它使用完全不同的锁策略,可以提供更好的并发性和或伸缩性。以前的同步容器在内部只有一把锁,即容器自身,而ConcurrentHashMap使用一个更加细化的锁机制,名叫“锁分离或分段锁”。这种机制允许更深层次的共享访问。任意数量的读线程可以并发访问Map,读和写线程可以并发访问Map,并且有限数量的写线程还可以并发修改Map。这样为并发访问带来了更高的吞吐量,同时几乎没有损失单个线程访问的性能。

ConcurrentHashMap提供了不会抛出ConcurrentModificationException异常的迭代器,因此不需要在容器迭代时加锁访问,它所返回的迭代器是弱一致性的,而非“及时失败”的。弱一致性的迭代可以允许并发修改,当迭代器被创建时,它会遍历已有的元素,并且可以(但是不保证)在迭代器被构造后将修改操作反映给容器。

尽管有这么多改进,但有一些还是需要权衡的地方。那些对整个Map进行操作的方法,如size和isEmpty,它们的语义在反映容器并发特性被弱化了。因为size的结果相对于在计算的时候可能已经过期,它仅仅只是一个估算值,所以允许size返回一个近似值而不是一个精确的值。这在一开始会让人有些困扰,不过事实上像size和isEmpty这样的方法在并发环境下几乎没有什么用处,因为它们的返回值总在不断变化,它们的目标是在于并发的读与写,所以这些操作的原子性被弱化了。相反,应该保证对最重要的操作进行性能优化,最重要的是get、put、containsKey和remove等。

相比于Hashtable和synchronizedMap,ConcurrentHashMap有很多的优势,因此大多数情况下ConcurrentHashMap取代同步Map实现只会带来更好的可伸缩性。只有当应用程序需要加锁Map以进行独占访问时,才应该放弃使用ConcurrentHashMap。

5.2.2 额外的原子Map操作

因为ConcurrentHashMap不能被独占访问,所以我们不能在客户端加锁来创建新的原子操作,比如我在前面对Vector复合操作施加的原子性。不过一些常的复合操作,如“若没有则添加”、“若相等则移除”和“若相等则替换”等都已被实现为原子操作。如果你正在已同步Map中加入这些功能时,你可能考虑使用ConcurrentHashMap来替代同步的Map。

public interface ConcurrentMap<K,V> extends Map<K,V>
{
    // 仅当K没有响应映射值时才插入
    public V putIfAbsent(K key,  V value);
    // 仅当K被映射到V时才移除
    public boolean remove(Object key, Object value);
    // 仅当K被映射到oldValue时才替换为newValue
    public boolean replace(K key, V oldValue, V newValue);
    // 仅当K被映射到某个值时才替换为newValue
    public V replace(K key, V value);
}

5.2.3 CopyOnWriteArrayList

CopyOnWriteArrayList用于替代同步List,在某些情况下它提供了更好地并发性能,并且在迭代期间不需要对容器进行加锁或复制。(类似地,CopyOnWriteArraySet的作用时替代同步Set,它是对CopyOnWriteArrayList包装,所有的操作都是转换给CopyOnWriteArrayList,与CopyOnWriteArrayList没什么区别)

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

CopyOnWriteArrayList<StringBuffer> cwa = new CopyOnWriteArrayList<StringBuffer>();
cwa.add(new StringBuffer("0"));
Iterator<StringBuffer> it = cwa.iterator();
cwa.get(0).append("1");
cwa.add(new StringBuffer("3"));
while (it.hasNext()) {
       // 不会抛异常,那怕在迭代创建后修改了结构,并只输出 01
       System.out.println(it.next());
}

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

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

Queue继承体系结构:

队列是一种数据结构,它有两个基本操作:在队列尾部加人一个元素,和从队列头部移除一个元素就是说,队列以一种先进先出的方式管理数据,如果你试图向一个已经满了的阻塞队列中添加一个元素或者是从一个空的阻塞队列中移除一个元索,将导致线程阻塞.在多线程进行合作时,阻塞队列是很有用的工具。工作者线程可以定期地把中间结果存到阻塞队列中而其他工作者线线程把中间结果取出并在将来修改它们。队列会自动平衡负载。如果第一个线程集运行得比第二个慢,则第二个线程集在等待结果时就会阻塞。如果第一个线程集运行得快,那么它将等待第二个线程集赶上来。下表显示了jdk1.5中的阻塞队列的操作:

add增加一个元素如果队列已满,则抛出一个IIIegaISlabEepeplian异常
remove移除并返回队列头部的元素如果队列为空,则抛出一个NoSuchElementException异常
element返回队列头部的元素如果队列为空,则抛出一个NoSuchElementException异常
offer添加一个元素并返回true如果队列已满,则返回false
poll移除并返问队列头部的元素如果队列为空,则返回null
peek返回队列头部的元素如果队列为空,则返回null
put添加一个元素如果队列满,则阻塞
take移除并返回队列头部的元素如果队列为空,则阻塞

remove、element、offer 、poll、peek 其实是属于Queue接口,这些方法都不会阻塞。

队列Queue接口与List、Set同一级别,都是继承了Collection接口。LinkedList现已经实现了Queue接口。Queue接口窄化了对LinkedList的方法的访问权限(即在方法中的参数类型如果是Queue时,就完全只能访问Queue接口所定义的方法了,而不能直接访问LinkedList的非Queue的方法),以使得只有恰当的方法才可以使用。BlockingQueue 继承自Queue接口。

阻塞队列的操作可以根据它们的响应方式分为以下三类:aad、removee和element操作在你试图为一个已满的队列增加元素或从空队列取得元素时抛出异常。当然,在多线程程序中,队列在任何时间都可能变成满的或空的,所以你可能想使用offer、poll、peek方法。这些方法在无法完成任务时只是给出一个出错示而不会抛出异常。

注意:poll和peek方法出错进返回null。因此,向队列中插入null值是不合法的。

还有带超时的offer和poll方法变种,例如,下面的调用:
boolean success = q.offer(x,100,TimeUnit.MILLISECONDS);
尝试在100毫秒内向队列尾部插入一个元素。如果成功,立即返回true;否则,当到达超时进,返回false。同样地,调用:
Object head = q.poll(100, TimeUnit.MILLISECONDS);
如果在100毫秒内成功地移除了队列头元素,则立即返回头元素;否则在到达超时时,返回null。

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

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

在基于阻塞队列构建的生产者-消费者设计中,当数据生成时,生产者把数据放入队列,而当消费者准备处理数据时,将从队列中获取数据。生产者不需要知道消费者的标示或数量,或者它们是否是唯一的生产者,而只需将数据放入队列即可。同样,消费者也不需要知道生产者是谁,或者工作来自何处。Blockingqueue简化了生产者-消费者设计的实现过程,它支持任意数量的生产者和消费者。

最常见的生产者-消费者设计是将线程池与工作队列相结合起来(据我所知,Executor框架有以下地方用到了池:池中的线程就是放在队列中的,但Executor的任务提交后不是会放入队列中,而是立刻准备执行;ScheduledExecutorService定制的计划任务会放入工作队列中,等到延迟到达后执行;另外CompletionService处理完后的结果会放在队列中),讲述Executor任务执行框架时会具体介绍这个模式。

“生产者”和“消费者”的角色是相对的,某种环境中的消费者在另一种不同的环境中可能会成为生产者。

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

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

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

在构建高可靠的应用程序时,有界队列是一种强大的资源管理工具:它们能抑制并防止产生过多的工作项,使应用程序在负荷过载的情况下变得更加健壮。

开发人员总会假设消费者处理工作的速率赶上生产者生成工作项的速率,因此通常不会为工作队列的大小设置边界,但这将导致在之后需要重新设计架构。因此,应该尽早通过阻塞队列在设计中构建资源管理机制——这件事情做得越早,就越容易。许多情况下,阻塞队列能使这项工作更加简单,如果阻塞队列并不完全符合设计需求那么还可以通过信号量(Semaphore)来创建其他的阻塞数据结构。

类库中中包含一些BlockingQueue的实现:
LinkedBlockingQueue,底层基于链表结构的阻塞队列,默认情况下容量是没有上限的(说的不准确,在不指定时容量为Integer.MAX_VALUE,要不然的话在put时怎么会受阻呢),但是也可以选择指定其最大容量,它是基于链表的队列,此队列按 FIFO(先进先出)排序元素。

ArrayBlockingQueue,底层基于数组结构的阻塞队列,在构造时需要指定容量,并可以选择是否需要公平性,如果公平参数被设置true,等待时间最长的线程会优先得到处理(其实就是通过将ReentrantLock设置为true来达到这种公平性的:即等待时间最长的线程会先操作)。通常,公平性会使你在性能上付出代价,只有在的确非常需要的时候再使用它。它的底层基于数组的阻塞循环队列,此队列按 FIFO(先进先出)原则对元素进行排序。

PriorityBlockingQueue,它是基于PriorityQueue来实现的,而PriorityQueue优先队列底层是基于堆数据结构的,是一个带优先级的队列,而不是先进先出队列。元素按优先级顺序被移除,该队列也没有上限(因PriorityQueue是没有容量限制的,与ArrayList一样,所以在优先阻塞队列上put时是不会受阻的。虽然此队列逻辑上是无界的,但是由于资源被耗尽,所以试图执行添加操作可能会导致 OutOfMemoryError),但是如果队列为空,那么取元素的操作take就会阻塞,所以它的检索操作take是受阻的。另外,放入的元素要具有比较力或构建队列时指定一个Comparator比较器。

DelayQueue,也是基于PriorityQueue来实现的,是一个存放Delayed 元素的无界阻塞队列,只有在延迟期满时才能从中提取元素。该队列的头部是延迟期满后保存时间最长的 Delayed 元素。如果延迟都还没有期满,则队列没有头部,并且poll将返回null。当一个元素的 getDelay方法返回一个小于或等于零的值时,则出现期满,poll就以移除这个元素了。此队列不允许使用 null 元素。下面是Delayed接口:

public interface Delayed extends Comparable<Delayed> {
   
     long getDelay(TimeUnit unit);
}

最后一个BlockingQueue的实现是Sync

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值