并发编程面试题——AQS源码解读

      这篇文章之前是没有计划要去写的,决定要去写的时间还是昨天半夜临时决定今天早起来给大家分享这篇文章。在没写这篇文章之前,我肯定能预料到这篇文章阅读量不会太高,但是还是决定来给大家分享吧。因为我自己写AQS的总结也是总结,写个博客给大家分享,大家一起进步也是总结,所以还是决定来写篇博客和大家一起分享,我哪里有理解的不对的大家还可以给我指正,大家一起进步。
      今天周日,这一周主要是回过头来研究AQS源码;说出来也不怕大家笑话,这个AQS源码让我读起来真的很难理解,我在四月份的时候,光看分析AQS源码的视频我看了两遍,然后当时找博客看了一遍,但是看的还是一头雾水,一脸懵逼,没办法,只能先暂且放一放;上周不是在总结并发编程的学习笔记吗,自己一看哪个笔记自己当时写的就像一坨屎一样,没办法,自己又开始找博客去看,后来找到一篇写的还不错的源码,看完之后,脑子稍微清晰一些,有个大概的印象,趁着还比较清晰,我又趁热看一遍源码视频(视频总时长4个小时左右),看完之后,又在昨天晚上和同学分享了这个AQS源码。目前总体来说,这个AQS源码大致已经理解,不敢说已经吃透。老规矩,下面来给大家扔干货吧。
      下面过程会比较枯燥而且绕来绕去,会比较难以理解,请大家先有个思想准备,并且希望大家一定一定要忍住耐着性子看完,那么你将收获满满,以后你也会感谢现在的自己。
      要谈起AQS,就不得先说Java并发编程,并发编程核心在于java.util.concurrent 简称JUC。
      这个包的作者是doug lea,大家看,他就是长这样:
并发编程面试题——AQS源码解读_第1张图片

      目测已经超过35了吧,所以广大程序员们,不要一直担心35失业之类的,只要自身强,就完全不用担心35是个坎。
      这个大佬写的JUC并发编程逻辑不得不佩服,但是我也不得不吐槽,这代码可读性真的差,把所有的东西都集成在了一起,也可能是我比较笨吧,一直看不懂、、、

什么是AQS?

      AQS全名是:AbstractQueuedSynchronize(抽象同步队列),它是一个抽象类。
      阅读源码时,你会发现很多并发工具的底层并没有直接使用AbstractQueuedSynchronize类,但是常用的类例如ReentrantLock、CountDownLatch、Semaphore),他们的内部都去维护了一个内部类Sync,然后这个类去继承了AbstractQueuedSynchronizer。
并发编程面试题——AQS源码解读_第2张图片      AQS定义了两种资源共享方式:
            1、Exclusive: 独占模式,只有一个线程能执行,例如ReentrantLock.
            2、Shared: 共享模式,多个线程可以同时执行,例如Semaphore和CountDownLatch
源码如下所示:
并发编程面试题——AQS源码解读_第3张图片

            今天由于本人技术水平有限且时间问题,这篇文章就只讲共享模式,由ReentrantLock 类展开来讲AQS源码。

            这篇文章分析源码我不打算直接去进入源码去进行讲解,我想先给大家反推,意思就是先大概猜测这个AQS的实现原理,然后我们带着我们推的结果去有目的的读取源码,看看源码中是不是和我们推断的一样。

反推加锁原理

下面是伪代码+思维逻辑,请不要较真哈,因为这也是我们的首先反推

//先加锁
lock();
	//推断加锁逻辑过程
		//死循环目的是为了使其线程加锁成功,
		while(true){
			if(加锁成功){
				//加锁成功,跳出循环
				break}
			//加锁失败
			//这里加锁失败后,咱们先思考出几种再次去循环加锁的逻辑
				//第一种:直接采用自旋,循环加锁——>这种方法能够明显发现太消耗CPU资源,不可取——(舍弃)
				//第二种:先让加锁失败的线程去睡眠Sleep(time)——>这种方式我们无法很好判断使其线程要睡眠时间,不可取——>(舍弃)
				//第三种:yield()让出cpu线程,这个也是不可取,假如下面业务逻辑执行时间特别长,你总不能把其他的多有线程一直去让出CPU吧,不可取——>(舍弃)
				//第四种:使其线程阻塞;后面在unlock();解锁时唤醒其所有阻塞的其中一个线程;
					//可以使用LockSupport.park();方法去阻塞;
					//这里的阻塞没有问题,但是在解锁后唤醒线程有个问题——>我不知道那些线程阻塞,我应该去唤醒哪个线程啊
					//获取那些线程阻塞和唤醒具体线程方法
						//目的是存储要阻塞线程
						//HashSet或者LinkdQueue()
						//HashSet.add(Thread);或者LinkdQueue().put(Thread);
						//使其线程阻塞
						//LockSupport.park();
		}

