深入源码解析 ReentrantLock、AQS:掌握 Java 并发编程关键技术(一)

本文涉及的产品
云解析 DNS,旗舰版 1个月
全局流量管理 GTM,标准版 1个月
公共DNS(含HTTPDNS解析),每月1000万次HTTP解析
简介: 深入源码解析 ReentrantLock、AQS:掌握 Java 并发编程关键技术

前言

介绍 ReentrantLock、AQS 之前,先分析它们的来源,来自于 JUC 中的核心组件,java.util.concurrent 在并发编程中是比较会常用的工具类,里面包含了很多在并发场景下使用的组件,比如:线程池 > ThreadPoolExecutor、阻塞队列 > BlockingQueue、计数器 > CountDownLatch、循环屏障 > CyclicBarrier、信号量 > Semaphore、并发集合 > ConcurrentHashMap | CopyOnWriteArrayList |ConcurrentSkipListMap 等

ReentrantLock 与 synchronized 具有相同的基本行为、语义,但它扩展了一些其他的功能且更能灵活控制锁

1、ReentrantLock 提供了公平锁、非公平锁的机制,而 synchronized 并没有公平锁的机制

2、ReentrantLock 提供了 tryLock 方法,尝试获取锁而不会阻塞线程去作其他的事情,更加灵活

3、ReentrantLock#lockInterruptibly 方法提供了响应中断的能力,若当前在等待锁的线程被中断了,通过此方法可以捕获到中断异常,以便作相应的异常处理

4、ReentrantLock > tryLock(long time, TimeUnit unit) 方法提供了锁超时等待能力,可以指定等待锁的超时时间,对于限时等待的场景很有用

5、ReentrantLock 可以通过 newCondition 方法获取多个 Condition 对象来实现多个条件变量,以便可以更加细粒度地调用 await、signal 等待、唤醒操作;synchronized 只能通过 wait、notify 方法实现简单的等待和唤醒

在该篇博文主要介绍 ReentrantLock 是如何实现的,以及它的核心方法源码,如何结合 AQS 实现锁解决并发安全问题的

Lock

Lock 是 JUC 组件下最核心的接口,绝大部分组件都使用到了 Lock 接口,所以先以 Lock 接口作为切入点讲解后续的源码

Lock 本质上是一个接口,它提供了获得锁、释放锁、条件变量、锁中断能力,定义为接口就意味着它定义了一个锁的标准规范,也同时意味着锁的不同实现。实现 Lock 接口的类有很多,以下为几个常见的锁实现

  1. ReentrantLock:表示为重入锁,它是唯一一个实现了 Lock 接口的类;重入锁是指当前线程获得锁以后,再次获取锁不需要进行阻塞,而是直接累加 AbstractQueuedSynchronizer#state 变量值
  2. ReentrantReadWriteLock:表示重入读写锁,实现了 ReadWriteLock 接口,在该类中维护了两种锁,一个是 ReadLock,另外一个是 WriteLock,它们各自实现了 Lock 接口。

读写锁是一种适合读多写少的场景下,来解决线程安全问题的组件,基本的原则:读读不互斥、读写互斥、写写互斥,一旦涉及到数据变化的操作都会是互斥的

  1. StampedLock:该锁是 JDK 8 引入的锁机制,是读写锁的一个改进版本,读写锁虽然通过分离读、写功能使得读、读之间可以并行,但是读、写是互斥的,若大量的读线程存在,可能会引起写线程的饥饿;StampedLock 是一种乐观锁的读策略,采用 CAS 乐观锁完全不会阻塞写线程

重要的方法,简介如下:

  1. lock:若锁可用就获得锁,若锁不可用就阻塞,直接锁被释放
  2. lockInterruptibly:与 lock 方法相似,但阻塞的线程可中断,会抛出 java.lang.InterruptedException 异常
  3. tryLock:非阻塞获取锁,尝试获取锁,若成功返回 true
  4. tryLock(long timeout, TimeUnit timeUnit):带有超时时间的获取锁方法
  5. unLock:释放锁

重入锁

重入锁,支持同一个线程在同一个时刻获取同一把锁;也就是说,若当前线程 T1 调用 lock 方法获取了锁以后,再次调用 lock,是不会再以阻塞的方式去获取锁的,直接增加锁的重入次数就 OK 了。

synchronized、ReentrantLock 都支持重入锁,存在多个加锁的方法相互调用时,其实就是一种锁可重入特性的场景,以下通过不同的代码案例来演示可重入锁是怎样的

