并发编程 | 同步控制器AQS - 从复杂到简单的并发管理

一、引言

在Java并发编程中,我们经常遇到各种复杂且令人困扰的问题,例如数据一致性,线程安全,以及死锁等等。我们是否曾经希望有一个更简单,更高效的工具帮助我们解决这些问题呢?答案就是Java的并发库中的一个隐藏的宝石 — AbstractQueuedSynchronizer,简称AQS。它是构建锁和其他同步组件的基础。我们可能在日常编程中并没有直接与之接触,但是我们常用的ReentrantLock,Semaphore,CountDownLatch等并发工具都是基于AQS实现的。今天,让我们一起深入理解AQS,以更好地掌握并发编程,提高我们代码的效率和稳定性。

二、AQS概念解析

只有当你对概念有深入明确的理解,它才会在你的记忆中牢固地存在。否则,随着时间的推移,你可能会渐渐遗忘它。所以,现在就跟我一起深入探索,让我们的理解变得更加清晰吧。

什么是AQS?

AQS,即AbstractQueuedSynchronizer,是Java并发编程框架中的一个核心组件,用于构建锁或者其他同步组件。它通过内部的FIFO队列管理那些尝试获取但未成功的线程,以及提供了一种条件变量机制,实现了高效稳定的多线程并发控制

现在我们用“分解和重组”来拆解概念方便你来理解:

  1. AQS (AbstractQueuedSynchronizer):这是Java并发编程框架中的一个抽象类,它提供了一种基础机制,用于创建自定义的同步控制工具。
  2. 构建锁或其他同步组件:AQS可以被用来构建多种同步工具,例如互斥锁(例如ReentrantLock),读写锁(例如ReentrantReadWriteLock),信号量(例如Semaphore),闭锁(例如CountDownLatch)等。
  3. 内部的FIFO队列:AQS内部维护了一个等待线程的FIFO(先进先出)队列。当一个线程尝试获取一个已经被其他线程占用的资源时,这个线程会被放入队列中等待。
  4. 尝试获取但未成功的线程:这指的是那些试图获取锁或者其他资源但是未能成功的线程。这些线程会被AQS管理并放入其内部的队列中。
  5. 条件变量机制:这是一种让线程能够等待某个条件的成立并在合适的时候被唤醒的机制。AQS提供了对应的方法来支持这种机制,比如ConditionObject类。
  6. 高效稳定的多线程并发控制:AQS提供的这些机制和方法,使得我们可以构建出能够高效稳定地控制多线程并发的同步工具。

三、前置知识

  1. AQS 对资源的共享方式:共享模式和独占模式
    AQS主要解决了两种类型的同步问题:共享模式和独占模式。在共享模式下,多个线程可以同时访问资源;而在独占模式下,只有一个线程能够访问资源。我们从状态(AQS类中的一个共享变量)的角度来进行理解:
    • 独占模式: 在这种模式下,状态变量通常表示持有锁的线程是否重入。例如,在ReentrantLock(可重入锁)中,如果一个线程已经获得了锁,那么它就可以重复地获得这个锁,每次获取锁,状态就会增加1,当线程释放锁时,状态就会减少1,只有当状态为0时,锁才真正地被释放,其他线程才有可能获取到这个锁。
    • 共享模式: 在这种模式下,状态变量通常表示资源的可用性。例如,在Semaphore(信号量)中,状态表示可用的许可证的数量。当一个线程试图获取一个许可证时,如果状态大于0,那么状态就会减少1,线程就可以继续执行;如果状态等于0,那么线程就会被阻塞,直到有其他线程释放许可证(即状态增加1)。

四、基于AQS实现自己的锁

再多的理论知识,也不如亲自动手实践来得直观和深入。让我们立即行动起来,一起来编写并实现我们自己的锁。赶紧打开源码看看有哪些模板方法是需要我们实现的:

// 用于尝试获取资源。参数arg定义了获取资源的数量。当资源获取成功时,应返回true,否则返回false。
protected boolean tryAcquire(int arg) {
    throw new UnsupportedOperationException();
}
// 用于尝试释放资源。参数arg定义了释放资源的数量。当资源释放成功时,应返回true,否则返回false。
protected boolean tryRelease(int arg) {
    throw new UnsupportedOperationException();
}
// 用于尝试获取共享资源。参数arg定义了获取资源的数量。这个方法应返回剩余可用资源的数量。如果返回值大于或等于0,则获取成功,否则获取失败。在某些实现中,可能将这个方法设计为始终返回负值,以表示资源总是以独占方式获取。
protected int tryAcquireShared(int arg) {
    throw new UnsupportedOperationException();
}
// 用于尝试释放共享资源。参数arg定义了释放资源的数量。当资源释放成功时,应返回true,否则返回false。在某些实现中,可能将这个方法设计为始终返回false,以表示资源总是以独占方式释放。
protected boolean tryReleaseShared(int arg) {
    throw new UnsupportedOperationException();
}
// 用于判断资源是否被当前线程独占。如果当前线程独占资源,应返回true,否则返回false。
protected boolean isHeldExclusively() {
    throw new UnsupportedOperationException();
}