//执行业务逻辑
System.out.println("我已经成功加锁,我要开始执行业务逻辑喽");


//解锁
unlock();
	//推断解锁逻辑过程
		//从阻塞队列中拿到要唤醒的线程
			//HashSet.get();或者LinkdQueue().take();
			//去唤醒线程
			//LockSupport.unPark();

      上面这段伪代码就是我们去反推的加锁逻辑,总体来看没有什么问题,所有反推的思维和为什么使用它与为什么不使用它的原因都在注释中,请仔细去看和动脑思考;哈哈,我们是不是也可以写个同步队列了。等下我们就带着我们上面的反推去走源码看看我们思考的是不是和源码一样。
      从反推中我们大概知道了锁的一些核心:
            1、自旋——>循环加锁,直到加锁成功
            2、LockSupport——>线程阻塞和唤醒
            3、CAS——>加锁成功的方法,利用类似乐观锁的CAS
            4、queue——>队列,存放阻塞的线程

ReentrantLock源码?

       忘记个大事,这篇文章我将围绕JDK11版本来分享,JDK8版本我也看过,他们源码大致相同,只有一些小的改动。
       ReentrantLock使用方法:

void testA(){
	
	ReentrantLock reentrantLock = new ReentrantLock(true);
    //加锁
    reentrantLock.lock();
    System.out.println("我已经成功加锁,我要开始执行业务逻辑喽");
    //解锁
    reentrantLock.lock();

}

       上面使用步骤是不是超级简单,但是里面的源码是真的难,把我给搞吐了,下面我们就围绕new ReentrantLock(true)对象;lock()加锁;unlock()
解锁来讲解源码。

节点Node和关键属性

       AQS中定义Node

             AQS内部维护了一个双向链表,是叫同步等待队列,也叫做CLH队列,这个双向链表的元素用Node表示。
             Node就定义在AbstractQueuedSynchronize类中
源码如下所示:

static final class Node {
	// 标记节点未共享模式
	static final Node SHARED = new Node();
	// 标记节点为独占模式
	static final Node EXCLUSIVE = null;
	// 在同步队列中等待的线程等待超时或者被中断,需要从同步队列中取消等待
	static final int CANCELLED = 1;
	/**
	 *  后继节点的线程处于等待状态,而当前的节点如果释放了同步状态或者被取消,
	 *  将会通知后继节点,使后继节点的线程得以运行。
	 */
	static final int SIGNAL = -1;
	/**
	 *  节点在等待队列中,节点的线程等待在Condition上,当其他线程对Condition调用了signal()方法后,
	 *  该节点会从等待队列中转移到同步队列中,加入到同步状态的获取中
	 */
	static final int CONDITION = -2;
	// 表示下一次共享式同步状态获取将会被无条件地传播下去
	static final int PROPAGATE = -3;
	/**
	 * 标记当前节点的信号量状态 (1,0,-1,-2,-3)5种状态
	 * 使用CAS更改状态,volatile保证线程可见性,高并发场景下,被一个线程修改后,状态会立马让其他线程可见。
	 */
	volatile int waitStatus;
	// 前驱节点,当前节点加入到同步队列中被设置
	volatile Node prev;
	// 后继节点
	volatile Node next;
	// 节点同步状态的线程
	volatile Thread thread;
	/**
	 * 等待队列中的后继节点,如果当前节点是共享的,那么这个字段是一个SHARED常量,
	 * 也就是说节点类型(独占和共享)和等待队列中的后继节点共用同一个字段。
	 */
	Node nextWaiter;
	// 如果节点是共享状态,返回true
	final boolean isShared() {
		return nextWaiter == SHARED;
	}
	// 返回前驱节点
	final Node predecessor() throws NullPointerException {
		Node p = prev;
		if (p == null)
			throw new NullPointerException();
		else
			return p;
	}
	Node() {    // Used to establish initial head or SHARED marker
	}
	Node(Thread thread, Node mode) {     // Used by addWaiter
		this.nextWaiter = mode;
		this.thread = thread;
	}
	Node(Thread thread, int waitStatus) { // Used by Condition
		this.waitStatus = waitStatus;
		this.thread = thread;
	}
}

       AQS关键属性

// 指向同步等待队列的头节点
private transient volatile Node head;
 
// 指向同步等待队列的尾节点
private transient volatile Node tail;
 
// 同步资源状态。例如在ReentrantLock中,state标识加锁的次数
private volatile int state;

       AQS双向链表如下图所示并发编程面试题——AQS源码解读_第4张图片       使用双向链表的目的是为了解决在等待队列遍历链表的时候可能从头到尾的往后遍历;也有可能从尾向前的向前遍历。因此在等待队列中使用了双向链表。

new ReentrantLock()源码

       创建ReentrantLock对象源码如下所示:
