JUC-读写锁&Semaphore

章节目录:

    • 一、ReentrantReadWriteLock
      • 1.1 概述
      • 1.2 基本使用
      • 1.3 注意事项
    • 二、缓存应用
      • 2.1 缓存更新策略
      • 2.2 读写锁实现一致性缓存
    • 三、读写锁原理
      • 3.1 t1 加写锁、t2 加读锁
      • 3.2 其它线程加读写锁
      • 3.3 t1 释放写锁
      • 3.4 t1、t2 释放读锁
      • 3.5 源码分析
    • 四、StampedLock
      • 4.1 基本使用
      • 4.2 读写优化
      • 4.3 注意事项
    • 五、Semaphore
      • 5.1 基本使用
      • 5.2 限制对共享资源的使用
      • 5.3 加锁解锁流程
      • 5.4 源码分析
    • 六、结束语

一、ReentrantReadWriteLock

1.1 概述

  • ReentrantReadWriteLock读写锁,和 ReentrantLock 会有所不同,对于读多写少的场景使用 ReentrantReadWriteLock 性能会比 ReentrantLock 高出不少。
  • 在多线程读时互不影响,不像 ReentrantLock 即使是多线程读也需要每个线程获取锁。当读操作远远高于写操作时,这时候使用 读写锁 让 读-读 可以并发,提高性能,类似于数据库中的 select ...from ... lock in share mode
  • 任何一个线程在写的时候就和 ReentrantLock 类似,其它线程无论读还是写都必须获取锁
  • 注意:同一个线程可以拥有 writeLockreadLock (但必须先获取 writeLock 获取 readLock, 反过来进行获取会导致死锁)。

1.2 基本使用

  • 代码示例
public class ReadWriteLockSample {

    /**
     * 测试『读-读』并发。
     *
     * @throws InterruptedException 中断异常
     */
    @Test
    public void test01() throws InterruptedException {
        DataContainer container = new DataContainer();
        Thread t1 = new Thread(container::read, "t1");
        Thread t2 = new Thread(container::read, "t2");
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        // 10:05:07.574 [t1] get read lock
        // 10:05:07.574 [t2] get read lock
        // 10:05:08.590 [t2] read unlock
        // 10:05:08.590 [t1] read unlock
        // 从上面结果可以看到,t1锁定期间,t2读操作不受影响。
    }

    /**
     * 测试『读-写』相互阻塞。
     *
     * @throws InterruptedException 中断异常
     */
    @Test
    public void test02() throws InterruptedException {
        DataContainer container = new DataContainer();
        Thread t1 = new Thread(container::read, "t1");
        sleep(1);
        Thread t2 = new Thread(container::write, "t2");
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        // 10:06:10.423 [t2] get write lock
        // 10:06:10.423 [t1] get read lock
        // 10:06:11.433 [t2] write unlock
        // 10:06:12.443 [t1] read unlock
    }

    /**
     * 测试『写-写』相互阻塞。
     *
     * @throws InterruptedException 中断异常
     */
    @Test
    public void test03() throws InterruptedException {
        DataContainer container = new DataContainer();
        Thread t1 = new Thread(container::write, "t1");
        sleep(1);
        Thread t2 = new Thread(container::write, "t2");
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        // 10:06:59.386 [t2] get write lock
        // 10:06:59.386 [t1] get write lock
        // 10:07:00.409 [t2] write unlock
        // 10:07:01.419 [t1] write unlock
    }
}

/**
 * 提供一个 数据容器类 内部分别使用:
 * 读锁保护数据的 `read()` 方法,写锁保护数据的 `write()` 方法。
 */
@Slf4j
class DataContainer {
    private Object data;
    private ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
    private ReentrantReadWriteLock.ReadLock r = rwl.readLock();
    private ReentrantReadWriteLock.WriteLock w = rwl.writeLock();

    public Object read() {
        log.debug("get read lock");
        r.lock();
        try {
            sleep(1);
            return data;
        } finally {
            log.debug("read unlock");
            r.unlock();
        }
    }

    public void write() {
        log.debug("get write lock");
        w.lock();
        try {
            sleep(1);
        } finally {
            log.debug("write unlock");
            w.unlock();
        }
    }
}

1.3 注意事项

  • 读锁不支持条件变量
  • 重入时升级不支持:即持有读锁的情况下去获取写锁,会导致获取写锁永久等待
