深入理解AQS(源码)

一,Lock

1. Lock 简介

在 Lock 接口出现之前,Java 中的应用程序对于多线程的并发安全处理只能基于,synchronized 关键字来解决。但是 synchronized 在有些场景中会存在一些短板,也就是它并不适合于所有的并发场景。但是在 Java5 以后,Lock的出现可以解决synchronized 在某些场景中的短板,它比 synchronized 更加灵活。

2. Lock 的实现

Lock 本质上是一个接口,它定义了释放锁和获得锁的抽象方法,定义成接口就意味着它定义了锁的一个标准规范,也同时意味着锁的不同实现。实现 Lock 接口的类有很多,以下为几个常见的锁实现

  • ReentrantLock:表示重入锁,它是唯一一个实现了Lock接口的类。重入锁指的是线程在获得锁之后,再次获取该锁不需要阻塞,而是直接关联一次计数器增加重入次数
  • ReentrantReadWriteLock:重入读写锁,它实现了ReadWriteLock接口,在这个类中维护了两个锁,一个是ReadLock,一个是 WriteLock,他们都分别实现了Lock接口。读写锁是一种适合读多写少的场景下解决线程安全问题的工具, 基本原则是: 读和读不互斥、读和写互斥、写和写互斥。也就是说涉及到影响数据变化的操作都会存在互斥。
  • StampedLock: stampedLock 是JDK8引入的新的锁机制,可以简单认为是读写锁的一个改进版本,读写锁虽然通过分离读和写的功能使得读和读之间可以完全并发,但是读和写是有冲突的,如果大量的读线程存在,可能会引起写线程的饥饿。
    stampedLock 是一种乐观的读策略,使得乐观锁完全不会阻塞写线程

3.Lock 的类关系图

void lock() // 如果锁可用就获得锁,如果锁不可用就阻塞直到锁释放
void lockInterruptibly() // 和lock()方法相似, 但阻塞的线程可中断,抛出java.lang.InterruptedException 异常
boolean tryLock() // 非阻塞获取锁;尝试获取锁,如果成功返回 true
boolean tryLock(long timeout, TimeUnit timeUnit) //带有超时时间的获取锁方法
void unlock() // 释放锁

二,ReentrantLock 重入锁

1.ReentrantLock 重入锁

表示支持重新进入的锁,也就是说,如果当前线程 t1 通过调用 lock 方法获取了锁之后,再次调用 lock,是不会再阻塞去获取锁的,直接增加重试次数就行了。synchronized 和 ReentrantLock 都是可重入锁。
比如调用 demo 方法获得了当前的对象锁,然后在这个方法中再去调用demo2,demo2中的存在同一个实例锁,这个时候当前线程会因为无法获得demo2 的对象锁而阻塞,就会产生死锁。重入锁的设计目的是避免线程的死锁。

public class ReentrantDemo{
    public synchronized void demo(){
        System.out.println("begin:demo");
        demo2();
    }
    public void demo2(){
        System.out.println("begin:demo1");
        synchronized (this){
        }
    }
    public static void main(String[] args) {
        ReentrantDemo rd=new ReentrantDemo();
        new Thread(rd::demo).start();
    } 
}

**ReentrantLock 的使用案例 **

public class AtomicDemo {
    private static int count=0;
    static Lock lock=new ReentrantLock();
    public static void inc(){
        lock.lock();
        try {
            Thread.sleep(1);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        count++;
        lock.unlock();
    }
    public static void main(String[] args) throws InterruptedException {
        for(int i=0;i<1000;i++){
            new Thread(()->{AtomicDemo.inc();}).start();
        }
        Thread.sleep(3000);
        System.out.println("result:"+count);
    } 
}

2.ReentrantReadWriteLock读写锁

我们以前理解的锁,基本都是排他锁,也就是这些锁在同一时刻只允许一个线程进
行访问,而读写所在同一时刻可以允许多个线程访问,但是在写线程访问时,所有
的读线程和其他写线程都会被阻塞。读写锁维护了一对锁,一个读锁、一个写锁;
一般情况下,读写锁的性能都会比排它锁好,因为大多数场景读是多于写的。在读
多于写的情况下,读写锁能够提供比排它锁更好的并发性和吞吐量.

public class LockDemo {
    static Map<String,Object> cacheMap=new HashMap<>();
    static ReentrantReadWriteLock rwl=new ReentrantReadWriteLock();
    static Lock read=rwl.readLock();
    static Lock write=rwl.writeLock();
    public static final Object get(String key) {
        System.out.println("开始读取数据");
        read.lock(); //读锁
        try {
            return cacheMap.get(key);
        }finally {
            read.unlock();
        }
    }
    public static final Object put(String key,Object value){
        write.lock();
        System.out.println("开始写数据");
        try{
            return cacheMap.put(key,value);
        }finally {
            write.unlock();
        }
    } 
}

在这个案例中,通过 hashmap 来模拟了一个内存缓存,然后使用读写所来保证这
个内存缓存的线程安全性。当执行读操作的时候,需要获取读锁,在并发访问的时
候,读锁不会被阻塞,因为读操作不会影响执行结果。
在执行写操作是,线程必须要获取写锁,当已经有线程持有写锁的情况下,当前线
程会被阻塞,只有当写锁释放以后,其他读写操作才能继续执行。使用读写锁提升
读操作的并发性,也保证每次写操作对所有的读写操作的可见性