并发编程面试题——AQS源码解读_第5张图片       从创建ReentrantLock()对象中我们发现有两种创建方式,一种是无参构造,一种是有参构造。
       无参构造: 默认创建了非公平锁,
       有参构造: 传值true或者false,然后利用三元运算符来创建公平锁还是非公平锁。

   公平锁与非公平锁

       公平锁: 当线程准备竞争锁时,发现已经有其他线程在排队获取该锁,那么该线程会自觉的加入到队列中依次排队获得锁
       优点:所有的线程都会加锁成功得到资源;缺点:只有第一个线程能够获得资源,其他线程都会阻塞,CPU线程唤醒线程开销比较大。
       非公平锁: 当拥有的锁的线程释放锁的时候,该线程会去竞争锁,它不会像公平锁一样直接排队获取锁,会去竞争获得锁,当获得锁失败的时候,会到等待队列中排队;加锁成功,那就直接获得资源。
       优点:减少CPU唤醒线程的开销;缺点:非公平,可能某些线程一直不能够或者长时间不能够获取到锁。

   可重入锁和不可重入锁

       可重入锁: 可重入锁指的是以线程为单位,当一个线程获取锁后,这个线程还可以再次获取本对象上的锁。注意: 只能是本线程再次获得,其他线程不能够获得。
ReentrantLock就是一个典型的可重入锁,下面我们看个例子:
并发编程面试题——AQS源码解读_第6张图片state用于存储加锁的次数。每加一次锁,state值加1;没解一次锁,state值减1;只有当state的值减为0的时候,该线程才会释放锁。

       不可重入锁: 不可重入锁,当然和重入锁是相反的,就是当一个线程获取锁的时候,该线程不能再一次获取该锁。

lock()源码

       lock()方法是加锁,我们直接看源码:

lock()方法源码:

并发编程面试题——AQS源码解读_第7张图片acquire()方法源码
并发编程面试题——AQS源码解读_第8张图片上面的selfInterrupt();功能忘了标出了,就是当该线程获取锁失败的时候,将该线程中断。
acquire()方法中由调用了三个方法:分别为tryAcquire()——>尝试加锁,acquireQueued()——>线程阻塞和addWaiter()——>加入等待队列 下面我们来一一分析。

尝试加锁tryAcquire()源码

//这里我是围绕公平锁的尝试加锁方法来讲解,它和非公平锁有唯一的区别,就是非公平锁在加锁前不需要判断
//队列中是否有节点等待,非公平锁直接对其尝试加锁
protected final boolean tryAcquire(int acquires) {
	//获得当前该线程
    final Thread current = Thread.currentThread();
    //取出锁标志状态——0代表该锁未被持有;大于0代表该锁已经被持有
    int c = getState();
    //锁未被持有
    if (c == 0) {
    	//if判断总的:先判断等待队列中是否有节点进行等待获取锁;如果没有节点等待,则去加锁,当加锁成功时,将该锁的持有者设置成为该线程
    	//hasQueuedPredecessors()方法是判断该队列中是否有节点
        if (!hasQueuedPredecessors() &&
        	//利用CAS来尝试加锁
            compareAndSetState(0, acquires)) {
            //加锁成功后,将锁的线程持有者设置为该线程
            setExclusiveOwnerThread(current);
            return true;
        }
    }
    //这里就类似与重入锁
    //该锁被持有,然后判断持有锁的线程是否与该尝试加锁的线程相等
    else if (current == getExclusiveOwnerThread()) {
    	
        int nextc = c + acquires;
        if (nextc < 0)
            throw new Error("Maximum lock count exceeded");
        //设置新的锁标志状态,一般是+1
        setState(nextc);
        return true;
    }
    return false;
}

注意:这里的尝试加锁方法tryAcquire()在子类中重写了,因此父类中该方法不会执行,找该方法的话去ReentrantLock类中查找,

addWaiter(node)将节点加入到等待队列中源码如下所示:

private Node addWaiter(Node mode) {
	  //将该节点赋值给新的节点node
     Node node = new Node(mode);

     for (;;) {
         Node oldTail = tail;
         //如果旧的尾结点不为空,说明该队列不为空
         if (oldTail != null) {
         	 //设置该节点的前置节点为老的节点
             node.setPrevRelaxed(oldTail);
             //利用CAS将尾结点tail为该node节点
             if (compareAndSetTail(oldTail, node)) {
             	 //将旧的尾结点的next指向node节点
                 oldTail.next = node;
                 return node;
             }
         } else {
         	  //当尾结点tail为空,说明该队列不存在,也就是为null;那么此时会初始化一个队列
             initializeSyncQueue();
         }
     }
 }

此时已经将加锁失败的节点加入到等待队列中,但是还没有将该队列阻塞;下面就讲解下阻塞方法源码——>acquireQueued(node,arg)