r.lock();
try {
    // ...
    w.lock();
    try {
        // ...
    } finally{
        w.unlock();
    }
} finally{
    r.unlock();
}
  • 重入时降级支持:即持有写锁的情况下去获取读锁。
public class CachedData {

    private Object data;
    private volatile boolean cacheValid;
    private static final ReentrantReadWriteLock RWL = new ReentrantReadWriteLock();

    public void processCachedData() {
        RWL.readLock().lock();
        if (!cacheValid) {
            // 加写锁前需要释放读锁。
            RWL.readLock().unlock();
            RWL.writeLock().lock();
            try {
                // 判断是否有其它线程已经获取了写锁、更新了缓存, 避免重复更新。
                if (!cacheValid) {
                    data = "value...";
                    cacheValid = true;
                }
                // 降级为读锁, 释放写锁, 这样能够让其它线程读取缓存。
                RWL.readLock().lock();
            } finally {
                RWL.writeLock().unlock();
            }
        }
        try {
            use(data);
        } finally {
            RWL.readLock().unlock();
        }
    }

    public void use(Object data) {
        // do something...
    }
}

二、缓存应用

2.1 缓存更新策略

  • 先清缓存策略

JUC-读写锁&Semaphore_第1张图片

  • 先更新数据库策略

JUC-读写锁&Semaphore_第2张图片

  • 补充一种情况(这种情况的出现几率非常小),假设查询线程 t1 查询数据时恰好缓存数据由于时间到期失效或是第一次查询

JUC-读写锁&Semaphore_第3张图片

2.2 读写锁实现一致性缓存

使用读写锁,实现一个简单的按需加载缓存。

public class GenericCachedDao<T> {

    Map<SqlPair, T> cache = new HashMap<>();
    ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
    GenericDao dao = new GenericDao();

    public boolean update(String sql, Object... params) {
        SqlPair key = new SqlPair(sql, params);
        // 加写锁, 防止其它线程对缓存读取和更改。
        rwl.writeLock().lock();
        try {
            boolean isUpdated = dao.update(sql, params);
            cache.clear();
            return isUpdated;
        } finally {
            rwl.writeLock().unlock();
        }
    }

    public T queryOne(Class<T> beanClass, String sql, Object... params) {
        SqlPair key = new SqlPair(sql, params);
        // 加读锁,防止其他线程对缓存更改。
        rwl.readLock().lock();
        try {
            T value = cache.get(key);
            if (null != value) {
                return value;
            }
        } finally {
            rwl.readLock().unlock();
        }

        // 加写锁,防止其它线程对缓存读取和更改。
        rwl.writeLock().lock();
        try {
            // 为了防止重复查询数据库,需要进行二次检查。
            T value = cache.get(key);
            if (null == value) {
                // 仍为空则查询数据库。
                value = dao.queryOne(beanClass, sql, params);
                // 将数据库查询的结果放入缓存。
                cache.put(key, value);
            }
            return value;
        } finally {
            rwl.writeLock().unlock();
        }
    }
}

class GenericDao {

    public <T> T queryOne(Class<T> beanClass, String sql, Object... params) {
        // do something...
        return null;
    }

    public boolean update(String sql, Object... params) {
        if (!sql.isEmpty() && params.length > 0) {
            // do something...
            return true;
        }
        return false;
    }
}

@AllArgsConstructor
@EqualsAndHashCode
class SqlPair {
    private String sql;
    private Object[] params;
}
  • 以上实现体现的是读写锁的应用,保证缓存和数据库的一致性,但有下面的问题没有考虑
    • 适合读多写少,如果写操作比较频繁,以上实现性能低;
    • 没有考虑缓存容量;
    • 没有考虑缓存过期;
    • 只适合单机;
    • 并发性还是低,目前只会用一把锁;
    • 更新方法太过简单粗暴,清空了所有 key(考虑按类型分区或重新设计 key);
  • 乐观锁实现:用 CAS 去更新。

三、读写锁原理

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

3.1 t1 加写锁、t2 加读锁

