并发编程(三)AQS简析

版权声明:本文为博主原创文章,遵循 CC 4.0 by-sa 版权协议,转载请附上原文出处链接和本声明。
本文链接:https://blog.csdn.net/liman65727/article/details/94653301

前言

重入锁可以完全替换synchronized关键字,synchronized是一个最简单的控制方式,重入锁相比synchronized是一种更加灵活的控制方式。重入锁的基础其实一定程度上是AQS(AbstractQueuedSynchronizer)

重入锁

这个是相关的类关系图,AbstractQueuedSynchronizer是一个抽象类,Sync是其的一个具体实现,Lock也是一个接口,ReentrantLock和ReadAndWriteLock其实都是Lock接口的一个实现,ReentrantLock其中的同步实现方式,其实就是通过Sync完成,Sync是一个内部抽象类,NonfairSync和FariSync分别对应公平锁和非公平锁的实现

简单实例

package com.learn.reentrantlockDemo;

import java.util.concurrent.locks.ReentrantLock;

/**
 * autor:liman
 * createtime:2019/7/4
 * comment:
 * 一段简单的重入锁实例
 */
public class ReenterLockDemo implements Runnable {

    public static ReentrantLock lock = new ReentrantLock();

    public static int i = 0;

    /**
     * lock.lock()和lock.unlock()之间的语句构成了类似同步代码块。
     * 何时加锁,何时释放锁,完全手动配置,更加灵活。
     */
    @Override
    public void run() {
        for (int j= 0; j < 100000; j++) {
            lock.lock();
            try {
                i++;
            } finally {
                lock.unlock();
            }
        }
    }

    public static void main(String[] args) throws InterruptedException {
        ReenterLockDemo reenterLockDemo = new ReenterLockDemo();
        Thread t1 = new Thread(reenterLockDemo);
        Thread t2 = new Thread(reenterLockDemo);
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(i);
    }
}

上述run方法中的逻辑很简单,如果用synchronized来实现,是如下的结果:

synchronized (Object.class){
    i++;
}

park和unpark

这两个方法的作用和wait和notify的作用一样,但是park和unpark相比wait和notify显得更加灵活,下面只贴出具体的使用实例:

package com.learn.lockSupportDemo;

import java.util.concurrent.locks.LockSupport;

/**
 * autor:liman
 * createtime:2019/7/7
 * comment:
 */
public class TestObjLockpark {

    public static void main(String[] args) throws InterruptedException {
        final Object obj = new Object();
        Thread threadA = new Thread(new Runnable() {
            @Override
            public void run() {
                int sum = 0;
                for(int i=0;i<10;i++){
                    sum+=i;
                }
                LockSupport.park();
                System.out.println(sum);
            }
        });

        threadA.start();
        Thread.sleep(1000);
        LockSupport.unpark(threadA);
    }

}

详细实例,请参考这位大牛的博客:LockSupport深入浅出 

AQS

翻看了很多资料,将AQS讲的清楚的,貌似只看到了一篇:清楚的分析清楚AQS。下面的部分,更多的是对这篇博客的学习笔记。这篇博客更多的是从源码级别分析AQS。

简介

AQS其中维护了一个FIFO的等待队列,阻塞中的线程,会被封装成一个Node对象,加入队列中。相关的属性就如下所示,下述代码块中每个属性的含义通过TODO标签写明。

/**
 * Head of the wait queue, lazily initialized.  Except for
 * initialization, it is modified only via method setHead.  Note:
 * If head exists, its waitStatus is guaranteed not to be
 * CANCELLED.
TODO: AQS队列中的头结点,其实就是当前持有锁的线程
 */
private transient volatile Node head;

/**
 * Tail of the wait queue, lazily initialized.  Modified only via
 * method enq to add new wait node.
TODO:AQS队列的尾结点,
 */
private transient volatile Node tail;

/**
 * The synchronization state.
 TODO:锁的状态,这里简单点表示。>0表示这个线程取消了等待,0初始化状态,-1表示后继节点等待被唤醒
 */
private volatile int state;

/**
 * The current owner of exclusive mode synchronization.
 TODO:当前持有独占锁的线程,这个属性并不存在AQS抽象类中,而是在其父类AbstractOwnableSynchronizer中
 */
private transient Thread exclusiveOwnerThread;//这个属性在AbstractOwnableSynchronizer类中

Node的数据结构

node的数据结构如下,其实也就四个属性比较重要,thread+waitStatus+prev+next,其中的thread表示封装的线程,waitStatus表示的是后继节点的状态。

/** Marker to indicate a node is waiting in shared mode 
TODO:标示当前节点在共享模式下
*/
static final Node SHARED = new Node();

