基础构建模块《java并发编程实战》
概述
委托是创建线程安全类的一个最有效的策略:只需要让线程安全类管理所有的状态即可。
同步容器类
同步容器类包括Vector和Hashtable。二者是早期JDK的一部分。
此外还包括在JDK1.2中添加的一些功能相似的类,这些同步的封装器是由Collections.synchronizedXxx等工厂方法创建的。这些类实现线程安全的方法是:将它们的状态封装起来,并对每个公有方法都进行同步,使得每次只能有一个线程能访问容器的状态。
1)同步容器类的问题:
同步容器类都是线程安全的,但在某些情况下可能需要额外的客户端加锁来保护复合操作。容器上常见的复合操作包括:迭代、跳转、条件运算。在同步容器类中,这些复合操作在没有客户端加锁的情况下仍然是线程安全的,但当其他线程并发地修改容器时,它们可能会表现出意料之外的行为。
2)迭代器与ConcurrentModificationException
容器迭代过程中发现被修改时,就会抛出一个ConcurrentModificationException异常。这个“及时失败”的迭代器并不完备,只是善意捕捉,只能作为并发问题预警指示器。采用实现方式是,将计数器的变化与容器关联起来:如果迭代期间计数器被修改,那么hasNext或next将抛出ConcurrentModificationException。
单线程代码中也可能抛出这个ConcurrentModificationException异常。当对象直接从容器中删除而不是通过Iterator.remove来删除时,就会抛出这个异常
想要避免出现ConcurrentModificationException,就必须在迭代过程持有对象的锁。如果不希望在迭代器期间对容器加锁,那么一种替代方法就是“克隆”容器,并在副本上进行迭代。副本被封闭在线程内。
3)隐藏迭代器
/** * @author zhen * @Date 2018/10/31 17:47 * 隐藏在字符串连接中的迭代操作 */ public class HiddenIterator { @GuardedBy("this") private final Set<Integer> set = new HashSet<Integer>(); public synchronized void add(Integer i) { set.add(i); } public synchronized void remove(Integer i) { set.remove(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方法隐藏了迭代操作、hashCode和equals方法也会间接地进行迭代操作
正如封装对象的状态有助于维持不变性条件一样,封装对象的同步机制同样有助于确保实施同步策略
并发容器
jdk5.0 提供了多种并发容器类来改进同步容器的性能。同步容器将所有对容器状态的访问都串行化,以实现它们的线程安全性。这种方法的代价是严重降低并发性,当多个线程竞争容器的锁时,吞吐量将严重降低。另一方面,并发容器是针对多个线程并发访问而设计的。
在jdk1.5中增加了ConcurrentHashMap,用来替代同步且基于散列的Map,以及CopyOnWriteArrayList,用于在遍历操作为主要操作的情况下代替同步的List。在新的ConcurrentMap接口中增加了一些常见复合操作的支持,例如:“若没有则添加”,替换以及有条件删除等。
jdk5增加了两种新的容器类型:Queue和BlockingQueue。
Queue用来临时保存一组等待处理的元素。它提供了几种实现,包括:ConcurrentLinkedQueue,这是一个传统的先进先出队列,以及PriorityQueue,这是一个(非并发的)优先队列。Queue上的操作不会阻塞,如果队列为空,那么获取元素的操作将返回空值。虽然可以用List来模拟Queue的行为——事实上,正式通过linkedList来实现Queue的,但还需要一个Queue的类,因为它能去掉List的随机访问需求,从而实现更高效的并发。
BlockingQueue扩展了Queue,增加了可阻塞的插入和获取等操作。如果队列为空,那么获取元素的操作将一直阻塞,知道队列中出现一个可用的元素。如果队列已满,那么插入元素的操作将一直阻塞,直到队列中出现可用的空间。
正如ConcurrentHashMap用于替代散列的同步Map,java6也引入了ConcurrentSkipListMap和ConcurrentSkipListSet,分别作为同步的SortedMap和SortedSet的并发替代品(例如用synchronizedMap包装的TreeMap或TreeSet)
1)ConcurrentHashMap
同步容器类在执行每个操作期间都持有一个锁。在一些操作中,例如HashMap.get或List.contains,可能包含大量的工作:当散列同或者链表查找某个特定的对象时,需要在许多元素上调用equals。在基于散列的容器中,如果hashCode不能很均匀地分布散列值,那么容器中的元素就不会均匀地分布在整个容器中,当遍历很长的链表并且在某些或全部元素上调用equals方法时,会花费很长的时间,而其他线程在这段时间内都不能访问该容器。
ConcurrentHashMap也是基于散列的Map,使用了一种不同的加锁策略来提供更高的并发性和伸缩性。并不是使用将每个方法在同一个锁上同步,而是使用一种粒度更细的加锁机制来实现更大程度的共享,这种机制称为分段锁。这种机制中,任意数量的读取线程可以并发地访问Map,执行读取操作和执行写入操作的线程可以并发地访问Map,并且一定数量的写入线程可以并发地修改Map。ConcurrentHashMap带来的结果是,在并发访问环境下降实现更高的吞吐量,而在单线程环境只损失非常小的性能。
ConcurrentHashMap与其他并发容器一起增强了同步容器类:它们提供的迭代器不会抛出ConcurrentModificationException,因此不需要在迭代过程中对容器加锁。ConcurrentHashMap返回的迭代器具有弱一致性,而并非“及时失败”。弱一致性的迭代器可以容忍并发,当创建迭代器时会遍历已有的元素,并可以(但是不保证)在迭代器被构造后将修改操作反映给容器。
在ConcurrentHashMap中没有实现对Map加锁以提供独占访问。在Hashtable和synchronizedMap中,获得Map的锁能防止其他线程访问这个Map。在一些不常见的情况中需要这种功能,例如通过原子方式添加一些映射,或者对Map迭代若干次并在此期间保持元素顺序相同。然而,总体来说这种权衡还是合理的,因为并发容器的内容会持续变化。大多数情况下,用ConcurrentHashMap来代替同步Map能进一步提高代码的可伸缩性。只有当应用程序需要加锁Map以进行独占访问时,才应该放弃使用ConcurrentHashMap。
2)额外的原子Map操作
由于ConcurrentHashMap不能被加锁来执行独占访问,因此我们无法使用客户端加锁来创建新的原子操作。但是一些常见的复合操作,例如“若没有则添加”、“若相等则逸出”和“若相等则替换”等,都已经实现为原子操作并且在ConcurrentMap的接口中声明,如果你需要现在的同步Map中添加这样的功能,那么可能就意味着应该考虑使用ConcurrentMap了。
public interface ConcurrentMap<K, V> extends Map<K,V> {
//仅当K没有对应的映射值时才插入
V putIfAbsent(K key, V value);
//仅当K被映射到V时才移除
boolean remove(K key, V value);
//仅当K被映射到oldValue时才替换为newValue
boolean replace(K key, V oldValue, V newValue);
//仅当K被映射到某个值时才替换为newValue
V replace(K key, V newValue);
}
3) CopyOnWriteArrayList
CopyOnWriteArrayList用于替代同步List,在某些情况下它提供了更好的并发性能,并且在迭代期间不需要对容器进行加锁或复制。(类似的CopyOnWriteArraySet的作用是替代同步Set)。
“写入时复制”容器的线程安全性在于,只要正确地发布一个事实不可变的对象,那么在访问该对象时就不再需要进一步的同步。在每次修改时,都会创建并重新发布一个新的容器副本,从而实现可变性。“写入时复制”容器的迭代器保留一个指向底层基础数组的引用,这个数组当前位于迭代器的起始位置,由于它不会被修改,因此在对其进行同步时只需确保数组内容的可见性。因此多个线程可以同时对这个容器进行迭代,而且不会彼此干扰或者与修改容器的线程互相干扰。
每次修改容器时都会复制底层数组,这需要一定的开销,特别是容器规模较大时。仅当迭代操作远远多于修改操作时,才应该使用“写入时复制”容器。这个准则很好地描述了许多事件通知系统:在分发通知时需要迭代已经注册监听链表,并调用每一个监听器,在大多数情况下,注册和注销事件监听器的操作远少于事件通知的操作。
阻塞队列和生产者-消费者模式
阻塞队列提供了可阻塞的put和take方法,以及支持定时的offer和pool方法。如果队列已经满了,那么put方法将阻塞直到有空间可用;如果队列为空,那么take方法将阻塞直到有元素可用。队列可以是有界也可以是无界的。
阻塞队列支持生产者-消费者这种设计模式。该模式将“找出需要完成的工作”与“执行工作”这两个过程分离开来,并将工作项放入一个“待完成”列表中以便在随后处理。生产者-消费者模式能简化开发过程,因为它消除了生产者和消费者之间的代码依赖性,此外,该模式还将生产过程与使用过程解耦以简化工作负载的管理。
基于阻塞队列构建的生产者-消费者设计比较简单。
在构建高可用的应用程序时,有界队列是一种强大的资源管理工具:它们能抑制并防止产生过多的工作项,使应用程序在负荷过载的情况下变得更加健壮
虽然生产者-消费者模式能够将生产者和消费者的代码彼此解耦开来,但它们的行为仍然会通过共享工作队列间接地耦合在一起。开发人员总会假设消费者处理工作的效率能赶上生产者生成工作项的速率,因此通常不会为工作队列的大小设置边界,但这将导致在之后需要重新设计系统架构。因此,应该尽早地通过阻塞队列在设计中构建资源管理机制——这件事情做的越早,就越容易。在许多情况下,阻塞队列能使这项工作更加简单,如果阻塞队列并不完全符合设计需求,那么还可以通过信号量来创建其他的阻塞数据结构
在类库中包含了BlockingQueue的多种实现,其中,LinkedBlockingQueue和ArrayBlockingQueue是FIFO队列,二者分别与LinkedList和ArrayList类似,但比同步List拥有更好的并发性。PriorityBlockingQueue是一个按照优先级排序的队列,当你希望按照某种顺序而不是FIFO来处理元素时,这个队列将非常有用。最后一个BlockingQueue实现是SynchronousQueue,实际上它不是一个真正的队列,因为它不会为队列中元素维护存储空间。与其他队列不同的是,它维护一组线程,这些线程在等待着把元素加入或者移除队列。因为SynchronousQueue没有存储功能,因此put和take会一直阻塞,直到有另一个线程已经准备好参与到交付过程中。仅当有足够多的消费者,并且总是有一个消费者准备好获取交付的工作时,才适合使用同步队列。
2)串行线程封闭
在java.util.concurrent中实现的各种阻塞队列都包含了足够的内部同步机制,从而安全地将对象从生产者线程发布到消费者线程。对于可变对象,生产者-消费者这种设计和阻塞队列一起,促进了串行线程封闭,从而将对象所有权从生产者交付给消费者。
我们可以使用其他发布机制来传递可变对象的所有权,但必须确保只有一个线程能接受被转义的对象。则色队列简化了这项工作。除此之外,还可以通过ConcurrentMap的原子方法remove或AtomicReference的原子方法compareAndSet来完成这项工作。
3)双端队列与工作密取
java6增加了两种容器类型,Deque和BlockingDeque,它们分别对Queue和BlockingQueue进行可扩展,Deque是一个双端队列,实现了在队列头和队列尾的高效插入和移除。具体实现包括ArrayDeque和LinkedBlockingDeque。
正如阻塞队列适用于生产者-消费者模式,双端队列同样适用于另一种相关模式,即工作密取。在生产者-消费者设计中,所有消费者有一个共享的工作队列,而在工作密取设计中,每个消费者都有各自的双端队列。如果一个消费者完成了自己双端队列中的全部工作,那么它可以从其他消费者双端队列末尾秘密地获取工作。密取工作模式比传统的生产者-消费者模式具有更高的可伸缩性,这是因为工作者线程不会在单个共享的任务队列上发生竞争。在大多数时候,它们都只是访问自己的双端队列,从而极小地减少了竞争。当工作者线程需要访问另一个队列时,它会从队列的尾部而不是头部获取工作,因此进一步降低了队列上的竞争程度。
工作密取非常适用于既是消费者也是生产者问题——当执行某个工作时可能导致出现更多的工作。例如,在网页爬虫程序中处理一个页面时,通常会发现有更多的页面需要处理。当一个工作线程找到新的任务单元时,它会将其放到自己队列的尾末(或者在工作共享模式中,放入其他工作者线程的队列中)。当双端队列为空时,它会在另一个线程的队列队尾查找新的任务,从而确保每个线程都保持忙碌状态。
阻塞方法与中断方法
线程可能会阻塞或暂停执行,原因有多种:等待I/O操作结束,等待获得一个锁,等待从Thread.sleep方法中醒来,或者是等待另一个线程的计算结果。当线程阻塞时,它通常被挂起,并且处于某种阻塞状态(BLOCKED、WAITING或TIMED_WAITING).阻塞操作与执行时间很长的普通操作的差别在于,被阻塞的线程必须等待某个不受它控制的事件发生后才能继续执行,例如等待I/O操作完成。当某个外部事件发生时,线程被置回RUNNABLE状态,并可以再次被调度执行
BlockingQueue的put和take等方法会抛出受检查异常InterruptedException,这与类库中其他一些方法的做法相同,例如Thread.sleep.当某方法抛出InterruptedException时,表示该方法是一个阻塞方法,如果这个方法被终端,那么它将努力提前结束阻塞状态。
Thread提供了interrupt方法,用于终端线程或者查询线程是否已经被终端。每个线程都有一个布尔类型的属性,表示线程的中断状态,当中断线程时将设置这个状态.
中断是一种协作机制。
一个线程不能强制要求其他线程停止正在执行的操作而去执行其他的操作。当线程A终端B时,A仅仅是要求B在执行到某个可以暂停的地方停止正在执行的操作——前提是如果线程B愿意停下来。
虽然在API或者语言规范中并没有为中断定义任何特定应用级别的语义,但最常使用中断的情况就是取消某个操作。方法对中断请求的响应度越高,就越容易及时取消那些执行时间很长的操作。
当在代码中调用了一个将抛出InterruptedException异常的方法时,你自己的方法也就变成了一个阻塞方法,并且必须要处理对中断的响应。对于库代码来说,有两种基本选择:1、传递InterruptedException;2、恢复中断。有些时候不能抛出InterruptedException,例如当代码是Runnable的一部分时,在这些情况下,必须捕获InterruptedException,并通过调用当前线程上的interrupt方法恢复中断状态
同步工具类
在容器类中,阻塞队列是一种独特的类:它们不仅能作为保存对象的容器,还能协调生产者和消费者等线程之间的控制流,因为take和pull等方法将阻塞,直到队列达到期望的状态(队列即非空,也非满)。同步工具类可以是任何一个对象,只要它根据自身的状态来协调线程的控制流。阻塞队列可以作为同步工具类,其他类型的同步工具类还包括信号量(Semaphore)、栅栏(Barrier)以及闭锁。所有的同步工具类的线程都包含一些特定的结构化属性:它们封装了一些状态,这些状态将决定执行同步工具类的线程是继续执行还是等待,此外还提供了一些方法对状态进行操作,以及另一些方法用于高效地等待同步工具类进入到预期状态
1)闭锁
闭锁是一种同步工具类,可以延迟线程的进度直到其到达终止状态。闭锁的作用相当于一扇门:在闭锁到达结束状态之前,这扇门一直是关闭的,并且没有任何线程能通过,当到达结束状态时,这扇门会打开并允许所有的线程通过。当闭锁到达结束状态后,将不会再改变状态,因此这扇门将永远保持打开状态。闭锁可以用来确保某些活动直到其他活动都完成后才继续执行。
CountDownLatch是一种灵活的闭锁实现,可以在上述各种情况中使用,它可以使一个或者多个线程等待一组事件发生。闭锁状态包括一个计数器,该计数器被初始化为一个正数,表示需要等待的事件数量。countDown方法递减计数器,表示有一个事件已经发生了,而await方法等待计数器达到0,这表示所有需要等待的事件都已经发生。如果计数器的值非零,那么await会一直阻塞直到计数器为零,或者等待中的线程终端,或者等待超时。
2)FutureTask
FutureTask也可以用作闭锁。(FutureTask实现了Future语义,表示一种抽象的可生成结果的计算)。FutureTask表示的计算是通过Callable来实现的,相当于一种可生成结果的Runnable,并且可处于以下3种状态:等待运行、正在运行和运行完成。“执行完成”表示计算的所有可能结束方式,包括正常结束、由于取消而结束和由于异常而结束等。当FutureTask进入完成状态后,它会永远停止在这个状态上。
Future.get的行为取决于任务的状态。如果任务已经完成,那么get会立即返回结果,否则get将阻塞直到任务进入到完成状态,然后返回结果或者抛出异常。FutureTask将计算结果从执行计算的线程传递到获取这个结果的线程,而FutureTask的规范确保了这种传递过程能实现结果的安全发布。
FutureTask在Executor框架中表示异步任务,此外还可以用来表示一些时间较长的计算,这些计算可以使用在计算结果之前启用。
3)信号量
计数信号量(Counting Semaphore)用来控制同时访问某个特定资源的操作数量,或者同事执行某个指定操作的数量。计数信号量还可以用来实现某种资源池,或者对容器施加边界。
Semaphore中管理着一组虚拟的许可,许可的初始数量可通过构造函数来指定。在执行操作时可以首先获得许可(只要还有剩余的许可),并在使用以后释放许可。如果没有许可,那么acquire将阻塞直到有许可(或者直到被中断或者操作超时)。release方法将返回一个许可给信号量。信号量的一种简化形式是二值信号量,即初始值为1的Semaphore。二值信号量可以用作互斥体,并具备不可重入的加锁语义:谁拥有这个唯一的许可,谁就拥有了互斥锁。
4)栅栏
我们已经看到通过闭锁来启动一组相关的操作,或者等待一组相关的操作结束。闭锁是一次性对象,一旦进入终止状态,就不能被重置。
栅栏类似于闭锁,它能阻塞一组线程知道某个事件发生。栅栏与闭锁的关键区别在于,所有线程必须同时到达栅栏位置,才能继续执行。闭锁用于等待事件,而栅栏用于等待其他线程。栅栏用于实现一些协议,例如几个家庭决定在某个地方集合。
CyclicBarrier可以使一定数量的参与方式反复在栅栏位置汇集,它在并行迭代算法中非常有用:这种算法通常将一个问题拆分成一些列相互独立的子问题。当线程到达栅栏位置时将调用await方法,这个方法将阻塞直到所有线程都到达栅栏位置。如果所有线程都达到了栅栏位置,那么栅栏将打开,此时所有线程都被释放,而栅栏将重置以便下次使用。如果对await的调用超时,或者await阻塞的线程被终端,那么栅栏就被认为是打破了,所有阻塞的await调用都将终止并抛出BrokenBarrierException。如果成功地通过栅栏,那么await将为每个线程返回一个唯一的到达索引号,我们可以利用这些索引来“选举”产生一个领导线程,并在下一次迭代中由该领导线程执行一些特殊的工作。CyclicBarrier还可以使你将一个栅栏操作传递给构造函数,这是一个Runnable,当成功通过栅栏时会(在一个字任务线程中)执行它,但在阻塞线程被释放之前是不能执行的。
另一种形式的栅栏时Exchanger,它是一种两方栅栏,各方在栅栏位置上交换数据。当两方执行部队称的操作时,Exchanger会非常有用,例如当一个线程向缓冲区写入数据,而另一个线程从缓冲区中读取数据。这些线程可以使用Exchanger来汇合,并将满的缓冲区与空的缓冲区交换。当两个线程通过Exchanger黄芽白对于选罢法时,这种交换就把这两个对象安全地发布给另一方。

浙公网安备 33010602011771号