Java 并发编程面试题——Lock 与 AbstractQueuedSynchronizer (AQS)

目录

  • 1.Lock
    • [1.1.Lock 是什么?](#1.1.Lock 是什么?)
    • [1.2.Lock 接口提供了哪些 synchronized 关键字不具备的主要特性?](#1.2.Lock 接口提供了哪些 synchronized 关键字不具备的主要特性?)
    • [1.3.✨Lock 与 synchronized 有什么区别?](#1.3.✨Lock 与 synchronized 有什么区别?)
    • [1.4.Lock 接口中有哪些方法?](#1.4.Lock 接口中有哪些方法?)
    • [1.5.哪些类实现了 Lock 接口?](#1.5.哪些类实现了 Lock 接口?)
  • 2.AbstractQueuedSynchronizer (AQS)
    • [2.1.✨谈谈你对 AbstractQueuedSynchronizer (AQS) 的理解。](#2.1.✨谈谈你对 AbstractQueuedSynchronizer (AQS) 的理解。)
    • [2.2.Lock 与 AQS 有什么联系?](#2.2.Lock 与 AQS 有什么联系?)
    • [2.3.AQS 的设计用到了哪种设计模式?具体是如何设计的?](#2.3.AQS 的设计用到了哪种设计模式?具体是如何设计的?)
    • [2.4.AQS 中有哪些重要的方法?](#2.4.AQS 中有哪些重要的方法?)
    • [2.5.如何使用 AQS?](#2.5.如何使用 AQS?)
    • [2.6.✨AQS 的实现原理是什么?](#2.6.✨AQS 的实现原理是什么?)
    • [2.7.如何获取 AQS 中的独占式锁?](#2.7.如何获取 AQS 中的独占式锁?)
    • [2.8.如何获取 AQS 中的共享式锁?](#2.8.如何获取 AQS 中的共享式锁?)
    • [2.9.✨AQS 中为什么要使用双向链表,而并非单向链表?](#2.9.✨AQS 中为什么要使用双向链表,而并非单向链表?)
    • [2.10.AQS 在 JDK 中有哪些具体的应用?](#2.10.AQS 在 JDK 中有哪些具体的应用?)

参考文章:

《Java 并发编程的艺术》
深入理解 AbstractQueuedSynchronizer (AQS)

1.Lock

1.1.Lock 是什么?

(1)Lock 是 java.util.concurrent 包下 locks 子包中的一个接口,锁是用来控制多个线程访问共享资源的方式 ,一般来说,一个锁能够防止多个线程同时访问共享资源(但是有些锁可以允许多个线程并发的访问共享资源,比如读写锁)。

(2)在 Lock 接口出现之前,Java 程序主要是靠 synchronized 关键字实现锁功能的,而 Java 5 之后,并发包中增加了Lock 接口,它提供了与 synchronized 一样的锁功能。虽然它失去了像 synchronize 关键字隐式加锁解锁的便捷性,但是却拥有了锁获取和释放的可操作性,可中断的获取锁以及超时获取锁等多种 synchronized 关键字所不具备的同步特性

(3)使用 synchronized 关键字将会隐式地获取锁,但是它将锁的获取和释放固化了,也就是先获取再释放。当然,这种方式简化了同步的管理,可是扩展性没有显示的锁获取和释放来的好。例如,针对一个场景,手把手进行锁获取和释放,先获得锁 A,然后再获取锁 B,当锁 B 获得后,释放锁 A 同时获取锁 C,当锁 C 获得后,再释放 B 同时获取锁 D,以此类推。这种场景下,synchronized 关键字就不那么容易实现了,而使用 Lock 却容易许多。通常使用显示使用锁的形式如下:

java 复制代码
Lock lock = new ReentrantLock();
lock.lock();
try {
	//...
} finally {
	lock.unlock();
}

注意:synchronized 同步块执行完成或者遇到异常是锁会自动释放 ,而 lock 必须调用 unlock() 方法释放锁,因此在 finally 块中释放锁。

1.2.Lock 接口提供了哪些 synchronized 关键字不具备的主要特性?

1.3.✨Lock 与 synchronized 有什么区别?

  • 特性 :synchronized 是一个关键字 ;Lock 是 JUC 包里面提供的一个接口 ,这个接口有很多的实现类,其中包括重入锁 ReentrantLock
  • 锁的获取方式
    • synchronized 是隐式锁,它依赖于 JVM 内部的监视器锁(也称为对象锁)。当线程进入 synchronized 代码块或方法时,它会自动获取对象的锁。
    • Lock 是显式锁,需要手动调用 lock() 方法获取锁,且在使用完毕后需要手动调用 unlock() 方法释放锁。
  • 锁粒度
    • synchronized 可以通过两种方式控制锁的粒度:修饰方法修饰同步代码块,并且我们可以通过 synchronized 加锁对象的生命周期,来控制锁的作用范围,比如锁对象是静态对象,或者类对象,那么这个锁就是属于全局锁,如果锁对象是实例对象,那么这个锁的范围取决于这个对象的生命周期。包裹在这两个方法之间的代码能够保证线程安全性。
    • 而 Lock 的作用域取决于 Lock 实例的生命周期
  • 灵活性
    • Lock 比 Synchronized 的灵活性更高,Lock 可以自主决定什么时候加锁,什么时候释放锁,只需要调用 lock()unlock() 这两个方法即可,同时 Lock 还提供了非阻塞的竞争锁方法 tryLock() 方法,这个方法通过返回 true 或者 false 来告诉当前线程是否已经有其他线程正在使用锁。
    • Lock 可以实现非阻塞获取锁(tryLock 方法)能被中断地获取锁(lockInterruptibly 方法)以及超时获取锁(tryLock(long time, TimeUnit unit)方法),而 synchronized 则不能;另外 synchronized 锁的释放是被动的,就是当 synchronized 同步代码块执行完以后或者代码出现异常时才会释放。
    • Lock 提供了公平锁非公平锁 的机制,公平锁是指线程竞争锁资源时,如果已经有其他线程正在排队等待锁释放,那么当前竞争锁资源的线程无法插队。而非公平锁,就是不管是否有线程在排队等待锁,它都会尝试去竞争一次锁。 synchronized 只提供了非公平锁的实现。
  • 性能 :synchronized 在性能方面和 Lock 相差不大,在实现上会有一些区别,synchronized 引入了偏向锁,轻量级锁,重量级锁,以及锁升级的机制去实现锁的优化,而 Lock 则用到了自旋锁的方式实现性能优化。

1.4.Lock 接口中有哪些方法?

java 复制代码
package java.util.concurrent.locks;
import java.util.concurrent.TimeUnit;

public interface Lock {
	void lock();	
    void lockInterruptibly() throws InterruptedException;
    boolean tryLock();	
    boolean tryLock(long time, TimeUnit unit) throws InterruptedException;    
    void unlock(); 
    Condition newCondition();
}

1.5.哪些类实现了 Lock 接口?

(1)在 Locks 包下有哪些类实现了该接口了?先从最熟悉的 ReentrantLock 说起。

java 复制代码
public class ReentrantLock implements Lock, java.io.Serializable {
	//...
}

很显然 ReentrantLock 实现了 Lock 接口,当查看源码时会发现 ReentrantLock 并没有多少代码,另外有一个很明显的特点是:基本上所有的方法的实现实际上都是调用了其静态内存类 Sync 中的方法,而 Sync 类继承了抽象类AbstractQueuedSynchronizer (AQS) 。可以看出要想理解 ReentrantLock 的关键核心在于对队列同步器 AbstractQueuedSynchronizer(简称同步器)的理解。Lock 接口的实现基本都是通过聚合了一个同步器的子类来完成线程访问控制的。

(2)此外,Lock 的实现类如下所示:

2.AbstractQueuedSynchronizer (AQS)

2.1.✨谈谈你对 AbstractQueuedSynchronizer (AQS) 的理解。

(1)AQS 翻译成中文是抽象队列同步器 ,下面简称同步器,它是 java.util.concurrent 包下 locks 子包中的一个抽象类 ,是构建阻塞式锁和相关的同步器工具的框架,为构建锁和同步器提供了一些通用功能的实现。

(2)从本质上来说,AQS 提供了两种锁机制,分别是排它锁共享锁

  • 独占式锁 :就是存在多线程竞争同一共享资源时,同一时刻只允许一个线程访问该共享资源,也就是多个线程中只能有一个线程获得锁资源,比如 ReentrantLock 重入锁实现就是用到了 AQS 中的排它锁功能。
  • 共享式锁 :也称为读锁,就是在同一时刻允许多个线程同时获得锁资源,比如 CountDownLatchSemaphore 都是用到了 AQS 中的共享锁功能。

(3)设计 AQS 整个体系需要解决的三个核心的问题:

  • 互斥变量的设计以及多线程同时更新互斥变量时的安全性 :AQS 采用了一个 int 类型的互斥变量 state 用来记录锁竞争的一个状态,0 表示当前没有任何线程竞争锁资源,而大于等于 1 表示已经有线程正在持有锁资源。一个线程来获取锁资源的时候,首先判断 state 是否等于 0,如果是(无锁状态),则把这个 state 更新成 1,表示占用到锁。此时如果多个线程进行同样的操作,会造成线程安全问题。AQS 采用了 CAS 机制来保证互斥变量 state 的原子性。
  • 未竞争到锁资源的线程的等待以及竞争到锁资源的线程释放锁之后的唤醒 :未获取到锁资源的线程通过 Unsafe 类中的 park 方法对线程进行阻塞,把阻塞的线程按照先进先出的原则加入到一个双向链表中,当获得锁资源的线程释放锁之后,会从双向链表的头部去唤醒下一个等待的线程再去竞争锁。
  • 锁竞争的公平性和非公平性 :关于该问题,AQS 的处理方式是,在竞争锁资源的时候,公平锁 需要判断双向链表中是否有阻塞的线程,如果有,则需要去排队等待;而非公平锁的处理方式是,不管双向链表中是否存在等待锁的线程,都会直接尝试更改互斥变量 state 去竞争锁。

2.2.Lock 与 AQS 有什么联系?

(1)AQS 是实现锁(也可以是任意同步组件)的关键,在锁的实现中聚合同步器,利用同步器实现锁的语义。二者之间的关系如下:

  • 锁是面向使用者的,它定义了使用者与锁交互的接口(比如可以允许两个线程并行访问),隐藏了实现细节;
  • 同步器面向锁的实现者,它简化了锁的实现方式,屏蔽了同步状态管理、线程的排队、等待与唤醒等底层操作。

(2)简单来说,锁和同步器很好地隔离了使用者和实现者所需关注的领域

2.3.AQS 的设计用到了哪种设计模式?具体是如何设计的?

AQS 的设计是基于模板方法模式 的,它将一些方法开放给子类进行重写,而同步器给同步组件所提供模板方法又会重新调用被子类所重写的方法 。举个例子,AQS 中需要重写的方法 tryAcquire

java 复制代码
protected boolean tryAcquire(int arg) {
        throw new UnsupportedOperationException();
}

ReentrantLockNonfairSync(继承 AQS)会重写该方法为:

java 复制代码
protected final boolean tryAcquire(int acquires) {
    return nonfairTryAcquire(acquires);
}

而 AQS 中的模板方法 acquire()

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

会调用 tryAcquire 方法,而此时当继承 AQS 的 NonfairSync 调用模板方法 acquire 时就会调用已经被 NonfairSync 重写的 tryAcquire 方法。这就是使用 AQS 的方式,具体可以归纳总结为以下几点:

  • 同步组件(这里不仅仅值锁,还包括 CountDownLatch 等)的实现依赖于同步器 AQS,在同步组件实现中,使用 AQS 的方式被推荐定义继承 AQS 的静态内存类;
  • AQS 采用模板方法进行设计,AQS 的 protected 修饰的方法需要由继承 AQS 的子类进行重写实现,当调用 AQS 的子类的方法时就会调用被重写的方法
  • AQS 负责同步状态的管理,线程的排队,等待和唤醒这些底层操作,而 Lock 等同步组件主要专注于实现同步语义;
  • 在重写 AQS 的方式时,使用 AQS 提供的 getState()setState()compareAndSetState() 方法进行修改同步状态;


有关模板方法模式的相关知识可参考 Java 设计模式------模板方法模式这篇文章。

2.4.AQS 中有哪些重要的方法?

(1)重写同步器指定的方法时,需要使用同步器提供的如下 3 个方法来访问或修改同步状态

  • getState():获取当前同步状态。
  • setState(int newState):设置当前同步状态。
  • compareAndSetState(int expect, int update):使用 CAS 设置当前状态,该方法能够保证状态设置的原子性。

(2)同步器可重写的方法与描述如如下表所示:

(4)实现自定义同步组件时,将会调用同步器提供的模板方法,这些(部分)模板方法与描述如下表所示:

同步器提供的模板方法基本上分为 3 类:独占式获取与释放同步状态共享式获取与释放同步状态查询同步队列中的等待线程情况。自定义同步组件将使用同步器提供的模板方法来实现自己的同步语义。

2.5.如何使用 AQS?

(1)AQS 的主要使用方式是继承,子类通过继承同步器并实现它的抽象方法来管理同步状态 ,在抽象方法的实现过程中免不了要对同步状态进行更改,这时就需要使用同步器提供的 3 个方法(getState()setState(int newState)compareAndSetState(int expect, int update))来进行操作,因为它们能够保证状态的改变是安全的。

(2)子类推荐被定义为自定义同步组件的静态内部类 ,同步器自身没有实现任何同步接口,它仅仅是定义了若干同步状态获取和释放的方法来供自定义同步组件使用,同步器既可以支持独占式 地获取同步状态,也可以支持共享式 地获取同步状态,这样就可以方便实现不同类型的同步组件,例如 ReentrantLockReentrantReadWriteLockCountDownLatch 等。

2.5.1.独占式锁

下面通过一个独占锁的示例来深入了解一下同步器的使用方法。顾名思义,独占锁就是在同一时刻只能有一个线程获取到锁,而其他获取锁的线程只能处于同步队列中等待,只有获取锁的线程释放了锁,后继的线程才能够获取锁。下面的代码来源于 AQS 源码。

java 复制代码
class Mutex implements Lock {
    //静态内部类,自定义同步器
    private static class Sync extends AbstractQueuedSynchronizer {
        //是否处于占用状态
        protected boolean isHeldExclusively() {
            return getState() == 1;
        }
        
        //当状态为 0 的时候获取锁
        public boolean tryAcquire(int acquires) {
            if (compareAndSetState(0, 1)) {
                setExclusiveOwnerThread(Thread.currentThread());
                return true;
            }
            return false;
        }
        
        //释放锁,将状态设置为 0
        protected boolean tryRelease(int releases) {
            if (getState() == 0) throw new
                    IllegalMonitorStateException();
            setExclusiveOwnerThread(null);
            setState(0);
            return true;
        }
        
        //返回一个 Condition,每个 condition 都包含了一个 condition 队列
        Condition newCondition() {
            return new ConditionObject();
        }
    }
    
    //仅需要将操作代理到 Sync 上即可
    private final Sync sync = new Sync();
    
    public void lock() {
        sync.acquire(1);
    }
    
    public boolean tryLock() {
        return sync.tryAcquire(1);
    }
    
    public void unlock() {
        sync.release(1);
    }
    
    public Condition newCondition() {
        return sync.newCondition();
    }
    
    public boolean isLocked() {
        return sync.isHeldExclusively();
    }
    
    public boolean hasQueuedThreads() {
        return sync.hasQueuedThreads();
    }
    
    public void lockInterruptibly() throws InterruptedException {
        sync.acquireInterruptibly(1);
    }
    
    public boolean tryLock(long timeout, TimeUnit unit) throws InterruptedException {
        return sync.tryAcquireNanos(1, unit.toNanos(timeout));
    }
}
java 复制代码
class MutexDemo {
    
    private static Mutex mutex = new Mutex();
    
    public static void main(String[] args) {
        for (int i = 0; i < 10; i++) {
            Thread thread = new Thread(() -> {
                mutex.lock();
                try {
                    Thread.sleep(3000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                } finally {
                    mutex.unlock();
                }
            });
            thread.start();
        }
    }
}

上述示例中,独占锁 Mutex 是一个自定义同步组件 ,它在同一时刻只允许一个线程占有锁。Mutex 中定义了一个静态内部类 ,该内部类继承了同步器并实现了独占式获取和释放同步状态。在 tryAcquire(int acquires) 方法中,如果经过 CAS 设置成功(同步状态设置为 1),则代表获取了同步状态,而在 tryRelease(int releases) 方法中只是将同步状态重置为 0。用户使用 Mutex 时并不会直接和内部同步器的实现打交道,而是调用 Mutex 提供的方法,在 Mutex 的实现中,以获取锁的 lock() 方法为例,只需要在方法实现中调用同步器的模板方法acquire(int args) 即可,当前线程调用该方法获取同步状态失败后会被加入到同步队列中等待,这样就大大降低了实现一个可靠自定义同步组件的门槛。

2.5.2.共享式锁

java 复制代码
public class TwinsLock implements Lock {
    private final Sync sync = new Sync(2);
    
    private static final class Sync extends AbstractQueuedSynchronizer {
        Sync(int count) {
            if (count <= 0) {
                throw new IllegalArgumentException("count must large than zero.");
            }
            setState(count);
        }
        
        public int tryAcquireShared(int reduceCount) {
            for (; ; ) {
                int current = getState();
                int newCount = current - reduceCount;
                if (newCount < 0 || compareAndSetState(current, newCount)) {
                    return newCount;
                }
            }
        }
        
        public boolean tryReleaseShared(int returnCount) {
            for (; ; ) {
                int current = getState();
                int newCount = current + returnCount;
                if (compareAndSetState(current, newCount)) {
                    return true;
                }
            }
        }
    }
    
    //其他接口方法略
}

在上述示例中,TwinsLock 实现了 Lock 接口,提供了面向使用者的接口,使用者调用 lock() 方法获取锁,随后调用 unlock() 方法释放锁,而同一时刻只能有两个线程同时获取到锁。TwinsLock 同时包含了一个自定义同步器 Sync,而该同步器面向线程访问和同步状态控制。以

共享式获取同步状态为例:同步器会先计算出获取后的同步状态,然后通过 CAS 确保状态的正确设置,当 tryAcquireShared(int reduceCount) 方法返回值大于等于 0 时,当前线程才获取同步状态,对于上层的 TwinsLock 而言,则表示当前线程获得了锁。

下面编写一个测试来验证 TwinsLock 是否能按照预期工作。在测试用例中,定义了工作者线程 Worker,该线程在执行过程中获取锁,当获取锁之后使当前线程睡眠 1 秒(并不释放锁),随后打印当前线程名称,最后再次睡眠 1 秒并释放锁,测试用例如下所示。

java 复制代码
public class TwinsLockTest {
    @Test
    public void test() {
        final Lock lock = new TwinsLock();
        class Worker extends Thread {
            public void run() {
                while (true) {
                    lock.lock();
                    try {
                        SleepUtils.second(1);
                        System.out.println(Thread.currentThread().getName());
                        SleepUtils.second(1);
                    } finally {
                        lock.unlock();
                    }
                }
            }
        }
		//启动 10 个线程
        for (int i = 0; i < 10; i++) {
            Worker w = new Worker();
            w.setDaemon(true);
            w.start();
        }
		//每隔 1 秒换行
        for (int i = 0; i < 10; i++) {
            SleepUtils.second(1);
            System.out.println();
        }
    }
}

运行该测试用例,可以看到线程名称成对输出,也就是在同一时刻只有两个线程能够获取到锁,这表明 TwinsLock 可以按照预期正确工作。

java 复制代码
Thread-0
Thread-1


Thread-0
Thread-1

Thread-0
Thread-1


Thread-0
Thread-1



Thread-1
Thread-0

2.5.3.总结

在新建一个同步组件时需要把握的两个关键点是:

  • 实现同步组件时推荐定义继承 AQS 的静态内存类,并重写需要的 protected 修饰的方法;
  • 同步组件语义的实现依赖于 AQS 的模板方法,而 AQS 模板方法又依赖于被 AQS 的子类所重写的方法。

2.6.✨AQS 的实现原理是什么?

接下来将从实现角度分析同步器是如何完成线程同步的,主要包括:同步队列独占式同步状态获取与释放共享式同步状态获取与释放 以及超时获取同步状态等同步器的核心数据结构与模板方法。

(1)AQS 核心思想是:

  • 如果被请求的共享资源未被占用,则将当前请求资源的线程设置为有效的工作线程,并且将共享资源设置为锁定状态。
  • 如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制 AQS 是用 CLH 队列锁实现的,即将暂时获取不到锁的线程加入到队列中。

(2)CLH (Craig, Landin and Hagersten) 队列是一个虚拟的 双向队列(虚拟的双向队列即不存在队列实例,仅存在结点之间的关联关系,可以看作是由一个双向链表 实现的队列)。AQS 是将每条请求共享资源的线程封装成一个 CLH 锁队列的一个结点 (Node) 来实现锁的分配。在同步队列中,一个节点表示一个线程,它保存着线程的引用 (thread)、当前节点在队列中的状态 (waitStatus)、前驱节点 (prev)、后继节点 (next)。CLH 队列结构如下图所示:

Node 是 AbstractQueuedSynchronizer 中的静态内部类:

(3)AQS 的核心原理图如下:

AQS 使用 int 成员变量 state 表示同步状态,通过内置的线程等待队列 来完成获取资源线程的排队工作。state 变量由 volatile 修饰,用于展示当前临界资源的获锁情况。

java 复制代码
// 共享变量,使用volatile修饰保证线程可见性
private volatile int state;

另外,状态信息 state 可以通过 protected 类型的getState()setState()compareAndSetState() 进行操作。并且,这几个方法都是 final 修饰的,在子类中无法被重写。

java 复制代码
//返回同步状态的当前值
protected final int getState() {
     return state;
}

// 设置同步状态的值
protected final void setState(int newState) {
     state = newState;
}

//原子地 (CAS 操作) 将同步状态值设置为给定值 update 如果当前同步状态的值等于 expect (期望值)
protected final boolean compareAndSetState(int expect, int update) {
      return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
}
  • ReentrantLock 为例,state 初始值为 0,表示未锁定状态。A 线程 lock() 时,会调用 tryAcquire() 独占该锁并将 state + 1 。此后,其他线程再 tryAcquire() 时就会失败,直到 A 线程 unlock() 到 state = 0(即释放锁)为止,其它线程才有机会获取该锁。当然,释放锁之前,A 线程自己是可以重复获取此锁的(state 会累加),这就是可重入的概念。但要注意,获取多少次就要释放多少次,这样才能保证 state 是能回到零态的
  • 再以 CountDownLatch 以例,任务分为 N 个子线程去执行,state 也初始化为 N(注意 N 要与线程个数一致)。这 N 个子线程是并行执行的,每个子线程执行完后 countDown() 一次,state 会 CAS 减 1。等到所有子线程都执行完后(即 state = 0),会 unpark() 主调用线程,然后主调用线程就会从 await() 函数返回,继续后余动作。

2.7.如何获取 AQS 中的独占式锁?

(1)上面的 2.5 中的第一个 Demo 中调用 mutex.lock() 方法是获取独占式锁 ,获取失败就将当前线程加入同步队列,成功则线程执行。而lock() 方法实际上会调用 AQS 的 acquire() 方法,源码如下:

java 复制代码
/**
 * Acquires in exclusive mode, ignoring interrupts.  Implemented
 * by invoking at least once {@link #tryAcquire},
 * returning on success.  Otherwise the thread is queued, possibly
 * repeatedly blocking and unblocking, invoking {@link
 * #tryAcquire} until success.  This method can be used
 * to implement method {@link Lock#lock}.
 *
 * @param arg the acquire argument.  This value is conveyed to
 *        {@link #tryAcquire} but is otherwise uninterpreted and
 *        can represent anything you like.
 */
public final void acquire(int arg) {
	/*
		先看同步状态是否获取成功:
		(1) 如果成功则方法结束返回;
		(2) 如果失败则先调用 addWaiter() 方法再调用 acquireQueued() 方法
	*/
    if (!tryAcquire(arg) &&
        acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
        selfInterrupt();
}

该方法的主要逻辑如下:首先调用自定义同步器实现的 tryAcquire(int arg) 方法,该方法保证线程安全的获取同步状态 ,如果同步状态获取失败,则构造同步节点(独占式 Node.EXCLUSIVE,同一时刻只能有一个线程成功获取同步状态)并通过 addWaiter(Node node) 方法将该节点加入到同步队列的尾部,最后调用 acquireQueued(Node node,int arg) 方法,使得该节点以"死循环"的方式获取同步状态。如果获取不到则阻塞节点中的线程,而被阻塞线程的唤醒主要依靠前驱节点的出队阻塞线程被中断来实现。

(2)在 acquireQueued(final Node node, int arg) 方法中,当前线程在"死循环"中尝试获取同步状态,而只有前驱节点是头节点才能够尝试获取同步状态,其原因在于:

  • 头节点是成功获取到同步状态的节点,而头节点的线程释放了同步状态之后,将会唤醒其后继节点,后继节点的线程被唤醒后需要检查自己的前驱节点是否是头节点。
  • 维护同步队列的 FIFO 原则。该方法中,节点自旋获取同步状态的行为如下图所示:

(3)独占式锁的获取过程也就是 acquire() 方法的执行流程如下图所示:

简单版本如下:

2.8.如何获取 AQS 中的共享式锁?

(1)共享式获取与独占式获取最主要的区别在于同一时刻能否有多个线程同时获取到同步状态。以文件的读写为例,如果一个程序在对文件进行读操作,那么这一时刻对于该文件的写操作均被阻塞,而读操作能够同时进行。写操作要求对资源的独占式访问,而读操作可以是共享式访问,两种不同的访问模式在同一时刻对文件或资源的访问情况,如下图所示。

在上图中,左半部分,共享式访问资源时,其他共享式的访问均被允许,而独占式访问被阻塞,右半部分是独占式访问资源时,同一时刻其他访问均被阻塞。

(2)通过调用同步器的 acquireShared(int arg) 方法可以共享式地获取同步状态

java 复制代码
/**
* Acquires in shared mode, ignoring interrupts.  Implemented by
* first invoking at least once {@link #tryAcquireShared},
* returning on success.  Otherwise the thread is queued, possibly
* repeatedly blocking and unblocking, invoking {@link
* #tryAcquireShared} until success.
*
* @param arg the acquire argument.  This value is conveyed to
*        {@link #tryAcquireShared} but is otherwise uninterpreted
*        and can represent anything you like.
*/
public final void acquireShared(int arg) {
    if (tryAcquireShared(arg) < 0)
        doAcquireShared(arg);
}

acquireShared(int arg) 方法中,同步器调用 tryAcquireShared(int arg) 方法尝试获取同步状态,tryAcquireShared(int arg) 方法返回值为 int 类型,当返回值大于等于 0 时,表示能够获取到同步状态。因此,在共享式获取的自旋过程中,成功获取到同步状态并退出自旋的条件就是 tryAcquireShared(int arg) 方法返回值大于等于 0。并且在 doAcquireShared(int arg) 方法的自旋过程中,如果当前节点的前驱为头节点时,尝试获取同步状态,如果返回值大于等于 0,表示该次获取同步状态成功并从自旋过程中退出。

2.9.✨AQS 中为什么要使用双向链表,而并非单向链表?

(1)双向链表的特征最主要的特点是:

  • 每个节点都有两个指针分别指向其前置节点和后驱节点,并且可以使用一个 head 和一个 tail 分别指向头部节点和尾部节点;
  • 双向链表可以以 O(1) 的时间复杂度找到某个节点的前驱节点,并且在插入和删除的时候比普通链表更加高效;

(2)AQS 中使用双向链表的主要原因如下:

  • 没有竞争到锁的线程加入到阻塞队列并且阻塞等待的一个前提是当前线程所在的节点的前置节点是一个正常状态 ,这样可以避免在链表里面存在异常状态的节点导致无法唤醒后续线程的问题,就是说当前节点在加入到阻塞队列时候需要去判断一下前置节点的状态,如果没有指针指向前置节点,需要从头部开始遍历,性能是非常低的。
  • 在 lock 接口里面有一个可以中断的锁的竞争方法 lockInterruptibly(),这个方法表示处于锁阻塞的线程是允许被中断的,没有竞争到锁的线程加入到同步队列等待以后是允许被外部线程触发 interrupt 方法去触发唤醒并且中断的,被中断的线程的状态就变为了 cancalled,而被标记为 cancalled 状态的线程是不需要竞争锁的,但是它仍然存在于整个双向链表里面,意味着后续锁的竞争中需要把整个节点从链表里面移除,否则会导致锁阻塞的线程无法被正常唤醒。如果使用单向链表,这种情况就需要从 head 节点逐个去遍历,移除的时间复杂度为 O(n)。而使用双向链表则可以通过其前驱节点直接将其移除,时间复杂度为 O(1)
  • 为了避免线程阻塞和唤醒的开销,加入到链表中的线程会通过自旋 的方式去尝试竞争锁,实际上按照公平锁的一个设计,只有头节点的下一个节点才有必要去竞争锁,后续节点去竞争锁的意义不大,否则大量线程在阻塞之前尝试竞争锁,带来一个比较大的性能开销,为了避免这个问题加入到链表里面的节点,在尝试竞争锁的时候会去判断前置节点是不是头节点,如果不是头节点就没有必要去触发锁竞争的动作,而单项链表是无法实现这样一个功能的

2.10.AQS 在 JDK 中有哪些具体的应用?

(1)在 JDK 中,AQS 这个基础框架被广泛应用于许多并发类和工具的实现。以下是一些在 JDK 中使用 AQS 的具体应用:

  • ReentrantLockjava.util.concurrent.locks.ReentrantLock 是可重入锁的实现,它使用 AQS 提供的同步状态和控制机制。AQS 内部的 Sync 类被 ReentrantLock 继承和实现,实现了锁的获取和释放逻辑。
  • ReentrantReadWriteLockjava.util.concurrent.locks.ReentrantReadWriteLock 是读写锁的实现,它使用 AQS 提供的共享模式实现对读锁和写锁的获取和释放。
  • Conditionjava.util.concurrent.locks.Condition 是 AQS 中的条件变量,它可以与锁配合使用,在多线程间实现更复杂的等待/通知机制。
  • CountDownLatchjava.util.concurrent.CountDownLatch 是一个同步工具类,它使用 AQS 实现了线程的阻塞和唤醒机制,以及线程间等待其他线程完成的功能。
  • Semaphorejava.util.concurrent.Semaphore 是一个信号量,它使用 AQS 实现了多个线程对共享资源的并发访问控制。AQS 内部的共享模式被用于实现线程的阻塞和唤醒。
  • CyclicBarrierjava.util.concurrent.CyclicBarrier 是一个栅栏同步工具,它使用 AQS 提供的同步状态来实现多个线程的同步点,在达到指定的等待数目后,所有线程同时触发。

(2)以上只是一些使用 AQS 的例子,实际上,在 JDK 的并发包中还有许多其他类也使用了 AQS,通过继承和实现 AQS 提供的同步方法和状态管理,实现了不同的同步和控制功能。通过 AQS 这个抽象框架,Java 并发包提供了强大而灵活的工具和类,可以帮助开发者构建高效、可扩展的并发应用。

相关推荐
开源之眼1 小时前
《github star 加星 Taimili.com 艾米莉 》为什么Java里面,Service 层不直接返回 Result 对象?
java·后端·github
zone77392 小时前
003:RAG 入门-LangChain 读取图片数据
后端·python·面试
zone77392 小时前
002:RAG 入门-LangChain 读取文本
后端·算法·面试
青青家的小灰灰2 小时前
从入门到精通:Vue3 ref vs reactive 最佳实践与底层原理
前端·vue.js·面试
Maori3162 小时前
放弃 SDKMAN!在 Garuda Linux + Fish 环境下的优雅 Java 管理指南
java
over6973 小时前
从 URL 输入到页面展示:一次完整的 Web 导航之旅
前端·面试·架构
用户908324602733 小时前
Spring AI 1.1.2 + Neo4j:用知识图谱增强 RAG 检索(上篇:图谱构建)
java·spring boot
小王和八蛋3 小时前
DecimalFormat 与 BigDecimal
java·后端
飞哥的AI笔记3 小时前
为什么 OpenClaw 在实时推送场景下选择拥抱 WebSocket?
面试
SuperEugene3 小时前
Vue状态管理扫盲篇:状态管理中的常见坑 | 循环依赖、状态污染与调试技巧
前端·vue.js·面试