JUC-读写锁&Semaphore_第4张图片

  • t1 成功上锁,流程与 ReentrantLock 加锁相比没有特殊之处,不同是写锁状态占了 state低 16 位,而读锁使用的是 state高 16 位
  • t2 执行 r.lock,这时进入读锁的 sync.acquireShared(1) 流程,首先会进入 tryAcquireShared() 流程。如果有写锁占据,那么 tryAcquireShared() 返回 -1 表示失败。(补充:0 表示成功,但后继节点不会继续唤醒;正数表示成功,而且数值是还有几个后继节点需要唤醒,读写锁返回 1。)
  • 这时会进入 sync.doAcquireShared(1) 流程,首先也是调用 addWaiter() 添加节点,不同之处在于节点被设置为 Node.SHARED 模式而非 Node.EXCLUSIVE 模式,注意此时 t2 仍处于活跃状态。
  • t2 会看看自己的节点是不是老二,如果是,还会再次调用 tryAcquireShared(1) 来尝试获取锁。
  • 如果没有成功,在 doAcquireShared()for (;;) 循环一次,把前驱节点的 waitStatus 改为 -1,再 for (;;) 循环一次尝试 tryAcquireShared(1) 如果还不成功,那么在 parkAndCheckInterrupt()park(灰色表示)。

3.2 其它线程加读写锁

JUC-读写锁&Semaphore_第5张图片

  • 这种状态下,假设又有 t3 加读锁和 t4 加写锁,这期间 t1 仍然持有锁,此时的状态就如图中所示。

3.3 t1 释放写锁

JUC-读写锁&Semaphore_第6张图片

  • 此时会走到写锁的 sync.release(1) 流程,调用 sync.tryRelease(1) 成功;
  • 接下来执行唤醒流程 sync.unparkSuccessor,即让老二恢复运行,这时 t2doAcquireShared()parkAndCheckInterrupt()恢复运行,这回再来一次 for (;;) 执行 tryAcquireShared() 成功则让读锁计数加一
  • 这时 t2 已经恢复运行,接下来 t2 调用 setHeadAndPropagate(node, 1),它原本所在节点被置为头节点

JUC-读写锁&Semaphore_第7张图片

  • setHeadAndPropagate() 方法内还会检查下一个节点是否是 shared,如果是则调用 ;doReleaseShared()head 的状态从 -1 改为 0 并唤醒老二,这时 t3doAcquireShared()parkAndCheckInterrupt()恢复运行
  • 这回再来一次 for (;;) 执行 tryAcquireShared() 成功则让读锁计数加一

JUC-读写锁&Semaphore_第8张图片

  • 这时 t3 已经恢复运行,接下来 t3 调用 setHeadAndPropagate(node, 1),它原本所在节点被置为头节点;
  • 下一个节点不是 shared 了,因此不会继续唤醒 t4 所在节点。

3.4 t1、t2 释放读锁

JUC-读写锁&Semaphore_第9张图片

  • t2 进入 sync.releaseShared(1) 中,调用 tryReleaseShared(1) 让计数减一,但由于计数还不为零;
  • t3 进入 sync.releaseShared(1) 中,调用 tryReleaseShared(1) 让计数减一,这回计数为零了,进入doReleaseShared() 将头节点从 -1 改为 0 并唤醒老二,即之后 t4acquireQueued()parkAndCheckInterrupt()恢复运行,再次 for (;;) 这次自己是老二,并且没有其他竞争,tryAcquire(1) 成功,修改头结点,流程结束

3.5 源码分析

  • 『写锁』上锁流程

JUC-读写锁&Semaphore_第10张图片

  • 『写锁』释放流程

JUC-读写锁&Semaphore_第11张图片

  • 『读锁』上锁流程

  • 『读锁』释放流程

JUC-读写锁&Semaphore_第12张图片

四、StampedLock

该类自 JDK 8 加入,是为了进一步优化读性能,它的特点是在使用读锁、写锁时都必须配合『』使用。StampedLock 是比 ReentrantReadWriteLock 更快的一种锁,支持乐观读、悲观读锁和写锁。和ReentrantReadWriteLock 不同的是,StampedLock 支持多个线程申请乐观读的同时,还允许一个线程申请写锁。

4.1 基本使用

public class StampedLockSample {

    private static final StampedLock LOCK = new StampedLock();

    public static void main(String[] args) {
        // 1.加解读锁。
        long stamp = LOCK.readLock();
        LOCK.unlockRead(stamp);

        // 2.加解写锁。
        long stamp1 = LOCK.writeLock();
        LOCK.unlockWrite(stamp1);

        /* *
         * 3.乐观锁:StampedLock 支持 tryOptimisticRead() 方法(乐观读)。
         * 读取完毕后需要做一次 戳校验 如果校验通过,表示这期间确实没有写操作,数据可以安全使用;
         * 如果校验没通过,需要重新获取读锁,保证数据安全。
         */
        long stamp2 = LOCK.tryOptimisticRead();
        // 验戳。
        if (!LOCK.validate(stamp)) {
            // 锁升级。
        }
    }
}

