共享锁和排它锁(ReentrantReadWriteLock)

原创 2014年11月16日 14:27:22
1、什么是共享锁和排它锁

     共享锁就是允许多个线程同时获取一个锁,一个锁可以同时被多个线程拥有。
     排它锁,也称作独占锁,一个锁在某一时刻只能被一个线程占有,其它线程必须等待锁被释放之后才可能获取到锁。

2、排它锁和共享锁实例

     ReentrantLock就是一种排它锁。CountDownLatch是一种共享锁。这两类都是单纯的一类,即,要么是排它锁,要么是共享锁。
   ReentrantReadWriteLock是同时包含排它锁和共享锁特性的一种锁,这里主要以ReentrantReadWriteLock为例来进行分析学习。我们使用ReentrantReadWriteLock的写锁时,使用的便是排它锁的特性;使用ReentrantReadWriteLock的读锁时,使用的便是共享锁的特性。

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/
版权声明:本文为博主原创文章,未经博主允许不得转载。

相关文章推荐

java中的共享锁与排它锁

什么是共享锁?什么是排他锁?共享锁:如果事务T对数据A加上共享锁后,则其他事务只能对A再加共享锁,不能加排他锁。获准共享锁的事务只能读数据,不能修改数据。排他锁:如果事务T对数据A加上排他锁后,则其他...

排它锁,共享锁,乐观锁,排它锁

1.共享锁只用于表级,排他锁用于行级。 2.加了共享锁的对象,可以继续加共享锁,不能再加排他锁。加了排他锁后,不能再加任何锁。 3.比如一个DML操作,就要对受影响的行加排他锁,这样就不允许再加别...

共享锁(S锁)和排它锁(X锁)

共享锁【S锁】又称读锁,若事务T对数据对象A加上S锁,则事务T可以读A但不能修改A,其他事务只能再对A加S锁,而不能加X锁,直到T释放A上的S锁。这保证了其他事务可以读A,但在T释放A上的S锁之前不能...

《.NET 4.0面向对象编程漫谈》前言及配套资源包发布

《.NET 4.0面向对象编程漫谈》前言及配套资源包发布
  • bitfan
  • bitfan
  • 2010-10-28 23:07
  • 5633

聊聊高并发(十五)实现一个简单的读-写锁(共享-排他锁)

读写锁是数据库中很常见的锁,又叫共享-排他锁,S锁和X锁。读写锁在大量读少量写的情况下有很高的效率优势。 读写锁是基于普通的互斥锁构建出来的更复杂的锁,它有两个基本特点: 1. 当任一线程持有读锁...

互斥锁、自旋锁和读写锁

一、互斥锁 对于多线程的程序,访问冲突的问题是很普遍的,解决的办法是引入互斥锁(Mutex,MutualExclusive Lock),获得锁的线程可以完成“读-修改-写”的操作,然后释放锁给其它线...

sql server行级锁,排它锁,共享锁的使用

锁的概述 一. 为什么要引入锁 多个用户同时对数据库的并发操作时会带来以下数据不一致的问题: 丢失更新 A,B两个用户读同一数据并进行修改,其中一个用户的修改结果破坏了另一...

(精)JAVA线程池原理以及几种线程池类型介绍

在什么情况下使用线程池?     1.单个任务处理的时间比较短     2.将需处理的任务的数量大     使用线程池的好处:     1.减少在创建和销毁线程上所花...
  • it_man
  • it_man
  • 2012-01-11 14:56
  • 30854

共享锁与排它锁

共享锁【S锁】 又称读锁,若事务T对数据对象A加上S锁,则事务T可以读A但不能修改A,其他事务只能再对A加S锁,而不能加X锁,直到T释放A上的S锁。这保证了其他事务可以读A,但在T释放A上的S锁之前...
  • p10010
  • p10010
  • 2015-12-13 15:11
  • 1586
内容举报
返回顶部
收藏助手
不良信息举报
您举报文章:深度学习:神经网络中的前向传播和反向传播算法推导
举报原因:
原因补充:

(最多只允许输入30个字)