java-并发和同步

前言

一般来说,函数式编程本身就是线程安全的。因为函数式编程的数据结构都是不可变的,数据在各自的线程空间存储和计算,不存在多线程共享(读取和修改)数据的情况。谈到线程安全,一般指的是面对对象的类。

线程安全类

当多个线程访问一个类时,如果不用考虑这些线程在运行时环境下的调度和交替执行,并且不需要额外的同步及在调用方代码不必作其他的协调,这个类的行为仍然是正确的,那么称这个类是线程安全的。

Java并发结构

线程

线程是并发的基本单位,有多个线程的程序才有并发的问题,单线程程序不存在并发问题。java的线程抽象是java.lang.Thread。

同步

对于在多个线程共享的数据,访问的时候需要同步。同步就是每次只能有一个线程对共享数据进行访问,多个线程到达同步点时,串行地执行访问代码。

一般把访问共享变量的代码放在synchronized关键字所包含的代码块中。

synchronized(...){***}

多个线程到达同步块时,串行执行代码。

那么,同步块是怎么做到让多个线程串行呢?答案是锁机制。

线程执行同步块的代码前,先获取同步块声明的锁,如果线程拿到了锁,则允许通行。没有拿到锁的线程,会阻塞等待,直到拿到锁的线程释放了锁为止。

当一个线程请求其他线程已经占有的锁时,请求线程将被阻塞。然而内部锁是可重进入的,因此线程在试图获得它自己占有的锁时,请求会成功。重进入意味着所的请求是基于“每线程( per-thread)”,而不是基于“每调用( per-invocation)”的。重进入的实现是通过为每个锁关联一个请求计数( acquisition count)和一个占有它的线程。当计数为0时,认为锁是未被占有的。线程请求一个未被占有的锁时,JVM将记录锁的占有者,并且将请求计数置为1。如果同一线程再次请求这个锁,计数将递增;每次占用线程退出同步块,计数器值将递减。直到计数器达到0时,锁被释放。

锁不仅仅是关于同步与互斥的,也是关于内存可见的。为了保证所有线程都能够看到共享的、可变变量的最新值,读取和写入线程必须使用公共的锁进行同步。

锁类型

内部锁一般分为两种:对象锁和类锁。对象锁指当前对象this,而类锁指的是当前类*.class。对象锁一般用于同步实例字段,而类锁用于同步静态字段。

阻塞

非竞争的同步可以由JVM完全掌控( Bacon等,1998);而竞争的同步可能需要Os的活动,这会增大开销。当锁为竞争性的时候,失败的线程(一个或多个)必然发生阻塞。
JVM既能自旋等待( spin-waiting,不断尝试获取锁,直到成功),或者在操作系统中挂起( suspending)这个被阻塞的线程。哪一个效率更高,取决于上下文切换的开销,以及成功地获取锁需要等待的时间这两者之间的关系。自旋等待更适合短期的等待,而挂起适合长时间等待。有一些JVM基于过去等待时间的数据剖析来在这两者之间进行选择,但是大多数等待锁的线程都是被挂起的。

监视器

就像每个对象都有一个锁一样,每个对象都有一个等待集(wait set),该等待集仅由wait、notify、notifyAll和Thread.interrupt方法操作。同时拥有锁和等待集的实体通常称为监视器(尽管几乎每种语言对细节的定义都有所不同)。任何对象都可以用作监视器。

每个对象的等待集由JVM在内部维护。每个集合持有对象上的等待阻塞的线程,直到相应的通知被调用或等待被释放。

由于等待集与锁的交互方式,仅当在其目标上持有同步锁时,才可以调用wait、notify和notifyAll方法。合规性通常无法在编译时验证。如果不遵从,将导致这些操作在运行时抛出IllegalMonitorStateException。

同步工具

java平台类库包含了一个并发构建块的丰富集合。

同步容器

Vector和Hashtable

Collections.synchronizedXxx

存在的问题

尽管 Vector是一个“遗留”的容器类,但为了将问题阐述清楚,我们在很多例子中都使用了它。其实,更多“现代”的容器类也并没有消除复合操作产生的问题。对Collection进行迭代的标准方式是使用 iterator,无论是显式地使用还是通过Java5.0引入新的 for-each循环语法。但是当有其他线程可能并发修改容器时,使用迭代器(Iterator)仍不可避免地需要在迭代期间对容器加锁。在设计同步容器返回的迭代器时,并没有考虑到并发修改的问题,它们是“及时失败( fail-fast)”的—意思是当它们察觉容器在迭代开始后被修改,会抛出一个未检查的 ConcurrentModificationException。

并发容器

用并发容器替换同步容器,这种作法以有很小风险带来了可扩展性显著的提高。

ConcurrentHashMap

代替Hashtable。

CopyOnWriteArrayList

List的同步实现。

ConcurrentLinkedQueue

Queue的同步实现

ArrayBlockingQueue/LinkedBlockingQueue/PriorityBlockingQueue

