Java并发编程之ReentrantReadWriteLock详解

简介

ReentrantLock是排他锁,排他锁在同一时刻仅有一个线程可以进行访问,实际上独占锁是一种相对比较保守的锁策略,在这种情况下任何“读/读”、“读/写”、“写/写”操作都不能同时发生,这在一定程度上降低了吞吐量。然而读操作之间不存在数据竞争问题,如果"读/读"操作能够以共享锁的方式进行,那会进一步提升性能。因此引入了ReentrantReadWriteLock,顾名思义,ReentrantReadWriteLock是Reentrant(可重入)Read(读)Write(写)Lock(锁),我们下面称它为读写锁。

读写锁内部又分为读锁和写锁,读锁可以在没有写锁的时候被多个线程同时持有,写锁是独占的。读锁和写锁分离从而提升程序性能,读写锁主要应用于读多写少的场景。

一般情况下,读写锁的性能都会比排它锁好,因为大多数场景是读多于写的。在读多写少的情况下,读写锁能够提供比排它锁更好的并发性和吞吐量,读写锁主要具有以下特性:

  • 公平性选择:支持非公平(默认)和公平的锁获取方式,吞吐量还是非公平优于公平;
  • 重进入:该锁支持重进入,以读写线程为例:读线程在获取了读锁之后,能够再次获取读锁。而写线程在获取了写锁之后能够再次获取写锁,同时也可以获取读锁;
  • 锁降级:遵循获取写锁、获取读锁在释放写锁的次序,写锁能够降级成为读锁;
  • 锁获取中断:读取锁和写入锁都支持获取锁期间被中断. 这个和独占锁一致;
  • 支持条件变量:写入锁提供了条件变量(Condition)的支持, 这个和独占锁一致, 但是读取锁却不允许获取条件变量, 将得到一个UnsupportedOperationException异常。

ReentrantReadWriteLock类图如下:


ReentrantReadWriteLock实现了接口ReadWriteLock,该接口提供了两个方法,一个用于获取读锁,另一个用于获取写锁。

public interface ReadWriteLock {
    /**
     * Returns the lock used for reading.
     *
     * @return the lock used for reading
     */
    Lock readLock();

    /**
     * Returns the lock used for writing.
     *
     * @return the lock used for writing
     */
    Lock writeLock();
}
它提供了和ReentrantLock类似的公平锁和非公平锁(默认构造方法是非公平锁),Sync类是一个继承于AQS的抽象类。Sync有FairSync公平锁和NonfairSync非公平锁两个子类:
public ReentrantReadWriteLock() {
    this(false);
}

public ReentrantReadWriteLock(boolean fair) {
    sync = fair ? new FairSync() : new NonfairSync();
    readerLock = new ReadLock(this);
    writerLock = new WriteLock(this);
}

ReentrantReadWriteLock中包含了下面三个对象:sync对象,读锁readerLock和写锁writerLock。读锁ReadLock和写锁WriteLock都实现了Lock接口。读锁ReadLock和写锁WriteLock中也都分别包含了"Sync对象",它们的Sync对象和ReentrantReadWriteLock的Sync对象 是一样的,就是通过sync,读锁和写锁实现了对同一个对象的访问。

ReentrantReadWriteLock源码详解

ReentrantReadWriteLock实现的ReadWriteLock接口方法为:

public ReentrantReadWriteLock.WriteLock writeLock() { return writerLock; }
public ReentrantReadWriteLock.ReadLock  readLock()  { return readerLock; }

可以看到,读写锁内部有两个类,分别为写锁WriteLock、读锁ReadLock:

public static class WriteLock implements Lock, java.io.Serializable {
    private static final long serialVersionUID = -4992448646407690164L;
    private final Sync sync;

    protected WriteLock(ReentrantReadWriteLock lock) {
        sync = lock.sync;
    }

    public void lock() {
        sync.acquire(1);
    }

    public void lockInterruptibly() throws InterruptedException {
        sync.acquireInterruptibly(1);
    }

    public boolean tryLock( ) {
        return sync.tryWriteLock();
    }

    public boolean tryLock(long timeout, TimeUnit unit)
            throws InterruptedException {
        return sync.tryAcquireNanos(1, unit.toNanos(timeout));
    }

    public void unlock() {
        sync.release(1);
    }

    public Condition newCondition() {
        return sync.newCondition();
    }
    ...
}

public static class ReadLock implements Lock, java.io.Serializable {
    private static final long serialVersionUID = -5992448646407690164L;
    private final Sync sync;

    protected ReadLock(ReentrantReadWriteLock lock) {
        sync = lock.sync;
    }

    public void lock() {
        sync.acquireShared(1);
    }