从名词我们可以猜到,有Shared后缀的是获取共享资源的, 那我想实现互斥锁,只需要实现第一个,第二个,第五个即可。我们来实现一下,代码如下:

import java.util.concurrent.locks.AbstractQueuedSynchronizer;

public class MutexLock {
    // 内部定义一个Sync类继承自AbstractQueuedSynchronizer,这是实现自定义同步组件的关键
    private static class Sync extends AbstractQueuedSynchronizer {
        // 判断是否锁定状态,0表示未锁定,1表示锁定
        protected boolean isHeldExclusively() {
            return getState() == 1;
        }

        // 尝试获取资源,立即返回,成功则返回true,失败则返回false。
        public boolean tryAcquire(int acquires) {
            assert acquires == 1; // Otherwise unused
            // 当状态为0的时候获取锁,这里的CAS操作就是AQS对于状态的管理,线程会尝试获取锁,如果获取不到(状态不为0),线程就会进入AQS的等待队列
            if (compareAndSetState(0, 1)) {
                // 设置当前所有的线程为当前线程,这里体现了AQS中的独占模式
                setExclusiveOwnerThread(Thread.currentThread());
                return true;
            }
            return false;
        }

        // 尝试释放资源,立即返回,成功则为true,失败则为false。
        protected boolean tryRelease(int releases) {
            assert releases == 1; // Otherwise unused
            // 释放锁,将状态设置为0
            if (getState() == 0) {
                throw new IllegalMonitorStateException();
            }
            // 释放锁的操作,将独占模式的线程设置为空
            setExclusiveOwnerThread(null);
            setState(0); // 释放资源,将状态设置为0
            return true;
        }
    }

    // 同步组件,实际是AQS
    private final Sync sync = new Sync();

    // 加锁操作,实际上是调用AQS的模板方法
    public void lock() {
        sync.acquire(1);
    }

    // 释放锁操作,实际上是调用AQS的模板方法
    public void unlock() {
        sync.release(1);
    }

    // 判断是否为锁定状态
    public boolean isLocked() {
        return sync.isHeldExclusively();
    }
}

简单解释一下:

  • 当我们尝试获取锁的时候最终会调用tryAcquire方法,紧接着compareAndSetState(0, 1)将state变更为1。当然,变更失败即加锁失败。那我们现在就可以知道,0-未加锁,1-加锁;
  • 回过头来,我们看下isHeldExclusively方法也就好理解了, 当state为1,那就是独占的状态;
  • 当我们解锁释放资源的时候调用tryRelease,则会设置状态为0-未加锁,并清空独占线程;

现在我们来测试执行下:

public class MutexLockTest {
    private static int sharedState = 0;
    private static MutexLock mutexLock = new MutexLock();

    public static void main(String[] args) throws InterruptedException {
        // 创建两个线程,每个线程各自增加sharedState 5000次
        Thread thread1 = new Thread(MutexLockTest::incrementSharedState);
        Thread thread2 = new Thread(MutexLockTest::incrementSharedState);

        thread1.start();
        thread2.start();

        // 主线程等待两个子线程执行完毕
        thread1.join();
        thread2.join();

        // 输出最后的sharedState的值
        System.out.println("The final shared state is: " + sharedState);
    }

    private static void incrementSharedState() {
        for (int i = 0; i < 5000; i++) {
            // 获取锁
            mutexLock.lock();
            try {
                // 在锁的保护下增加sharedState
                sharedState++;
            } finally {
                // 无论如何,最后都要释放锁
                mutexLock.unlock();
            }
        }
    }
}

执行结果如下:

Connected to the target VM, address: '127.0.0.1:1856', transport: 'socket'
The final shared state is: 10000
Disconnected from the target VM, address: '127.0.0.1:1856', transport: 'socket'

Process finished with exit code 0

完美,互不干扰。但是…Java并发包中ReentrantLockSemaphore 也可以实现,而且互斥的功能只是冰山一角。咋办,学了不能用啊!别急,我们接着往下讨论。

四、AQS存在的意义

答案是:有特殊的需求需要我们定制化锁,需要用到AQS来简化我们的开发。

接下来,我将通过一个需求来带大家感受一下

需求

有这么一个需求:在数据库系统中,多个客户端可能需要并发访问数据库中的数据。有时,我们可能希望一个客户端在一段时间内独占某些数据(例如,当客户端正在执行一个复杂的事务时),然后在另一段时间内允许多个客户端共享访问这些数据(例如,当客户端只是在读取数据时)。

