1.AQS原理概览

167 阅读5分钟

AQS原理概览

全称是 AbstractQueuedSynchronizer,是阻塞式锁和相关的同步器工具的框架,类似于synchronize关键字加的锁。

1614060853084.png

功能目标

-阻塞获取锁:acquire(),获取不到锁阻塞。

-阻塞尝试获取锁:tryAcquire(),获取不到锁快速失败返回。

-获取锁超时机制:tryAcquireNanos()指定时间内获取不到锁返回。

-打断取消机制:acquireInterruptibly()获取锁阻塞可以被打断。

-独占机制及共享机制

-条件不满足时的等待机制

设计思想

AQS 的基本思想其实很简单 获取锁的逻辑

while(state 状态为已加锁) {
    if(队列中还没有此线程) {
        //入队阻塞,等待被唤醒去竞争锁
    }
}
//当前线程出队

释放锁的逻辑

if(state 状态允许释放锁) {
    //唤醒阻塞的线程去竞争锁
}

设计要点

1.原子操作+volatile 维护state状态

2.park、unpark阻塞及恢复线程

3.维护同步队列和等待队列

state设计

state使用volatile配合compareAndSetState即cas保证其修改时的原子性。

state 使用了 32bit int 来维护同步状态,因为当时使用 long 在很多平台下测试的结果并不理想。

使用state属性来表示资源的状态,子类需要定义如何维护这个状态,控制如何获取锁和释放锁。

共享方式

AQS定义两种资源共享方式:

Exclusive(独占,只有一个线程能执行,如ReentrantLock)

Share(共享,多个线程可同时执行,如Semaphore/CountDownLatch)

独占模式是只有一个线程能够访问资源,而共享模式可以允许多个线程访问资源。

独占模式

以ReentrantLock(独占)为例,state初始化为0,表示未锁定状态。

A线程lock()时,会调用tryAcquire()独占该锁并将state+1。

此后,其他线程再tryAcquire()时就会失败,直到A线程unlock()到state=0(即释放锁)为止。

其它线程才有机会获取该锁。

当然,释放锁之前,A线程自己是可以重复获取此锁的(state会累加),这就是可重入的概念。

但要注意,获取多少次就要释放多么次,这样才能保证state是能回到零态的。

public class Singleton {
    private static final AtomicReference<Singleton> INSTANCE
            = new AtomicReference<Singleton>();
    private Singleton() {
        System.out.println("我被初始化了");
        CasSingletonTest.objectcount.getAndIncrement();
    }
    public static Singleton getInstance() {
        for (;;) {
            Singleton singleton = INSTANCE.get();
            if (null != singleton) {
                return singleton;
            }
            singleton = new Singleton();
            if (INSTANCE.compareAndSet(null, singleton)) {
                return singleton;
            }
        }
    }
}
package com.xttblog.canal.test;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.atomic.AtomicInteger;
​
public class CasSingletonTest {
    public static AtomicInteger objectcount = new AtomicInteger();
    public static void main(String[] args) throws InterruptedException {
        //0.创建1个总门栓
        final CountDownLatch begin = new CountDownLatch(1);
        //0.创建1000个分门栓
        final CountDownLatch last = new CountDownLatch(1000);
        for(int i=0;i<1000;i++){
            new Thread(new Runnable() {
                @Override
                public void run() {
                    try {
                        //1.所有的线程都会阻塞在这
                        begin.await();
                        System.out.println
                            (Thread.currentThread().getName()+":begin...");
                        //3.阻塞的1000个线程并发执行
                        Singleton sba = Singleton.getInstance();
                        System.out.println(Thread.currentThread().getName()+":OK");
                        //4.每执行1次 释放门栓
                        last.countDown();
                    } catch (InterruptedException ex) {
                        ex.printStackTrace();
                    }
                }
            }).start();
        }
        //2.释放门栓
        begin.countDown();
        //5.等待1000个线程释放门栓 即执行完毕 
        last.await();
        System.out.println("new objects: "+objectcount.get());
    }
}

共享模式

以CountDownLatch(共享)以例,任务分为N个子线程去执行,state也初始化为N(注意N要与线程个数一致)。

这N个子线程是并行执行的,调用await()函数会park()住主调用线程,每个子线程执行完后countDown()一次,state会CAS减1。

等到所有子线程都执行完后(即state=0),然后主调用线程就会从await()函数返回,继续后续动作。

一般来说,自定义同步器要么是独占方法,要么是共享方式。