/** Marker to indicate a node is waiting in exclusive mode 
TODO: 标示当前节点在独占模式下
*/
static final Node EXCLUSIVE = null;

/** waitStatus value to indicate thread has cancelled 
TODO: 线程取消了争抢这个锁
*/
static final int CANCELLED =  1;

/** waitStatus value to indicate successor's thread needs unparking 
TODO: 当前节点的后继节点需要被唤醒
*/
static final int SIGNAL    = -1;

/** waitStatus value to indicate thread is waiting on condition 
TODO: 进入条件队列
*/
static final int CONDITION = -2;

/**
TODO:取值为上述的几个属性值,waitStatus大于0标示这个线程取消了等待
 */
volatile int waitStatus;

/**
TODO:前驱节点
 */
volatile Node prev;

/**
TODO:后继节点
 */
volatile Node next;

/**
TODO:这个就是封装的线程
 */
volatile Thread thread;

获取锁的过程

上面介绍了基本的AQS的内容之后,开始介绍线程进入阻塞队列和锁争抢的过程。

static final class FairSync extends Sync {
    final void lock() {
        acquire(1);//这个就是争取锁的方法
    }
	
    public final void acquire(int arg) {
    if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg)){
            selfInterrupt();
	    }
    }
}

可以看到上述的代码主要调用了acquire方法,这个方法中比较重要的就是tryAcquire方法,这个方法其实就是尝试获取锁。返回值是boolean类型,表示是否获取了锁

tryAcquire

protected final boolean tryAcquire(int acquires) {
    final Thread current = Thread.currentThread();
    int c = getState();
    if (c == 0) {//TODO:c=0表示当前没有线程获得锁,这会儿就尝试利用CAS替换state
        if (!hasQueuedPredecessors() && compareAndSetState(0, acquires)) {
            setExclusiveOwnerThread(current);
            return true;
        }
    }
    else if (current == getExclusiveOwnerThread()) {//TODO:如果这个线程就是当前线程,因为这个是支持可重入的
        int nextc = c + acquires;
        if (nextc < 0)
            throw new Error("Maximum lock count exceeded");
        setState(nextc);
        return true;
    }
    return false;
}

state就是表示重入的次数,如果state=0 表示当前没有线程持有锁,说明一下,这里是以公平锁为实例进行说明的,hasQueuedPredecessors()方法就是判断有没有线程在队列中等待了很久。如果没有就利用CAS替换state,如果替换成功,表示获取到了锁。同时,支持可重入,如果重入成功也返回true表示获取当前锁。如果两个条件都没满足,直接返回false,表示获取锁失败。

回到我们的入口主要逻辑

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

如果通过tryAcquire没有获取到锁,则会通过acquireQueued将线程加入到阻塞队列中。

addWaiter是将线程包装成node,同时放入到队列中,acquireQueued方法中包含真正的线程挂起。

addWaiter

将node加入队列中

private Node addWaiter(Node mode) {
    Node node = new Node(Thread.currentThread(), mode);
    // Try the fast path of enq; backup to full enq on failure
    Node pred = tail;
    if (pred != null) {//TODO:初始化的时候队列的tail是空的,如果tail不为空,表示队列不为空,有尾部节点。后面的操作都是入队。
        node.prev = pred;
        if (compareAndSetTail(pred, node)) {
            pred.next = node;
            return node;
        }
    }
    enq(node);
    return node;
}

注意enq,如果pred为空,就表示队列是空的,或者CAS替换尾节点失败。这个时候就需要调用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;
            }
        }
    }
}

enq采用的是自旋的方式入队,这里线程会进行多次竞争进行入队,总会入队的。

如果tail为空,则需要重新初始化一个节点,作为尾节点,同时head指向初始化的tail,这个算是一个数据结构中的基本操作。如果tail不为空,然后就进行入队操作,这个操作和之前的addWaiter中的入队操作一样。

acquireQueued

回到之前的代码,acquireQueued这个方法,这个就是真正的线程挂起。

final boolean acquireQueued(final Node node, int arg) {
    boolean failed = true;
    try {
        boolean interrupted = false;
        for (;;) {
            final Node p = node.predecessor();//获取node节点的前置节点
            if (p == head && tryAcquire(arg)) {//如果前置节点就是头结点,就可以尝试去获取锁
                setHead(node);
                p.next = null; // help GC
                failed = false;
                return interrupted;
            }
            //如果前结点不是头节点,或者没有获得锁,则判断是否挂起当前线程
            if (shouldParkAfterFailedAcquire(p, node) &&
                parkAndCheckInterrupt())
                interrupted = true;
        }
    } finally {
        if (failed)
            cancelAcquire(node);
    }
}