BlockingQueue的并发实现

LinkedTransferQueue

TransferQueue的并发实现

ConcurrentSkipListMap

SortedMap的并发替代

ConcurrentSkipListSet

SortedSet的并发替代

同步器

CountDownLatch

闭锁,阻塞线程直到计数减为0

FutureTask

也可作为闭锁,get方法阻塞线程

Semaphore

计数信号量。管理一个许可集合,如果许可不够,acquire会阻塞,release会释放许可。

CyclicBarrier

关卡( barrier)类似于闭锁,它们都能够阻塞一组线程,直到某些事件发生。其中关卡与闭锁关键的不同在于,所有线程必须同时到达关卡点,才能继续处理。闭锁等待的是事件;关卡等待的是其他线程。关卡实现的协议,就像一些家庭成员指定商场中的集合地点:“我们每个人6:00在麦当劳见,到了以后不见不散,之后我们再决定接下来做什么。”

显示锁

在Java5.0之前,用于调节共享对象访问的机制只有 synchronized和volatile。Java 5.0提供了新的选择:Reentrantlock。与我们已经提到过的机制相反, Reentrantlock并不是作为内部锁机制的替代,而是当内部锁被证明受到局限时,提供可选择的高级特性。

Lock

public interface Lock {
    void lock();
    void lockInterruptibly() throws InterruptedException;
    boolean tryLock();
    boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
    void unlock();
    Condition newCondition();
}

与内部加锁机制不同,Lock提供了无条件的、可轮询的、定时的、可中断的锁获取操作,所有加锁和解锁的方法都是显式的。Lock的实现必须提供具有与内部加锁相同的内存可见性的语义。但是加锁的语义调度算法,顺序保证,性能特性这些可以不同。

为什么要创建与内部锁如此相似的机制呢?内部锁在大部分情况下都能很好地工作,但是有一些功能上的局限——不能中断那些正在等待获取锁的线程,并且在请求锁失败的情况下,必须无限等待。内部锁必须在获取它们的代码块中被释放;这很好地简化了代码,与异常处理机制能够进行良好的互动,但是在某些情况下,一个更灵活的加锁机制提供了更好的活跃度和性能。

可轮询的锁

boolean tryLock();

仅当锁在调用时处于空闲状态时才获取锁。获得锁如果它是可用的,并立即返回的值为true。如果锁不可用,那么这个方法将立即返回值false。

可定时的锁

boolean tryLock(long time, TimeUnit unit) throws InterruptedException;

如果锁在给定的等待时间内是空闲的,则获取锁。当前线程不是被Thread.interrupt中断。如果锁是可用的,这个方法立即返回的值为true。如果锁不可用,当前线程将被禁用以进行线程调度目的和休眠,直到以下三种情况之一:

  • 锁被当前线程获取,返回true
  • 一些其他线程中断当前线程(支持锁获取中断),抛出InterruptedException
  • 指定的等待时间已经过了,返回false

可中断的锁

void lockInterruptibly() throws InterruptedException

获取锁直到当前线程被中断。

如果锁可用,则获取锁并立即返回。如果锁不可用,则当前线程可用为线程调度目的禁用,休眠到会发生以下两种情况之一:

  • 锁被当前线程获取;
  • 一些其他线程中断当前线程(支持锁获取中断)。

非块结构的锁

在内部锁中,获取和释放这样成对的行为是块结构的—总是在其获得的相同的基本程序块中释放锁,而不考虑控制权是如何退出阻塞块的。自动释放锁简化了程序的分析,并避免了潜在的代码错误造成的麻烦,但是有时需要更灵活的加锁规则。

公平的锁

ReentrantLock构造函数提供了两种公平性的选择:创建非公平锁(默认)或者公平锁。线程按顺序请求获得公平锁,然而一个非公平锁允许“闯入”:当请求这样的锁时,如果锁的状态变为可用,线程的请求可以在等待线程的队列中向前跳跃,获得该锁。(Semaphore同样提供了公平和非公平的获取顺序。)非公平的 ReentrantLock并不是有意鼓励“闯入”—倘若遇到闯入的发生,它们不会有意避开。在公平锁中,如果锁已经被其他线程占有,新的请求线程会加入到等待队列,或者已经有一些线程在等待锁了在非公平的锁中,线程只有当锁正在被占用时才会等待。

synchronized vs ReentrantLock

ReentrantLock与内部锁在加锁和内存语义上是相同的,在以下附加特性的语义上也相同,比如定时锁的等待,可中断锁的等待,公平性,以及实现非块结构的锁。ReentrantLock的性能看起来胜过内部锁,Java6中时略微胜过,而Java5.0中是大大超越。那么为什么不放弃使用 synchronized,鼓励大家都使用新的并发 ReentrantLock呢?

事实上的确有一些作者建议如此,把 synchronized作为“遗留”结构。但是这会把好事情变坏。

