AbstractQueuedSynchronizer(AQS)锁阻塞机制分析----自旋等待与挂起

版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。
本文链接:https://blog.csdn.net/qq_28275283/article/details/76697120
生命是一袭华美的袍,爬满了蚤子。                    ------张爱玲前言AbstractQueuedSynchronizer(AQS)类是JUC(java.util.concurrent)的基石,它为并发工具类提供了显式锁的策略。通过实现AQS的抽象方法tryAcquire(),tryRelease()等可以实现公平锁与非公平锁策略,并且AQS内部提供了基于信号量的独占锁与共享锁的实现。可以说AQS是每个学习并发编程的人所必须学习的。然而网络上关于AQS源码过程讲解的博客多如牛毛且质量参差不齐,本文主要分析的是AQS如何通过自旋锁与操作系统挂起来实现对线程的阻塞,并且分析AQS的作者Doug Lea为什么要如此设计。而AQS具体源码的实现过程将会简单带过。
概念(熟悉并发和锁概念可跳过)公平锁:根据线程竞争锁的顺序来安排线程持有锁的顺序,即根据FIFO队列顺序来为阻塞线程分配持有锁的顺序。简单点说就是排队打饭,先来先打后来后打。非公平锁:与公平锁相反,当线程竞争锁是如果锁未被其他线程持有,则该线程可以插队来竞争锁。独占锁与共享锁:独占锁只允许一个线程持有锁而共享锁允许多个线程同时持有锁,简单的例子就是ReentrantReadWriteLock,读操作允许多个线程同时持有锁来实现读(共享锁),而同一时刻只允许一个线程执行写操作(独占锁),且读与写之间是互斥的。CAS : compare and swap,即CUP提供的比较修改原子操作,通过内存偏移量读取该内存瞬时的值,比较传入值(之前获取的值)与内存值是否一致,若一致则表明此时间段内该变量未被其他线程修改,即可将update值写入内存(cpu指令级保证操作的原子性)。具体实现被封装在Unfase类的native方法。自旋锁:在未获得锁之前,线程一值空转,即在一个循环体内等待持有锁从而阻塞线程。挂起:线程的挂起操作实质上就是使线程进入“非可执行”状态下,在这个状态下CPU不会分给线程时间片,进入这个状态可以用来阻塞一个线程的运行。在线程挂起后,可以通过重新唤醒线程来使之恢复运行。
考虑到不是每个读者都了解AQS锁机制,下面通过类图与AQS队列示意图来简单的讲解AQS原理。
类图分析


AbstractOwnableSynchronizer:保存维护持有锁的线程,即head节点线程,即当线程获得执行权时将被设置为AbstractOwnableSynchronizer的exclusiveOwnerThread,表示该线程此时持有该显式锁
Node:双向链表节点类,内部维护一个线程,即将每个请求锁的线程封装到Node节点对象中,再将节点对象放入阻塞队列。并且通过EXCLUSIVE和SHARED的对象来设置独占锁还是共享锁,waistate表明节点在队列中的状态。
LockSupport:通过Unsafe类的park()和unpark()方法来实现对线程的挂起与唤醒,提供个AQS使用。
Unsafe:提供CAS原子操作,AQS中要对存在竞态条件的共享变量的原子修改依赖于Unsafe的CAS方法。如果说AQS是JUC的基石那么可以说Unsafe是JUC的灵魂。Unsafe类被大量的应用于concurrent包。而该类提供的绕过虚拟机直接操作内存和CPU等功能被认为是不符合java的设计理念(愚蠢的程序员就应该在虚拟机的监管下编程,蛤蛤),以至于Java的维护人员计划在java9将该类删除或隐藏,但是该类在jdk和其他开源框架中被广泛应用,我们期待一直跳票的java9中会如何抉择。
Codition:条件接口,声明await(),notify()等线程状态修改(线程间通信)释放持有锁等方法。为显式锁提供Object的wait()和notify()功能。即使持有锁的线程主动放弃锁进入等待状态等待被其他线程唤醒重新去竞争锁(进入阻塞队列)。相对于Object的wait()和notify()方法,你不需要知道线程具体持有的是哪个对象的锁,从而减少了出错的可能。并且你可以同时根据不同的条件创建多个condition对象,这不仅提高了代码的可读性而且还简化了线程在多条件下的协作。
ConditionObject:Condition接口的具体实现类。
Sync:继承AQS提供锁的操作方法和condition获取方法,并作为公平锁于非公平锁的父类。
FairSync:公平锁
NonfairSync:非公平锁

队列示意图分析


如图,我们可以看出AQS将请求锁的线程封装在Node节点对象中,然后添加到阻塞队列(双向链表),只有头节点线程才能持有锁获得运行权,而其他节点的线程将被阻塞,通过Syc.newCondition()方法创建一个ConditionObject对象,该对象将维护一条等待队列,即将持有锁(获得运行权的head节点线程)线程添加到等待队列(被挂起park)直到另一个获得锁的线程调用signal()方法将其重新放回阻塞队列等待持有锁。以上我们大概了解了AQS的锁机制,下面我们来正式的分析AQS线程阻塞的策略。


阻塞线程策略分析