    public void lockInterruptibly() throws InterruptedException {
        sync.acquireSharedInterruptibly(1);
    }

    public boolean tryLock() {
        return sync.tryReadLock();
    }

    public boolean tryLock(long timeout, TimeUnit unit)
            throws InterruptedException {
        return sync.tryAcquireSharedNanos(1, unit.toNanos(timeout));
    }

    public void unlock() {
        sync.releaseShared(1);
    }

    public Condition newCondition() {
        throw new UnsupportedOperationException();
    }
    
    public String toString() {
        int r = sync.getReadLockCount();
        return super.toString() +
            "[Read locks = " + r + "]";
    }
}

这两个类都实现了Lock接口,从源码中可以看出,读锁、写锁的操作都是依靠Sync类来实现的,而Sync只是一个抽象类,所以同步的具体实现都是通过NonfairSync和FairSync类。所以ReentrantReadWriteLock实际上只有一个锁,只是在获取读取锁和写入锁的方式上不一样而已。我们来看一看Sync类的主要属性和方法:

// 读锁同步状态占用的位数
static final int SHARED_SHIFT   = 16;
// 每次增加读锁同步状态,就相当于增加SHARED_UNIT
static final int SHARED_UNIT    = (1 << SHARED_SHIFT);
// 读锁或写锁的最大请求数量(包含重入)
static final int MAX_COUNT      = (1 << SHARED_SHIFT) - 1;
// 低16位的MASK,用来计算写锁的同步状态
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; }

读写状态的设计

在ReentrantLock自定义同步器的实现中,同步状态表示锁被一个线程重复获取的次数,而读写锁的自定义同步器需要在同步状态(一个整形变量)上维护多个读线程和一个写线程的状态,那就需要“按位切割”使用这个状态变量,读写锁将变量切分成两部分,高16位表示读,低16位表示写,划分方式如下:


当前同步状态表示一个线程已经获取了写锁,且重进入了2次,同时也连续获取了两次读锁。同步状态是通过位运算进行更新的,假设当前同步状态是S,写状态等于S & EXCLUSIVE_MASK,即S & 0x0000FFFF,读状态等于S >>> 16.当写状态加1时,等于S+1,当读状态加1时,等于S+SHARED_UNIT,即S+(1 << 16),也就是S + 0x00010000。

即读锁和写锁的状态获取和设置如下:

  • 读锁状态的获取:S >> 16
  • 读锁状态的增加:S + (1 << 16)
  • 写锁状态的获取:S & 0x0000FFFF
  • 写锁状态的增加:S + 1

写锁

写锁就是一个支持可重入的排他锁。

写锁的获取

WriteLock类提供了lock()方法获取写锁,获取写锁的调用过程就不再具体介绍了,写锁的获取最终会调用Sync类的tryAcquire(int)方法:

protected final boolean tryAcquire(int acquires) {
    // 获取当前线程对象
    Thread current = Thread.currentThread();
    // 获取同步状态
    int c = getState();
    // 获取写状态
    int w = exclusiveCount(c);
    // 同步状态不为0,表示至少有一个线程获取了写锁或者读锁
    if (c != 0) {
        // 写状态为0,读状态不为0;或者写状态不为0,读状态为0,且获取写锁的线程不是当前线程
        if (w == 0 || current != getExclusiveOwnerThread())
            return false;
        // 写状态不为0,读状态为0,且获取写锁的线程是当前线程,判断写锁的获取次数是不是要超出限制
        if (w + exclusiveCount(acquires) > MAX_COUNT)
            throw new Error("Maximum lock count exceeded");
        // 重入获取写锁,修改同步状态
        setState(c + acquires);
        return true;
    }
    // 是否需要阻塞,该方法由子类实现,或者同步状态修改失败
    if (writerShouldBlock() ||
        !compareAndSetState(c, c + acquires))
        return false;
    // 不需要阻塞且同步状态修改成功,设置获取独占锁的线程为当前线程
    setExclusiveOwnerThread(current);
    return true;
}
该方法和ReentrantLock的tryAcquire(int)方法大致一样,只不过在判断重入时增加了一个读锁是否存在的判断。因为要确保写锁的操作对读锁是可见的,如果在读锁存在的情况下允许获取写锁,那么那些已经获取读锁的其他线程可能就无法感知当前写线程的操作。因此只有等读锁完全释放后,写锁才能够被当前线程所获取,一旦写锁获取了,所有其他读、写线程均会被阻塞。
写锁的释放

WriteLock类提供了unlock()方法释放写锁,获取写锁的调用过程就不再具体介绍了,写锁的释放最终会调用Sync类的tryRelease(int)方法:

protected final boolean tryRelease(int releases) {
    // 当前线程不是写锁的持有线程
    if (!isHeldExclusively())
        throw new IllegalMonitorStateException();
    // 计算新的同步状态
    int nextc = getState() - releases;
    // 判断写锁是否完全释放,若是,则将写锁持有线程设置为null
    boolean free = exclusiveCount(nextc) == 0;
    if (free)
        setExclusiveOwnerThread(null);
    // 更新状态
    setState(nextc);
    return free;
}

写锁的释放与ReentrantLock的释放过程基本类似,每次释放均是减少写状态,当写状态为0时表示写锁已经完全释放了,从而等待的读写线程能够继续访问读写锁,获取同步状态,同时此次写线程的修改对后续的写线程可见。

读锁

读锁是一个支持重入的共享锁,它能够被多个线程同时持有,在没有其他写线程访问时,读锁总是会被成功获取。

读锁的获取

ReadLock提供了lock()方法来获取读锁,最终会调用Sync类的tryAcquireShared(int)方法来获取读锁:

protected final int tryAcquireShared(int unused) {
    // 获取当前线程对象
    Thread current = Thread.currentThread();
    // 获取同步状态
    int c = getState();
    // 如果存在写锁,且持有写锁的线程不是当前对象,返回-1,表示获取读锁失败
    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);
            rh.count++;
        }
        return 1;
    }
    // 获取读锁失败,调用fullTryAcquireShared(Thread)方法,放到循环里重试
    return fullTryAcquireShared(current);
}
final int fullTryAcquireShared(Thread current) {
    HoldCounter rh = null;
    for (;;) {
        int c = getState();
        if (exclusiveCount(c) != 0) {
            // 这里对应锁降级,若当前线程已持有写锁,则允许当前线程继续获取读锁,否则直接返回
            if (getExclusiveOwnerThread() != current)
                return -1;
        } else if (readerShouldBlock()) {
            // 如果读线程需要阻塞
            // 当前线程是第一次获取读锁的线程
            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)) {
            // 下面的处理与tryAcquireShared(int)类似
            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;
        }
    }
}

fullTryAcquireShared(Thread)会判断是否要锁降级,然后根据“是否需要阻塞等待”,“读锁状态是否超过限制”等进行处理。如果不需要阻塞等待,并且读锁没有超过限制,则通过CAS尝试获取锁,并返回1。

读锁的释放

与写锁一样,读锁的释放也通过AQS的模板方法完成,最终会调用在Sync重写的tryReleaseShared(int)方法

protected final boolean tryReleaseShared(int unused) {
    // 获取当前线程对象
    Thread current = Thread.currentThread();
    // 如果想要释放锁的线程为第一个获取锁的线程
    if (firstReader == current) {
        // 当前线程仅获取了一次读锁,则需要将firstReader设置null,否则firstReaderHoldCount - 1
        if (firstReaderHoldCount == 1)
            firstReader = null;
        else
            firstReaderHoldCount--;
    } else {
        HoldCounter rh = cachedHoldCounter;
        if (rh == null || rh.tid != getThreadId(current))
            rh = readHolds.get();
        int count = rh.count;
        if (count <= 1) {
            readHolds.remove();
            if (count <= 0)
                throw unmatchedUnlockException();
        }
        --rh.count;
    }
    // 通过CAS操作更新同步状态
    for (;;) {
        int c = getState();
        int nextc = c - SHARED_UNIT;
        if (compareAndSetState(c, nextc))
            return nextc == 0;
    }
}

读锁的获取与释放过程中都用到了HoldCounter类,下面我们对HoldCounter类进行一下源码分析。

HoldCounter

HoldCounter保存了线程持有共享锁(读锁)的数量,包括重入的数量,HoldCounter类主要起着计数器的作用,对读锁的获取与释放操作会更新对应的计数值。若线程获取读锁,则该计数器+1,释放读锁,该计数器-1。只有当线程获取读锁后才能对读锁进行释放、重入操作。

static final class HoldCounter {
    // 计数器
    int count = 0;
    // 线程ID
    final long tid = getThreadId(Thread.currentThread());
}

HoldCounter类很简单,只有一个计数器count变量和线程ID tid变量,在Java中,若是我们需要将某个对象与线程绑定,就只有ThreadLocal类才能实现了。在ReentrantReadWriteLock类中还有一个ThreadLocal类的子类:

static final class ThreadLocalHoldCounter
    extends ThreadLocal<HoldCounter> {
    public HoldCounter initialValue() {
        return new HoldCounter();
    }
}