final boolean acquireQueued(final Node node, int arg) {
    boolean interrupted = false;
    try {
        for (;;) {
        	//取出该节点的前置节点
            final Node p = node.predecessor();
            //如果前直节点时head头节点,
            //这里尤其注意:如果是头结点,那么我会在加入阻塞之前先去尝试一下再获取该锁
            if (p == head && tryAcquire(arg)) {
            	//当加锁成功后,那么会将该node设置为head头结点
                setHead(node);
                //旧的头结点设置为null
                p.next = null; // help GC
                return interrupted;
            }
            //如果不是头结点,或者头结点再次尝试加锁失败,那么就会将该节点加入到阻塞中
            //shouldParkAfterFailedAcquire()方法会执行两次,第一次会将前置节点p的waitStatus设置为-1可唤醒状态;第二次才成功的阻塞
            if (shouldParkAfterFailedAcquire(p, node))
            	//parkAndCheckInterrupt()作用是利用LockSupport.park()阻塞且将线程打断
                interrupted |= parkAndCheckInterrupt();
        }
    } catch (Throwable t) {
        cancelAcquire(node);
        if (interrupted)
            selfInterrupt();
        throw t;
    }
}

shouldParkAfterFailedAcquire(p, node)应该阻塞源码如下所示:

private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
     int ws = pred.waitStatus;
     if (ws == Node.SIGNAL)
         
         return true;
     if (ws > 0) {
         
         do {
             node.prev = pred = pred.prev;
         } while (pred.waitStatus > 0);
         pred.next = node;
     } else {
         
         pred.compareAndSetWaitStatus(ws, Node.SIGNAL);
     }
     return false;
 }

parkAndCheckInterrupt去阻塞且该线程中断源码如下所示:

private final boolean parkAndCheckInterrupt() {

	//该节点阻塞
    LockSupport.park(this);
    //线程中断
    return Thread.interrupted();
}

      以上就是lock的源码,我来来个总结,这样大家能有个大致的逻辑:
并发编程面试题——AQS源码解读_第9张图片上面流程图是个大概的逻辑,具体细节在这里没有展示,大家可以对上面的源码中进行了解,注释中写的比较详细。

unlock()源码

      这里的解锁相对于上面的加锁源码要相对简单dandadd’nd’nad’nd些,那下面咱们就直接上源码,在源码中注释写具体解锁逻辑,请大家一定好好看源码中我写的注释。

public void unlock() {
	//释放锁
    sync.release(1);
}

release()释放锁源码:

public final boolean release(int arg) {
	//尝试解锁
    if (tryRelease(arg)) {
        Node h = head;
        //解锁成功后,head的头结点的状态如果不为0
        if (h != null && h.waitStatus != 0)
        	//去唤醒后面的线程
            unparkSuccessor(h);
        return true;
    }
    return false;
}

tryRelease()尝试解锁源码如下所示:

protected final boolean tryRelease(int releases) {
	//锁的状态减1
    int c = getState() - releases;
    //判断该线程是否是锁持有的线程
    if (Thread.currentThread() != getExclusiveOwnerThread())
        throw new IllegalMonitorStateException();
    boolean free = false;
    //当state减至0的时候,才代表释放锁
    if (c == 0) {
        free = true;
        //将该锁的线程持有者设置为null
        setExclusiveOwnerThread(null);
    }
    //设置新的锁的状态
    setState(c);
    return free;
}

unparkSuccessor(h)唤醒head头节点下一个可唤醒的node节点源码如下所示:

private void unparkSuccessor(Node node) {
        
        //取出head头结点的等待状态
        int ws = node.waitStatus;
        //头结点状态可唤醒
        if (ws < 0)
        	//利用CAS将等待状态设置为0
            node.compareAndSetWaitStatus(ws, 0);

        
        Node s = node.next;
        //如果头结点的下一个节点为空,或者头结点的下一个节点等待状态不是可唤醒状态
        if (s == null || s.waitStatus > 0) {
            s = null;
            //那么去从tail尾结点向前一次遍历,遍历取出head头结点下一个可被唤醒的节点位置
            for (Node p = tail; p != node && p != null; p = p.prev)
                if (p.waitStatus <= 0)
                    s = p;
        }
        if (s != null)
       		//将该节点的线程利用LockSuport唤醒
            LockSupport.unpark(s.thread);
    }

      好了,AQS利用ReentractLock创建、lock()和unLock()来详细的给大家分享了我对源码的理解。通过对源码的分析,大家应该对AQS有个大概的了解,希望能帮到大家。
      怼到了第二天凌晨,终于把AQS这篇文章给大家分享出来了,也算兑现了早上的诺言吧。
      由于水平有限,上面分享有错误的地方,欢迎大家在评论区中指正。
      希望对大家有所帮助。

你可能感兴趣的:(java,java,juc)