synchronized

/**
 * @author vnjohn
 * @since 2023/6/17
 */
public class SynchronizedDemo {
    public synchronized void lockMethodOne() {
        System.out.println("begin:lockMethodOne");
        lockMethodTwo();
    }
    public void lockMethodTwo() {
        synchronized (this) {
            System.out.println("begin:lockMethodTwo");
        }
    }
    public static void main(String[] args) {
        SynchronizedDemo synchronizedDemo = new SynchronizedDemo();
        new Thread(() -> synchronizedDemo.lockMethodOne()).start();
    }
}

调用 lockMethodOne 方法获取了当前实例的锁,然后在这个方法里面还调用了 lockMethodTwo 方法,lockMethodTwo 虽然是代码块锁,但锁住的也是当前实例;若不支持锁可重入时,当前线程会因为无法获取 lockMethodTwo 实例锁而被阻塞,即会发生死锁现象,重入锁设计的目的是为了避免线程的死锁

ReentrantLock

ReentrantLock 与 synchronized 同理,示例代码如下:

/**
 * @author vnjohn
 * @since 2023/6/17
 */
public class ReentrantLockDemo {
    static Lock lock = new ReentrantLock();
    public void lockMethodOne() {
        lock.lock();
        try {
            System.out.println("begin:lockMethodOne");
            lockMethodTwo();
        } finally {
            lock.unlock();
        }
    }
    public void lockMethodTwo() {
        lock.lock();
        try {
            System.out.println("begin:lockMethodTwo");
        } finally {
            lock.unlock();
        }
    }
    public static void main(String[] args) throws InterruptedException {
        ReentrantLockDemo reentrantLockDemo = new ReentrantLockDemo();
        new Thread(()-> reentrantLockDemo.lockMethodOne()).start();
    }
}

ReentrantReadWriteLock 读写锁

上面提及到的 synchronized、ReentrantLock 重入锁的特性其实是排它锁,也是悲观锁,该锁在同一时刻只允许一个线程进行访问,而读写锁在同一个时刻可以允许多个线程(读)访问,但是在写线程访问时,所有的读线程、其他写线程都会被阻塞。读写锁维护了一对锁:读锁 > ReentrantReadWriteLock.ReadLock、写锁 > ReentrantReadWriteLock.WriteLock;一般情况下,读写锁的性能会比悲观锁性能好,因为在大多数场景下读都是多于写的,读写锁能够比排它锁提供更好的并发性、吞吐量;通过案例来演示读写锁如何使用,如下:

/**
 * @author vnjohn
 * @since 2023/6/18
 */
public class ReentrantReadWriteLockDemo {
    private static Map<String, Object> CACHE_MAP = new HashMap<>();
    private static ReentrantReadWriteLock readWriteLock = new ReentrantReadWriteLock();
    private static Lock readLock = readWriteLock.readLock();
    private static Lock writeLock = readWriteLock.writeLock();
    /**
     * 通过读锁从本地缓存中获取数据
     *
     * @param key
     * @return
     */
    public static Object get(String key) {
        readLock.lock();
        try {
            System.out.println("本地缓存读取数据:" + key);
            TimeUnit.SECONDS.sleep(1);
            return CACHE_MAP.get(key);
        } catch (InterruptedException e) {
            return null;
        } finally {
            readLock.unlock();
        }
    }
    /**
     * 通过写锁从本地缓存中获取数据
     *
     * @param key
     * @return
     */
    public static Object put(String key, Object obj) {
        writeLock.lock();
        try {
            System.out.println("本地缓存写入数据:" + key);
            TimeUnit.SECONDS.sleep(1);
            return CACHE_MAP.put(key, obj);
        } catch (InterruptedException e) {
            return null;
        } finally {
            writeLock.unlock();
        }
    }
    public static void main(String[] args) {
        String keyOnce = "thread-batch-once";
        for (int i = 0; i < 5; i++) {
            // 演示读写锁互斥的情况,
            new Thread(()-> ReentrantReadWriteLockDemo.get(keyOnce)).start();
            new Thread(()-> ReentrantReadWriteLockDemo.put(keyOnce, Thread.currentThread().getName())).start();
        }
    }
}

在该案例中,通过 HashMap 来模拟了一个本地缓存,然后使用读写锁来保证这个本地缓存线程安全性。当执行读操作的时候,需要获取读锁,在并发访问的时候,读锁不会阻塞,因为读操作不会影响执行结果