这个方法是获取锁过程中的核心代码,首先是获取Node的前驱节点,如果前驱节点是头结点,就可以尝试去获取锁,如果不是头结点,或者获取锁失败则直接判断线程是否可以进行中断。

这里我们重点说一下,为什么前驱节点是head节点就可以尝试去获取锁:首先,它是队头,这个是第一个条件,其次,当前的head有可能是刚刚初始化的node,enq(node) 方法里面有提到,head是延时初始化的,而且new Node()的时候没有设置任何线程,也就是说,当前的head不属于任何一个线程,所以作为队头,可以去试一试,tryAcquire已经分析过了, 忘记了请往前看一下,就是简单用CAS试操作一下state。(这一段是大牛博客中的原话)

如果节点的前驱节点不是头结点或者没有获取锁,就需要判断当前线程是否能挂起,这就进入了shouldParkAfterFailedAcquire方法

shouldParkAfterFailedAcquire

private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
    int ws = pred.waitStatus;//pred是前驱节点,ws是前驱节点的waitStatus
    if (ws == Node.SIGNAL)//如果前驱节点的ws=-1,标示前驱节点正常,当前节点需要挂起
        return true;
    if (ws > 0) {//如果ws>0表示前驱节点取消了等待状态,需要从等待队里中移出,需要为当前节点找一个正常的前驱节点。
        do {
            node.prev = pred = pred.prev;
        } while (pred.waitStatus > 0);
        pred.next = node;
    } else {//如果ws不是-1,且小于等于0,则将前驱节点的waitStatus设置为-1
        compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
    }
    return false;//如果返回false,会在acquireQueued方法的自旋逻辑中再次进入这个方法,这个时候前驱及节点的ws已经为-1了,就会直接返回true
}

 这里重点解释一下最后一个分支,这个分支中只是利用CAS将waitStatus替换成-1,在我们前面的源码中,都没有看到有设置waitStatus的,所以每个新的node入队时,waitStatu都是0,正常情况下,前驱节点是之前的 tail,那么它的 waitStatus 应该是 0。

shouldParkAfterFailedAcquire逻辑其实也不复杂,如果前驱节点的waitStatus是-1,则该当前线程需要被挂起,就会返回true,等待后面被唤醒,如果返回false,说明当前线程不需要被挂起。如果经过这个方法之后,node已经是head的直接后继节点了,就可能返回false。

如果shouldParkAfterFailedAcquire方法返回true,则后续会调用parkAndCheckInterrupt方法,将线程挂起。

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

释放锁的过程

唤醒的操作还是相对获得锁要简单的多

public void unlock() {
    sync.release(1);
}

//这里就是释放锁
public final boolean release(int arg) {
    if (tryRelease(arg)) {
        Node h = head;
        if (h != null && h.waitStatus != 0)
            unparkSuccessor(h);
        return true;
    }
    return false;
}

//由上面的方法可以看出,这里的node其实就是头结点
private void unparkSuccessor(Node node) {

    int ws = node.waitStatus;
    if (ws < 0)//如果头节点的ws小于0,则将其修改为0
        compareAndSetWaitStatus(node, ws, 0);
    Node s = node.next;
    if (s == null || s.waitStatus > 0) {//从队尾往前找,扎到最前面一个ws<=0的节点
        s = null;
        for (Node t = tail; t != null && t != node; t = t.prev)
            if (t.waitStatus <= 0)
                s = t;
    }
    if (s != null)//然后唤醒找到的最前面的ws<=0的节点
        LockSupport.unpark(s.thread);
}

这个看上述代码中的注释就能看懂。

总结:

在并发环境下,加锁和解锁需要以下三个部件的协调:

1、锁状态。我们要知道锁是不是被别的线程占有了,这个就是 state 的作用,它为 0 的时候代表没有线程占有锁,可以去争抢这个锁,用 CAS 将 state 设为 1,如果 CAS 成功,说明抢到了锁,这样其他线程就抢不到了,如果锁重入的话,state进行 +1 就可以,解锁就是减 1,直到 state 又变为 0,代表释放锁,所以 lock() 和 unlock() 必须要配对啊。然后唤醒等待队列中的第一个线程,让其来占有锁。

2、线程的阻塞和解除阻塞。AQS 中采用了 LockSupport.park(thread) 来挂起线程,用 unpark 来唤醒线程。

3、阻塞队列。因为争抢锁的线程可能很多,但是只能有一个线程拿到锁,其他的线程都必须等待,这个时候就需要一个 queue 来管理这些线程,AQS 用的是一个 FIFO 的队列,就是一个链表,每个 node 都持有后继节点的引用。

强烈推荐大牛的博客——AQS详细解析。小生这篇博客就是针对大牛的学习笔记,大牛博客最后还有一个很详细的图解

展开阅读全文

没有更多推荐了,返回首页