写点什么

史上最全的 Java 并发系列之 Java 中的锁的使用和实现介绍(二)

作者:自然
  • 2022 年 8 月 13 日
    广东
  • 本文字数:4411 字

    阅读完需:约 14 分钟

前言

文本已收录至我的 GitHub 仓库,欢迎 Star:https://github.com/bin392328206/six-finger

种一棵树最好的时间是十年前,其次是现在

我知道很多人不玩 qq 了,但是怀旧一下,欢迎加入六脉神剑 Java 菜鸟学习群,群聊号码:549684836 鼓励大家在技术的路上写博客

重入锁

重入锁 ReentrantLock,顾名思义,就是支持重进入的锁,它表示该锁能够支持一个线程对 资源的重复加锁。除此之外,该锁的还支持获取锁时的公平和非公平性选择。我们回顾下 TestLock 的 lock 方法,在 tryAcquire(int acquires)方法时没有考虑占有锁的线程再次获取锁的场景,而在调用 tryAcquire(int acquires)方法时返回了 false,导致该线程被阻塞。


在绝对时间上,先对锁进行获取的请求一定先被满足,那么这个锁是公平的,反之,是不公平的。事实上,公平的锁机制往往没有非公平的效率高,但是,并不是任何场景都是以 TPS 作为唯一的指标,公平锁能够减少“饥饿”发生的概率,等待越久的请求越是能够得到优先满足。


下面我们来分析下 ReentrantLock 的实现:


  • 实现重进入

  • 重进入是指任意线程在获取到锁之后能够再次获取该锁而不会被锁所阻塞,该特性的实现需要解决以下两个问题:

  • 线程再次获取锁

  • 锁的最终释放


下面是 ReentrantLock 通过组合自定义同步器来实现锁的获取与释放,以非公平性(默认的)实现:


final boolean nonfairTryAcquire(int acquires) {    final Thread current = Thread.currentThread();    int c = getState();    if (c == 0) {        if (compareAndSetState(0, acquires)) {            setExclusiveOwnerThread(current);            return true;        }    }    else if (current == getExclusiveOwnerThread()) {        int nextc = c + acquires;        if (nextc < 0) // overflow            throw new Error("Maximum lock count exceeded");        setState(nextc);        return true;    }    return false;}
复制代码


此方法通过判断 当前线程是否为获取锁的线程 来决定获取操作是否成功,如果是获取锁的线程再次请求,则将同步状态值进行增加并返回 true,表示获取同步状态成功。


公平与非公平获取锁的区别


公平性与否是针对获取锁而言的,如果一个锁是公平的,那么锁的获取顺序就应该符合请求的绝对时间顺序,也就是 FIFO。

读写锁

之前提到锁(如 TestLock 和 ReentrantLock)基本都是排他锁,这些锁在同一时刻只允许一个线程进行访问,而读写锁在同一时刻可以允许多个读线程访问,但是在写线程访问时,所有的读线程和其他写线程均被阻塞。读写锁维护了一对锁,一个 读锁 和一个 写锁,通过分离读锁和写锁,使得并发性相比一般的排他锁有了很大提升。


一般情况下,读写锁 的性能都会比 排它锁 好,因为大多数场景 读是多于写 的。在读多于写的情况下,读写锁 能够提供比 排它锁 更好的 并发性 和 吞吐量。Java 并发包提供读写锁的实现是 ReentrantReadWriteLock


  • 公平性选择 :支持公平和非公平的方式获取锁,吞吐量非公平优于公平。

  • 重进入 : 读锁在获取锁之后再获取读锁,写锁在获取锁之后再获取读锁和写锁。

  • 锁降级 :遵循获取写锁、获取读锁在释放写锁的次序,写锁能够降级为读锁。


读写锁的接口与示例 ReadWriteLock 仅定义了获取读锁和写锁的两个方法,即 readLock()方法和 writeLock()方法,而其实现类 ReentrantReadWriteLock,除了接口方法之外,还提供了一些便于外界监控其内部工作状态的方法,这些方法如下:


  • getReadLockCount():返回当前读锁获取的次数

  • getReadHoldCount():返回当前线程获取读锁的次数

  • isWriteLocked():判断写锁是否被获取

  • getWriteHoldCount():返回当前写锁被获取的次数



通过读写锁保证 非线程安全的 HashMap 的读写是线程安全的。


static Map<String, Object> map = new HashMap<>();static ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();static Lock r = rwl.readLock();static Lock w = rwl.writeLock();/** * 获取一个key对应的value */public static final Object get(String key) {    r.lock();    try {        return map.get(key);    } finally {        r.unlock();    }}/** * 设置key对应的value,并返回旧的value */public static final Object put(String key, Object value) {    w.lock();    try {        return map.put(key, value);    } finally {        w.unlock();    }}/** * 清空所有的内容 */public static final void clear() {    w.lock();    try {        map.clear();    } finally {        w.unlock();    }}
复制代码

锁降级

锁降级指的是 写锁降级成为读锁。如果当前线程拥有写锁,然后将其释放,最后再获取读锁,这种分段完成的过程不能称之为锁降级。锁降级是指把持住(当前拥有的)写锁,再获取到读锁,随后释放(先前拥有的)写锁的过程。


//当数据发生变更后,update变量(布尔类型且volatile修饰)被设置为falsepublic void processData() {    readLock.lock();    if (!update) {        // 必须先释放读锁        readLock.unlock();        // 锁降级从写锁获取到开始        writeLock.lock();        try {            if (!update) {                // 准备数据的流程(略)                update = true;            }            readLock.lock();        } finally {            writeLock.unlock();        }        // 锁降级完成,写锁降级为读锁    }    try {        // 使用数据的流程(略)    } finally {        readLock.unlock();    }}
复制代码


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

锁降级中读锁的获取是否必要呢?

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


RentrantReadWriteLock 不支持锁升级。目的也是保证数据可见性,如果读锁已被多个线程获取,其中任意线程成功获取了写锁并更新了数据,则其更新对其他获取到读锁的线程是不可见的。

LockSupport 工具

当需要阻塞或唤醒一个线程的时候,都会使用 LockSupport 工具类来完成相应工作。LockSupport 定义了一组的公共静态方法,这些方法提供了最基本的 线程阻塞和唤醒功能,而 LockSupport 也成为构建同步组件的基础工具。


LockSupport 提供的 阻塞和唤醒的方法 如下:


  • park():阻塞当前线程,只有调用 unpark(Thread thread)或者被中断之后才能从 park()返回。

  • parkNanos(long nanos):再 park()的基础上增加了超时返回。

  • parkUntil(long deadline):阻塞线程知道 deadline 对应的时间点。

  • park(Object blocker):Java 6 时增加,blocker 为当前线程在等待的对象。

  • parkNanos(Object blocker, long nanos):Java 6 时增加,blocker 为当前线程在等待的对象。

  • parkUntil(Object blocker, long deadline):Java 6 时增加,blocker 为当前线程在等待的对象。

  • unpark(Thread thread):唤醒处于阻塞状态的线程 thread。有对象参数的阻塞方法在线程 dump 时,会有更多的现场信息

Condition 接口

任意一个 Java 对象,都拥有一组监视器方法,定义在 java.lang.Object),主要包括 wait()、wait(long timeout)、notify()以及 notifyAll()方法,这些方法与 synchronized 同步关键字配合,可以实现等待/通知模式。


Condition 接口也提供了类似 Object 的监视器方法,与 Lock 配合可以实现 等待/通知 模式,但是这两者在使用方式以及功能特性上还是有差别的。


以下是 Object 的监视器方法与 Condition 接口的对比:



Condition 的使用方式比较简单,需要注意在调用方法前获取锁,如下:


Lock lock = new ReentrantLock();Condition condition = lock.newCondition();public void conditionWait() throws InterruptedException {    lock.lock();    try {        condition.await();    } finally {        lock.unlock();    }}public void conditionSignal() throws InterruptedException {    lock.lock();    try {        condition.signal();    } finally {        lock.unlock();    }}
复制代码


Condition 接口方法介绍:


  • void await() throws InterruptedException : 当前线程进入等待状态直到被通知或中断

  • void awaitUninterruptibly() :当前线程进入等待状态直到被通知,对中断不敏感

  • long awaitNanos(long var1) throws InterruptedException :当前线程进入等待状态直到被通知、中断或超时

  • boolean await(long var1, TimeUnit var3) throws InterruptedException :当前线程进入等待状态直到被通知、中断或超时

  • boolean awaitUntil(Date var1) throws InterruptedException :当前线程进入等待状态直到被通知、中断或到某一时间

  • void signal() :唤醒 Condition 上一个在等待的线程

  • void signalAll() :唤醒 Condition 上全部在等待的线程


获取一个 Condition 必须通过 Lock 的 newCondition()方法。


Condition 的实现分析


主要包括 等待队列、等待和通知。


  • 等待队列

  • 等待队列是一个 FIFO 的队列,在队列中的每个节点都包含了一个线程引用,该线程就是在 Condition 对象上等待的线程,如果一个线程调用了 Condition.await()方法,那么该线程将会释放锁、构造成节点加入等待队列并进入等待状态。同步队列和等待队列中节点类型都是同步器的静态内部类 AbstractQueuedSynchronizer.Node。

  • 线程调用 Condition.await(),即以当前线程构造节点,并加入等待队列的尾部。


锁的知识点

  • Lock 接口提供的方法 lock()、unlock()等获取和释放锁的介绍

  • 队列同步器的使用 以及 自定义队列同步器

  • 重入锁 的使用和实现介绍

  • 读写锁 的 读锁 和 写锁

  • LockSupport 工具实现 阻塞和唤醒线程

  • Condition 接口实现 等待/通知模式

结尾

本章介绍了 Java 并发包中与锁相关的 API 和组件,通过示例讲述了这些 API 和组件的使用 方式以及需要注意的地方,并在此基础上详细地剖析了队列同步器、重入锁、读写锁以及 Condition 等 API 和组件的实现细节,只有理解这些 API 和组件的实现细节才能够更加准确地运 用它们


因为博主也是一个开发萌新 我也是一边学一边写 我有个目标就是一周 二到三篇 希望能坚持个一年吧 希望各位大佬多提意见,让我多学习,一起进步。

日常求赞

好了各位,以上就是这篇文章的全部内容了,能看到这里的人呀,都是真粉


创作不易,各位的支持和认可,就是我创作的最大动力,我们下篇文章见


六脉神剑 | 文 【原创】如果本篇博客有任何错误,请批评指教,不胜感激 !

发布于: 刚刚阅读数: 3
用户头像

自然

关注

还未添加个人签名 2020.03.01 加入

小六六,目前负责营收超百亿的支付中台

评论

发布
暂无评论
史上最全的Java并发系列之Java中的锁的使用和实现介绍(二)_多线程_自然_InfoQ写作社区