1、什么是共享锁和排它锁
共享锁就是允许多个线程同时获取一个锁,一个锁可以同时被多个线程拥有。
排它锁,也称作独占锁,一个锁在某一时刻只能被一个线程占有,其它线程必须等待锁被释放之后才可能获取到锁。
2、排它锁和共享锁实例
ReentrantLock就是一种排它锁。
CountDownLatch是一种共享锁。这两类都是单纯的一类,即,要么是排它锁,要么是共享锁。
ReentrantReadWriteLock是同时包含排它锁和共享锁特性的一种锁,这里主要以R
eentrantReadWriteLock为例来进行分析学习。我们使用
R
eentrantReadWriteLock的写锁时,使用的便是排它锁的特性;使用
R
eentrantReadWriteLock的读锁时,使用的便是共享锁的特性。
3、
锁的等待队列组成
ReentrantReadWriteLock有一个读锁(ReadLock)和一个写锁(WriteLock)属性,分别代表可重入读写锁的读锁和写锁。有一个Sync属性来表示这个锁上的等待队列。ReadLock和WriteLock各自也分别有一个Sync属性表示在这个锁上的队列
通过构造函数来看,
public
ReentrantReadWriteLock(
boolean
fair) {
sync = (fair)?
new
FairSync() :
new
NonfairSync();
readerLock =
new
ReadLock(
this
);
writerLock =
new
WriteLock(
this
);
}
在创建读锁和写锁对象的时候,会把这个可重入的读写锁上的Sync属性传递过去。
protected
ReadLock(ReentrantReadWriteLock lock) {
sync = lock.sync;
}
protected
WriteLock(ReentrantReadWriteLock lock) {
sync = lock.sync;
}
所以,最终的效果是读锁和写锁使用的是同一个线程等待队列。这个队列就是通过我们在前面介绍过的AbstractQueuedSynchronizer实现的。
4、
锁的状态
既然读锁和写锁使用的是同一个等待队列,那么这里要如何区分一个锁的读状态(有多少个线程正在读这个锁)和写状态(是否被加了写锁,哪个线程正在写这个锁)。
首先每个锁都有一个
exclusiveOwnerThread
属性,这是继承自AbstractQueuedSynchronizer,来表示当前拥有这个锁的线程。那么,剩下的主要问题就是确定,有多少个线程正在读这个锁,以及是否加了写锁。
这里可以通过线程获取锁时执行的逻辑来看,下面是线程获取读锁时会执行的一部分代码。
final
boolean
tryReadLock() {
Thread current = Thread.currentThread();
for
(;;) {
int
c = getState();
if
(exclusiveCount(c) != 0 &&
getExclusiveOwnerThread() != current)
return
false
;
if
(sharedCount(c) == MAX_COUNT)
throw
new
Error(
"Maximum lock count exceeded"
);
if
(compareAndSetState(c, c + SHARED_UNIT)) {
HoldCounter rh = cachedHoldCounter;
if
(rh ==
null
|| rh.tid != current.getId())
cachedHoldCounter = rh = readHolds.get();
rh.count++;
return
true
;
}
}
}
注意这个函数的调用
exclusiveCount(c)
,用来计算这个锁当前的写加锁次数(同一个进程多次进入会累加)。代码如下
/** Returns the number of shared holds represented in count */
static
int
sharedCount(
int
c) {
return
c >>> SHARED_SHIFT; }
/** Returns the number of exclusive holds represented in count */
static
int
exclusiveCount (
int
c) {
return
c & EXCLUSIVE_MASK; }
相关常量的定义如下
static
final
int
SHARED_SHIFT = 16;
static
final
int
EXCLUSIVE_MASK = (1 << SHARED_SHIFT) - 1;
如果从二进制来看EXCLUSIVE_MASK的表示,这个值的低16位全是1,而高16位则全是0,所以exclusiveCount是把state的低16位取出来,表示当前这个锁的写锁加锁次数。
再来看sharedCount,取出了state的高16位,用来表示这个锁的读锁加锁次数。所以,这里是用state的高16位和低16位来分别表示这个锁上的读锁和写锁的加锁次数。
现在再回头来看tryReadLock实现,首先检查这个锁上是否被加了写锁,同时检查加写锁的是不是当前线程。如果不是被当前线程加了写锁,那么试图加读锁就失败了。如果没有被加写锁,或者是被当前线程加了写锁,那么就把读锁加锁次数加1,通过
compareAndSetState(c, c + SHARED_UNIT)
来实现
SHARED_UNIT
的定义如下,刚好实现了高16位的加1操作。
static
final
int
SHARED_UNIT = (1 << SHARED_SHIFT);
5、线程阻塞和唤醒的时机
线程的阻塞和访问其他锁的时机相似,在线程视图获取锁,但这个锁又被其它线程占领无法获取成功时,线程就会进入这个锁对象的等待队列中,并且线程被阻塞,等待前面线程释放锁时被唤醒。
但因为加读锁和加写锁进入等待队列时存在一定的区别,加读锁时,
final
Node node = addWaiter(Node.SHARED);节点的nextWaiter指向一个共享节点,表明当前这个线程是处于共享状态进入等待队列。
加写锁时如下,
public
final
void
acquire (
int
arg) {
if
(!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
线程是处于排它状态进入等待队列的。
在线程的阻塞上,读锁和写锁的时机相似,但在线程的唤醒上,读锁和写锁则存在较大的差别。
读锁通过AbstractQueuedSynchronizer的
doAcquireShared来完成获取锁的动作。
private
void
doAcquireShared(
int
arg) {
final
Node node = addWaiter(Node.SHARED);
try
{
boolean
interrupted =
false
;
for
(;;) {
final
Node p = node.predecessor();
if
(p == head) {
int
r = tryAcquireShared(arg);
if
(r >= 0) {
setHeadAndPropagate(node, r);
p.next =
null
;
// help GC
if
(interrupted)
selfInterrupt();
return
;
}
}
if
(shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
interrupted =
true
;
}
}
catch
(RuntimeException ex) {
cancelAcquire(node);
throw
ex;
}
}
在tryAcquireShared获取读锁成功后(返回正数表示获取成功),有一个
setHeadAndPropagate的函数调用。
写锁通过AbstractQueuedSynchronizer的
acquire来实现锁的获取动作。
public
final
void
acquire(
int
arg) {
if
(!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
如果tryAcquire获取成功则直接返回,否则把线程加入到锁的等待队列中。和一般意义上的ReentrantLock的原理一样。
所以在加锁上,主要的差别在于这个setHeadAndPropagate方法,其代码如下
private
void
setHeadAndPropagate (Node node,
int
propagate) {
Node h = head;
// Record old head for check below
setHead(node);
/*
* Try to signal next queued node if:
* Propagation was indicated by caller,
* or was recorded (as h.waitStatus) by a previous operation
* (note: this uses sign-check of waitStatus because
* PROPAGATE status may transition to SIGNAL.)
* and
* The next node is waiting in shared mode,
* or we don't know, because it appears null
*
* The conservatism in both of these checks may cause
* unnecessary wake-ups, but only when there are multiple
* racing acquires/releases, so most need signals now or soon
* anyway.
*/
if
(propagate > 0 || h ==
null
|| h.waitStatus < 0) {
Node s = node.next;
if
(s ==
null
|| s.isShared())
doReleaseShared();
}
}
主要操作是把这个节点设为头节点(成为头节点,则表示不在等待队列中,因为获取锁成功了),同时释放锁(doReleaseShared)。
下面来看doReleaseShared的实现
private
void
doReleaseShared() {
/*
* Ensure that a release propagates, even if there are other
* in-progress acquires/releases. This proceeds in the usual
* way of trying to unparkSuccessor of head if it needs
* signal. But if it does not, status is set to PROPAGATE to
* ensure that upon release, propagation continues.
* Additionally, we must loop in case a new node is added
* while we are doing this. Also, unlike other uses of
* unparkSuccessor, we need to know if CAS to reset status
* fails, if so rechecking.
*/
for
(;;) {
Node h = head;
if
(h !=
null
&& h != tail) {
int
ws = h.waitStatus;
if
(ws == Node.SIGNAL) {
if
(!compareAndSetWaitStatus(h, Node.SIGNAL, 0))
continue
;
// loop to recheck cases
unparkSuccessor(h);
}
else
if
(ws == 0 &&
!compareAndSetWaitStatus(h, 0, Node.PROPAGATE))
continue
;
// loop on failed CAS
}
if
(h == head)
// loop if head changed
break
;
}
}
把头节点的waitStatus这只为0或者Node.
PROPAGATE,并且唤醒下一个线程,然后就结束了。
总结一下,就是一个线程在获取读锁后,会唤醒锁的等待队列中的第一个线程。如果这个被唤醒的线程是在获取读锁时被阻塞的,那么被唤醒后,就会在for循环中,又执行到setHeadAndPropagate,这样就实现了读锁获取时的传递唤醒。这种传递在遇到一个因为获取写锁被阻塞的线程节点时被终止。
下面通过代码来理解这种等待和线程唤醒顺序。
package
lynn.lock;
import
java.util.concurrent.locks.ReentrantReadWriteLock;
public
class
TestThread
extends
Thread {
private
ReentrantReadWriteLock
lock
;
private
String
threadName
;
private
boolean
isWriter
;
public
TestThread(ReentrantReadWriteLock lock, String name,
boolean
isWriter) {
this
.
lock
= lock;
this
.
threadName
= name;
this
.
isWriter
= isWriter;
}
@Override
public
void
run() {
while
(
true
) {
try
{
if
(
isWriter
) {
lock
.writeLock().lock();
}
else
{
lock
.readLock().lock();
}
if
(
isWriter
) {
Thread. sleep(3000);
System.
out
.println(
"----------------------------"
);
}
System.
out
.println(System.currentTimeMillis() +
":"
+
threadName
);
if
(
isWriter
) {
Thread. sleep(3000);
System.
out
.println(
"-----------------------------"
);
}
}
catch
(Exception e) {
e.printStackTrace();
}
finally
{
if
(
isWriter
) {
lock
.writeLock().unlock();
}
else
{
lock
.readLock().unlock();
}
}
break
;
}
}
}
TestThread是一个自定义的线程类,在生成线程的时候,需要传递一个可重入的读写锁对象进去,线程在执行时会先加锁,然后进行内容输出,然后释放锁。如果传递的是写锁,那线程在输出结果前后会先沉睡3秒,便于区分输出的结果时间。
package
lynn.lock;
import
java.util.concurrent.locks.ReentrantReadWriteLock;
public
class
Main {
public
static
void
blockByWriteLock() {
ReentrantReadWriteLock lock =
new
ReentrantReadWriteLock();
lock.writeLock().lock();
TestThread[] threads =
new
TestThread[10];
for
(
int
i = 0; i < 10; i++) {
boolean
isWriter = (i + 1) % 4 == 0 ?
true
:
false
;
TestThread thread =
new
TestThread(lock,
"thread-"
+ (i + 1), isWriter);
threads[i] = thread;
}
for
(
int
i = 0; i < threads.
length
; i++) {
threads[i].start();
}
System.
out
.println(System.currentTimeMillis() +
": block by write lock"
);
try
{
Thread. sleep(3000);
}
catch
(Exception e) {
e.printStackTrace();
}
lock.writeLock().unlock();
}
public
static
void
main(String[] args) {
blockByWriteLock();
}
}
在Main中构造了10个线程,由于这个锁一开始是被主线程拥有,并且是在排它状态下加锁的,所以我们构造的10个线程,在一开始执行便是按照其编号从小到大在等待队列中(1到10)。然后主线程打印结果,等待3秒后释放锁。由于前3个线程,编号1到3是处于共享状态阻塞的,而第4个线程是处于排它状态阻塞,所以,按照上面的唤醒顺序,唤醒传递到第4个线程时就结束。
依次类推,理论上的打印顺序是 :主线程 [1,2,3] 4 [5,6,7] 8 [9,10]
从下面的执行结果来看,也是符合我们的预期的。
6、读线程之间的唤醒
如果一个线程在共享模式下获取了锁状态,这个时候,它是否要唤醒其它在共享模式下等待在该锁上的线程?
由于多个线程可以同时获取共享锁而不相互影响,所以,当一个线程在共享状态下获取了锁之后,理论上是可以唤醒其它在共享状态下等待该锁的线程。但如果这个时候,在这个等待队列中,既有共享状态的线程,同时又有排它状态的线程,这个时候又该如何唤醒?
实际上对于锁来说,在共享状态下,一个线程无论是获取还是释放锁的时候,都会试着去唤醒下一个等待在这个锁上的节点(通过上面的
doAcquireShared
代码能看出
)。如果下一个线程也是处于共享状态等待在锁上,那么这个线程就会被唤醒,然后接着试着去唤醒下一个等待在这个锁上的线程,这种唤醒动作会一直持续下去,直到遇到一个在排它状态下阻塞在这个锁上的线程,或者等待队列全部被释放为止。
因为线程是在一个FIFO的等待队列中,所以,这这样一个一个往后传递,就能保证唤醒被传递下去。
参考资料 http://www.liuinsect.com/2014/09/04/jdk1-8-abstractqueuedsynchronizer-part2/