  • 读锁与读锁可以共享
  • 读锁与写锁不可以共享(排他)
  • 写锁与写锁不可以共享(排他)

三,ReentrantLock 的实现原理

1.AQS 是什么

在 Lock 中,用到了一个同步队列 AQS,全称 AbstractQueuedSynchronizer,它
是一个同步工具也是 Lock 用来实现线程同步的核心组件。

2.AQS 的两种功能

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

  • 独占锁,每次只能有一个线程持有锁,比如前面给大家演示的 ReentrantLock 就是以独占方式实现的互斥锁
  • 共享锁 ,允许多个线程同时获取锁,并发访问共享资源,比如ReentrantReadWriteLock

3.AQS 的内部实现

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-iYLY3TYg-1583998874942)(751843908F914595B50B04598FD2561A)]

node的组成

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-TIGDqb8d-1583998874943)(0DF876C1E05D400A8A25B92BB97D17E2)]

4.添加线程以及释放锁对于队列的变化

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-uFzjytGp-1583998874944)(BDA3561983464496A60F9DA54CE397C0)]

  1. 新的线程封装成 Node 节点追加到同步队列中,设置 prev 节点以及修改当前节
    点的前置节点的 next 节点指向自己
  2. 通过 CAS 讲 tail 重新指向新的尾部节点

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

在这里插入图片描述

这个过程也是涉及到两个变化

  1. 修改 head 节点指向下一个获得锁的节点
  2. 新的获得锁的节点,将 prev 的指针指向 null
    设置 head 节点不需要用 CAS,原因是设置 head 节点是由获得锁的线程来完成
    的,而同步锁只能由一个线程获得,所以不需要 CAS 保证,只需要把 head 节点
    设置为原首节点的后继节点,并且断开原 head 节点的 next 引用即可

5.ReentrantLock 的源码分析

sync 实际上是一个抽象的静态内部类,它继承了 AQS 来实现重入锁的逻辑,我们
前面说过 AQS 是一个同步队列,它能够实现线程的阻塞以及唤醒,但它并不具备
业务功能,所以在不同的同步场景中,会继承 AQS 来实现对应场景的功能

Sync 有两个具体的实现类,分别是:

  • NofairSync:表示可以存在抢占锁的功能,也就是说不管当前队列上是否存在其他
    线程等待,新线程都有机会抢占锁
  • FailSync: 表示所有线程严格按照 FIFO 来获取锁
    NofairSync.lock
    以非公平锁为例,来看看 lock 中的实现
  1. 非公平锁和公平锁最大的区别在于,在非公平锁中我抢占锁的逻辑是,不管有
    没有线程排队,我先上来 cas 去抢占一下
  2. CAS 成功,就表示成功获得了锁
  3. CAS 失败,调用 acquire(1)走锁竞争逻辑
final void lock() {
 if (compareAndSetState(0, 1))
    setExclusiveOwnerThread(Thread.currentThread());
 else
    acquire(1);
}

CAS 的实现原理

通过 cas 乐观锁的方式来做比较并替换,这段代码的意思是,如果当前内存中的
state 的值和预期值 expect 相等,则替换为 update。更新成功返回 true,否则返
回 false.
这个操作是原子的,不会出现线程安全问题,这里面涉及到Unsafe这个类的操作,
以及涉及到 state 这个属性的意义。
state 是 AQS 中的一个属性,它在不同的实现中所表达的含义不一样,对于重入
锁的实现来说,表示一个同步状态。它有两个含义的表示

  1. 当 state=0 时,表示无锁状态
  2. 当 state>0 时,表示已经有线程获得了锁,也就是 state=1,但是因为
    ReentrantLock 允许重入,所以同一个线程多次获得同步锁的时候,state 会递增,
    比如重入 5 次,那么 state=5。而在释放锁的时候,同样需要释放 5 次直到 state=0
    其他线程才有资格获得锁