在执行写操作时,线程必须要获取写锁,当已经有线程持有写锁的情况下,当前线程会被阻塞,只有当锁释放以后,其他读写操作才能继续执行。使用读写锁提升读操作的并发性,也保证每次写操作对所有的读写操作的可见性

读锁、读锁可以共享

读锁、写锁不可以共享(排它)

写锁、写锁不可以共享(排它)

ReentrantLock 实现原理

锁的基本原理是,将多线程并行任务基于某一种机制实现线程的串行执行,从而达到线程安全性的目的。在 synchronize 中,存在锁升级的概念 > 偏向锁、轻量级锁、重量级锁。基于 CAS 乐观自旋锁优化了 synchronize 加锁开销,同时在重量级锁阶段,通过线程的阻塞以及唤醒来达到线程竞争、同步的目的。那么在 ReentrantLock 中,也一定会存在这样的问题需要去解决

那么在多线程竞争重入锁时,竞争失败的线程是如何实现阻塞以及被唤醒的呢?提及这个必须先说说 AQS 是什么了!

AQS

AQS > 全称 AbstractQueuedSynchronizer,内部用到了一个同步等待队列,它是一个同步工具也是 Lock 用来实现线程同步的核心组件

从 AQS 功能、使用层面来说,AQS 分为两种:独占、共享

  • 独占锁:同一时刻只能有一个线程持有锁,操作、写入资源,比如:ReentrantLock
  • 共享锁:允许多个线程同时获取锁,并发访问共享资源,比如:ReentrantReadWriteLock

AQS 内部实现

AQS 内部的同步等待队列其实就是维护了一个 FIFO 的双向链表,这种结构的特点是每个节点都会两个指针,分别指向直接后继节点、直接前驱节点。所以双向链表可以从任意一个节点开始很方便的访问前驱、后继节点。节点由内部类 Node 表示,Node 内部类其实是由线程封装,当线程争抢锁失败后会封装成 Node 加入到 AQS 队列中去;当获取锁的线程释放锁以后,会从队列中唤醒其中一个阻塞的节点(线程)

Node 内部结构

static final class Node {
    /** Marker to indicate a node is waiting in shared mode */
    static final Node SHARED = new Node();
    /** Marker to indicate a node is waiting in exclusive mode */
    static final Node EXCLUSIVE = null;
    /** 线程已取消等待锁,调用 tryLock(TimeUnit) 或 intercept 中断方法*/
    static final int CANCELLED =  1;
    /** 表明后续线程需要被唤醒 */
    static final int SIGNAL    = -1;
    /** 表明线程在等待状态 */
    static final int CONDITION = -2;
    /**
     * 在共享模式下,该值表明下一个需要被分享的节点应该无条件被分享
     */
    static final int PROPAGATE = -3;
    /**
     * 0-默认值、CANCELLED、SIGNAL、CONDITION、PROPAGATE,
     * 后续会通过 CAS 操作改变该值状态
     */
    volatile int waitStatus;
    /**
     * 前驱节点
     */
    volatile Node prev;
    /**
     * 后继节点
     */
    volatile Node next;
    /**
     * 当前线程
     */
    volatile Thread thread;
    /**
     * 存储在 Condition 队列中的后继节点
     */
    Node nextWaiter;
    /**
     * 是否为共享模式(共享锁)
     */
    final boolean isShared() {
        return nextWaiter == SHARED;
    }
    /**
     * 获取前驱节点或抛出空指针异常
     * @return the predecessor of this node
     */
    final Node predecessor() throws NullPointerException {
        Node p = prev;
        if (p == null)
            throw new NullPointerException();
        else
            return p;
    }
  // 用于建立初始的头或共享标记
    Node() {    // Used to establish initial head or SHARED marker
    }
  // 该构造方法会构造成一个 Node,添加到等待队列中
    Node(Thread thread, Node mode) {     // Used by addWaiter
        this.nextWaiter = mode;
        this.thread = thread;
    }
  // 该构造方法会在 Condition 队列中使用
    Node(Thread thread, int waitStatus) { // Used by Condition
        this.waitStatus = waitStatus;
        this.thread = thread;
    }
}

Node 变更过程

当出现锁竞争或释放锁时, AQS 同步等待队列中的节点会发生变化

添加节点

下面来看一下添加节点的场景是怎样的

在这里会发生三个变化:

  1. 新的竞争锁线程会封装成 Node 节点追加到同步队列中,设置 prev 节点指向原有的 tail 尾部节点
  2. 通过 CAS 操作将 tail 指针指向新加入的 Node 节点
  3. 修改原有的 tail 尾部节点 next 指针指向新加入的 Node