在这样的场景中,动态切换资源访问模式的能力可以提高系统的并发性能,并使得系统更好地适应不同的工作负载和访问模式.

代码没写出来, 卡了5个版本...


五、AQS源码解析

为了更好的写好锁, 而且不出现bug,熟悉源码是关键
看源码之前,我们不妨看一下代码的全貌,我把简化后的AQS代码贴在下面,我们来看下(请大家仔细查看下文的代码以及注释):

public abstract class AbstractQueuedSynchronizer {
    // 这是AQS内部队列的头部,通常表示当前已经获取到资源的线程。在`head`节点释放资源后,将会通知其后继节点。
    private transient volatile Node head;
    // 这是AQS内部队列的尾部,当有新的线程无法获取到资源进入等待状态时,将会入队至队列尾部。
    private transient volatile Node tail;
    
    // 这是一个volatile变量,用于保存同步状态。在不同的同步器实现中,`state`可能代表不同的含义。	   	         
    // 例如在 `ReentrantLock`中,`state`表示了获取到锁的重入次数;
    // 在`Semaphore`中,`state`则表示还剩余的许可证数量。
    private volatile int state;
    static final class Node {}
}

static final class Node {
    // 略...
    // 表示该节点的前驱节点,通过前驱节点可以向前遍历队列。
    volatile Node prev;
    // 表示该节点的后继节点,通过后继节点可以向后遍历队列。
    volatile Node next;
    // 表示入队的线程。当资源被释放时,会唤醒这个线程。
    volatile Thread thread;
    // 略...
}

核心工作流程见图:
在这里插入图片描述
好,接着我们开始讲解源码,首先是acquire方法:

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

对于AQS的主要方法的分析,我将主要关注以下几个方法:

  1. acquire(int arg): 尝试获取资源,若获取失败则进入等待队列。
  2. release(int arg): 尝试释放资源,成功则唤醒等待队列中的其他线程。
  3. tryAcquire(int arg)tryRelease(int arg): 这两个方法需要子类来实现,具体实现取决于是互斥锁还是共享锁。
public abstract class AbstractQueuedSynchronizer
    extends AbstractOwnableSynchronizer
    implements java.io.Serializable {

    // ...

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

    protected boolean tryAcquire(int arg) {
        throw new UnsupportedOperationException();
    }

    public final boolean release(int arg) {
        if (tryRelease(arg)) {
            Node h = head;
            if (h != null && h.waitStatus != 0)
                unparkSuccessor(h);
            return true;
        }
        return false;
    }

    protected boolean tryRelease(int arg) {
        throw new UnsupportedOperationException();
    }

    // ...

}

acquire(int arg)方法是获取锁的主要入口,它的实现非常简洁。在方法中,首先调用tryAcquire(arg)方法尝试直接获取锁,如果成功,则方法结束;如果失败,则调用addWaiter(Node.EXCLUSIVE)将当前线程封装为一个节点,并添加到等待队列中,然后调用acquireQueued(addWaiter(Node.EXCLUSIVE), arg)方法让当前线程进入等待状态,直到获取到锁为止。

tryAcquire(int arg) 方法

tryAcquire(int arg)方法是一个需要子类来实现的抽象方法,它用于尝试直接获取锁。如果成功,则返回true;如果失败,则返回false。这个方法应该定义获取锁的规则,如果有可用的资源,则获取这些资源,并返回true;如果没有可用的资源,则直接返回false。

release(int arg) 方法

release(int arg)方法是释放锁的主要入口,它的实现也很简洁。在方法中,首先调用tryRelease(arg)方法尝试直接释放锁,如果成功,则调用unparkSuccessor(h)方法唤醒等待队列中的其他线程,并返回true;如果失败,则直接返回false。

tryRelease(int arg) 方法

tryRelease(int arg)方法和tryAcquire(int arg)方法一样,也是一个需要子类来实现的抽象方法,它用于尝试直接释放锁。这个方法应该定义释放锁的规则,如果锁已经被当前线程持有,则释放锁,并返回true;如果锁并未被当前线程持有,则直接返回false。

请注意,这四个方法的行为非常低级,并且和具体的同步器实现紧密相关。实现这些方法时需要谨慎,否则可能导致并发问题或者其他的复杂问题。

六、参考文献

  1. 一行一行源码分析清楚AbstractQueuedSynchronizer
  2. 老板让只懂Java基本语法的我,基于AQS实现一个锁

七、总结

我们来总结一下,本文带你了解有关AQS的概念,并通过一个代码示例带你实现AQS的互斥锁,并分析源码。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Kfaino

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值