AQS.acquire

acquire 是 AQS 中的方法,如果 CAS 操作未能成功,说明 state 已经不为 0,此
时继续 acquire(1)操作
➢ 大家思考一下,acquire 方法中的 1 的参数是用来做什么呢?
这个方法的主要逻辑是

  1. 通过 tryAcquire 尝试获取独占锁,如果成功返回 true,失败返回 false
  2. 如果 tryAcquire 失败,则会通过 addWaiter 方法将当前线程封装成 Node 添加
    到 AQS 队列尾部
  3. acquireQueued,将 Node 作为参数,通过自旋去尝试获取锁。
public final void acquire(int arg) {
    if (!tryAcquire(arg) &&
        acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
    selfInterrupt();
}

NonfairSync.tryAcquire

protected final boolean tryAcquire(int acquires) {
 return nonfairTryAcquire(acquires);
}

ReentrantLock.nofairTryAcquire

  1. 获取当前线程,判断当前的锁的状态
  2. 如果 state=0 表示当前是无锁状态,通过 cas 更新 state 状态的值
  3. 当前线程是属于重入,则增加重入次数
final boolean nonfairTryAcquire(int acquires) {
    final Thread current = Thread.currentThread();//获取当前执行的线程
    int c = getState();//获得 state 的值
    if (c == 0) {//表示无锁状态
        if (compareAndSetState(0, acquires)) {//cas 替换 state 的值,cas 成功表示获取锁成功
            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; 
}

AQS.addWaiter

当 tryAcquire 方法获取锁失败以后,则会先调用 addWaiter 将当前线程封装成
Node.
入参 mode 表示当前节点的状态,传递的参数是 Node.EXCLUSIVE,表示独占状
态。意味着重入锁用到了 AQS 的独占锁功能

  1. 将当前线程封装成 Node
  2. 当前链表中的 tail 节点是否为空,如果不为空,则通过 cas 操作把当前线程的
    node 添加到 AQS 队列
  3. 如果为空或者 cas 失败,调用 enq 将节点添加到 AQS 队列
//假设有三个线程同时进来(并发的方法)
private Node addWaiter(Node mode) {
    Node node = new Node(Thread.currentThread(), mode);//把当前线程封装为 Node
    Node pred = tail; //tail 是 AQS 中表示同比队列队尾的属性,默认 是 null
    if (pred != null) {//tail 不为空的情况下,说明队列中存在节点
        node.prev = pred;//把当前线程的 Node 的 prev 指向 tail
        if (compareAndSetTail(pred, node)) {//通过 cas 把 node加入到 AQS 队列,也就是设置为 tail
            pred.next = node;//设置成功以后,把原 tail 节点的 next指向当前 node
            return node;
        }
    }
    enq(node);//tail=null,把 node 添加到同步队列
    return node;
}

enq

enq 就是通过自旋操作把当前节点加入到队列中

//假设有三个线程同时进来(并发的方法)
private Node enq(final Node node) {
    for (;;) {
        Node t = tail;
        if (t == null) { // Must initialize
            if (compareAndSetHead(new Node()))
                tail = head;
        } else {
            node.prev = t;
            if (compareAndSetTail(t, node)) {
                t.next = node;
                return t;
            }
        }
    } 
}

图解分析

假设 3 个线程来争抢锁,那么截止到 enq 方法运行结束之后,或者调用 addwaiter方法结束后,AQS 中的链表结构图
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ZKiviqaR-1583998874946)(0C051E13D738474CB4A93AE1BC404494)][外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-qgnzUGzA-1583998874946)(9BECD99F2D5641E687139977A9F57E0A)]

AQS.acquireQueued

通过 addWaiter 方法把线程添加到链表后,会接着把 Node 作为参数传递给
acquireQueued 方法,去竞争锁

  1. 获取当前节点的 prev 节点
  2. 如果 prev 节点为 head 节点,那么它就有资格去争抢锁(因为这个时候可能前一个节点刚好释放锁),调用 tryAcquire 抢占锁
  3. 抢占锁成功以后,把获得锁的节点设置为 head,并且移除原来的初始化 head
    节点
  4. 如果获得锁失败,则根据 waitStatus 决定是否需要挂起线程
  5. 最后,通过 cancelAcquire 取消获得锁的操作