以上的变化发生在核心方法:AbstractQueuedSynchronizer#addWaiter 中

释放节点

head 节点表示获取锁成功的节点,当头节点在释放同步状态时,会唤醒后继节点,若后继节点获取锁成功,会将自身设置为头节点,节点的变化过程如下:

在这里会发生两个变化:

  1. 设置 head 头节点指向下一个获取锁的节点
  2. 新的获取锁节点,将 prev 指针指向 null

设置 head 头节点不需要使用 CAS 操作,原因:设置 head 头节点是由获取锁的线程来完成的,同步锁只能由一个线程获取,所以不适合通过 CAS 来保证,只需要把 head 头节点设置为原 head 头节点的后继节点,并且切断原 head 头节点的 next 引用即可

ReentrantLock 类源码分析

以 ReentrantLock 类作为入口,看看该类源码级别是如何使用 AQS 来实现线程同步的

时序图

ReentrantLock#lock 方法源码的调用过程,通过时序图的方式来进行展示

锁竞争核心方法

简单梳理了一下 lock 流程以后,下面来介绍 ReentrantLock、AQS 中一些核心方法内容以及其作用

NonfairSync#lock

NonfairSync 实现类是 ReentrantLock 类内部接口 Sync 的实现类,它采用非公平锁的方式进行锁竞争, 下面来看其源码内容是如何实现的

final void lock() {
    if (compareAndSetState(0, 1))
        setExclusiveOwnerThread(Thread.currentThread());
    else
        acquire(1);
}

非公平锁、公平锁最大的区别:在于非公平锁抢占锁的逻辑是不管有没有等待队列中有没有线程在排队,我先上来用 CAS 操作抢占一下

  • CAS 成功,即表示成功获取到了锁
  • CAS 失败,调用 AbstractQueuedSynchronizer#acquire 方法走竞争锁逻辑

CAS(Compare And Set-比较并交换)

protected final boolean compareAndSetState(int expect, int update) {
    // See below for intrinsics setup to support this
    return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
}

通过 CAS 乐观锁的方式来作比较并替换,若当前内存中的 state 值与预期值 expect 相等,则替换为 update 值;更新成功返回 true,否则返回 false;该操作是原子性的,不会出现线程安全问题,这里面会涉及到 Unsafe 类的操作

state 是 AQS 中的一个属性,它在不同的组件实现中所表达的含义不一样,对于重入锁 ReentrantLock 来说,它有以下两个含义:

  1. 当 state = 0 时,表示无锁状态
  2. 当 state = 1 时,表示已经有线程获取到了锁

因为 ReentrantLock 允许可重入,所以同一个线程多次获取锁时,state 会递增,比如:在一段代码中,当前线程重复获取同一把锁三次(未释放的情况下)state 为 3;而在释放锁时,同样需要释放 3 次直至 state = 0 其他线程才有资格去获取这把锁

AQS#acquire

acquire 方法是 AQS 中的核心方法,若 CAS 操作未能成功,说明 state 值已经不为 0,此时继续调用 acquire(1) 方法操作,如下:

public final void acquire(int arg) {
    if (!tryAcquire(arg) &&
        acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
        selfInterrupt();
}

该方法分以下几块逻辑进行:

  1. 通过 tryAcquire 尝试去获取独占锁,若成功返回 true,失败返回 false
  2. 若 tryAcquire 执行结果为 false,则会调用 addWaiter 方法,将当前线程封装成 Node 添加到 AQS 队列的尾部
  3. acquireQueued:将 Node 作为参数,通过自旋的方式去尝试获取锁,这里会执行线程的阻塞等待逻辑
目录
相关文章
|
5天前
|
机器学习/深度学习 人工智能 自然语言处理
AI技术深度解析:从基础到应用的全面介绍
人工智能(AI)技术的迅猛发展,正在深刻改变着我们的生活和工作方式。从自然语言处理(NLP)到机器学习,从神经网络到大型语言模型(LLM),AI技术的每一次进步都带来了前所未有的机遇和挑战。本文将从背景、历史、业务场景、Python代码示例、流程图以及如何上手等多个方面,对AI技术中的关键组件进行深度解析,为读者呈现一个全面而深入的AI技术世界。
52 10
|
18天前
|
Java 程序员
Java编程中的异常处理:从基础到高级
在Java的世界中,异常处理是代码健壮性的守护神。本文将带你从异常的基本概念出发,逐步深入到高级用法,探索如何优雅地处理程序中的错误和异常情况。通过实际案例,我们将一起学习如何编写更可靠、更易于维护的Java代码。准备好了吗?让我们一起踏上这段旅程,解锁Java异常处理的秘密!
|
2天前
|
算法 Java 调度
java并发编程中Monitor里的waitSet和EntryList都是做什么的
在Java并发编程中,Monitor内部包含两个重要队列:等待集(Wait Set)和入口列表(Entry List)。Wait Set用于线程的条件等待和协作,线程调用`wait()`后进入此集合,通过`notify()`或`notifyAll()`唤醒。Entry List则管理锁的竞争,未能获取锁的线程在此排队,等待锁释放后重新竞争。理解两者区别有助于设计高效的多线程程序。 - **Wait Set**:线程调用`wait()`后进入,等待条件满足被唤醒,需重新竞争锁。 - **Entry List**:多个线程竞争锁时,未获锁的线程在此排队,等待锁释放后获取锁继续执行。
25 12
|
12天前
|
机器学习/深度学习 人工智能 自然语言处理
秒级响应 + 99.9%准确率:法律行业文本比对技术解析
本工具基于先进AI技术,采用自然语言处理和语义匹配算法,支持PDF、Word等格式,实现法律文本的智能化比对。具备高精度语义匹配、多格式兼容、高性能架构及智能化标注与可视化等特点,有效解决文本复杂性和法规更新难题,提升法律行业工作效率。
|
9天前
|
数据采集 存储 JavaScript
网页爬虫技术全解析:从基础到实战
在信息爆炸的时代,网页爬虫作为数据采集的重要工具,已成为数据科学家、研究人员和开发者不可或缺的技术。本文全面解析网页爬虫的基础概念、工作原理、技术栈与工具,以及实战案例,探讨其合法性与道德问题,分享爬虫设计与实现的详细步骤,介绍优化与维护的方法,应对反爬虫机制、动态内容加载等挑战,旨在帮助读者深入理解并合理运用网页爬虫技术。
|
15天前
|
机器学习/深度学习 自然语言处理 监控
智能客服系统集成技术解析和价值点梳理
在 2024 年的智能客服系统领域,合力亿捷等服务商凭借其卓越的技术实力引领潮流,它们均积极应用最新的大模型技术,推动智能客服的进步。
50 7
|
15天前
|
安全 算法 Java
Java多线程编程中的陷阱与最佳实践####
本文探讨了Java多线程编程中常见的陷阱,并介绍了如何通过最佳实践来避免这些问题。我们将从基础概念入手,逐步深入到具体的代码示例,帮助开发者更好地理解和应用多线程技术。无论是初学者还是有经验的开发者,都能从中获得有价值的见解和建议。 ####
|
19天前
|
存储 编译器 C语言
【C语言】数据类型全解析:编程效率提升的秘诀
在C语言中,合理选择和使用数据类型是编程的关键。通过深入理解基本数据类型和派生数据类型,掌握类型限定符和扩展技巧,可以编写出高效、稳定、可维护的代码。无论是在普通应用还是嵌入式系统中,数据类型的合理使用都能显著提升程序的性能和可靠性。
40 8
|
15天前
|
Java 调度
Java中的多线程编程与并发控制
本文深入探讨了Java编程语言中多线程编程的基础知识和并发控制机制。文章首先介绍了多线程的基本概念,包括线程的定义、生命周期以及在Java中创建和管理线程的方法。接着,详细讲解了Java提供的同步机制,如synchronized关键字、wait()和notify()方法等,以及如何通过这些机制实现线程间的协调与通信。最后,本文还讨论了一些常见的并发问题,例如死锁、竞态条件等,并提供了相应的解决策略。
40 3
|
18天前
|
存储 算法 Java
Java内存管理深度解析####
本文深入探讨了Java虚拟机(JVM)中的内存分配与垃圾回收机制,揭示了其高效管理内存的奥秘。文章首先概述了JVM内存模型,随后详细阐述了堆、栈、方法区等关键区域的作用及管理策略。在垃圾回收部分,重点介绍了标记-清除、复制算法、标记-整理等多种回收算法的工作原理及其适用场景,并通过实际案例分析了不同GC策略对应用性能的影响。对于开发者而言,理解这些原理有助于编写出更加高效、稳定的Java应用程序。 ####

推荐镜像

更多
下一篇
DataWorks