内部锁相比于显式锁仍然具有很大的优势。这个标识更为人们所熟悉,也更简洁,而且很多现有的程序已经在使用内部锁了——混合这两者会造成混淆,反而并更易发生错误。 ReentrantLock绝对是最危险的同步工具;如果你忘记在finally块中调用unlock你的代码将很可能看起来能够正常运行,但是已经埋下定时炸弹,并很有可能伤及无辜。在内部锁不能满足需求,需要使用 ReentrantLock的情况下才应该使用。

在内部锁不能够满足使用时, ReentrantLock才被作为更高级的工具。当你需要以下高级特性时,才应该使用:可定时的、可轮询的与可中断的锁获取操作,公平队列,或者非块结构的锁。否则,请使用 synchronized。

阻塞同步

拿到锁的线程可以顺利执行同步块的代码,但是没有拿到锁的线程就没有那么幸运了,它们处于一种阻塞的状态,等待着锁的释放,然后去获取。

拙劣的阻塞:轮询加睡眠

@Threadsafe 
public class Sleepy BoundedBuffer<V> extends BaseBoundedBuffer<V> {
	public Sleepy BoundedBuffer(int size)		{ super(size); }
	public void put(V v)throws InterruptedException {
		while(true) {
			synchronized(this)	{
				if(!isFull()){
					doPut(v);
					return;
				}
			} 
			Thread.sleep(SLEEP_GRANULARITY);
		}
	}
	public V take()  throws InterruptedException {
		while (true)	{
			synchronized (this) {
				if(!isEmpty())
					return doTake();
			}
			Thread.sleep(SLEEP_GRANULARITY);
		}
	}

SleepyBoundedBuffer尝试通过在put和take操作内部封装相同的“轮询和休眠”重试机制,为每次调用实现了重试逻辑,从而分担调用者的麻烦。如果缓存是空的,take将休眠,直到另一个线程在缓存中置入了一些数据;如果缓存是满的,put将休眠,直到另一个线程移除了一些数据,在缓存中腾出地方来。这个方法封装了对先验条件的管理,简化了缓存的使用—这向正确的方向上迈出了一步。

SleepyBoundedBuffer的实现远比前面所做的尝试更复杂。缓存代码必须在持有缓存的锁时才能测试相应的状态条件,因为表示状态条件的变量是由缓存的锁保护的。如果测试失败,执行线程会暂时休眠,不过首先会释放锁,这样其他线程才能够访问缓存。一旦线程被唤醒,它会重新请求锁并再次尝试,在操作可以处理前,它可以休眠也可以检查状态条件。

从调用者的角度看,它的运行表现很好——如果操作可以立即执行,就去做,否则就阻塞——调用者不必处理失败和重试。选择休眠的时间间隔,是在响应性与CPU使用率之间作出的权衡;休眠的间隔越小,响应性越好,但是CPU的消耗也越高。图14.1演示了休眠间隔是如何影响响应性的:缓存空间变为可用的时刻与线程被唤醒并再次检查的时刻之间可能有延迟。

更好的阻塞:条件队列

条件队列可以让一组线程—称作等待集—以某种方式等待相关条件变成真,它也由此得名。不同于传统的队列,它们的元素是数据项;条件队列的元素是等待相关条件的线程

就像每个Java对象都能当作锁一样,每个对象也能当作条件队列, Object中的wait、notify、 notifyAll方法构成了内部条件队列的API。一个对象的内部锁与它的内部条件队列是相关的:为了能够调用对象X中的任一个条件队列方法,你必须持有对象X的锁。
这是因为“等待基于状态的条件”机制必须和“维护状态一致性”机制紧密地绑定在一起:除非你能检査状态,否则你不能等待条件;同时,除非你能改变状态,否则你不能从条件等待(队列)中释放其他的线程。

Object.wait会自动释放锁,并请求OS(操作系统)挂起当前线程,让其他线程获得该锁进而修改对象的状态。当它被唤醒时,它会在返回前重新获得锁。直观上看,调用wait意味着“我要去休息了,但是发生了需要关注的事情后叫醒我”,调用通知(notification)方法意味着“需要关注的事情发生了”。

@Threadsafe 
public class BoundedBuffer<V> extends BaseBoundedBuffer<V> {
	//条件谓词:not-full (isFull())
	//条件谓词:not-empty (!isEmpty())
	public BoundedBuffer(int size) { super(size);}
	//阻塞,直到:not-full
	public synchronized void put(V v) throws InterruptedException {
		while(isFull())
			wait();
		doPut(v);
		notifyAll();
	}
	//阻塞,直到:not-empty 
	public synchronized V take() throws InterruptedException {
		while (isEmpty())
			wait();
		V v=doTake();
		notifyAll();
		return v;
	}
}

BoundedBuffer使用wait和notifyAll实现了有限缓存。这个不仅比休眠”版本更高效(如果缓存状态未变化,就不会频繁地“唤醒”),而且响应性更佳(当所关注的事情发生后会及时唤醒)。这是一项很大的改进,不过要注意:相比于“休眠”版本,条件队列的引入并没有改变原有语意。它不过是在多方面进行优化后的结果CPU效率、上下文切换开销和响应性。你不能利用“轮询和休眠”完成的任何事情,用条件队列也无法完成,但是它使得表达和管理状态的依赖性变得更加简单和高效。

BoundedBuffer目前已经足够好了——简单易用,而且把状态独立性管理得非常清晰。用于生产环境的版本还应该包括限时的put和take版本,这样如果阻塞操作不能在预计的时间内完成,可以超时。限时版的put和take可一个通过Object.wai的限时版来实现,这很简单。

条件谓词

条件谓词是先验条件的第一站,它在一个操作与状态之间建立起依赖关系。在有限缓存中,只有缓存不为空时take才能执行,否则它必须等待。就take而言,它的条件谓词是“缓存不空”,take执行前必须先测试。类似地,put的条件谓词是“缓存不满”。条件谓词是由类的状态变量构成的表达式; BaseBoundedBuffer是通过比较count与零,测试是否“缓存不空”;并通过比较count与缓存大小,测试是否“缓存不满”。

每次调用wai都会隐式地与特定的件谓词相关联。当调用特定条件谓词的wait时,调用者必须已经持有了与条件队列相关的锁,这个锁必须同时还保护着组成条件谓词的状态变量。

锁、条件谓词和条件队列之间存在的三元关系看似并不错综复杂,但wait的返回并不一定意味着线程正在等待的条件谓词已经变成真了。

一个单独的内部条件队列可以与多个条件谓词共同使用。当有人调用 notifyAll,从而唤醒了你的线程时,并不意味你正在等待条件谓词现在变成真了。(这就像让你的烤面包机和咖啡机共用一个振铃;当它响起后,你还必须查看是哪个设备发出的这个信号?。)另外,wait甚至可以“假装”返回—不作为对任何线程调用 notify的响应。

当控制流重新进入调用wai的代码时,它会重新请求与条件队列相关联的锁。现在条件谓词就一定是真了么?不一定。它可能在通知线程(notifying thread)调用 notifyAll的时刻已经变成真的,但是会在你重新请求锁的时刻又再次变为假。在你的线程被唤醒到wait重新请求锁的这段时间内,其他线程可能已经请求到锁,并改变了对象的状态。更有甚者,自从你调用了wait之后,条件谓词可能根本就没有变成过真。你无法知道另个线程为什么调用notify或notifyAll;也许是因为与同一条件队列相关的另一个条件谓词变成了真。“每条件队列多条件谓词”是相当常见的— BoundedBuffer就为“非满”与“非空”两个谓词使用了相同的条件队列。

基于所有这些原因,当你从wait中唤醒后,都必须再次测试条件谓词,如果条件谓词尚未成真,就继续等待(或失败)。在条件谓词没有成真的情况下,你可以一次次地唤醒等待线程,因此你必须永远在循环内部调用wait,在每次迭代中测试条件谓词。下列清单所示的是条件等待的规范式。

void stateDependentMethod() throws InterruptedException {
	//条件谓词必须被锁守护
	synchronized(lock) {
		while (!conditionPredicate())
			lock.wait();
		//现在,对象处于期望的状态中
	}
}

通知

无论何时,当你在等待一个条件,一定要确保有人会在条件谓词变为真时通知你。

在条件队列API中有两个通知方法 notify和notifyAll。无论调用哪一个,你都必须持有与条件队列对象相关联的锁。调用notify的结果是:JVM会从在这个条件队列中等待的众多线程中挑选出一个,并把它唤醒;而调用 notifyAll会唤醒所有正在这个条件队列中等待的线程。由于你调用 notify和 notifyAll时必须持有条件队列对象的锁,这导致等待线程此时不能重新获得锁,无法从wait返回,因此该通知线程应该尽快释放锁,以确保等待线程尽可能快地解除阻塞。

由于会有多个线程因为不同的原因在同一个条件队列中等待,因此不用 notifyAll而使用 notify是危险的。这主要是因为单一的通知容易导致同类的线程丢失全部信号。

BoundedBuffer提供了一个很好的示范,说明为什么notifyAll在大多数情况下都是优于 notify的选择。这里条件队列用于两个不同的条件谓词:“非空”和“非满”。假设线程A因为谓词PA而在条件队列中等待,同时线程B因为谓词PB也在同一个条件队列中等待。现在,假设PB变成真,线程C执行一个单一的notify:JVM将从它所拥有的众多线程中选择一个并唤醒。如果A被选中,它随后被唤醒,看到PA尚未变成真,转而继续等待。其间,本应该可以执行的B却没有被唤醒。这不是严格意义上的“丢失信号”,它更像一个“被劫持的( hijacked)”信号—不过问题是一样的:线程正在等待个已经(或者本应该)发生过的信号。

只有同时满足下述条件后,才能用单一的 notify取代notifyAll:

