读《Java并发编程的艺术》学习笔记(五)
第5章 Java中的锁
5.1 Lock接口
锁是用来控制多个线程访问共享资源的方式。
在Lock出现之前,Java程序只能靠synchronized实现锁的功能,在JavaSE5之后,有了Lock接口。虽然缺少了synchronized的隐式获取和释放锁的方便,但是拥有了锁获取与释放的可操作性、可中断的获取所以及超时获取锁等synchronized不具备的同步特性。
Lock的使用:
Lock lock = new ReentrantLock(); lock.lock(); try { } finally { lock.unlock(); }
Lock接口提供的synchronized关键字不具备的主要特性:
Lock是一个接口,它定义了锁获取与释放的基本操作:
5.2 队列同步器(AQS)
队列同步器AbstractQueuedSynchronizer,是用来构建锁或其他同步组件的基础框架,它使用一个int类型的成员变量表示同步状态,通过内置的FIFO队列完成资源获取线程的排队工作。
同步器的主要使用方式是继承,子类继承同步同步器并实现它的抽象方法来管理同步状态,可以通过同步器提供的如下3个方法进行访问和修改同步器状态:
getState():获取当前同步状态
setState(int newState):设置当前同步状态
compareAndSetState(int expect, int update):使用CAS设置当前状态,该方法能够保证状态设置的原子性。
以上3种方法能够保证状态改变的线程安全,同步器支持独占式和共享式获取同步状态。
5.2.1 队列同步器的接口与示例
同步器的设计是基于模板方法模式的,也就是说使用者需要继承同步器并重写指定的方法,随后将同步器组合在自定义同步组件的实现中,而这些模板方法将会调用使用者重写的方法。
重写同步器指定方法时,使用以下3种方法访问和修改同步状态:
1、getState():获取当前同步状态
2、setState(int newState):设置当前同步状态
3、compareAndSetState(int expect, int update):使用CAS设置当前状态,该方法能够保证状态设置的原子性。
同步器可以重写的方法:
实现自定义同步组件时,将会调用同步器提供的模板方法,同步器提供的模板方法如下:
同步器提供的模板方法基本上分为3类:独占式获取与释放同步状态、共享式获取与释放同步状态和查询同步队列中的等待线程情况。自定义同步组件将使用同步器提供的模板方法来实现自己的同步语义。
只有掌握了同步器的工作原理才能更加深入地理解并发包中其他的并发组件,所以下面通过一个独占锁的示例来深入了解一下同步器的工作原理。
5.2.2 队列同步器的实现分析
接下来从实现角度分析同步器是如何完成线程同步的,主要包括:同步队列、独占式同步状态获取与释放、共享式同步状态获取与释放以及超时获取同步状态等同步器核心数据结构与模板方法。
1、同步队列
同步器依赖内部的同步队列(一个FIFO双向队列)来完成同步状态的管理,当前线程获取同步状态失败时,同步器会将当前线程以及等待状态等信息构造成为一个节点(Node)并将其加入同步队列,同时会阻塞当前线程,当同步状态释放时,会把首节点中的线程唤醒,使其再次尝试获取同步状态。
同步队列中的节点(Node)用来保存获取同步状态失败的线程引用、等待状态以及前驱和后继节点。节点的属性类型与名称以及描述如表所示:
节点是构成同步队列(等待队列,在5.6节中将会介绍)的基础,同步器拥有首节点(head)和尾节点(tail),没有成功获取同步状态的线程将会成为节点加入该队列的尾部,同步队列的基本结构如图所示。
在图中,同步器包含了两个节点类型的引用,一个指向头节点,而另一个指向尾节点。同步器提供了一个基于CAS的设置尾节点的方法:compareAndSetTail(Node expect,Nodeupdate),它需要传递当前线程“认为”的尾节点和当前节点,只有设置成功后,当前节点才正式与之前的尾节点建立关联。
同步队列遵循FIFO,首节点是获取同步状态成功的节点,首节点的线程在释放同步状态时,将会唤醒后继节点,而后继节点将会在获取同步状态成功时将自己设置为首节点。
设置首节点是通过获取同步状态成功的线程来完成的,由于只有一个线程能够成功获取到同步状态,因此设置头节点的方法并不需要使用CAS来保证,它只需要将首节点设置成为原首节点的后继节点并断开原首节点的next引用即可。
2、独占式同步状态获取与释放
通过调用同步器的acquire(int arg)方法可以获取同步状态,该方法对中断不敏感,也就是由于线程获取同步状态失败后进入同步队列中,后续对线程进行中断操作时,线程不会从同步队列中移出,该方法代码:
public final void acquire(int arg) { if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) selfInterrupt(); }
上述代码主要完成了同步状态获取、节点构造、加入同步队列以及在同步队列中自旋等待的相关工作,其主要逻辑是:首先调用自定义同步器实现的tryAcquire(int arg)方法,该方法保证线程安全的获取同步状态,如果同步状态获取失败,则构造同步节点(独占式Node.EXCLUSIVE,同一时刻只能有一个线程成功获取同步状态)并通过addWaiter(Node node)方法将该节点加入到同步队列的尾部,最后调用acquireQueued(Node node,int arg)方法,使得该节点以“死循环”的方式获取同步状态。如果获取不到则阻塞节点中的线程,而被阻塞线程的唤醒主要依靠前驱节点的出队或阻塞线程被中断来实现。
下面分析一下相关工作,首先是节点的构造以及加入同步队列:
1 private Node addWaiter(Node mode) { 2 Node node = new Node(Thread.currentThread(), mode); 3 // 快速尝试在尾部添加 4 Node pred = tail; 5 if (pred != null) { 6 node.prev = pred; 7 if (compareAndSetTail(pred, node)) { 8 pred.next = node; 9 return node; 10 } 11 } 12 enq(node); 13 return node; 14 } 15 private Node enq(final Node node) { 16 for (;;) { 17 Node t = tail; 18 if (t == null) { // Must initialize 19 if (compareAndSetHead(new Node())) 20 tail = head; 21 } else { 22 node.prev = t; 23 if (compareAndSetTail(t, node)) { 24 t.next = node; 25 return t; 26 } 27 } 28 } 29 }
上述代码通过使用compareAndSetTail(Node expect,Node update)方法来确保节点能够被线程安全添加。在enq(final Node node)方法中,同步器通过“死循环”来保证节点的正确添加,在“死循环”中只有通过CAS将节点设置成为尾节点之后,当前线程才能从该方法返回,否则,当前线程不断地尝试设置。可以看出,enq(final Node node)方法将并发添加节点的请求通过CAS变得“串行化”了。
节点进入同步队列之后,就进入了一个自旋的过程,每个节点(或者说每个线程)都在自省地观察,当条件满足,获取到了同步状态,就可以从这个自旋过程中退出,否则依旧留在这个自旋过程中(并会阻塞节点的线程):
1 final boolean acquireQueued(final Node node, int arg) { 2 boolean failed = true; 3 try { 4 boolean interrupted = false; 5 for (;;) { 6 final Node p = node.predecessor(); 7 if (p == head && tryAcquire(arg)) { 8 setHead(node); 9 p.next = null; // help GC 10 failed = false; 11 return interrupted; 12 } 13 if (shouldParkAfterFailedAcquire(p, node) && 14 parkAndCheckInterrupt()) 15 interrupted = true; 16 } 17 } finally { 18 if (failed) 19 cancelAcquire(node); 20 } 21 }
在acquireQueued(final Node node,int arg)方法中,当前线程在“死循环”中尝试获取同步状态,而只有前驱节点是头节点才能够尝试获取同步状态,这是为什么?原因有两个:
第一,头节点是成功获取到同步状态的节点,而头节点的线程释放了同步状态之后,将会唤醒其后继节点,后继节点的线程被唤醒后需要检查自己的前驱节点是否是头节点。
第二,维护同步队列的FIFO原则。该方法中,节点自旋获取同步状态的行为如图
由于非首节点线程前驱节点出队或者被中断而从等待状态返回,随后检查自己的前驱是否是头节点:如果是,则尝试获取同步状态。可以看到节点和节点之间在循环检查的过程中基本不相互通信,而是简单地判断自己的前驱是否为头节点,这样就使得节点的释放规则符合FIFO,并且也便于对过早通知的处理(过早通知是指前驱节点不是头节点的线程由于中断而被唤醒)。
独占式同步状态获取流程,也就是acquire(int arg)方法调用流程:
前驱节点为头节点且能够获取同步状态的判断条件和线程进入等待状态是获取同步状态的自旋过程。当同步状态获取成功之后,当前线程从acquire(int arg)方法返回,如果对于锁这种并发组件而言,代表着当前线程获取了锁。
当前线程获取同步状态并执行了相应逻辑之后,就需要释放同步状态,使得后续节点能够继续获取同步状态。通过调用同步器的release(int arg)方法可以释放同步状态,该方法在释放了同步状态之后,会唤醒其后继节点(进而使后继节点重新尝试获取同步状态)
1 public final boolean release(int arg) { 2 if (tryRelease(arg)) { 3 Node h = head; 4 if (h != null && h.waitStatus != 0) 5 unparkSuccessor(h); 6 return true; 7 } 8 return false; 9 }
该方法执行时,会唤醒头节点的后继节点线程,unparkSuccessor(Node node)方法使用LockSupport(在后面的章节会专门介绍)来唤醒处于等待状态的线程。
分析了独占式同步状态获取和释放过程后,适当做个总结:在获取同步状态时,同步器维护一个同步队列,获取状态失败的线程都会被加入到队列中并在队列中进行自旋;移出队列(或停止自旋)的条件是前驱节点为头节点且成功获取了同步状态。在释放同步状态时,同步器调用tryRelease(int arg)方法释放同步状态,然后唤醒头节点的后继节点。
3、共享式同步状态获取与释放
通过调用同步器的acquireShared(int arg)方法可以获取同步状态
1 public final void acquireShared(int arg) { 2 if (tryAcquireShared(arg) < 0) 3 doAcquireShared(arg); 4 } 5 private void doAcquireShared(int arg) { 6 final Node node = addWaiter(Node.SHARED); 7 boolean failed = true; 8 try { 9 boolean interrupted = false; 10 for (;;) { 11 final Node p = node.predecessor(); 12 if (p == head) { 13 int r = tryAcquireShared(arg); 14 if (r >= 0) { 15 setHeadAndPropagate(node, r); 16 p.next = null; 17 if (interrupted) 18 selfInterrupt(); 19 failed = false; 20 return; 21 } 22 } 23 if (shouldParkAfterFailedAcquire(p, node) && 24 parkAndCheckInterrupt()) 25 interrupted = true; 26 } 27 } finally { 28 if (failed) 29 cancelAcquire(node); 30 } 31 }
在acquireShared(int arg)中,同步器代用了tryAcquireShared(int arg)尝试获取同步状态,当其返回值大于等于0时,表示能够获取同步状态。因此,在共享获取的自旋过程中,成功获取到同步状态并退出自旋的条件就是tryAcquireShared(int arg)的返回值大于等于0.在doAcquireShared(int arg)的自旋过程中,如果当前节点的前驱为头结点时,尝试获取同步状态,如果返回值大于等于0,表示该次获取同步状态成功并且从自旋过程中退出。
与独占式一样。共享式获取也需要释放同步状态,通过调用releaseShared(int arg)可以释放同步状态。
1 public final boolean releaseShared(int arg) { 2 if (tryReleaseShared(arg)) { 3 doReleaseShared(); 4 return true; 5 } 6 return false; 7 }
releaseShared(int arg)释放同步状态之后,将会唤醒后续处于等待状态的节点。对于能够支持多个线程同时访问的并发组件(如Semaphore),它和独占式的主要区别在于tryReleaseShared(int arg)方法必须确保同步状态线程安全释放,一般通过循环和CAS来保证的,因为释放同步状态的操作会同时来自多个线程。
4.独占式超时获取同步状态
通过调用同步器的doAcquireNanos(int arg,long nanosTimeout)方法可以超时获取同步状态,即在指定的时间段内获取同步状态,如果获取到同步状态则返回true,否则,返回false。该方法提供了传统Java同步操作(比如synchronized关键字)所不具备的特性。
在分析该方法的实现前,先介绍一下响应中断的同步状态获取过程。在Java 5之前,当一个线程获取不到锁而被阻塞在synchronized之外时,对该线程进行中断操作,此时该线程的中断标志位会被修改,但线程依旧会阻塞在synchronized上,等待着获取锁。在Java 5中,同步器提供了acquireInterruptibly(int arg)方法,这个方法在等待获取同步状态时,如果当前线程被中断,会立刻返回,并抛出InterruptedException。
超时获取同步状态过程可以被视作响应中断获取同步状态过程的“增强版”,doAcquireNanos(int arg,long nanosTimeout)方法在支持响应中断的基础上,增加了超时获取的特性。针对超时获取,主要需要计算出需要睡眠的时间间隔nanosTimeout,为了防止过早通知,nanosTimeout计算公式为:nanosTimeout-=now-lastTime,其中now为当前唤醒时间,lastTime为上次唤醒时间,如果nanosTimeout大于0则表示超时时间未到,需要继续睡眠nanosTimeout纳秒,反之,表示已经超时。
1 private boolean doAcquireNanos(int arg, long nanosTimeout) 2 throws InterruptedException { 3 long lastTime = System.nanoTime(); 4 final Node node = addWaiter(Node.EXCLUSIVE); 5 boolean failed = true; 6 try { 7 for (;;) { 8 final Node p = node.predecessor(); 9 if (p == head && tryAcquire(arg)) { 10 setHead(node); 11 p.next = null; // help GC 12 failed = false; 13 return true; 14 } 15 if (nanosTimeout <= 0) 16 return false; 17 if (shouldParkAfterFailedAcquire(p, node) 18 && nanosTimeout > spinForTimeoutThreshold) 19 LockSupport.parkNanos(this, nanosTimeout); 20 long now = System.nanoTime(); 21 //计算时间,当前时间now减去睡眠之前的时间lastTime得到已经睡眠 22 //的时间delta,然后被原有超时时间nanosTimeout减去,得到了 23 //还应该睡眠的时间 24 nanosTimeout -= now - lastTime; 25 lastTime = now; 26 if (Thread.interrupted()) 27 throw new InterruptedException(); 28 } 29 } finally { 30 if (failed) 31 cancelAcquire(node); 32 } 33 }
该方法在自旋过程中,当节点的前驱节点为头节点时尝试获取同步状态,如果获取成功则从该方法返回,这个过程和独占式同步获取的过程类似,但是在同步状态获取失败的处理上有所不同。如果当前线程获取同步状态失败,则判断是否超时(nanosTimeout小于等于0表示已经超时),如果没有超时,重新计算超时间隔nanosTimeout,然后使当前线程等待nanosTimeout纳秒(当已到设置的超时时间,该线程会从LockSupport.parkNanos(Object blocker,long nanos)方法返回)。
如果nanosTimeout小于等于spinForTimeoutThreshold(1000纳秒)时,将不会使该线程进行超时等待,而是进入快速的自旋过程。原因在于,非常短的超时等待无法做到十分精确,如果这时再进行超时等待,相反会让nanosTimeout的超时从整体上表现得反而不精确。因此,在超时非常短的场景下,同步器会进入无条件的快速自旋。
独占式超时获取同步状态doAcquireNanos(int arg,long nanosTimeout)和独占式获取同步状态acquire(int args)在流程上非常相似,其主要区别在于未获取到同步状态时的处理逻辑。acquire(int args)在未获取到同步状态时,将会使当前线程一直处于等待状态,而doAcquireNanos(int arg,long nanosTimeout)会使当前线程等待nanosTimeout纳秒,如果当前线程在nanosTimeout纳秒内没有获取到同步状态,将会从等待逻辑中自动返回。
5、自定义同步组件——TwinsLock
这部分省略,详细请看书籍。
5.3 重入锁
重入锁ReentrantLock,能够支持一个线程对资源的重复加锁,除此之外,该锁还支持获取锁时的公平与非公平选择。
1、实现重进入
重进入是指任意线程在获取到锁之后能够再次获取该锁而不会被锁所阻塞,该特性的实现需要解决以下两个问题。
1)线程再次获取锁。锁需要去识别获取锁的线程是否为当前占据锁的线程,如果是,则再次成功获取。
2)锁的最终释放。线程重复n次获取了锁,随后在第n次释放该锁后,其他线程能够获取到该锁,锁的最终释放要求锁对于获取进行计数自增,计数表示当前锁被重复获取的次数,而锁被释放时,计数自减,当计数等于0时表示锁已经成功释放。
ReentrantLock是通过组合自定义同步器来实现锁的获取与释放,以非公平性(默认的)实现为例:
1 final boolean nonfairTryAcquire(int acquires) { 2 final Thread current = Thread.currentThread(); 3 int c = getState(); 4 if (c == 0) { 5 if (compareAndSetState(0, acquires)) { 6 setExclusiveOwnerThread(current); 7 return true; 8 } 9 } else if (current == getExclusiveOwnerThread()) { 10 int nextc = c + acquires; 11 if (nextc < 0) 12 throw new Error("Maximum lock count exceeded"); 13 setState(nextc); 14 return true; 15 } 16 return false; 17 }
该方法增加了再次获取同步状态的处理逻辑:通过判断当前线程是否为获取锁的线程来决定获取操作是否成功,如果是获取锁的线程再次请求,则将同步状态值进行增加并返回true,表示获取同步状态成功。
成功获取锁的线程再次获取锁,只是增加了同步状态值,这也就要求ReentrantLock在释放同步状态时减少同步状态值。
1 protected final boolean tryRelease(int releases) { 2 int c = getState() - releases; 3 if (Thread.currentThread() != getExclusiveOwnerThread()) 4 throw new IllegalMonitorStateException(); 5 boolean free = false; 6 if (c == 0) { 7 free = true; 8 setExclusiveOwnerThread(null); 9 } 10 setState(c); 11 return free; 12 }
如果该锁被获取了n次,那么前n-1次tryRelease(int releases)方法必须返回false,而只有同步状态安全释放了,才能返回true。可以看到,该方法将同步状态是否为0作为最终释放的条件,当同步状态为0时,将占有线程设置为null,并返回true,表示释放成功。
2、公平与非公平获取锁的区别
公平性与否是针对获取锁而言的,如果一个锁是公平的,那么锁的获取顺序就应该符合请求的绝对时间顺序,也就是FIFO。
1 protected final boolean tryAcquire(int acquires) { 2 final Thread current = Thread.currentThread(); 3 int c = getState(); 4 if (c == 0) { 5 if (!hasQueuedPredecessors() && compareAndSetState(0, acquires)) { 6 setExclusiveOwnerThread(current); 7 return true; 8 } 9 } else if (current == getExclusiveOwnerThread()) { 10 int nextc = c + acquires; 11 if (nextc < 0) 12 throw new Error("Maximum lock count exceeded"); 13 setState(nextc); 14 return true; 15 } 16 return false; 17 }
该方法与nofairTryAcquire(int acquires)比较,唯一不同的位置为判断条件多了hasQueuedPredecessors()方法,即加入同步队列中当前节点是否有前驱节点的判断,如果该方法返回true,表示有线程比当前线程更早地请求获取锁,因为需要等待前驱线程获取并释放锁之后才能继续获取锁。
总结:公平性锁与非公平性锁的区别:
公平性锁保证了锁的获取按照FIFO原则,而代价是进行大量的线程切换。非公平性锁虽然可能造成线程”饥饿”,但极少的线程切换,保证了其更大的吞吐量。
5.4 读写锁
读写锁在同一时刻可以允许多个读线程访问,但是在写线程访问时,所有的读线程和其他写线程均被阻塞。读写锁维护了一对锁,一个读锁和一个写锁,通过分离读锁和写锁,使得并发性相比一般的排他锁有了很大提升。
一般情况下,读写锁性能都会比排它锁好,适用于读多于写的情况。Java并发包提供的读写锁的实现是ReentrantReadWriteLock,提供的特性如下:公平性选择、重入性、锁降级。
5.4.1 读写锁的接口与示例
ReadWriteLock仅定义了获取读锁和写锁这两个方法,即readLock()和writeLock()方法,而其实现类——ReentrantReadWriteLock,处理接口方法之外,还提供了一些便于外界监控其内部工作状态的方法,如下:
5.4.2 读写锁的实现分析
接下来分析ReentrantReadWriteLock的实现,主要包括:读写状态设计、写锁的获取与释放、读锁的获取与释放以及锁降级
1、读写状态设计
读写锁同样依赖自定义同步器来实现同步功能,而读写状态就是其同步器的同步状态。回想ReentrantLock中自定义同步器的实现,同步状态表示锁被一个线程重复获取的次数,而读写锁的自定义同步器需要在同步状态(一个整数变量)上维护多个读线程和一个写线程的状态,使得该状态的设计成为读写锁实现的关键。
如果在一个整数变量上维护多种状态,就一定需要”按位切割使用“这个变量,读写锁将变量切分了两个部分,高16位表示读,低16位表示写。
上图中的同步状态表示一个线程已经获取了写锁,并且重进入了2次;同时也连续获取了2次读锁。读写锁是如何迅速确定读和写各自的状态呢?答案是:通过位运算。假设当前同步状态值为S,写状态等于S&0x0000FFFF(将高16位全部抹去),读状态等于S>>>16(无符号补0右移16位)。当写状态增加1时,等于S+1,当读状态增加1时,等于S+(1<<16),也就是S+0x00010000。
根据状态的划分能得出一个推论:S不等于0时,当写状态(S&0x0000FFFF)等于0时,则读状态(S>>>16)大于0,即读锁已被获取。
2、写锁的获取与释放
写锁是一个支持重进入的排他锁。如果当前线程已经获取了写锁,则增加写状态。如果当前线程在获取写锁时,读锁已经被获取(读状态不为0)或者该线程已经获取写锁的线程,则当前线程进入等待状态。
1 protected final boolean tryAcquire(int acquires) { 2 Thread current = Thread.currentThread(); 3 int c = getState(); 4 int w = exclusiveCount(c); 5 if (c != 0) { 6 // 存在读锁或者当前获取线程不是已经获取写锁的线程 7 if (w == 0 || current != getExclusiveOwnerThread()) 8 return false; 9 if (w + exclusiveCount(acquires) > MAX_COUNT) 10 throw new Error("Maximum lock count exceeded"); 11 setState(c + acquires); 12 return true; 13 } 14 if (writerShouldBlock() || !compareAndSetState(c, c + acquires)) { 15 return false; 16 } 17 setExclusiveOwnerThread(current); 18 return true; 19 }
该方法除了重入条件(当前线程为获取了写锁的线程)之外,增加了一个读锁是否存在的判断。如果存在读锁,则写锁不能被获取,原因在于:读写锁要确保写锁的操作对读锁可见,如果允许读锁在已被获取的情况下对写锁的获取,那么正在运行的其他读线程就无法感知到当前写线程的操作。因此,只有等待其他读线程都释放了读锁,写锁才能被当前线程获取,而写锁一旦被获取,则其他读写线程的后续访问均被阻塞。
写锁的释放与ReentrantLock的释放过程基本类似,每次释放均减少写状态,当写状态为0时,表示写锁已被释放,从而等待的读写线程能够继续访问读写锁,同时前次写线程的修改对后续读写线程可见。
3、读锁的获取与释放
读锁是一个支持重进入的共享锁,它能够被多个线程同时获取,在没有其他写线程访问(或者写状态为0)时,读锁总会被成功地获取,而所做的也只是(线程安全的)增加读状态。如果当前线程已经获取了读锁,则增加读状态。如果当前线程在获取读锁时,写锁已经被其他线程获取,则进入等待状态。
1 protected final int tryAcquireShared(int unused) { 2 for (;;) { 3 int c = getState(); 4 int nextc = c + (1 << 16); 5 if (nextc < c) 6 throw new Error("Maximum lock count exceeded"); 7 if (exclusiveCount(c) != 0 && owner != Thread.currentThread()) 8 return -1; 9 if (compareAndSetState(c, nextc)) 10 return 1; 11 } 12 }
在tryAcquireShared(int unused)方法中,如果其他线程已经获取了写锁,则当前线程获取读锁失败,进入等待状态。如果当前线程获取了写锁或者写锁未被获取,则当前线程(线程安全,依靠CAS保证)增加读状态,成功获取读锁。读锁的每次释放均减少读状态,减少的值是(1<<16)。
4、锁降级
锁降级:指的是写锁降级成为读锁。如果当前线程拥有写锁,然后将其释放,最后再获取读锁,这种分段完成的过程不能称之为锁降级,锁降级是指把持住(当前拥有的)写锁,再获取到读锁,随后释放(先前拥有的)写锁的过程。
public void processData() { readLock.lock(); if (!update) { // 必须先释放读锁 readLock.unlock(); // 锁降级从写锁获取到开始 writeLock.lock(); try { if (!update) { // 准备数据的流程(略) update = true; } readLock.lock(); } finally { writeLock.unlock(); } // 锁降级完成,写锁降级为读锁 } try { // 使用数据的流程(略) } finally { readLock.unlock(); } }
锁降级中读锁的获取是必要的。主要是为了保证数据的可见性,如果当前线程不获取读锁而是直接释放写锁,假设此刻另一个线程获取了写锁并修改了数据,那么当前线程无法感知线程T的数据更新。如果当前线程获取读锁,即遵循锁降级的步骤,则线程T将被阻塞,直到当前线程使用数据并释放读锁之后,线程T才能获取写锁进行数据更新。
5.5 LockSupport工具
LockSupport定义了一组公共的静态方法,这些方法提供了最基本的阻塞或者唤醒的功能,也是构建同步组件的基础工具。
5.6 Condition接口
任意一个Java对象都拥有一组监视器方法(定义在java.lang.Object上),主要包括:wait()、wait(long timeout)、notify()、notifyAll(),这些方法和synchronized关键字结合使用,可以实现等待/通知模式。
Condition接口也提供了类似Object的监视器方法,与Lock接口配合使用实现等待/通知模式。对比Object的监视器方法和Condition接口,可以更加详细的了解Condition 的特性:
5.6.1 Condition接口与示例
Condition定义了等待/通知两种类型的方法,当前线程调用这些方法时,需要提前获取到Condition对象关联的锁。Condition对象是由Lock对象(调用Lock对象的newCondition()方法)创建出来的,换句话说,Condition是依赖Lock对象的。
Condition定义的(部分)方法以及描述:
5.6.2 Condition的实现分析
ConditionObject是同步器AbstractQueuedSynchronizer的内部类,因为Condition的操作需要获取相关联的锁,所以作为同步器的内部类也较为合理。 关于Condition的实现,主要包括:等待队列、等待和通知。
1、等待队列
等待队列是一个FIFO的队列,在队列中的每个节点都包含了一个线程引用,该线程就是在Condition对象上等待的线程,如果一个线程调用了Condition.await()方法,那么该线程将会释放锁、构造成节点加入到等待队列并进入等待状态。事实上,同步队列和等待队列中的节点类型都是同步器的静态内部类AbstractQueuedSynchronizer.Node。
一个Condition包含一个等待队列,Condition有首节点和尾节点。当前线程调用Condition.await()时会将当前线程构造节点,并将节点从尾部加入到等待队列。基本结构如下:
将新增节点添加到等待队列尾部不需要CAS操作,因为调用await()方法的线程肯定是获取了锁的线程,也就是说该过程是由锁来保证线程安全的。
在Object的监视器模型上,一个对象拥有一个同步队列和一个等待队列;而并发包中的Lock拥有一个同步队列和多个等待队列,其对应关系是:
2、等待
调用Condition的await(),或以await开头的方法,会使当前线程进入等待队列并释放锁,同时线程状态变为等待状态。当从await()方法中返回时,一定是获取了Condition相关联的锁。
如果从队列(同步队列和等待队列)的角度看await()方法,当调用await()方法时,相当于同步队列的首节点(获取了锁的节点)移动到了等待队列中。
Condition的await()方法源码如下:
1 public final void await() throws InterruptedException { 2 if (Thread.interrupted()) 3 throw new InterruptedException(); 4 // 当前线程加入等待队列 5 Node node = addConditionWaiter(); 6 // 释放同步状态,也就是释放锁 7 int savedState = fullyRelease(node); 8 int interruptMode = 0; 9 while (!isOnSyncQueue(node)) { 10 LockSupport.park(this); 11 if ((interruptMode = checkInterruptWhileWaiting(node)) != 0) 12 break; 13 } 14 if (acquireQueued(node, savedState) && interruptMode != THROW_IE) 15 interruptMode = REINTERRUPT; 16 if (node.nextWaiter != null) 17 unlinkCancelledWaiters(); 18 if (interruptMode != 0) 19 reportInterruptAfterWait(interruptMode); 20 }
调用了该方法的线程成功获取了锁的线程,也就是同步队列中的首节点,该方法会将当前线程构造出节点并加入等待队列中,然后释放同步状态,唤醒同步队列中的后继结点,然后当前线程会进入等待状态。
当等待队列中的结点被唤醒,则唤醒结点的线程开始尝试获取同步状态。如果不是通过其他线程调用的signal()唤醒,而是对等待线程进行中断,则会抛出InterruptedException.
当前线程加入到等待队列的过程:
3、通知
调用Condition的signal(),将会唤醒在等待队列中等待时间最长的节点(首节点),在唤醒节点之前,会将节点移到同步队列中。 Condition的signal()方法源码:
1 public final void signal() { 2 if (!isHeldExclusively()) 3 throw new IllegalMonitorStateException(); 4 Node first = firstWaiter; 5 if (first != null) 6 doSignal(first); 7 }
调用该方法的前置条件是当前线程必须获取锁,接着获取等待队列的首节点,将其移动到同步队列并使用LockSupport唤醒节点中的线程。
节点从等待队列移到同步队列的过程:
Condition的signalAll()方法,相当于等待队列中的每个节点均被执行一次signal()方法,效果就是将等待队列中所有节点全部移动到同步队列中,并唤醒每个节点的线程。