通过上面的类HoldCounter就可以与线程进行绑定了。从上面我们可以看到ThreadLocal将HoldCounter绑定到当前线程上,同时HoldCounter也持有线程ID,这样在释放锁的时候才能知道ReadWriteLock里面缓存的上一个读取线程(cachedHoldCounter)是否是当前线程。这样做的好处是可以减少ThreadLocal.get()的次数,因为这也是一个耗时操作。需要说明的是这样HoldCounter绑定线程ID而不绑定线程对象的原因是避免HoldCounter和ThreadLocal互相绑定而GC难以释放它们(尽管GC能够智能的发现这种引用而回收它们,但是这需要一定的代价),所以其实这样做只是为了帮助GC快速回收对象而已。

看到这里我们明白了HoldCounter的作用,我们再看看fullTryAcquireShared(Thread)方法获取读锁的代码段:

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
}

这段代码涉及了几个变量:firstReader 、firstReaderHoldCount、cachedHoldCounter 。我们先理清楚这几个变量:

firstReader 看名字就明白了为第一个获取读锁的线程,firstReaderHoldCount为第一个获取读锁的重入数,cachedHoldCounter为HoldCounter的缓存。

理清楚上面所有的变量了,HoldCounter也明白了,我们就来给上面那段代码标明注释,如下:

if (sharedCount(c) == 0) {
    firstReader = current;
    firstReaderHoldCount = 1;
} else if (firstReader == current) {
    // 如果获取读锁的线程为第一次获取读锁的线程,则firstReaderHoldCount重入数 + 1
    firstReaderHoldCount++;
} else {
    // 非firstReader计数
    if (rh == null)
        rh = cachedHoldCounter;
    // rh == null或者rh.tid != current.getId(),需要获取rh
    if (rh == null || rh.tid != getThreadId(current))
        rh = readHolds.get();
    // 加入到readHolds中
    else if (rh.count == 0)
        readHolds.set(rh);
    // 计数器 + 1
    rh.count++;
    cachedHoldCounter = rh; // cache for release
}

这里解释下为何要引入firstRead、firstReaderHoldCount。这是为了一个效率问题,firstReader是不会放入到readHolds中的,如果读锁仅有一个的情况下就会避免查找readHolds。

锁降级

锁降级是指写锁降级成为读锁,但是需要遵循先获取写锁、获取读锁在释放写锁的次序,注意如果当前线程先获取写锁,然后释放写锁,再获取读锁这个过程不能称之为锁降级,锁降级一定要遵循那个次序。我们看一个锁降级的示例。因为数据不常变化,所以多个线程可以并发地进行数据处理,当数据变更后,如果当前线程感知到数据变化,则进行数据的准备工作,同时其他处理线程则被阻塞,直到当前线程完成数据的准备工作,如下述代码所示:

public void processData() {
    readLock.lock();
    if (!update) {
        // 必须先释放读锁
        readLock.unlock();
        // 锁降级从获取到写锁开始
        writeLock.lock();
        
        try {
            if (!update) {
                // 准备数据的流程(略)
                update = true;
            }
            readLock.lock();
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            writeLock.unlock();
        }
        // 锁降级完成,写锁降级为读锁
    }
    
    try {
        // 使用数据的流程(略)
    } catch (Exception e) {
        e.printStackTrace();
    } finally {
        readLock.unlock();
    }
}

在上述代码中,当数据发生变化后,update变量(布尔volatile类型)被设置为false,此时所有访问processData()方法的线程都能够感知到变化,但是只有一个线程能够获取到写锁,其它线程都会阻塞在读锁和写锁的lock()方法上。当前线程获取写锁完成数据准备之后,再获取读锁,随后释放写锁,完成锁降级。

锁降级中读锁的获取是否是必要的呢?答案是必要的。主要原因是为了保证数据的可见性,如果当前线程不获取读锁而是直接释放写锁,假设此刻另一个线程(记作线程T)获取了写锁并修改了数据,那么当前线程无法感知线程T的数据更新。如果当前线程获取了读锁,既遵循锁降级的步骤,则线程T将会被阻塞,直到当前线程使用数据并释放读锁之后,线程T才能获取写锁进行数据更新。

ReentrantReadWriteLock不支持锁升级(把持读锁、获取写锁,最后释放读锁的过程)。目的也是为了保证数据可见性,如果读锁已被多个线程获取,其中任意线程成功获取了写锁并更新了数据,则其更新对其他获取到读锁的线程是不可见的。

相关博客

Java并发编程之ThreadLocal详解

Java并发编程之Lock接口简介

AbstractQueuedSynchronizer简介

AbstractQueuedSynchronizer同步队列详解

AbstractQueuedSynchronizer独占式同步状态获取与释放

AbstractQueuedSynchronizer共享式同步状态获取与释放

参考资料

方腾飞:《Java并发编程的艺术》

【死磕Java并发】-----J.U.C之读写锁:ReentrantReadWriteLock

猜你喜欢

转载自blog.csdn.net/qq_38293564/article/details/80533821