4.2 读写优化

需求:提供一个数据容器类,内部分别使用 StampedLock 实现读锁保护数据的 read() 方法,写锁保护数据的 write() 方法,并测试在多线程下的使用情况。

  • 数据容器类
@Slf4j
public class DataContainerStamped {

    private int data;

    private static final StampedLock LOCK = new StampedLock();

    public DataContainerStamped(int data) {
        this.data = data;
    }

    public int read(int readTime) {
        // 乐观读。
        long stamp = LOCK.tryOptimisticRead();
        log.debug("optimistic read locking...stamp:[{}]", stamp);
        sleep(readTime, TimeUnit.SECONDS);
        // 验戳。
        if (LOCK.validate(stamp)) {
            log.debug("read finish...stamp:[{}], data:[{}]", stamp, data);
            return data;
        }

        // 读锁升级。
        log.debug("updating to read lock... stamp:[{}]", stamp);
        stamp = LOCK.readLock();
        try {
            log.debug("read lock stamp:[{}]", stamp);
            sleep(readTime, TimeUnit.SECONDS);
            log.debug("read finish...stamp:[{}], data:[{}]", stamp, data);
            return data;
        } finally {
            log.debug("read unlock stamp:[{}]", stamp);
            // 释放读锁。
            LOCK.unlockRead(stamp);
        }
    }

    public void write(int newData) {
        long stamp = LOCK.writeLock();
        log.debug("write lock stamp:[{}]", stamp);
        try {
            sleep(2, TimeUnit.SECONDS);
            this.data = newData;
        } finally {
            log.debug("write unlock stamp:[{}]", stamp);
            LOCK.unlockWrite(stamp);
        }
    }
}
  • 测试
public class DataContainerStampedTests {

    /**
     * 测试 『读-读』可以优化。
     */
    @Test
    public void testReadAndRead() throws InterruptedException {
        DataContainerStamped dataContainer = new DataContainerStamped(1);
        Thread t1 = new Thread(() -> dataContainer.read(1), "t1");
        sleep(500, TimeUnit.MILLISECONDS);
        Thread t2 = new Thread(() -> dataContainer.read(0), "t2");
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        // [t1] optimistic read locking...stamp:[256]
        // [t2] optimistic read locking...stamp:[256]
        // [t2] read finish...stamp:[256], data:[1]
        // [t1] read finish...stamp:[256], data:[1]
        // 输出结果,可以看到实际没有加读锁。
    }


    /**
     * 测试 『读-写』 时优化读补加读锁。
     */
    @Test
    public void testReadAndWrite() throws InterruptedException {
        DataContainerStamped dataContainer = new DataContainerStamped(1);
        Thread t1 = new Thread(() -> dataContainer.read(1), "t1");
        sleep(500, TimeUnit.MILLISECONDS);
        Thread t2 = new Thread(() -> dataContainer.write(100), "t2");
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        // [t1] optimistic read locking...stamp:[256]
        // [t2] write lock stamp:[384]
        // [t1] updating to read lock... stamp:[256]
        // [t2] write unlock stamp:[384]
        // [t1] read lock stamp:[513]
        // [t1] read finish...stamp:[513], data:[100]
        // [t1] read unlock stamp:[513]
    }
}

4.3 注意事项

  • StampedLock 不支持条件变量
  • StampedLock 不支持可重入
  • 总结:虽然它相较于读写锁优化了性能,但也不等同于能代替读写锁,使用还得根据实际场景进行选择。

五、Semaphore

[ˈsɛməˌfɔr] Semaphore(信号量)是用来控制同时访问特定资源的线程数量,它通过协调各个线程,以保证合理的使用公共资源

5.1 基本使用

    public static void main(String[] args) {
        // 许可设置为3。
        Semaphore semaphore = new Semaphore(3);

        // 假设 5 个线程同时运行。
        for (int i = 0; i < 5; i++) {
            new Thread(() -> {
                try {
                    // 获取许可。
                    semaphore.acquire();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }

                try {
                    log.debug("running...");
                    sleep(2, TimeUnit.SECONDS);
                    log.debug("ending...");
                } finally {
                    // 释放许可。
                    semaphore.release();
                }
            }).start();
            // 16:26.370 [Thread-1] running...
            // 16:26.370 [Thread-0] running...
            // 16:26.370 [Thread-2] running...

            // 16:28.379 [Thread-2] ending...
            // 16:28.379 [Thread-1] ending...
            // 16:28.379 [Thread-0] ending...

            // 16:28.379 [Thread-4] running...
            // 16:28.379 [Thread-3] running...

            // 16:30.391 [Thread-3] ending...
            // 16:30.391 [Thread-4] ending...
        }
    }