  • 相同的等待者。只有一个条件谓词与条件队列相关,每个线程从wait返回后执行相同的逻辑;
  • 一进一出。一个对条件变量的通知,至多只激活一个线程执行。

BoundedBuffer中的put和take完成的通知是很保守的:每次向缓存置入对象或从中移出对象的时候,执行一次通知。我们可以对其进行优化:首先,观察只有当缓存从空转为非空,或者从满转为非满时,才需要从等待(队列)中释放一个线程;并且,只有当put或take影响到这些状态转换的某一种时,才发出通知。这叫做“依据条件通知( conditional notification)”。尽管“依据条件通知”可以提升性能,但它毕竟只是一种小技巧(而且还让子类的实现变得复杂),应该谨慎使用。

单一的通知和“依据条件通知”都是优化行为。通常,进行优化时应该遵循“先让它跑起来,再让它快起来—如果它还没有足够快”的原则;错误地进行优化很容易给程序带来无法预料的活跃度失败。

显示的条件队列

在某些情况下,当内部锁非常不灵活时,显式锁就可以派上用场。正如Lock是广义的内部锁, Condition也是广义的内部条件队列。

内部条件队列有一些缺陷。每个内部锁只能有一个与之相关联的条件队列,这意味着在像 BoundedBuffer这种类中,多个线程可能为了不同的条件谓词在同一个条件队列中等待,而且大多数常见的锁模式都会暴露条件队列对象。这些因素都导致不可能为了使用notifyAll,而强迫等待线程统一。如果你想编写一个含有多个条件谓词的并发对象,或者你想获得比条件队列的可见性之外更多的控制权,那么显式的Lock和Condition的实现类提供了一个比内部锁和条件队列更加灵活的选择。

一个Condition和一个单独的Lock相关联,就像条件队列和单独的内部锁相关联一样;调用与 Condition相关联的Lock的 Lock.newCondition方法,可以创建一个Condition。如同Lock提供了比内部加锁要丰富得多的特征集一样, Condition也提供了比内部条件队列要丰富得多的特征集:每个锁可以有多个等待集、可中断/不可中断的条件等待、基于时限的等待以及公平/非公平队列之间的选择。

不同于内部条件队列,你可以让每个Lock都有任意数量的Condition对象。Condition对象继承了与之相关的锁的公平性特性;如果是公平的锁,线程会依照FIFO的顺序从 Condition.await中被释放。

危险警告:wait、notify和notifyAll在Condition对象中的对等体是await、signal和signalAll。但是, Condition继承于Object,这意味着它也有wait和notify方法。一定要确保使用了正确的版本—await和signal!

ConditionboundedBuffer的行为和BoundedBuffer相同,但是它使用条件队列的方式,具有更好的可读性——分析使用多个Condition的类,要比分析一个使用单一内部队列加多个条件谓词的类简单得多。通过把两个条件谓词分离到两个等待集中Condition简化了使用单一通知的条件。使用更有效的signal,而不是signalAll,这就会减少相当数量的上下文转换,而且每次缓存操作都会触发对锁的请求。

就像内置的锁和条件队列一样,当使用显式的Lock和Condition时,也必须要满足锁、条件谓词和条件变量之间的三元关系。涉及条件谓词的变量必须由Lock保护,检查条件谓词时以及调用await和signal时,必须持有Lock对象。

在使用显式的Condition和内部条件队列之间作出选择,与你在Reentrantlock和synchronized之间进行选择是一样的:如果你需要使用需要一些高级特性,比如使用公平队列或者让每个锁对应多个等待集,这时使用Condition要好于使用内部条件队列。(如果你需要Reentrantlock的高级特性,并已经在使用它,那么你已经作出了选择。)

AbstractQueuedSynchronizer

Reentrantlock和Semaphore这两个接口有很多共同点。这些类都扮演了“阀门”的角色,每次只允许有限数目的线程通过它;线程到达阀门后,可以允许通过(lock或acquire成功返回),可以等待(lock或acqulre阻塞),也可以被取消( tryLock或tryAcqulre返回false,指明在允许的时间内,锁或者“许可”不可用)。更进一步,它们都允许可中断的、不可中断的、可限时的请求尝试,它们也都允许选择公平、非公平的等待线程队列。

事实上,它们的实现都用到一个共同的基类, AbstractQueuedSynchronizer(AQS)
和其他很多的Synchronizer一样。AQS是一个用来构建锁和Synchronizer的框架,令人惊讶的是,使用AQS能够简单且高效地构造出应用广泛的大量的Synchronizer。不仅 ReentrantLock和Semaphore是构建于AQS上的,其他的还有CountDownLatch、 ReentrantReadWriteLock, SynchronousQueue和FutureTask。

非阻塞同步

java.util.concurrent包中的许多类,比如 Semaphore和ConcurrentLinkedQueue,都提供了比使用 synchronized更好的性能和可伸缩性。这一章,我们来学习这些性能提升的原始来源:原子变量和非阻塞的同步机制。

近来很多关于并发算法的研究都聚焦在非阻塞算法( nonblocking algorithms)上,这种算法使用低层原子化的机器指令取代锁,比如比较并交换( compare-and-swap),从而保证数据在并发访问下的一致性。非阻塞算法广泛用于操作系统和JM中的线程和进程调度、垃圾回收以及实现锁和其他的并发数据结构。

与基于锁的方案相比,非阻塞算法的设计和实现都要复杂得多,但是它们在可伸缩性和活跃度上占有很大的优势。因为非阻塞算法可以让多个线程在竞争相同资源时不会发生阻塞,所以它能在更精化的层面上调整粒度,并能大大减少调度的开销。进一步而言,它们对死锁和其他活跃度问题具有免疫性。在基于锁的算法中,如果一个线程在持有锁的时候休眠,或者停滞不前,那么其他线程就都不可能前进了,而非阻塞算法不会受到单个线程失败的影响。在Java5.0中,使用原子变量类( atomic variable classes),比如AtomicInteger和 AtomicReference,能够高效地构建非阻塞算法。

即使你不使用原子变量开发非阻塞算法,它也同样可以用作“更优的 volatile变量”。原子变量提供了与 volatile类型变量相同的内存语义,同时还额外支持原子更新—使它们能更加理想地用于计数器、序列发生器和统计数据收集等,另外也比基于锁的方案具有更加出色的可伸缩性。

悲观锁和乐观锁

现代JVM能够对非竞争锁的获取和释放进行优化,让它们非常高效,但是如果有多个线程同时请求锁,JⅥM就需要向操作系统寻求帮助。倘若了出现这种情况,一些“不幸的”线程将会挂起,并稍后恢复运行’。从线程开始恢复起,到它真正被调度前,可能必须等待其他线程完成它们的调度限额规定的时间。挂起和恢复线程会带来很大的开销,并通常伴有冗长的中断。对于基于锁,并且其操作过度细分的类(比如同步容器类,大多数方法只包含很少的操作),当频繁地发生锁的竞争时,调度与真正用于工作的开销间的比值会很可观。

volatile变量与锁相比是更轻量的同步机制,因为它们不会引起上下文的切换和线程调度。然而, volatile变量与锁相比有一些局限性:尽管它们提供了相似的可见性保证,但是它们不能用于构建原子化的复合操作。这意味着当一个变量依赖其他变量时,或者当变量的新值依赖于旧值时,是不能用 volatile变量的。这些都限制了 volatile变量的使用,因此它们不能用于实现可靠的通用工具,比如计数器,或互斥体( mutex)。

加锁还有其他的缺点。当一个线程正在等待锁时,它不能做任何其他事情。如果一个线程在持有锁的情况下发生了延迟(原因包括页错误、调度延迟,或者类似情况),那么其他所有需要该锁的线程都不能前进了。如果阻塞的线程是优先级很高的线程,持有锁的线程优先级较低,那么会造成严重问题—性能风险,被称为优先级倒量( priority inversion)。即使更高的优先级占先,它仍然需要等待锁被释放,这导致它的优先级会降至与优先级较低的线程相同的水平。如果持有锁的线程发生了永久性的阻塞(因为无限循环,死锁,活锁和其他活跃度失败),所有等待该锁的线程都不会前进了。

独占锁是一项悲观的技术——它假设最坏情况(如果你不锁门,捣蛋鬼就会闯入,并破坏物品的秩序),并且会通过获得正确的锁来避免其他线程的打扰,直到作出保证才能继续进行。

对于细粒度的操作,有另外一种选择通常更加有效—乐观的解决方法。凭借新的方法,我们可以指望不受打扰地完成更新。这个方法依赖于冲突监测,从而能判定更新过程中是否存在来自于其他成员的干涉,在冲突发生的情况下,操作失败,并会重试(也可能不重试)。这个乐观的方案就好比我们常说的:“宽恕比准许更容易”,其中“更容易”
意味着“更有效率”。

针对多处理器系统设计的处理器提供了特殊的指令,用来管理并发访问的共享数据。
早期处理器具有原子化的测试并设置(test-and-set),获取并增加( fetch-and- increment)以及交换(swap)指令,这些对于实现互斥已经足够了,并能够用于实现更成熟的并发对象。如今,几乎所有现代的处理器都具有一些形式的原子化的读改写指令,比如比较并交换( compare-and-swap)和加载链接/存储条件(load- linked/store-conditional)。操作系统和JVM使用这些指令来实现锁和并发的数据结构,但是直到Java5.0以前这些还不能直接为Java类所使用

比较并交换(CAS)

CAS有3个操作数—内存位置V、旧的预期值A和新值B。当且仅当V符合旧预期值A时,CAS用新值B原子化地更新V的值;否则它什么都不会做。

使用CAS的典型模式是:首先从V中读取值A,由A生成新值B,然后使用CAS原子化地把V的值由A改成B,并且期间不能有其他线程改变V的值。因为CAS能够发现来自其他线程的干扰,所以即使不使用锁,它也能够解决原子化地实现读-写-改的问题。

非阻塞计数器

CasCounter利用CAS实现了线程安全的计数器。自增操作遵循了经典形式——取得旧值,根据它计算出新值(加1),并使用CAS设定新值。如果CAS失败,立即重试该操作。尽管在竞争十分激烈的情况下,更希望等待或者回退,以避免重试造成的活锁,但是,通常反复重试都是合理的策略。

Cascounter不会发生阻塞,如果其他线程同时更新计数器,它会进行数次重试。(实践中,如果你仅仅需要一个计数器,或者序列生成器,直接使用AtomicInteger或者AtomicLong就可以了,它们提供原子化的自增方法和其他算术方法。)

@Threadsafe 
public class CasCounter {
	private SimulatedCAs value; 
	public int getvalue() {
		return value.get();
	} 
	public int increment() {
		int v;
		do {
			v=value.get();
		}
		while(v!=value.compareAndSwap(v,v+1));
		return v+1;
	}
}

初看起来,基于CAS的计数器看起来比基于锁的计数器性能差一些;它具有更多的操作和更复杂的控制流,表面看来还依赖于复杂的CAS操作。但是,实际上基于CAS的计数器,性能上远远胜过了基于锁的计数器,即使只有很小的竞争,或者不存在竞争。获取非竞争锁的快速路径,通常至少需要一个CAS加上一个锁相关的细节琐事,所以,基于锁的计数器的最好情况也要比基于CAS的一般情况做更多的事情。因为CAS在大多数情况下都能成功(假设竞争程度中等偏下),硬件将能够正确地预知隐藏在 while循环中的分支,使本应更复杂的控制逻辑的开销最小化。

加锁的语法可能比较简洁,但是VM和OS管理锁的工作却并不简单。加锁需要遍历JVM中整个复杂的代码路径,并可能引起系统级的加锁、线程挂起以及上下文切换。在最优的情况下,加锁需要至少一个CAS,所以使用锁时把CAS抛在一边,几乎不能节省任何真正的执行开销。另一方面,程序内部执行CAS不会调用到JVM的代码、系统调用或者调度活动。在应用级看起来越长的代码路径,在考虑到VM和OS的时候,事实上会变成更短的代码。CAS最重要的缺点是:它强迫调用者处理竞争(通过重试、回退,或者放弃):然而在锁被获得之前,却可以通过阻塞自动处理竞争。

CAS的性能随处理器数量变化很大。在单CPU系统中,CAS通常的职责与一个脉冲周期相似,因为不需要处理器间的同步。到写作本书时为止,非竞争的CAS在多CPU系统中花费10到150个CPU周期的开销;CAS的性能是一个不断改变的标准,不仅在不同的体系架构之间,就是在相同处理器的不同版本之间也会发生改变。厂商迫于竞争的压力,在近几年内还会持续提高CAS的性能。对此的一句金玉良言是,即使是在“快速路径”
上,获取和释放无竞争锁的开销大约也是CAS的两倍。

原子变量类

原子变量比锁更精巧,更轻量,并且在多处理器系统中,对实现高性能的并发代码非常关键。原子变量限制了单个变量的竞争范围;这是你所能得到的最好结果了(假设你的算法能够以这么细的粒度来实现)。更新原子变量的快速(非竞争)路径,并不会比获取锁的快速路径差,并且通常会更快;而慢速路径绝对会比锁的慢速路径快,因为它不会引起线程的挂起和重新调度。在使用原子变量取代锁的算法中,线程更不易出现延迟,如果它们遇到竞争,也更容易恢复。

尽管原子化的计量器类扩展于 Number,它们并没有扩展基本类型的包装类,比如Integer或Long。事实上,它们也不应该这样:基本类型的包装类是不可变的,而原子变量类是可变的。原子变量类同样没有重新定义hashCode或equals;每一个实例都是独特的。与其他可变对象一样,它们也不适合作哈希容器的键。

性能比较

在激烈竞争下,锁胜过原子变量,但是在真实的竞争条件下,原子变量会胜过锁.这是因为锁通过挂起线程来响应竞争,减小了CPU的利用和共享内存总线上的同步通信量。(这与在生产者消费者线程中,以阻塞生产者来减小消费者负荷,使它们能够赶上进度的设计是类似的。)从另一方面来看,使用原子变量把竞争管理推回给调用类。与大多数基于CAS的算法相比, AtomicPseudoRandom通过不断反复尝试来响应竞争,这通常是正确的,但是在激烈竞争环境下,会带来更多竞争。

锁与原子化随竞争度的不同,性能发生的改变阐明了各自的优势和劣势。在中低程度的竞争下,原子化提供更好的可伸缩性;在高强度的竞争下,锁能够更好地帮助我们避免竞争。(基于CAS的算法在单CPU系统中,胜过基于锁的算法,因为CAS在单CPU的系统下总是成功的,除非遇到偶发情况,线程在读改写操作的中途被抢占)。

非阻塞算法

基于锁的算法会带来一些活跃度失败的风险。如果线程在持有锁的时候因为阻塞ⅣO页面错误,或其他原因发生延迟,很可能所有线程都不能前进了。一个线程的失败或挂起不应该影响其他线程的失败或挂起,这样的算法被称为非阻骞( nonblocking)算法;如果算法的每一步骤中都有一些线程能够继续执行,那么这样的算法称为锁自由( lock-free算法。在线程间使用CAS进行协调,这样的算法如果能构建正确的话,它既是非阻塞的,又是锁自由的。非竞争的CAS总是能够成功,如果多个线程以一个CAS竞争,总会有个胜出并前进。非阻塞算法对死锁和优先级倒置有“免疫性”(但它们可能会出现饥饿和活锁,因为它们允许重进入)。到目前为止,我们已经见到一个非阻塞算法:CasCounter好的非阻塞算法已经在多种常见的数据结构上现身,包括栈、队列、优先级队列、哈希表设计新的数据结构的任务最好还是留给专家们。

实现同等功能前提下,非阻塞算法被认为比基于锁的算法更加复杂。创建非阻塞算法的前提是为维护数据的一致性,解决如何把原子化范围缩小到一个唯一变量。非阻塞算法阐释了使用CAS“投机地”更新值这一最基本的模式,如果更新失败就重试。构建非阻塞算法的窍门是:缩小原子化的范围到唯一的变量。

原子化的域更新器

下列代码阐释了 ConcurrentlinkedQueue的算法,但是真正的实现与它略有区别。
ConcurrentLinkedQueue并未使用原子化的引用,而是使用普通的volatile引用来代表每个节点,并通过基于反射的 AtomicReferenceFleldUpdater来进行更新。

private class Node<e>	{
	private final E item;
	private volatile Node<E> next; 
	public Node(e item) {
		this.item=item; 
	}
}

private static AtomicReferenceFieldUpdater<Node, Node> nextUpdater AtomicReferenceFieldupdater.newUpdater(Node class, Node class, "next");

原子化的域更新器类(在 integer、Long,以及 Reference版本中可用),代表着已存在的volatile域基于反射的“视图”,使得CAS能够用于已有的volatile域。更新器类没有构造函数;为了创建,你可以调用newUpdater的工厂方法,声明类和域的名称。域更新器类并不依赖特定的实例;它可以用于更新目标类任何实例的目标域。更新器类提供的原子性保护比普通的原子类差一些,因为你不能保证底层的域不被直接修改——compareAndSet和算术方法只在其他线程使用原子化的域更新器方法时保证其原子性。

在 ConcurrentlinkedQueue中,更新Node的next域是通过使用 nextUpdater的compareAndSet方法实现的。这个有点迂回的方案完全是因性能原因使用的。对于频繁分配的、生命周期短暂的对象,比如队列的链接节点,减少每个Node的 AtomicReference创建,对于减小插入操作的开销是非常有效的。

然而,几乎在所有的情况下,普通原子变量表现已经相当不错了——仅仅在很少的情况才下需要使用原子化的域更新器。(当你想要保存现有类的串行化形式时,原子化的域更新器会非常有用。)

ABA问题

ABA问题因为在算法中误用比较并交换而引起的反常现象,节点被循环使用(主要存在于不能被垃圾回收的环境中)。CAS有效地请求“V的值仍为A么?”,并且如果成立就继续处理更新。在大多数情况下,包括这一章展示的例子,已经足够使用了。但是有时我们还想询问“V在我上次观察过后发生了改变么?”在某些算法中,把V的值由A转换为B,再转换为A仍然记为一次改变,需要我们重新进行算法中的某些步骤。

算法中如果进行自身链接节点对象的内存管理,那么可能出现ABA问题。在这种情况下,即使列表的头仍然指向之前观察到的节点,这也不足以说明列表的内容没有发生改变。如果让垃圾回收器为你管理链表的节点,仍然不能避免ABA问题,还有一个相对简单的解决方案:更新一对值,包括引用和版本号,而不是仅更新该值的引用。即使值由A改为B,又再改回A,版本号也是不同的。 AtomicStampedReference(以及它的同系AtomicMarkableReference)提供了一对变量原子化的条件更新。AtomicStampedReference更新对象引用的整数对,允许“版本化”引用是能够避免ABA问题的。类似地, AtomicMarkableReference更新一个对象引用的布尔对,它能够用于些算法,使节点在被标记为“deleted”后仍保留在列表中。

总结

非阻塞算法通过使用低层级并发原语,比如比较并交换,取代了锁。原子变量类向用户提供了这些低层级原语,也能够当作“更佳的 volatile变量”使用,同时提供了整数类和对象引用的原子化更新操作。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值