java.util.concurrent包中的绝大多数同步工具,如锁(locks)和屏障(barriers)等,都基于AbstractQueuedSynchronizer(简称AQS)构建而成。这个框架提供了一套同步管理的通用机制,如同步状态的原子性管理、线程阻塞与解除阻塞,还有线程排队等。
在JDK1.5引入了java.util.concurrent包,其中包含多个支持中等级别线程并发的类,如可重入锁(ReentrantLock)、读锁(ReentrantReadWriteLock.ReadLock)、写锁(Reentrant-ReadWriteLock.WriteLock)、信号量(Semaphore)、屏障(CyclicBarrier)、Future对象、事件指示器以及传送队列等。这些同步类主要有如下功能:
(1)对象内部同步状态的维护(如表示锁的状态是已获取还是已释放)。
(2)更新和检查状态的操作。而且至少有一个方法会导致调用线程在同步状态被获取时被阻塞,以及在其他线程改变这个同步状态时解除线程的阻塞。
几乎任何一个知名的同步器都可以用来实现其他形式的同步器。例如,可以用可重入锁(ReentrantLock)来实现信号量(Semaphore);反之,用信号量也可以实现可重入锁。但是,这样做会带来复杂性高、开销过大、不灵活等问题,使其最终只能成为一个二流项目。而使用AQS用户可以用简洁的方式定义自己的线程同步器。
所有的线程同步器至少应该包含两个方法:一个是acquire,另一个是release。acquire方法阻塞调用的线程,直到同步状态允许其继续执行。而release操作则是通过某种方式改变同步状态,使得一个或多个被阻塞的线程解锁。
在JUC包中并没有对同步器定义统一的API。因此,有些类通过Lock接口来定义(如ReentrantLock、ReentrantReadWriteLock.ReadLock、ReentrantReadWriteLock.WriteLock),而另外一些则定义了其专有的版本(如Semaphore、CountDownLatch)。因此acquire和release方法的操作,会有各种不同的形式、不同的类。例如,Lock.lock、Semaphore.acquire、CountDownLatch.await和FutureTask.get等,都会映射到acquire操作。
JUC为了支持同步器的常用功能,对所有同步类做了一致性约定,即每个同步器都应支持下面的操作:
同步器的实现根据其状态是否独占而有所不同。独占状态的同步器,在同一时间只有一个线程可以通过阻塞点,而共享状态的同步器可以同时有多个线程进入阻塞点。通过AQS实现的同步器,必须同时支持独占与共享两种模式。
在JUC包里还定义了Condition接口,支持监视器风格的await/signal操作。Condition的操作必须与独占模式的Lock类相关。
synchronized的监视器锁依赖于JVM,常规的JVM锁优化策略用于避免CPU竞争。这种优化策略对于依赖多核服务器的JUC同步器来说,并不适用。
对于那些需要控制资源分配的应用,更重要的是去维持多线程读取的公平性,可以容忍较差的吞吐量。因此,应该提供不同的公平策略。
无论同步器的内部实现多么精致,它还是会在某些应用中产生性能瓶颈。因此,AQS必须提供监视工具让用户发现这些瓶颈,至少需要提供一种方式来确定有多少线程被阻塞了
volatile是一个特征修饰符,它的作用是作为指令关键字,确保本条指令不会因编译器的优化而省略,而且要求直接读取这个变量值,不能从缓存或寄存器等位置读取。
在Java的并发编程中,多线程共享的成员变量或静态变量,为了保证线程安全性,需要使用synchronized内部监视器进行同步控制,还可以使用更加轻量级的volatile保护。
在JVM 1.2之前,Java的内存模型实现总是从主存读取变量,这是不需要进行特别的注意的。而随着JVM的成熟和优化,现在多线程环境下,volatile关键字的使用变得非常重要。
在当前的Java内存模型下,线程可以把变量保存在本地内存(如机器的寄存器)中,而不是直接在主存(RAM)中进行读写。这就可能造成一个线程在主存中修改了一个变量的值,而另外一个线程还继续使用它在寄存器中的变量值的复制,从而出现数据不一致的情况。
要解决这个问题,只需要把该变量声明为volatile即可,这就指示JVM,这个变量是不稳定的,每次使用它都要到主存中进行读取。一般情况下,多任务环境中各任务间共享的变量都应该加volatile修饰。
volatile修饰的成员变量在每次被线程访问时,都强迫线程从共享内存中重读该成员变量的值。而且,当成员变量发生变化时,强迫线程将变化值回写到共享内存。这样在任何时刻,两个不同的线程总是看到某个成员变量的同一个值。
Java语言规范中指出:为了获得最佳速度,允许线程保存共享成员变量的私有复制,而且只有当线程进入或者离开同步代码块时,才会与共享成员变量的原始值进行对比。这样当多个线程同时与某个对象交互时,就必须要让线程及时得到共享成员变量的变化。而volatile关键字就是提示JVM:对于这个成员变量不能保存它的私有复制,而应直接与共享成员内存交互。
使用建议:在两个或者更多的线程访问的成员变量上使用volatile;当要访问的变量已在synchronized代码块中,或者为常量时,不必使用。由于使用volatile屏蔽掉了JVM中必要的代码优化,所以其在效率上比较低,因此一定在必要时才使用此关键字。
总结:对于并发修改共享变量,volatile无法起到线程安全的作用,应该使用可重用锁ReentrantLock或synchronized锁住代码块。但是,对于并发读取共享变量,使用volatile修饰共享变量,则可以使变量值的变化马上同步给其他线程,如上例的IntGenerator中的canceled属性,它只需被某个线程改变一次即可,这时如果使用synchronized,则不如用volatile简单、直观。
CAS是Compare And Swap的简称,即比较再交换。使用CAS可以解决多线程并发下的变量同步问题,而且CAS比使用传统锁机制,性能要好得多。
CAS操作包含三个操作数——内存位置(V)、原值(A)和新值(B)。即准备把指定内存位置替换成新值前,需要校验原有的旧值,防止被其他线程修改。如果校验合格,即在运算的中间过程,指定地址的值未被修改,则替换成功,否则退出。
CAS是一种无锁算法,CAS实现依赖的是专有CPU指令集,因此它的运行效率非常高!
CAS算法在遇到ABA问题时,会出现判断错误,即CAS在有些特例场景下是无法判断数据是否被修改的,需要小心。
ABA问题描述如下:
(1)若线程1从内存Y中取出A。
(2)线程2也从内存Y中取出A,然后线程2将内存Y中的值变更为B,接着线程2又将内存Y中的数据修改为A。
(3)线程1进行CAS操作发现内存Y中仍然是A,然后线程1替换操作成功。
虽然线程1的CAS操作成功,但是整个过程是有问题的。比如,虽然链表的头在变化了两次后恢复了原值,但这并不代表链表就没有变化。所以Java中提供了AtomicStampedReference、AtomicMarkableReference来处理会发生ABA问题的场景,即在对象中额外再增加一个版本号来标识对象是否有过变更。
在java.util.concurrent.atomic包中提供了AtomicInteger、AtomicBoolean、AtomicLong、AtomicIntegerArray等很多用于在高并发环境下进行原子变化值的类。
Java内存的分配与垃圾回收依赖于JVM,而Java中没有指针,因此无法直接操作内存,因此JVM的底层代码都是通过C或C++语言实现的。
Unsafe类通过JNI的方式访问本地的C++实现库,从而使Java具有了直接操作内存空间的能力。但这同时也带来了一定的问题,如果不合理地使用Unsafe类操作内存空间,可能导致内存泄漏和指针越界,这可能导致程序崩溃,因此不推荐开发者直接调用Unsafe。
在JSR166之前,阻塞线程和解除线程阻塞都是基于Java Object内置的监视器,没有基于Java API创建的同步器。唯一可以选择的是Thread.suspend和Thread.resume,但是它们都有无法解决的竞态问题(这两个方法后期已被废除)。
JUC包有一个LockSuport类,这个类中包含了解决这个问题的方法。LockSupport.park阻塞当前线程直到有个LockSupport.unpark方法被调用。unpark的调用是没有被计数的,因此在一个park调用前多次调用unpark方法只会解除一个park操作。在缺少一个unpark操作时,下一次调用park就会阻塞。
park方法同样支持超时设置,以及与JVM的Thread.interrupt结合,可通过中断来unpark一个线程。
AQS的核心就是如何使用队列管理被阻塞的线程,该队列是严格的FIFO队列,因此AQS不支持基于优先级的同步。
同步队列的最佳选择是自身不使用底层锁来构造非阻塞数据结构,目前,业界对此很少有争议。而其中主要有两个选择:一个是Mellor-Crummey和Scott锁(MCS锁)的变体,另一个是Craig、Landin和Hagersten锁(CLH锁)的变体。
一直以来,CLH锁仅被用于自旋锁。但是,在AQS中,CLH锁显然比MCS锁更合适。因为CLH锁可以更容易地去实现“取消”和“超时”功能。
CLH队列实际上并不那么像队列,因为它的入队和出队操作都与锁紧密相关。它是一个链表队列,通过两个字段head和tail来存取,这两个字段是可原子更新的,两者在初始化时都指向一个空节点(见图7-1)。
一个新节点Node,通过原子操作入队。
每个节点的“释放”,状态都保存在其前驱节点中,因此,自旋锁的“自旋”操作如下:
while (pred.status != RELEASED); //自旋
自旋等待后的出队操作,只需将head字段指向刚刚得到锁的节点即可:
head = node;
CLH锁的优点在于其入队和出队操作是快速、无锁、无障碍的。即使在多线程竞争环境下,某个线程也总会赢得一次插入机会,从而能继续执行。
为了将CLH队列用于阻塞式同步器,需要做些额外的修改以提供一种高效的方式定位某个节点的后继节点。在自旋锁中,一个节点只需要改变其状态,下一次自旋中其后继节点就能注意到这个改变,所以节点间的链接并不是必需的。但在阻塞式同步器中,一个节点需要显式地唤醒(unpark)其后继节点。
AQS队列的每个节点都包含一个next指向它的后继节点。但是,由于没有针对双向链表节点的类似compareAndSet的原子性无锁插入指令,因此这个next的设置并非作为原子性插入操作的一部分,而仅是在节点被插入后进行简单赋值:
pred.next = node;
next仅是一种优化。如果通过某个节点的next字段发现其后继节点不存在,总是可以使用pred字段从尾部开始向前遍历来检查是否真的有后续节点。
第二个对CLH队列的修改是将每个节点都有的状态字段用于控制阻塞而非自旋。在AQS框架中,仅在线程调用具体子类中的tryAcquire方法返回true时,队列中的线程才能从acquire操作中返回;而单一的释放标记位是不够的,仍然需要做些控制以确保当一个活动的线程位于队列头部时,仅允许其调用tryAcquire;这时的acquire可能会失败,然后阻塞。这种情况不需要读取状态标识,因为可以通过检查当前节点的前驱是否为head来确定权限。与自旋锁不同,CPU读取head以保证复制时不会有太多的内存竞争。
AQS框架提供了一个条件对象(ConditionObject)类,服务于实现Lock接口的排他锁同步器。一个锁对象可以关联任意数目的条件对象,可以提供典型的await、signal和signalAll等操作,以及一些检测、监控的方法。
ConditionObject有效地将条件(condition)与其他同步操作结合到了一起。只有当前线程持有锁且要操作的条件属于该锁时,条件操作才是合法的。
一个ConditionObject关联到一个ReentrantLock上,这与调用Object.wait构建监视器阻塞当前线程的功效一样(前面我们已经学习过了Condition的await()和signal()方法)。
AQS类将上述功能和性能目标结合到一起,基于设计模式的template method(模板方法),作为基类提供给同步器。子类只需实现控制acquire和release操作的状态检查、更新等代码。
所有java.util.concurrent包中的同步器类都声明了一个私有的继承了AbstractQueued-Synchronizer的内部类,并且把所有同步方法都委托给这个内部类来完成。这样各个同步器类的公开方法就可以使用适合自己的名称了。
尽管同步器是基于FIFO队列的,但它们并不一定是公平的。在基础的acquire算法中,tryAcquire是在入队前被执行的。因此一个新的acquire线程能够“窃取”本该属于队列头部第一个线程的进入同步器的机会。
可竞争抢夺的FIFO策略通常会提供比其他技术更高的吞吐量。当一个有竞争的锁已经空闲,而下一个准备获取锁的线程又正在解除阻塞的过程中,这时就没有线程可以获取到这个锁。如果使用抢夺策略,则可减少这之间的时间间隔。与此同时,这种策略还可避免过分的、无效率的竞争。在只要求短时间持有同步器的场景中,创建同步器的开发者可以通过定义tryAcquire在控制权返回之前重复调用自己若干次,来进一步凸显抢夺效果(见图7-2)。
可抢夺的FIFO同步器只有概率上的公平属性。锁队列头部第一个解除了阻塞的线程拥有一次机会来赢得与闯入线程之间的竞争。如果竞争失败,要么重新阻塞、要么再次重试。
图7-2 抢夺同步器
然而,如果闯入的线程到达的速度比队列头的线程解阻塞的速度快,那么在队列中的第一个线程将很难赢得竞争,以至于几乎总要重新阻塞,并且它的后继节点也会一直保持阻塞。对于短暂持有的同步器来说,在队列中第一个线程被解除阻塞期间,多处理器上很可能发生过多次闯入和release了,因此保持一个或多个线程高速运行的同时,要尽量避免其他线程饥饿的发生。
当有更高的公平性需求时,你可以把tryAcquire方法定义为:若当前线程不是队列的头节点,则立即失败,返回false即可。
一个更快,但并非严格公平的变体可以这样做,若判断瞬间队列为空,允许tryAcquire执行成功。在这种情况下,多个线程同时遇到一个空队列时可能会去竞争以使自己第一个获得锁,这样通常至少有一个线程是无须入队列的。JUC包中所有支持公平模式的同步器都采用了这种策略。
ReentrantLock类使用AQS同步状态来保存锁持有的次数。当锁被一个线程获取时,ReentrantLock也会记录下当前获得锁的线程标识,以便检查是不是重复获取,以及当错误的线程试图进行解锁操作时检测是否存在非法状态异常。
一个线程是无须入队列的。JUC包中所有支持公平模式的同步器都采用了这种策略。
ReentrantLock类使用AQS同步状态来保存锁持有的次数。当锁被一个线程获取时,ReentrantLock也会记录下当前获得锁的线程标识,以便检查是不是重复获取,以及当错误的线程试图进行解锁操作时检测是否存在非法状态异常。
ReentrantLock也使用了AQS提供的ConditionObject,还向外暴露了其他监控方法。ReentrantLock通过在内部声明两个不同的AQS实现类来实现可选的公平模式,在创建ReentrantLock实例的时候可以指定是否采用公平模式。