并发编程(学习笔记-共享模型之JUC-读写锁原理)-part6

Posted LL.LEBRON

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了并发编程(学习笔记-共享模型之JUC-读写锁原理)-part6相关的知识,希望对你有一定的参考价值。

读写锁

1.ReentrantReadWriteLock

1.1 概述

当读操作远远高于写操作时,这时候使用读写锁 让 读-读 可以并发,提高性能。 类似于数据库中的 select ... from ... lock in share mode

栗子:

提供一个数据容器类,内部分别使用读锁保护数据的 read() 方法,写锁保护数据的 write() 方法

@Slf4j
class Datacontainer 
    private Object data;
    private ReentrantReadWriteLock rw = new ReentrantReadWriteLock();
    private ReentrantReadWriteLock.ReadLock r = rw.readLock();
    private ReentrantReadWriteLock.WriteLock w = rw.writeLock();

    public Object read() 
        r.lock();
        log.debug("获取读锁..");
        try 
            log.debug("读取");
            Sleeper.sleep(1);
            return data;
         finally 
            log.debug("释放读锁...");
            r.unlock();
        
    

    public void write() 
        w.lock();
        log.debug("获取写锁...");
        try 
            log.debug("写入");
            Sleeper.sleep(1);
         finally 
            log.debug("释放写锁...");
            w.unlock();
        
    

测试 读锁-读锁 可以并发:

public class TestReadWriteLock 
    public static void main(String[] args) 
        Datacontainer datacontainer = new Datacontainer();
        new Thread(() -> 
            datacontainer.read();
        , "t1").start();
        new Thread(() -> 
            datacontainer.read();
        , "t2").start();
    

输出结果,从这里可以看到 Thread-0 锁定期间,Thread-1 的读操作不受影响:

测试 读锁-写锁 相互阻塞:

@Slf4j
public class TestReadWriteLock 
    public static void main(String[] args) throws InterruptedException 
        Datacontainer datacontainer = new Datacontainer();
        new Thread(() -> 
            datacontainer.read();
        , "t1").start();
        Thread.sleep(100);
        new Thread(() -> 
            datacontainer.write();
        , "t2").start();
    

结果:

注意:

  • 读锁不支持条件变量
  • 重入时升级不支持:即持有读锁的情况下去获取写锁,会导致获取写锁永久等待
  • 重入时降级支持:即持有写锁的情况下去获取读锁

看一个重入降级获得锁栗子:

class CachedData 
    Object data;
    // 是否有效,如果失效,需要重新计算 data
    volatile boolean cacheValid;
    final ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
    void processCachedData() 
        rwl.readLock().lock();
        if (!cacheValid) 
            // 获取写锁前必须释放读锁
            rwl.readLock().unlock();
            rwl.writeLock().lock();
            try 
                // 判断是否有其它线程已经获取了写锁、更新了缓存, 避免重复更新
                if (!cacheValid) 
                    data = ...
                        cacheValid = true;
                
                // 降级为读锁, 释放写锁, 这样能够让其它线程读取缓存
                rwl.readLock().lock();
             finally 
                rwl.writeLock().unlock();
            
        
        // 自己用完数据, 释放读锁 
        try 
            use(data);
         finally 
            rwl.readLock().unlock();
        
    

1.2 原理演示

读写锁用的是同一个 Sycn 同步器,因此等待队列、state 等也是同一个。

t1 w.lock,t2 r.lock

1) t1 成功上锁,流程与 ReentrantLock 加锁相比没有特殊之处,不同是写锁状态占了 state 的低 16 位,而读锁使用的是 state 的高 16 位

2)t2 执行 r.lock,这时进入读锁的 sync.acquireShared(1) 流程,首先会进入 tryAcquireShared 流程。如果有写锁占据,那么 tryAcquireShared 返回 -1 表示失败

tryAcquireShared 返回值表示:

  • -1 表示失败
  • 0 表示成功
  • 正数表示成功(而且数值是还有几个后继节点需要唤醒,读写锁返回1)

3)这时会进入 sync.doAcquireShared(1) 流程,首先也是调用 addWaiter 添加节点,不同之处在于节点被设置为 Node.SHARED 模式而非 Node.EXCLUSIVE 模式,注意此时 t2 仍处于活跃状态

4)t2 会看看自己的节点是不是老二,如果是,还会再次调用 tryAcquireShared(1) 来尝试获取锁