他们也只需实现tryAcquire-tryRelease、tryAcquireShared-tryReleaseShared中的一种即可。

但AQS也支持自定义同步器同时实现独占和共享两种方式,如ReentrantReadWriteLock。

阻塞恢复设计

每个线程都有自己的一个 Parker 对象,由三部分组成 _counter ,_cond 和 _mutex 。

打个比喻线程就像一个旅人,Parker 就像他随身携带的背包, _cond条件变量就好比背包中的帐篷。

_counter 就好比背包中的备用干粮,0 为没有干粮或者干粮被耗尽,1 为干粮充足,默认是没有干粮的即0。

调用 park 的时候就是看有没有干粮,有干粮就继续。

参考链接: zhuanlan.zhihu.com/p/110746871

使用 park & unpark 来实现线程的暂停和恢复,先 unpark 再 park 也没问题。

在调用park的时候会停止,再unpark 会先补充一份干粮,然后结束park继续往下走。

先 unpark再park 会先补充一份干粮,然后继续往下走,在调用park的时候不会停止。

park&unpark 是针对线程的,而不是针对同步器的,因此控制粒度更为精细,park 线程还可以通过 interrupt 打断。它们是 LockSupport 类中的方法

//先park再unpark
// 暂停当前线程
LockSupport.park();
// 恢复t1线程的运行
LockSupport.unpark(t1)
//先 park 再 unpark:先暂停再继续往下执行
Thread t1 = new Thread(() -> {
    log.debug("start...");
    sleep(1);
    log.debug("park...");
    LockSupport.park();
    log.debug("resume...");
}
,"t1");
t1.start();
sleep(2);
log.debug("unpark...");
LockSupport.unpark(t1);
输出:
18:42:52.585 c.TestParkUnpark [t1] - start...
18:42:53.589 c.TestParkUnpark [t1] - park...
18:42:54.583 c.TestParkUnpark [main] - unpark...
18:42:54.583 c.TestParkUnpark [t1] - resume...
//先 unpark 再 park:发现park还是继续往下执行
Thread t1 = new Thread(() -> {
    log.debug("start...");
    sleep(2);
    log.debug("park...");
    LockSupport.park();
    log.debug("resume...");
}
, "t1");
t1.start();
sleep(1);
log.debug("unpark...");
LockSupport.unpark(t1);
18:43:50.765 c.TestParkUnpark [t1] - start...
18:43:51.764 c.TestParkUnpark [main] - unpark...
18:43:52.769 c.TestParkUnpark [t1] - park...
18:43:52.769 c.TestParkUnpark [t1] - resume...

CLH同步队列

使用了 FIFO 先入先出队列,并不支持优先级队列。

设计时借鉴了 CLH 队列,CLH是一种单向无锁队列。注意只是借鉴了。

CLH 好处: 无锁,使用自旋 快速,无阻塞。

AQS 在一些方面改进了 CLH

条件等待队列

使用了 FIFO 先入先出队列,并不支持优先级队列。

设计时借鉴了 CLH 队列,它是一种单向无锁队列。

提供了基于 FIFO 的等待队列,类似于 Monitor 的 EntryList。

使用条件变量来实现等待、唤醒机制,支持多个条件变量。

一个条件变量类似于一个 Monitor 的 WaitSet。

队列中有 head 和 tail 两个指针节点,都用 volatile 修饰配合 cas 使用,每个节点有 waitStatus 维护节点状态。

入队伪代码,只需要考虑 tail 赋值的原子性

自定义同步器API

不同的自定义同步器争用共享资源的方式也不同。

自定义同步器在实现时只需要实现共享资源state的获取与释放方式即可,至于具体线程等待队列的维护(如获取资源失败入队/唤醒出队等),AQS已经在顶层实现好了。

自定义同步器实现时需要继承AbstractQueuedSynchronizer主要实现以下几种方法:

  • isHeldExclusively():该线程是否正在独占资源。只有用到condition才需要去实现它。
  • tryAcquire(int):独占方式。尝试获取资源,成功则返回true,失败则返回false。
  • tryRelease(int):独占方式。尝试释放资源,成功则返回true,失败则返回false。
  • tryAcquireShared(int):共享方式。尝试获取资源。负数表示失败;0表示成功,但没有剩余可用资源;正数表示成功,且有剩余资源。
  • tryReleaseShared(int):共享方式。尝试释放资源,如果释放后允许唤醒后续等待结点返回true,否则返回false。
  • 以上方法默认抛出 UnsupportedOperationException