首先我们先分清楚阻塞与自旋锁,挂起之间的关系。
阻塞是指线程跳出当前执行任务,由于某种原因暂时无法继续执行任务逻辑。而自旋和挂起可以使线程处于阻塞状态,所以自旋锁与挂起是阻塞的子集关系。
    /**
     * Acquires in exclusive uninterruptible mode for thread already in
     * queue. Used by condition wait methods as well as acquire.
     *
     * @param node the node
     * @param arg the acquire argument
     * @return {@code true} if interrupted while waiting
     */
    final boolean acquireQueued(final Node node, int arg) {
        boolean failed = true;
        try {
            boolean interrupted = false;
            for (;;) {
                final Node p = node.predecessor();//获取当前节点的前置节点
                if (p == head && tryAcquire(arg)) {//若前置节点不是head节点则短路,否则调用tryAcquire尝试去修改AQS state的值并设置为exclusiveOwnerThread
                    setHead(node);                 
                    p.next = null; // help GC
                    failed = false;
                    return interrupted;
                }
                if (shouldParkAfterFailedAcquire(p, node) &&    
                    parkAndCheckInterrupt())
                    interrupted = true;
            }
        } finally {
            if (failed)//程序未正常执行释放节点,安全防范
                cancelAcquire(node);
        }
    }
以上代码为AQS阻塞线程及线程被唤醒后执行逻辑。在调用该方法之前线程已经被封装到Node对象并添加到阻塞队列(位于队列尾部tail)。
我们看到第二个if,shouldParkAfterFailedAcquire(p, node)方法通过设置前置节点Node的waitstatus或者清除失效节点来实现至少一次的“自旋”操作。代码如下,在第一次进入该方法时前置节点pred为原来的tail尾节点,此时pred的waitestatus为0或1(超时失效)以至于在该方法中线程需要修改waistatus的值或者清除失效节点,从而实现短暂的“自旋”。即第一次进入shouldParkAfterFailedAcquire(p, node)方法时,第二个if必定被短路而进入下一次循环,而如果此时当前线程节点的前置节点为head节点,线程再一次进入第一个if,此时线程未被挂起,从新尝试去获取一次锁(请求信号量)。如果第二次循环中pred节点不是head节点,或者请求锁失败,线程第二次进入第二个if,此时pred节点waitstatus必定小于0 ,若为-1(Node.SIGNAL)则返回true调用parkAndCheckInterrupt()将线程挂起否则则修改waitsatus的值重新进入循环体。

因此我们可以看到,AQS通过shouldParkAfterFailedAcquire(p, node)方法保证在挂起线程之前至少1次的"自旋"并在"自旋"前后尝试去占有锁(请求信号量)。即AQS的阻塞策略为通过至少1次“自旋“来多次尝试获取锁如果失败则将线程挂起。那么为什么Doug Lea要如此设计呢?
    /**
     * Checks and updates status for a node that failed to acquire.
     * Returns true if thread should block. This is the main signal
     * control in all acquire loops.  Requires that pred == node.prev.
     *
     * @param pred node's predecessor holding status
     * @param node the node
     * @return {@code true} if thread should block
     */
    private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
        int ws = pred.waitStatus;
        if (ws == Node.SIGNAL)
            /*
             * This node has already set status asking a release
             * to signal it, so it can safely park.
             */
            return true;
        if (ws > 0) {
            /*
             * Predecessor was cancelled. Skip over predecessors and
             * indicate retry.
             */
            do {
                node.prev = pred = pred.prev;//清除超时失效节点
            } while (pred.waitStatus > 0);
            pred.next = node;
        } else {
            /*
             * waitStatus must be 0 or PROPAGATE.  Indicate that we
             * need a signal, but don't park yet.  Caller will need to
             * retry to make sure it cannot acquire before parking.
             */
            compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
        }
        return false;
    }

   /**
     * Convenience method to park and then check if interrupted
     *
     * @return {@code true} if interrupted
     */
    private final boolean parkAndCheckInterrupt() {
        LockSupport.park(this);//通过Unsafe类的park方法挂起线程
        return Thread.interrupted();
    }

自旋等待与挂起分析

自旋等待与挂起两种方式的效率高低取决于上下文切换的开销以及成功获取锁之前需要等待的时间,如果等待时间短则适合使用自旋锁,如果等待时间长则适合使用挂起操作。---《Java并发编程实践》

我们都知道挂起和唤醒一个线程将会带来上下文切换的开销,而如果在一个低并发的环境下,线程频繁的挂起和唤醒将消耗大量的系统资源,与CUP密集型程序发生大量的上下文切换,从而增加调度开销降低吞吐量。因此在低并发的环境下(线程等待时间较短)通过自旋等待将可以完全避免上下文切换带来的系统开销。

然而,自旋等待会使阻塞线程与其他线程竞争CUP的时间片,占有cup资源,尽管这种开销是很小的,但是当大量的线程长时间的去竞争CUP的时间片将给操作系统带来毁灭性的灾难。有些事情如果我们不把它推向极端,我们是不会知道有多荒谬。同样的我们只有在一个极端条件下,才能发现多线程长时间自旋等待的危害。

下面程序在一个死循环中不断新建自旋等待线程,当大量的线程通过自旋去竞争CUP时间片时,将导致操作系统运行的程序因为无法获得CUP时间片而使系统出现假死现象。即操作系统完全处于停顿状态。


package CSDN;
/**
 *证明极端条件下自旋等待对服务器性能的影响
 *该程序将导致操作系统出现假死现象,不建议执行
 * @author liyaodongzzz
 */
public class UnsafeThreadOpt {
	public static void main(String args[]){
		while(true){
			new Thread(
				new Runnable(){
					public void run(){
							for(;;);
					}
				}).start();
		}
		
	}
}


总结

综上所述,Doug Lea在设计AQS的线程阻塞策略使用了自旋等待和挂起两种方式,通过挂起线程前的低频自旋保证了AQS阻塞线程上下文切换开销及CUP时间片占用的最优化选择。保证在等待时间短通过自旋去占有锁而不需要挂起,而在等待时间长时将线程挂起。实现锁性能的最大化。


展开阅读全文

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