final boolean acquireQueued(final Node node, int arg) {
    boolean failed = true;
    try {
        boolean interrupted = false;
        for (;;) {
            final Node p = node.predecessor();//获取当前节点的 prev 节点
            if (p == head && tryAcquire(arg)) {//如果是 head 节点,说明有资格去争抢锁
                setHead(node);//获取锁成功,也就是ThreadA 已经释放了锁,然后设置 head 为 ThreadB获得执行权限
                p.next = null; //把原 head 节点从链表中移除
                failed = false;
                return interrupted;
            }//ThreadA 可能还没释放锁,使得 ThreadB 在执行 tryAcquire 时会返回 false
            
            //如果线程调用了parkAndCheckInterrupt,则会阻塞在这里等待unpark唤醒
            if (shouldParkAfterFailedAcquire(p, node) && parkAndCheckInterrupt())
                interrupted = true; //并且返回当前线程在等待过程中有没有中断过。
        }
    } finally {
        if (failed)
            cancelAcquire(node);
    } 
}

shouldParkAfterFailedAcquire

如果 ThreadA 的锁还没有释放的情况下,ThreadB 和 ThreadC 来争抢锁肯定是会
失败,那么失败以后会调用 shouldParkAfterFailedAcquire 方法
Node 有 5 中状态,分别是:CANCELLED(1),SIGNAL(-1)、CONDITION(- 2)、PROPAGATE(-3)、默认状态(0) - = - CANCELLED: 在同步队列中等待的线程等待超时或被中断,需要从同步队列中取
消该 Node 的结点, 其结点的 waitStatus 为 CANCELLED,即结束状态,进入该状
态后的结点将不会再变化

  • SIGNAL: 只要前置节点释放锁,就会通知标识为 SIGNAL 状态的后续节点的线程
  • CONDITION: 和 Condition 有关系,后续会讲解
  • PROPAGATE:共享模式下,PROPAGATE 状态的线程处于可运行状态0:初始状态

这个方法的主要作用是,通过 Node 的状态来判断,ThreadA 竞争锁失败以后是
否应该被挂起。

  1. 如果 ThreadA 的 pred 节点状态为 SIGNAL,那就表示可以放心挂起当前线程
  2. 通过循环扫描链表把 CANCELLED 状态的节点移除
  3. 修改 pred 节点的状态为 SIGNAL,返回 false.返回 false 时,也就是不需要挂起,返回 true,则需要调用 parkAndCheckInterrupt挂起当前线程
//这个方法的意思:判断是否需要挂起,在获取锁失败之后
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
    int ws = pred.waitStatus;//前置节点的 waitStatus
    if (ws == Node.SIGNAL)//如果前置节点为 SIGNAL,意味着只需要等待其他前置节点的线程被释放,
        return true;//返回 true,意味着可以直接放心的挂起了
    if (ws > 0) {//ws 大于 0,意味着 prev 节点取消了排 队,直接移除这个节点就行
        do {
            node.prev = pred = pred.prev;//相当于: pred=pred.prev; node.prev=pred;
        } while (pred.waitStatus > 0); //这里采用循环,从双向列表中移除 CANCELLED 的节点
            pred.next = node;
    } else {//利用 cas 设置 prev 节点的状态为 SIGNAL(-1)
        compareAndSetWaitStatus(pred, ws,  Node.SIGNAL);
    }
    return false; 
}

parkAndCheckInterrupt

使用 LockSupport.park 挂起当前线程编程 WATING 状态
Thread.interrupted,返回当前线程是否被其他线程触发过中断请求,也就是
thread.interrupt(); 如果有触发过中断请求,那么这个方法会返回当前的中断标识
true,并且对中断标识进行复位标识已经响应过了中断请求。如果返回 true,意味
着在 acquire 方法中会执行 selfInterrupt()。

private final boolean parkAndCheckInterrupt() {
 LockSupport.park(this);
 return Thread.interrupted();
}

selfInterrupt: 标识如果当前线程在 acquireQueued 中被中断过,则需要产生一
个中断请求,原因是线程在调用 acquireQueued 方法的时候是不会响应中断请求

static void selfInterrupt() {
 Thread.currentThread().interrupt();
}

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-MSIo5dEa-1583998874946)(CD6BE172E2364B0B8A33142038C5BF44)]

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值