5)如果没有成功,在 doAcquireShared 内 for (;;) 循环一次,把前驱节点的 waitStatus 改为 -1,再 for (;😉 循环一 次尝试 tryAcquireShared(1) 如果还不成功,那么在 parkAndCheckInterrupt() 处 park

t3 r.lock,t4 w.lock

这种状态下,假设又有 t3 加读锁和 t4 加写锁,这期间 t1 仍然持有锁,就变成了下面的样子

t1 w.unlock

这时会走到写锁的 sync.release(1) 流程,调用 sync.tryRelease(1) 成功,变成下面的样子

接下来执行唤醒流程 sync.unparkSuccessor,即让老二恢复运行,这时 t2 在 doAcquireSharedparkAndCheckInterrupt 处恢复运行 这回再来一次 for (;;) 执行 tryAcquireShared 成功则让读锁计数加一

这时 t2 已经恢复运行,接下来 t2 调用 setHeadAndPropagate(node, 1),它原本所在节点被置为头节点

事情还没完,在 setHeadAndPropagate 方法内还会检查下一个节点是否是 shared,如果是则调用 doReleaseShared() 将 head 的状态从 -1 改为 0 并唤醒老二,这时 t3 在 doAcquireSharedparkAndCheckInterrupt处恢复运行

这回再来一次 for (;;) 执行 tryAcquireShared 成功则让读锁计数加一

这时 t3 已经恢复运行,接下来 t3 调用 setHeadAndPropagate(node, 1),它原本所在节点被置为头节点

下一个节点不是 shared 了,因此不会继续唤醒 t4 所在节点

t2 r.unlock,t3 r.unlock

t2 进入 sync.releaseShared(1) 中,调用 tryReleaseShared(1) 让计数减一,但由于计数还不为零

t3 进入 sync.releaseShared(1) 中,调用 tryReleaseShared(1) 让计数减一,这回计数为零了,进入 doReleaseShared() 将头节点从 -1 改为 0 并唤醒老二,即

之后 t4 在 acquireQueuedparkAndCheckInterrupt 处恢复运行,再次 for (;;) 这次自己是老二,并且没有其他竞争,tryAcquire(1) 成功,修改头结点,流程结束

2.StampedLock

2.1 概述

该类自 JDK 8 加入,是为了进一步优化读性能,它的特点是在使用读锁、写锁时都必须配合【戳】使用

加解读锁:

long stamp = lock.readLock();
lock.unlockRead(stamp);

加解写锁:

long stamp = lock.writeLock();
lock.unlockWrite(stamp);

乐观读StampedLock 支持 tryOptimisticRead() 方法(乐观读),读取完毕后需要做一次戳校验如果校验通过,表示这期间确实没有写操作,数据可以安全使用,如果校验没通过,需要重新获取读锁,保证数据安全。

long stamp = lock.tryOptimisticRead();
// 验戳
if(!lock.validate(stamp))
 // 锁升级

2.例子

提供一个 数据容器类 内部分别使用读锁保护数据的 read() 方法,写锁保护数据的 write() 方法:

@Slf4j
class DataContainerStamper 
    private int data;
    private final StampedLock lock = new StampedLock();

    public DataContainerStamper(int data) 
        this.data = data;
    

    public int read(int readTime) 
        //乐观读
        long stamp = lock.tryOptimisticRead();
        log.debug("optimistic read lockuing ... ", stamp);
        //模拟读取时间
        Sleeper.sleep(readTime);
        //校验戳,通过直接返回
        if (lock.validate(stamp)) 
            log.debug("read finish... ", stamp);
            return data;
        
        //锁的升级->读锁
        log.debug("updating to read lock... ", stamp);
        try 
            //获取读锁
            stamp = lock.readLock();
            log.debug("read lock ", stamp);
            Sleeper.sleep(readTime);
            log.debug("read finish... ", stamp);
            return data;
         finally 
            log.debug("read unlock ", stamp);
            //释放读锁
            lock.unlockRead(stamp);
        

    

    public void write(int newData) 
        //加写锁
        long stamp = lock.writeLock();
        log.debug("write lock ", stamp);
        try 
            Sleeper.sleep(2);
            this.data = newData;
         finally 
            log.debug("write unlock ", stamp);
            //释放写锁,需要传入戳
            lock.unlockWrite(stamp);
        
    

测试 读-读 可以优化:

public class TestStampedLock 
    public static void main(String[] args) 
        DataContainerStamper dataContainer = new DataContainerStamper(1);
        new Thread(() -> 
            dataContainer.read(1);
        , "t1").start();
        Sleeper.sleep(0.5);
        new Thread(() -> 
            dataContainer.read(0);
        , "t2").start();
    

输出结果,可以看到实际没有加读锁:

测试 读-写 时优化读补加读锁:

public class TestStampedLock 
    public static void main(String[] args) 
        DataContainerStamper dataContainer = new DataContainerStamper(1);
        new Thread(() -> 
            dataContainer.read(1);
        , "t1").start();
        Sleeper.sleep(0.5);
        new Thread(() -> 
            dataContainer.write(0);
        , "t2").start();
    

输出结果:

注意:

  • StampedLock 不支持条件变量
  • StampedLock 不支持可重入

以上是关于并发编程(学习笔记-共享模型之JUC-读写锁原理)-part6的主要内容,如果未能解决你的问题,请参考以下文章

JUC学习之共享模型工具之JUC并发工具包上

JUC并发编程 共享模式之工具 JUC 读写锁 StampedLock -- 介绍 & 使用

JUC并发编程 共享模式之工具 JUC 读写锁 ReentrantReadWriteLock -- ReentrantReadWriteLock(不可重入锁)使用 & 注意事项

并发编程(学习笔记-共享模型之无锁)-part5

JUC并发编程 共享模式之工具 JUC ReentrantLock -- ReentrantLock原理

JUC并发编程 共享模式之工具 JUC CountdownLatch(倒计时锁) -- CountdownLatch(使用CountdownLatch原理改进: 配合线程池使用)