6.3.1 类图结构
ReentrantLock是可重入的独占锁,同时只能有一个线程可以获取该锁,其他获取该锁的线程会被阻塞而被放入该锁的AQS阻塞队列里面。首先看下ReentrantLock的类图以便对它的实现有个大致了解,如图6-4所示。
图6-4
从类图可以看到,ReentrantLock最终还是使用AQS来实现的,并且根据参数来决定其内部是一个公平还是非公平锁,默认是非公平锁。
public ReentrantLock() {
sync = new NonfairSync();
}
public ReentrantLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
}
其中Sync类直接继承自AQS,它的子类NonfairSync和FairSync分别实现了获取锁的非公平与公平策略。
在这里,AQS的state状态值表示线程获取该锁的可重入次数,在默认情况下,state的值为0表示当前锁没有被任何线程持有。当一个线程第一次获取该锁时会尝试使用CAS设置state的值为1,如果CAS成功则当前线程获取了该锁,然后记录该锁的持有者为当前线程。在该线程没有释放锁的情况下第二次获取该锁后,状态值被设置为2,这就是可重入次数。在该线程释放该锁时,会尝试使用CAS让状态值减1,如果减1后状态值为0,则当前线程释放该锁。
6.3.2 获取锁
1.void lock() 方法
当一个线程调用该方法时,说明该线程希望获取该锁。如果锁当前没有被其他线程占用并且当前线程之前没有获取过该锁,则当前线程会获取到该锁,然后设置当前锁的拥有者为当前线程,并设置AQS的状态值为1,然后直接返回。如果当前线程之前已经获取过该锁,则这次只是简单地把AQS的状态值加1后返回。如果该锁已经被其他线程持有,则调用该方法的线程会被放入AQS队列后阻塞挂起。
public void lock() {
sync.lock();
}
在如上代码中,ReentrantLock的lock()委托给了sync类,根据创建ReentrantLock构造函数选择sync的实现是NonfairSync还是FairSync,这个锁是一个非公平锁或者公平锁。这里先看sync的子类NonfairSync的情况,也就是非公平锁时。
final void lock() {
//(1)CAS设置状态值
if (compareAndSetState(0, 1))
setExclusiveOwnerThread(Thread.currentThread());
else
//(2)调用AQS的acquire方法
acquire(1);
}
在代码(1)中,因为默认AQS的状态值为0,所以第一个调用Lock的线程会通过CAS设置状态值为1, CAS成功则表示当前线程获取到了锁,然后setExclusiveOwnerThread设置该锁持有者是当前线程。
如果这时候有其他线程调用lock方法企图获取该锁,CAS会失败,然后会调用AQS的acquire方法。注意,传递参数为1,这里再贴下AQS的acquire的核心代码。
public final void acquire(int arg) {
//(3)调用ReentrantLock重写的tryAcquire方法
if (! tryAcquire(arg) &&
// tryAcquiref返回false会把当前线程放入AQS阻塞队列
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
之前说过,AQS并没有提供可用的tryAcquire方法,tryAcquire方法需要子类自己定制化,所以这里代码(3)会调用ReentrantLock重写的tryAcquire方法。我们先看下非公平锁的代码。
protected final boolean tryAcquire(int acquires) {
return nonfairTryAcquire(acquires);
}
final boolean nonfairTryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
//(4)当前AQS状态值为0
if (c == 0) {
if (compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}//(5)当前线程是该锁持有者
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires;
if (nextc < 0) // overflow
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}//(6)
return false;
}
首先代码(4)会查看当前锁的状态值是否为0,为0则说明当前该锁空闲,那么就尝试CAS获取该锁,将AQS的状态值从0设置为1,并设置当前锁的持有者为当前线程然后返回,true。如果当前状态值不为0则说明该锁已经被某个线程持有,所以代码(5)查看当前线程是否是该锁的持有者,如果当前线程是该锁的持有者,则状态值加1,然后返回true,这里需要注意,nextc<0说明可重入次数溢出了。如果当前线程不是锁的持有者则返回false,然后其会被放入AQS阻塞队列。
介绍完了非公平锁的实现代码,回过头来看看非公平在这里是怎么体现的。首先非公平是说先尝试获取锁的线程并不一定比后尝试获取锁的线程优先获取锁。
这里假设线程A调用lock()方法时执行到nonfairTryAcquire的代码(4),发现当前状态值不为0,所以执行代码(5),发现当前线程不是线程持有者,则执行代码(6)返回false,然后当前线程被放入AQS阻塞队列。
这时候线程B也调用了lock()方法执行到nonfairTryAcquire的代码(4),发现当前状态值为0了(假设占有该锁的其他线程释放了该锁),所以通过CAS设置获取到了该锁。明明是线程A先请求获取该锁呀,这就是非公平的体现。这里线程B在获取锁前并没有查看当前AQS队列里面是否有比自己更早请求该锁的线程,而是使用了抢夺策略。那么下面看看公平锁是怎么实现公平的。公平锁的话只需要看FairSync重写的tryAcquire方法。
protected final boolean tryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
//(7)当前AQS状态值为0
if (c == 0) {
//(8)公平性策略
if (! hasQueuedPredecessors() &&
compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
//(9)当前线程是该锁持有者
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires;
if (nextc < 0)
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}//(10)
return false;
}
如以上代码所示,公平的tryAcquire策略与非公平的类似,不同之处在于,代码(8)在设置CAS前添加了hasQueuedPredecessors方法,该方法是实现公平性的核心代码,代码如下。
public final boolean hasQueuedPredecessors() {
Node t = tail; // Read fields in reverse initialization order
Node h = head;
Node s;
return h ! = t &&
((s = h.next) == null || s.thread ! = Thread.currentThread());
}
在如上代码中,如果当前线程节点有前驱节点则返回true,否则如果当前AQS队列为空或者当前线程节点是AQS的第一个节点则返回false。其中如果h==t则说明当前队列为空,直接返回false;如果h! =t并且s==null则说明有一个元素将要作为AQS的第一个节点入队列(回顾前面的内容,enq函数的第一个元素入队列是两步操作:首先创建一个哨兵头节点,然后将第一个元素插入哨兵节点后面),那么返回true,如果h! =t并且s! =null和s.thread ! = Thread.currentThread()则说明队列里面的第一个元素不是当前线程,那么返回true。
2.void lockInterruptibly() 方法
该方法与lock()方法类似,它的不同在于,它对中断进行响应,就是当前线程在调用该方法时,如果其他线程调用了当前线程的interrupt()方法,则当前线程会抛出InterruptedException异常,然后返回。
public void lockInterruptibly() throws InterruptedException {
sync.acquireInterruptibly(1);
}
public final void acquireInterruptibly(int arg)
throws InterruptedException {
//如果当前线程被中断,则直接抛出异常
if (Thread.interrupted())
throw new InterruptedException();
//尝试获取资源
if (! tryAcquire(arg))
//调用AQS可被中断的方法
doAcquireInterruptibly(arg);
}
3.boolean tryLock() 方法
尝试获取锁,如果当前该锁没有被其他线程持有,则当前线程获取该锁并返回true,否则返回false。注意,该方法不会引起当前线程阻塞。
public boolean tryLock() {
return sync.nonfairTryAcquire(1);
}
final boolean nonfairTryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
if (compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires;
if (nextc < 0) // overflow
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}
如上代码与非公平锁的tryAcquire()方法代码类似,所以tryLock()使用的是非公平策略。
4.boolean tryLock(long timeout, TimeUnit unit) 方法
尝试获取锁,与tryLock()的不同之处在于,它设置了超时时间,如果超时时间到没有获取到该锁则返回false。
public boolean tryLock(long timeout, TimeUnit unit)
throws InterruptedException {
//调用AQS的tryAcquireNanos方法
return sync.tryAcquireNanos(1, unit.toNanos(timeout));
}
6.3.3 释放锁
1.void unlock() 方法
尝试释放锁,如果当前线程持有该锁,则调用该方法会让该线程对该线程持有的AQS状态值减1,如果减去1后当前状态值为0,则当前线程会释放该锁,否则仅仅减1而已。如果当前线程没有持有该锁而调用了该方法则会抛出IllegalMonitorStateException异常,代码如下。
public void unlock() {
sync.release(1);
}
protected final boolean tryRelease(int releases) {
//(11)如果不是锁持有者调用UNlock则抛出异常
int c = getState() - releases;
if (Thread.currentThread() ! = getExclusiveOwnerThread())
throw new IllegalMonitorStateException();
boolean free = false;
//(12)如果当前可重入次数为0,则清空锁持有线程
if (c == 0) {
free = true;
setExclusiveOwnerThread(null);
}
//(13)设置可重入次数为原始值-1
setState(c);
return free;
}
如代码(11)所示,如果当前线程不是该锁持有者则直接抛出异常,否则查看状态值是否为0,为0则说明当前线程要放弃对该锁的持有权,则执行代码(12)把当前锁持有者设置为null。如果状态值不为0,则仅仅让当前线程对该锁的可重入次数减1。
6.3.4 案例介绍
下面使用ReentrantLock来实现一个简单的线程安全的list。
public static class ReentrantLockList {
//线程不安全的list
private ArrayList array = new ArrayList();
//独占锁
private volatile ReentrantLock lock = new ReentrantLock();
//添加元素
public void add(String e) {
lock.lock();
try {
array.add(e);
} finally {
lock.unlock();
}
}
//删除元素
public void remove(String e) {
lock.lock();
try {
array.remove(e);
} finally {
lock.unlock();
}
}
//获取数据
public String get(int index) {
lock.lock();
try {
return array.get(index);
} finally {
lock.unlock();
}
}
}
如上代码通过在操作array元素前进行加锁保证同一时间只有一个线程可以对array数组进行修改,但是也只能有一个线程对array元素进行访问。
同样最后使用图(见图6-5)来加深理解。
图6-5
如图6-5所示,假如线程Thread1、Thread2和Thread3同时尝试获取独占锁ReentrantLock,假设Thread1获取到了,则Thread2和Thread3就会被转换为Node节点并被放入ReentrantLock对应的AQS阻塞队列,而后被阻塞挂起。
如图6-6所示,假设Thread1获取锁后调用了对应的锁创建的条件变量1,那么Thread1就会释放获取到的锁,然后当前线程就会被转换为Node节点插入条件变量1的条件队列。由于Thread1释放了锁,所以阻塞到AQS队列里面的Thread2和Thread3就有机会获取到该锁,假如使用的是公平策略,那么这时候Thread2会获取到该锁,从而从AQS队列里面移除Thread2对应的Node节点。
图6-6
6.3.5 小结
本节介绍了ReentrantLock的实现原理,ReentrantLock的底层是使用AQS实现的可重入独占锁。在这里AQS状态值为0表示当前锁空闲,为大于等于1的值则说明该锁已经被占用。该锁内部有公平与非公平实现,默认情况下是非公平的实现。另外,由于该锁是独占锁,所以某时只有一个线程可以获取该锁。
解决线程安全问题使用ReentrantLock就可以,但是ReentrantLock是独占锁,某时只有一个线程可以获取该锁,而实际中会有写少读多的场景,显然ReentrantLock满足不了这个需求,所以ReentrantReadWriteLock应运而生。ReentrantReadWriteLock采用读写分离的策略,允许多个线程可以同时获取读锁。
6.4.1 类图结构
为了了解ReentrantReadWriteLock的内部构造,我们先看下它的类图结构,如图6-7所示。
图6-7
读写锁的内部维护了一个ReadLock和一个WriteLock,它们依赖Sync实现具体功能。而Sync继承自AQS,并且也提供了公平和非公平的实现。下面只介绍非公平的读写锁实现。我们知道AQS中只维护了一个state状态,而ReentrantReadWriteLock则需要维护读状态和写状态,一个state怎么表示写和读两种状态呢?ReentrantReadWriteLock巧妙地使用state的高16位表示读状态,也就是获取到读锁的次数;使用低16位表示获取到写锁的线程的可重入次数。
static final int SHARED_SHIFT = 16;
//共享锁(读锁)状态单位值65536
static final int SHARED_UNIT = (1 << SHARED_SHIFT);
//共享锁线程最大个数65535
static final int MAX_COUNT = (1 << SHARED_SHIFT) -1;
//排它锁(写锁)掩码,二进制 ,15个1
static final int EXCLUSIVE_MASK = (1 << SHARED_SHIFT) -1;
/** 返回读锁线程数 */
static int sharedCount(int c) { return c >>> SHARED_SHIFT; }
/** 返回写锁可重入个数 */
static int exclusiveCount(int c) { return c & EXCLUSIVE_MASK; }
其中firstReader用来记录第一个获取到读锁的线程,firstReaderHoldCount则记录第一个获取到读锁的线程获取读锁的可重入次数。cachedHoldCounter用来记录最后一个获取读锁的线程获取读锁的可重入次数。
static final class HoldCounter {
int count = 0;
//线程id
final long tid = getThreadId(Thread.currentThread());
}
readHolds是ThreadLocal变量,用来存放除去第一个获取读锁线程外的其他线程获取读锁的可重入次数。ThreadLocalHoldCounter继承了ThreadLocal,因而initialValue方法返回一个HoldCounter对象。
static final class ThreadLocalHoldCounter
extends ThreadLocal {
public HoldCounter initialValue() {
return new HoldCounter();
}
}
拓展延伸:
1、为什么要记录第一个和最后一个线程呢?
为了让性能快那么一点点。线程会存放到两个地方,一个是ThreadLocalMap当中,另一个就是firstReader、firstReaderHoldCount、cachedHoldCounter变量当中,变量当中取肯定比map当中取快。
2、为什么只存放第一个和最后一个,中间的为什么不存放?
简单的说,如果存放了,那是不是得很多变量,你会那样写代码吗?第一个和最后一个比较特殊一点。
下来看源码:
protected final int tryAcquireShared(int unused) { Thread current = Thread.currentThread(); int c = getState(); if (exclusiveCount(c) != 0 && getExclusiveOwnerThread() != current) return -1; int r = sharedCount(c); if (!readerShouldBlock() && r < MAX_COUNT && compareAndSetState(c, c + SHARED_UNIT)) { if (r == 0) { //第一个线程它不会在变了,所以直接存到变量中 firstReader = current; firstReaderHoldCount = 1; } else if (firstReader == current) { firstReaderHoldCount++; } else { HoldCounter rh = cachedHoldCounter; if (rh == null || rh.tid != getThreadId(current)) cachedHoldCounter = rh = readHolds.get(); else if (rh.count == 0)//场景:加锁——释放锁——再加锁时,期间没有其他线程 readHolds.set(rh); //不等于null,并且当前线程是cachedHoldCounter缓存中的最后一个线程, //则直接从变量rh中拿,不从map中获取,性能快那么一点点。 rh.count++; } return 1; } return fullTryAcquireShared(current); }
6.4.2 写锁的获取与释放
在ReentrantReadWriteLock中写锁使用WriteLock来实现。
1.void lock()
写锁是个独占锁,某时只有一个线程可以获取该锁。如果当前没有线程获取到读锁和写锁,则当前线程可以获取到写锁然后返回。如果当前已经有线程获取到读锁和写锁,则当前请求写锁的线程会被阻塞挂起。另外,写锁是可重入锁,如果当前线程已经获取了该锁,再次获取只是简单地把可重入次数加1后直接返回。
public void lock() {
sync.acquire(1);
}
public final void acquire(int arg) {
// sync重写的tryAcquire方法
if (! tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
如以上代码所示,在lock()内部调用了AQS的acquire方法,其中tryAcquire是ReentrantReadWriteLock内部的sync类重写的,代码如下。
protected final boolean tryAcquire(int acquires) {
Thread current = Thread.currentThread();
int c = getState();
int w = exclusiveCount(c);
//(1)c! =0说明读锁或者写锁已经被某线程获取
if (c ! = 0) {
//(2)w=0说明已经有线程获取了读锁,w! =0并且当前线程不是写锁拥有者,则返回
false
if (w == 0 || current ! = getExclusiveOwnerThread())
return false;
//(3)说明当前线程获取了写锁,判断可重入次数
if (w + exclusiveCount(acquires) > MAX_COUNT)
throw new Error("Maximum lock count exceeded");
//(4)设置可重入次数(1)
setState(c + acquires);
return true;
}
//(5)第一个写线程获取写锁
if (writerShouldBlock() ||
!compareAndSetState(c, c + acquires))
return false;
setExclusiveOwnerThread(current);
return true;
}
在代码(1)中,如果当前AQS状态值不为0则说明当前已经有线程获取到了读锁或者写锁。在代码(2)中,如果w==0说明状态值的低16位为0,而AQS状态值不为0,则说明高16位不为0,这暗示已经有线程获取了读锁,所以直接返回false。
而如果w! =0则说明当前已经有线程获取了该写锁,再看当前线程是不是该锁的持有者,如果不是则返回false。
执行到代码(3)说明当前线程之前已经获取到了该锁,所以判断该线程的可重入次数是不是超过了最大值,是则抛出异常,否则执行代码(4)增加当前线程的可重入次数,然后返回true.
如果AQS的状态值等于0则说明目前没有线程获取到读锁和写锁,所以执行代码(5)。其中,对于writerShouldBlock方法,非公平锁的实现为
final boolean writerShouldBlock() {
return false; // writers can always barge
}
如果代码对于非公平锁来说总是返回false,则说明代码(5)抢占式执行CAS尝试获取写锁,获取成功则设置当前锁的持有者为当前线程并返回true,否则返回false。
公平锁的实现为
final boolean writerShouldBlock() {
return hasQueuedPredecessors();
}
这里还是使用hasQueuedPredecessors来判断当前线程节点是否有前驱节点,如果有则当前线程放弃获取写锁的权限,直接返回false。
2.void lockInterruptibly()
类似于lock()方法,它的不同之处在于,它会对中断进行响应,也就是当其他线程调用了该线程的interrupt()方法中断了当前线程时,当前线程会抛出异常InterruptedException异常。
public void lockInterruptibly() throws InterruptedException {
sync.acquireInterruptibly(1);
}
3.boolean tryLock()
尝试获取写锁,如果当前没有其他线程持有写锁或者读锁,则当前线程获取写锁会成功,然后返回true。如果当前已经有其他线程持有写锁或者读锁则该方法直接返回false,且当前线程并不会被阻塞。如果当前线程已经持有了该写锁则简单增加AQS的状态值后直接返回true。
public boolean tryLock( ) {
return sync.tryWriteLock();
}
final boolean tryWriteLock() {
Thread current = Thread.currentThread();
int c = getState();
if (c ! = 0) {
int w = exclusiveCount(c);
if (w == 0 || current ! = getExclusiveOwnerThread())
return false;
if (w == MAX_COUNT)
throw new Error("Maximum lock count exceeded");
}
if (! compareAndSetState(c, c + 1))
return false;
setExclusiveOwnerThread(current);
return true;
}
如上代码与tryAcquire方法类似,这里不再讲述,不同在于这里使用的是非公平策略。
4.boolean tryLock(long timeout, TimeUnit unit)
与tryAcquire()的不同之处在于,多了超时时间参数,如果尝试获取写锁失败则会把当前线程挂起指定时间,待超时时间到后当前线程被激活,如果还是没有获取到写锁则返回false。另外,该方法会对中断进行响应,也就是当其他线程调用了该线程的interrupt()方法中断了当前线程时,当前线程会抛出InterruptedException异常。
public boolean tryLock(long timeout, TimeUnit unit)
throws InterruptedException {
return sync.tryAcquireNanos(1, unit.toNanos(timeout));
}
5.void unlock()
尝试释放锁,如果当前线程持有该锁,调用该方法会让该线程对该线程持有的AQS状态值减1,如果减去1后当前状态值为0则当前线程会释放该锁,否则仅仅减1而已。如果当前线程没有持有该锁而调用了该方法则会抛出IllegalMonitorStateException异常,代码如下。
public void unlock() {
sync.release(1);
}
public final boolean release(int arg) {
//调用ReentrantReadWriteLock中sync实现的tryRelease方法
if (tryRelease(arg)) {
//激活阻塞队列里面的一个线程
Node h = head;
if (h ! = null && h.waitStatus ! = 0)
unparkSuccessor(h);
return true;
}
return false;
}
protected final boolean tryRelease(int releases) {
//(6)看是否是写锁拥有者调用的unlock
if (! isHeldExclusively())
throw new IllegalMonitorStateException();
//(7)获取可重入值,这里没有考虑高16位,因为获取写锁时读锁状态值肯定为0
int nextc = getState() - releases;
boolean free = exclusiveCount(nextc) == 0;
//(8)如果写锁可重入值为0则释放锁,否则只是简单地更新状态值
if (free)
setExclusiveOwnerThread(null);
setState(nextc);
return free;
}
在如上代码中,tryRelease首先通过isHeldExclusively判断是否当前线程是该写锁的持有者,如果不是则抛出异常,否则执行代码(7),这说明当前线程持有写锁,持有写锁说明状态值的高16位为0,所以这里nextc值就是当前线程写锁的剩余可重入次数。代码(8)判断当前可重入次数是否为0,如果free为true则说明可重入次数为0,所以当前线程会释放写锁,将当前锁的持有者设置为null。如果free为false则简单地更新可重入次数。
6.4.3 读锁的获取与释放
ReentrantReadWriteLock中的读锁是使用ReadLock来实现的。
1.void lock()
获取读锁,如果当前没有其他线程持有写锁,则当前线程可以获取读锁,AQS的状态值state的高16位的值会增加1,然后方法返回。否则如果其他一个线程持有写锁,则当前线程会被阻塞。
public void lock() {
sync.acquireShared(1);
}
public final void acquireShared(int arg) {
//调用ReentrantReadWriteLock中的sync的tryAcquireShared方法
if (tryAcquireShared(arg) < 0)
//调用AQS的doAcquireShared方法
doAcquireShared(arg);
}
在如上代码中,读锁的lock方法调用了AQS的acquireShared方法,在其内部调用了ReentrantReadWriteLock中的sync重写的tryAcquireShared方法,代码如下。
protected final int tryAcquireShared(int unused) {
//(1)获取当前状态值
Thread current = Thread.currentThread();
int c = getState();
//(2)判断是否写锁被占用
if (exclusiveCount(c) ! = 0 &&
getExclusiveOwnerThread() ! = current)
return -1;
//(3)获取读锁计数
int r = sharedCount(c);
//(4)尝试获取锁,多个读线程只有一个会成功,不成功的进入fullTryAcquireShared进行重试
if (! readerShouldBlock() &&
r < MAX_COUNT &&
compareAndSetState(c, c + SHARED_UNIT)) {
//(5)第一个线程获取读锁
if (r == 0) {
firstReader = current;
firstReaderHoldCount = 1;
//(6)如果当前线程是第一个获取读锁的线程
} else if (firstReader == current) {
firstReaderHoldCount++;
} else {
//(7)记录最后一个获取读锁的线程或记录其他线程读锁的可重入数
HoldCounter rh = cachedHoldCounter;
if (rh == null || rh.tid ! = current.getId())
cachedHoldCounter = rh = readHolds.get();
else if (rh.count == 0)
readHolds.set(rh);
rh.count++;
}
return 1;
}
//(8)类似tryAcquireShared,但是是自旋获取
return fullTryAcquireShared(current);
}
如上代码首先获取了当前AQS的状态值,然后代码(2)查看是否有其他线程获取到了写锁,如果是则直接返回-1,而后调用AQS的doAcquireShared方法把当前线程放入AQS阻塞队列。
如果当前要获取读锁的线程已经持有了写锁,则也可以获取读锁。但是需要注意,当一个线程先获取了写锁,然后获取了读锁处理事情完毕后,要记得把读锁和写锁都释放掉,不能只释放写锁。
否则执行代码(3),得到获取到的读锁的个数,到这里说明目前没有线程获取到写锁,但是可能有线程持有读锁,然后执行代码(4)。其中非公平锁的readerShouldBlock实现代码如下。
final boolean readerShouldBlock() {
return apparentlyFirstQueuedIsExclusive();
}
final boolean apparentlyFirstQueuedIsExclusive() {
Node h, s;
return (h = head) ! = null &&
(s = h.next) ! = null &&
!s.isShared() &&
s.thread ! = null;
}
如上代码的作用是,如果队列里面存在一个元素,则判断第一个元素是不是正在尝试获取写锁,如果不是,则当前线程判断当前获取读锁的线程是否达到了最大值。最后执行CAS操作将AQS状态值的高16位值增加1。
代码(5)(6)记录第一个获取读锁的线程并统计该线程获取读锁的可重入数。代码(7)使用cachedHoldCounter记录最后一个获取到读锁的线程和该线程获取读锁的可重入数,readHolds记录了当前线程获取读锁的可重入数。
如果readerShouldBlock返回true则说明有线程正在获取写锁,所以执行代码(8)。fullTryAcquireShared的代码与tryAcquireShared类似,它们的不同之处在于,前者通过循环自旋获取。
final int fullTryAcquireShared(Thread current) {
HoldCounter rh = null;
for (; ; ) {
int c = getState();
if (exclusiveCount(c) ! = 0) {
if (getExclusiveOwnerThread() ! = current)
return -1;
// else we hold the exclusive lock; blocking here
// would cause deadlock.
} else if (readerShouldBlock()) {
// Make sure we're not acquiring read lock reentrantly
if (firstReader == current) {
// assert firstReaderHoldCount > 0;
} else {
if (rh == null) {
rh = cachedHoldCounter;
if (rh == null || rh.tid ! = getThreadId(current)) {
rh = readHolds.get();
if (rh.count == 0)
readHolds.remove();
}
}
if (rh.count == 0)
return -1;
}
}
if (sharedCount(c) == MAX_COUNT)
throw new Error("Maximum lock count exceeded");
if (compareAndSetState(c, c + SHARED_UNIT)) {
if (sharedCount(c) == 0) {
firstReader = current;
firstReaderHoldCount = 1;
} else if (firstReader == current) {
firstReaderHoldCount++;
} else {
if (rh == null)
rh = cachedHoldCounter;
if (rh == null || rh.tid ! = getThreadId(current))
rh = readHolds.get();
else if (rh.count == 0)
readHolds.set(rh);
rh.count++;
cachedHoldCounter = rh; // cache for release
}
return 1;
}
}
}
2.void lockInterruptibly()
类似于lock()方法,不同之处在于,该方法会对中断进行响应,也就是当其他线程调用了该线程的interrupt()方法中断了当前线程时,当前线程会抛出InterruptedException异常。
3.boolean tryLock()
尝试获取读锁,如果当前没有其他线程持有写锁,则当前线程获取读锁会成功,然后返回true。如果当前已经有其他线程持有写锁则该方法直接返回false,但当前线程并不会被阻塞。如果当前线程已经持有了该读锁则简单增加AQS的状态值高16位后直接返回true。其代码类似tryLock的代码,这里不再讲述。
4.boolean tryLock(long timeout, TimeUnit unit)
与tryLock()的不同之处在于,多了超时时间参数,如果尝试获取读锁失败则会把当前线程挂起指定时间,待超时时间到后当前线程被激活,如果此时还没有获取到读锁则返回false。另外,该方法对中断响应,也就是当其他线程调用了该线程的interrupt()方法中断了当前线程时,当前线程会抛出InterruptedException异常。
5.void unlock()
public void unlock() {
sync.releaseShared(1);
}
如上代码具体释放锁的操作是委托给Sync类来做的,sync.releaseShared方法的代码如下:
public final boolean releaseShared(int arg) {
if (tryReleaseShared(arg)) {
doReleaseShared();
return true;
}
return false;
}
其中tryReleaseShared的代码如下。
protected final boolean tryReleaseShared(int unused) {
Thread current = Thread.currentThread();
....
//循环直到自己的读计数-1, CAS更新成功
for (; ; ) {
int c = getState();
int nextc = c - SHARED_UNIT;
if (compareAndSetState(c, nextc))
return nextc == 0;
}
}
如以上代码所示,在无限循环里面,首先获取当前AQS状态值并将其保存到变量c,然后变量c被减去一个读计数单位后使用CAS操作更新AQS状态值,如果更新成功则查看当前AQS状态值是否为0,为0则说明当前已经没有读线程占用读锁,则tryReleaseShared返回true。然后会调用doReleaseShared方法释放一个由于获取写锁而被阻塞的线程,如果当前AQS状态值不为0,则说明当前还有其他线程持有了读锁,所以tryReleaseShared返回false。如果tryReleaseShared中的CAS更新AQS状态值失败,则自旋重试直到成功。
6.4.4 案例介绍
上节介绍了如何使用ReentrantLock实现线程安全的list,但是由于ReentrantLock是独占锁,所以在读多写少的情况下性能很差。下面使用ReentrantReadWriteLock来改造它,代码如下。
public static class ReentrantLockList {
//线程不安全的list
private ArrayList array = new ArrayList();
//独占锁
private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
private final Lock readLock = lock.readLock();
private final Lock writeLock = lock.writeLock();
//添加元素
public void add(String e) {
writeLock.lock();
try {
array.add(e);
} finally {
writeLock.unlock();
}
}
//删除元素
public void remove(String e) {
writeLock.lock();
try {
array.remove(e);
} finally {
writeLock.unlock();
}
}
//获取数据
public String get(int index) {
readLock.lock();
try {
return array.get(index);
} finally {
readLock.unlock();
}
}
}
以上代码调用get方法时使用的是读锁,这样运行多个读线程来同时访问list的元素,这在读多写少的情况下性能会更好。
最后使用一张图(见图6-8)来加深对ReentrantReadWriteLock的理解。
图6-8
6.4.5 小结
本节介绍了读写锁ReentrantReadWriteLock的原理,它的底层是使用AQS实现的。ReentrantReadWriteLock巧妙地使用AQS的状态值的高16位表示获取到读锁的个数,低16位表示获取写锁的线程的可重入次数,并通过CAS对其进行操作实现了读写分离,这在读多写少的场景下比较适用。
6.5.1 概述
StampedLock是并发包里面JDK8版本新增的一个锁,该锁提供了三种模式的读写控制,当调用获取锁的系列函数时,会返回一个long型的变量,我们称之为戳记(stamp),这个戳记代表了锁的状态。其中try系列获取锁的函数,当获取锁失败后会返回为0的stamp值。当调用释放锁和转换锁的方法时需要传入获取锁时返回的stamp值。
StampedLock提供的三种读写模式的锁分别如下。
● 写锁writeLock:是一个排它锁或者独占锁,某时只有一个线程可以获取该锁,当一个线程获取该锁后,其他请求读锁和写锁的线程必须等待,这类似于ReentrantReadWriteLock的写锁(不同的是这里的写锁是不可重入锁);当目前没有线程持有读锁或者写锁时才可以获取到该锁。请求该锁成功后会返回一个stamp变量用来表示该锁的版本,当释放该锁时需要调用unlockWrite方法并传递获取锁时的stamp参数。并且它提供了非阻塞的tryWriteLock方法。
● 悲观读锁readLock:是一个共享锁,在没有线程获取独占写锁的情况下,多个线程可以同时获取该锁。如果已经有线程持有写锁,则其他线程请求获取该读锁会被阻塞,这类似于ReentrantReadWriteLock的读锁(不同的是这里的读锁是不可重入锁)。这里说的悲观是指在具体操作数据前其会悲观地认为其他线程可能要对自己操作的数据进行修改,所以需要先对数据加锁,这是在读少写多的情况下的一种考虑。请求该锁成功后会返回一个stamp变量用来表示该锁的版本,当释放该锁时需要调用unlockRead方法并传递stamp参数。并且它提供了非阻塞的tryReadLock方法。
● 乐观读锁tryOptimisticRead:它是相对于悲观锁来说的,在操作数据前并没有通过CAS设置锁的状态,仅仅通过位运算测试。如果当前没有线程持有写锁,则简单地返回一个非0的stamp版本信息。获取该stamp后在具体操作数据前还需要调用validate方法验证该stamp是否已经不可用,也就是看当调用tryOptimisticRead返回stamp后到当前时间期间是否有其他线程持有了写锁,如果是则validate会返回0,否则就可以使用该stamp版本的锁对数据进行操作。由于tryOptimisticRead并没有使用CAS设置锁状态,所以不需要显式地释放该锁。该锁的一个特点是适用于读多写少的场景,因为获取读锁只是使用位操作进行检验,不涉及CAS操作,所以效率会高很多,但是同时由于没有使用真正的锁,在保证数据一致性上需要复制一份要操作的变量到方法栈,并且在操作数据时可能其他写线程已经修改了数据,而我们操作的是方法栈里面的数据,也就是一个快照,所以最多返回的不是最新的数据,但是一致性还是得到保障的。
StampedLock还支持这三种锁在一定条件下进行相互转换。例如long tryConvertToWriteLock(long stamp)期望把stamp标示的锁升级为写锁,这个函数会在下面几种情况下返回一个有效的stamp(也就是晋升写锁成功):
● 当前锁已经是写锁模式了。
● 当前锁处于读锁模式,并且没有其他线程是读锁模式
● 当前处于乐观读模式,并且当前写锁可用。
另外,StampedLock的读写锁都是不可重入锁,所以在获取锁后释放锁前不应该再调用会获取锁的操作,以避免造成调用线程被阻塞。当多个线程同时尝试获取读锁和写锁时,谁先获取锁没有一定的规则,完全都是尽力而为,是随机的。并且该锁不是直接实现Lock或ReadWriteLock接口,而是其在内部自己维护了一个双向阻塞队列。
6.5.2 案例介绍
下面通过JDK 8里面提供的一个管理二维点的例子来理解以上介绍的概念。
class Point {
// 成员变量
private double x, y;
// 锁实例
private final StampedLock sl = new StampedLock();
// 排它锁——写锁(writeLock)
void move(double deltaX, double deltaY) {
long stamp = sl.writeLock();
try {
x += deltaX;
y += deltaY;
} finally {
sl.unlockWrite(stamp);
}
}
// 乐观读锁(tryOptimisticRead)
double distanceFromOrigin() {
//(1)尝试获取乐观读锁
long stamp = sl.tryOptimisticRead();
//(2)将全部变量复制到方法体栈内
double currentX = x, currentY = y;
//(3)检查在(1)处获取了读锁戳记后,锁有没被其他写线程排它性抢占
if (! sl.validate(stamp)) {
//(4)如果被抢占则获取一个共享读锁(悲观获取)
stamp = sl.readLock();
try {
//(5)将全部变量复制到方法体栈内
currentX = x;
currentY = y;
} finally {
//(6)释放共享读锁
sl.unlockRead(stamp);
}
}
//(7)返回计算结果
return Math.sqrt(currentX * currentX + currentY * currentY);
}
// 使用悲观锁获取读锁,并尝试转换为写锁
void moveIfAtOrigin(double newX, double newY) {
//(1)这里可以使用乐观读锁替换
long stamp = sl.readLock();
try {
//(2)如果当前点在原点则移动
while (x == 0.0 && y == 0.0) {
//(3)尝试将获取的读锁升级为写锁
long ws = sl.tryConvertToWriteLock(stamp);
//(4)升级成功,则更新戳记,并设置坐标值,然后退出循环
if (ws ! = 0L) {
stamp = ws;
x = newX;
y = newY;
break;
} else {
//(5)读锁升级写锁失败则释放读锁,显式获取独占写锁,然后循环重试
sl.unlockRead(stamp);
stamp = sl.writeLock();
}
}
} finally {
//(6)释放锁
sl.unlock(stamp);
}
}
}
在如上代码中,Point类里面有两个成员变量(x, y)用来表示一个点的二维坐标,和三个操作坐标变量的方法。另外实例化了一个StampedLock对象用来保证操作的原子性。
首先分析下move方法,该方法的作用是使用参数的增量值,改变当前point坐标的位置。代码先获取到了写锁,然后对point坐标进行修改,而后释放锁。该锁是排它锁,这保证了其他线程调用move函数时会被阻塞,也保证了其他线程不能获取读锁,来读取坐标的值,直到当前线程显式释放了写锁,保证了对变量x, y操作的原子性和数据一致性。
然后看distanceFromOrigin方法,该方法的作用是计算当前位置到原点(坐标为0,0)的距离,代码(1)首先尝试获取乐观读锁,如果当前没有其他线程获取到了写锁,那么代码(1)会返回一个非0的stamp用来表示版本信息,代码(2)复制坐标变量到本地方法栈里面。
代码(3)检查在代码(1)中获取到的stamp值是否还有效,之所以还要在此校验是因为代码(1)获取读锁时并没有通过CAS操作修改锁的状态,而是简单地通过与或操作返回了一个版本信息,在这里校验是看在获取版本信息后到现在的时间段里面是否有其他线程持有了写锁,如果有则之前获取的版本信息就无效了。
如果校验成功则执行代码(7)使用本地方法栈里面的值进行计算然后返回。需要注意的是,在代码(3)中校验成功后,在代码(7)计算期间,其他线程可能获取到了写锁并且修改了x, y的值,而当前线程执行代码(7)进行计算时采用的还是修改前的值的副本,也就是操作的值是之前值的一个副本,一个快照,并不是最新的值。
另外还有个问题,代码(2)和代码(3)能否互换?答案是不能。假设位置换了,那么首先执行validate,假如validate通过了,要复制x, y值到本地方法栈,而在复制的过程中很有可能其他线程已经修改了x, y中的一个值,这就造成了数据的不一致。那么你可能会问,即使不交换代码(2)和代码(3),在复制x, y值到本地方法栈时,也会存在其他线程修改了x, y中的一个值的情况,这不也会存在问题吗?这个确实会存在,但是,别忘了复制后还有validate这一关呢,如果这时候有线程修改了x, y中的某一值,那么肯定是有线程在调用validate前,调用sl.tryOptimisticRead后获取了写锁,这样进行validate时就会失败。
现在你应该明白了,这也是乐观读设计的精妙之处,而且也是在使用时容易出问题的地方。下面继续分析,validate失败后会执行代码(4)获取悲观读锁,如果这时候其他线程持有写锁,则代码(4)会使当前线程阻塞直到其他线程释放了写锁。如果这时候没有其他线程获取到写锁,那么当前线程就可以获取到读锁,然后执行代码(5)重新复制新的坐标值到本地方法栈,再然后就是代码(6)释放了锁。复制时由于加了读锁,所以在复制期间如果有其他线程获取写锁会被阻塞,这保证了数据的一致性。另外,这里的x, y没有被声明为volatie的,会不会存在内存不可见性问题呢?答案是不会,因为加锁的语义保证了内存的可见性。
最后代码(7)使用方法栈里面的数据计算并返回,同理,这里在计算时使用的数据也可能不是最新的,其他写线程可能已经修改过原来的x, y值了。
最后一个方法moveIfAtOrigin的作用是,如果当前坐标为原点则移动到指定的位置。代码(1)获取悲观读锁,保证其他线程不能获取写锁来修改x, y值。然后代码(2)判断,如果当前点在原点则更新坐标,代码(3)尝试升级读锁为写锁。这里升级不一定成功,因为多个线程都可以同时获取悲观读锁,当多个线程都执行到代码(3)时只有一个可以升级成功,升级成功则返回非0的stamp,否则返回0。这里假设当前线程升级成功,然后执行代码(4)更新stamp值和坐标值,之后退出循环。如果升级失败则执行代码(5)首先释放读锁,然后申请写锁,获取到写锁后再循环重新设置坐标值。最后代码(6)释放锁。
使用乐观读锁还是很容易犯错误的,必须要小心,且必须要保证如下的使用顺序。
long stamp = lock.tryOptimisticRead(); //非阻塞获取版本信息
copyVaraibale2ThreadMemory(); //复制变量到线程本地堆栈
if(! lock.validate(stamp)){ // 校验
long stamp = lock.readLock(); //获取读锁
try {
copyVaraibale2ThreadMemory(); //复制变量到线程本地堆栈
} finally {
lock.unlock(stamp); //释放悲观锁
}
}
useThreadMemoryVarables(); //使用线程本地堆栈里面的数据进行操作
最后通过一张图(见图6-9)来一览StampedLock的组成。
图6-9
6.5.3 小结
StampedLock提供的读写锁与ReentrantReadWriteLock类似,只是前者提供的是不可重入锁。但是前者通过提供乐观读锁在多线程多读的情况下提供了更好的性能,这是因为获取乐观读锁时不需要进行CAS操作设置锁的状态,而只是简单地测试状态。