5.2 限制对共享资源的使用

  • 需求说明

    • 使用 Semaphore 限流,在访问高峰期时,让请求线程阻塞,高峰期过去再释放许可,当然它只适合限制单机线程数量,并且仅是限制线程数,而不是限制资源数(例如连接数,请对比 Tomcat LimitLatch 的实现)。
    • Semaphore 实现简单连接池,对比『享元模式』下的实现(用 wait-notify),性能和可读性显然更好,注意下面的实现中线程数和数据库连接数是相等的。
  • 代码实现

@Slf4j
public class ConnectionPoolBySemaphoreSample {

    /* *
     * 连接池大小。
     */
    private int poolSize;

    /* *
     * 连接对象数组。
     */
    private Connection[] connections;

    /* *
     * 连接状态数组 0 表示空闲, 1 表示繁忙。
     */
    private AtomicIntegerArray states;

    /* *
     * 信号量。
     */
    private Semaphore semaphore;


    /**
     * 初始化构造器。
     *
     * @param poolSize 池大小
     */
    public ConnectionPoolBySemaphoreSample(int poolSize) {
        this.poolSize = poolSize;
        // 让许可数与资源数一致
        this.semaphore = new Semaphore(poolSize);
        this.connections = new Connection[poolSize];
        this.states = new AtomicIntegerArray(new int[poolSize]);
        for (int i = 0; i < poolSize; i++) {
            connections[i] = new MockConnection("connect-" + (i + 1));
        }
    }

    /**
     * 借连接。
     *
     * @return {@link Connection}
     */
    public Connection borrow() { // t0, t1, t2 线程进入。
        // 获取许可。
        try {
            // 没有许可的线程,在此等待。
            semaphore.acquire();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        for (int i = 0; i < poolSize; i++) {
            // 获取空闲连接。
            if (states.get(i) == 0) {
                if (states.compareAndSet(i, 0, 1)) {
                    log.debug("borrow {}", connections[i]);
                    return connections[i];
                }
            }
        }
        // *该代码段不会被执行到。
        return null;
    }

    /**
     * 归还连接。
     *
     * @param conn 具体的连接
     */
    public void free(Connection conn) {
        for (int i = 0; i < poolSize; i++) {
            if (connections[i] == conn) {
                states.set(i, 0);
                log.debug("free {}", conn);
                semaphore.release();
                break;
            }
        }
    }
}
  • 测试
public class ConnectionPoolBySemaphoreSampleTests {
    public static void main(String[] args) {
        ConnectionPoolBySemaphoreSample pool = new ConnectionPoolBySemaphoreSample(2);
        // 模拟三个线程去使用连接池中的两个连接。
        for (int i = 0; i < 3; i++) {
            new Thread(() -> {
                Connection conn = pool.borrow();
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                pool.free(conn);
            }).start();
        }
        // [Thread-0] borrow connect-1
        // [Thread-1] borrow connect-2
        // [Thread-0] free connect-1
        // [Thread-1] free connect-2
        // [Thread-2] borrow connect-1
        // [Thread-2] free connect-1
    }
}

5.3 加锁解锁流程

Semaphore 有点像一个停车场,permits 就好像停车位数量,当线程获得了 permits 就像是获得了停车位,然后停车场显示空余车位减一。

JUC-读写锁&Semaphore_第13张图片

JUC-读写锁&Semaphore_第14张图片

  • 刚开始,permitsstate)为 3,这时 5 个线程来获取资源;
  • 假设其中 t1,t2,t4 cas 竞争成功,而 t0 和 t3 竞争失败,进入 AQS 队列 park 阻塞;
  • 这时 t4 释放了 permits,接下来 t0 竞争成功,permits 再次设置为 0,设置自己为 head 节点,断开原来的 head 节点,unpark 接下来的 t3 节点,但由于 permits 是 0,因此 t3 在尝试不成功后再次进入 park 状态。

5.4 源码分析

  • 上锁流程

JUC-读写锁&Semaphore_第15张图片

  • 释放流程

JUC-读写锁&Semaphore_第16张图片

六、结束语


“-------怕什么真理无穷,进一寸有一寸的欢喜。”

微信公众号搜索:饺子泡牛奶

你可能感兴趣的:(JUC,java)