面试准备 -- 线程池队列LinkedTransferQueue详解

今天解析的这个队列可能比之前学习的队列都要难,知识点涉及到锁自旋,源代码中出现 CAS 操作也会较难理解。

老样子,我们先看看类相关结构:
面试准备 -- 线程池队列LinkedTransferQueue详解_第1张图片
看到 BlockingQueue,条件反射下大致知道有哪些方法了。这里接不过多的介绍了,我们来看看 TransferQueue 接口提供了哪些方法:

//该方法放入元素后,一定要被消费者消费后,线程才释放,否则会一直堵塞
void transfer(E e) throws InterruptedException;
/**
* tryTransfer 和上面的 transfer 方法相比,
* 该方入队元素后,无论是否消费都立即返回
* 如果没有消费者接收元素,则元素不入队,返回的是 false
*/
boolean tryTransfer(E e);
/**
* 该方法加入了时间等待,假设超过时间没有消费者线程接收
* 则元素不会入队,并返回false
*/
boolean tryTransfer(E e, long timeout, TimeUnit unit)
        throws InterruptedException;
 /**
 *  判断是否有等待中的客户端线程
 */       
boolean hasWaitingConsumer();
/**
* 获取等待接收元素的消费者数量
*/
int getWaitingConsumerCount();

大致了解了上述方法的作用后。接下来,我们再看看以下几个重要的字段:

//获取处理器数量,判断是否是多个
private static final boolean MP =
        Runtime.getRuntime().availableProcessors() > 1;
//自旋次数,阻塞前的自旋次数(这里向左偏移,一定是 2 的 n 次方)
private static final int FRONT_SPINS   = 1 << 7;
//自旋次数,一样是为 2 的 n 次方
private static final int CHAINED_SPINS = FRONT_SPINS >>> 1;
//达到该阈值时关闭
static final int SWEEP_THRESHOLD = 32;

了解完几个重要字段,下面我们来看看 Node 内部类:

isData 这个字段是判断是请求节点还是数据节点
item 是我们存储的对象
next 指向下一个节点
waiter 等待的线程

static final class Node {
        final boolean isData;   
        volatile Object item;  
        volatile Node next;
        volatile Thread waiter;

        // CAS 方式将设置当前节点的 next 指向下一个节点
        final boolean casNext(Node cmp, Node val) {
            return UNSAFE.compareAndSwapObject(this, nextOffset, cmp, val);
        }
		//将当前节点的对象设为 val(传入的值对象)
        final boolean casItem(Object cmp, Object val) {
            return UNSAFE.compareAndSwapObject(this, itemOffset, cmp, val);
        }
        //不用说了吧。isData 上面有说
        Node(Object item, boolean isData) {
            UNSAFE.putObject(this, itemOffset, item); // relaxed write
            this.isData = isData;
        }

       //将当前节点 next 指向自己
        final void forgetNext() {
            UNSAFE.putObject(this, nextOffset, this);
        }
        
        //将当前节点的 next 指向自己,并将等待的线程设置为空 
        final void forgetContents() {
            UNSAFE.putObject(this, itemOffset, this);
            UNSAFE.putObject(this, waiterOffset, null);
        }
        
		//判断是否匹配
        final boolean isMatched() {
            Object x = item;
            return (x == this) || ((x == null) == isData);
        }

     	//判断是否为未匹配的请求结点
        final boolean isUnmatchedRequest() {
            return !isData && item == null;
        }
        
		//校验当前节点
        final boolean cannotPrecede(boolean haveData) {
            boolean d = isData;
            Object x;
            return d != haveData && (x = item) != this && (x != null) == d;
        }

    	//尝试匹配数据节点
        final boolean tryMatchData() {
            // assert isData;
            Object x = item;
            if (x != null && x != this && casItem(x, null)) {
                LockSupport.unpark(waiter);
                return true;
            }
            return false;
        }
    }

在看核心方法钱,我们需要知道几个常量,这几个常量决定了入队方式:

private static final int NOW = 0;   // for untimed poll, tryTransfer
private static final int ASYNC = 1; // for offer, put, add
private static final int SYNC = 2; // for transfer, take
private static final int TIMED = 3; // for timed poll, tryTransfer

NOW
表示即时操作,不会对线程造成阻塞。但是有一点需要注意,NOW 常量是 tryTransfer 调用传入的,上面说过 tryTransfer 会可能会出现失败的情况(没有消费线程在等待下会失败),失败的情况下,元素是不会入队的。

ASYNC
表示异步操作,会往队尾加入元素,插入成功后悔返回 true。上面代码的注释也说明了哪些操作是异步的。注意一点:LinkedTransferQueue 是无界队列,属于不可控。

SYNC
表示同步入队,会造成线程阻塞。使用 transfer 方法时,如果没有一个消费线程来消费,那它会一直等待。take 消费线程也同理,只不过 take 操作是队列为空的时候,才会进行阻塞线程。

TIMED
表示 有限制时间的阻塞线程。


下面开始看看我们整个队列的核心方法:
这个方法基本上每个入队,出队操作都是调用该方法,haveData 参数传入 true 或者 false,来判别是入队操作还是出队操作。

private E xfer(E e, boolean haveData, int how, long nanos) {
		//判断传入元素是否为空
        if (haveData && (e == null))
            throw new NullPointerException();
        Node s = null;                        // the node to append, if needed
		
        retry:
        for (;;) {                            // restart on append race
			//判断当前头结点是否为空,为空就跳过,执行下面的操作
            for (Node h = head, p = h; p != null;) { // find & match first node
            	//拿到这个节点,判断属于是数据节点还是请求节点
                boolean isData = p.isData;
                //拿到该节点的元素
                Object item = p.item;
                //匹配的情况下
                if (item != p && (item != null) == isData) { // unmatched
                    if (isData == haveData)   // can't match
                        break;
                    //匹配节点,会从队首一直往后,直到匹配上
                    if (p.casItem(item, e)) { // match
                        for (Node q = p; q != h;) {
                            Node n = q.next;  // update by 2 unless singleton
                            if (head == h && casHead(h, n == null ? q : n)) {
                                h.forgetNext();
                                break;
                            }                 // advance and retry
                            if ((h = head)   == null ||
                                (q = h.next) == null || !q.isMatched())
                                break;        // unless slack < 2
                        }
                        //有阻塞的消费线程
                        LockSupport.unpark(p.waiter);
                        //将元素返回
                        return LinkedTransferQueue.<E>cast(item);
                    }
                }
                Node n = p.next;
                p = (p != n) ? n : (h = head); // Use head if p offlist
            }
			//以下是入队的操作,下面会介绍几个常量
            if (how != NOW) {                 // No matches available
                if (s == null)
                    s = new Node(e, haveData);
                //入队操作
                Node pred = tryAppend(s, haveData);
                //如果入队失败返回空,则重新再一次 CAS
                if (pred == null)
                    continue retry;           // lost race vs opposite mode
                //线程阻塞
                if (how != ASYNC)
                    return awaitMatch(s, pred, e, (how == TIMED), nanos);
            }
            return e; // not waiting
        }
    }

入队操作有 3 种情况。

  1. 队列中没有节点,也就是 head tail 为空,直接返回节点自己
  2. 入队失败,这里没理解透(个人感觉是被其他线程取值后,isData 不匹配就直接返回入队失败),返回空
  3. 执行入队操作,入队成功后发现队列中有多个节点,此时返回该成功入队节点的前置节点
 private Node tryAppend(Node s, boolean haveData) {
 		//
        for (Node t = tail, p = t;;) {        // move p to last node and append
            Node n, u;                        // temps for reads of next & tail
			
			/**
			* 分支一
			* 尾节点为空且头结点也为空的情况下,说明队列为空,就直接入队
			*/
            if (p == null && (p = head) == null) {   
                if (casHead(null, s))
                    return s;                 // initialize
            }
            /**
            * 分支二  
            * 节点连接失败,入队失败 ,返回空重新进行 CAS
            */
            else if (p.cannotPrecede(haveData))      
                return null;                  // lost race vs opposite mode
            /**
            * 分支三
            * 从头遍历到尾
            */
            else if ((n = p.next) != null)   
                p = p != t && t != (u = tail) ? (t = u) : 
                    (p != n) ? n : null;      // restart if off list
            /**
            * 分支四
            * 将节点放入队列中  
            */
            else if (!p.casNext(null, s))	//分支四		
                p = p.next;                   // re-read on CAS failure
            /**
            * 分支五
            * 松弛操作,这个下面讲
            */
            else {								
                if (p != t) {                 // update if slack now >= 2
                    while ((tail != t || !casTail(t, s)) &&
                           (t = tail)   != null &&
                           (s = t.next) != null && // advance and retry
                           (s = s.next) != null && s != t);
                }
                return p;
            }
        }
    }

单单看代码,估计也是一脸懵逼,下面用图来解释 LinkedTransferQueue 中 transfer 方法的入队原理。
队列刚刚开始时:head 和 tail 都指向空。
面试准备 -- 线程池队列LinkedTransferQueue详解_第2张图片
接下来,A 线程执行 transfer 方法要入队。线程 A 进来后,发现队列是为空的,且没有阻塞的消费线程。这时,入队操作,走的是分支一,将元素入队后,将 head 指向新入队的节点,最后返回自身节点。操作后如下图:
面试准备 -- 线程池队列LinkedTransferQueue详解_第3张图片
看完上图,可能有部分疑惑,怎么会线程阻塞了?这个问题下面在进行讲解。
接下来,我们继续进行入队操作:
这时候线程 B 调用 transfer 方法进行入队。最后,线程会走分支四,将元素入队到队尾,接下来循环走到分支五,将 tail 指向插入的节点。
假设无法连接到前置,那表示入队失败,返回空。执行结果结果如下:面试准备 -- 线程池队列LinkedTransferQueue详解_第4张图片
线程 C 在入队,方式和上述一致,就不分析了:
面试准备 -- 线程池队列LinkedTransferQueue详解_第5张图片
注:上述 tail 没有更新,这属于一种 松弛 的方式,我们知道 LinkedTransferQueue 队列是无锁队列,使用 CAS 来进行入队、出队操作,目的是为了节省 CAS 操作的开销。假设保证强的 tail 位置一致,那无锁队列可能还不如直接加个锁更快。这是一个设计上的权衡吧。

介绍完入队,我们来看看出队的操作。下面用图来解释 LinkedTransferQueue 中 take 方法的出队原理。队列初始状态如下图:
面试准备 -- 线程池队列LinkedTransferQueue详解_第6张图片
线程 D 是消费线程,来从队列中取元素。下面代码便是从队列中取出元素的实现:
面试准备 -- 线程池队列LinkedTransferQueue详解_第7张图片
认真看一下,将队首节点的 item 设置为 null,并唤醒阻塞的线程 A (线程 A 在队首),然后返回设置在头节点的值对象。


紧接着,线程 A 被唤醒后,会继续执行。因为唤醒线程 A 前将 item 值置 null 了,所以返回的值为空。之后调用 forgetContents() 方法,将 item 指向自己。下面我们来看看一个方法 awaitMatch:

 private E awaitMatch(Node s, Node pred, E e, boolean timed, long nanos) {
        final long deadline = timed ? System.nanoTime() + nanos : 0L;
        Thread w = Thread.currentThread();
        int spins = -1; // initialized after first item and cancel checks
        ThreadLocalRandom randomYields = null; // bound if needed

        for (;;) {
        //只看着一点,如果对自旋有兴趣可以自行研究
            Object item = s.item;
            //上面图解,item被置空,所以肯定不相等
            if (item != e) {                  // matched
                // assert item != s;
                s.forgetContents();           // avoid garbage
                return LinkedTransferQueue.<E>cast(item);
            }
            if ((w.isInterrupted() || (timed && nanos <= 0)) &&
                    s.casItem(e, s)) {        // cancel
                unsplice(pred, s);
                return e;
            }

            if (spins < 0) {                  // establish spins at/near front
                if ((spins = spinsFor(pred, s.isData)) > 0)
                    randomYields = ThreadLocalRandom.current();
            }
            else if (spins > 0) {             // spin
                --spins;
                if (randomYields.nextInt(CHAINED_SPINS) == 0)
                    Thread.yield();           // occasionally yield
            }
            else if (s.waiter == null) {
                s.waiter = w;                 // request unpark then recheck
            }
            else if (timed) {
                nanos = deadline - System.nanoTime();
                if (nanos > 0L)
                    LockSupport.parkNanos(this, nanos);
            }
            else {
                LockSupport.park(this);
            }
        }
    }

从如上代码可以看出,这个一个经典的 “锁优化” 的实现方式,自旋 -> yield -> 阻塞,我们的线程不会立即进入阻塞状态,而是要经过自旋一定次数后依旧没被消费才会进行线程阻塞。
所以,从队列中取出队首会经历如下几个过程:
将值置为 null。
面试准备 -- 线程池队列LinkedTransferQueue详解_第8张图片
将值设为自己本身:
面试准备 -- 线程池队列LinkedTransferQueue详解_第9张图片
接下来我们再次调用 take 方法时:
面试准备 -- 线程池队列LinkedTransferQueue详解_第10张图片
根据上图,我们可以看到, 原来的队首节点将的 next 自己指向自己,head 指向第三个节点,一次性跳了两个节点。 这就是 “松弛” 策略。上图线程被唤醒后进行的一系列操作和原来相同,不继续介绍,直接到最后结构:
面试准备 -- 线程池队列LinkedTransferQueue详解_第11张图片
可以看到 tail 还是指向那个自身的节点,没有切换。下面在来一条线程 G:
面试准备 -- 线程池队列LinkedTransferQueue详解_第12张图片
线程 G 也是消费线程,入队后发现队列空了,只好线程阻塞,等下入队。不过这里有一点注意:因为是有阻塞的消费线程,这时如果入队线程一来,会直接将元素交给等待的消费线程,而不是入队后,在唤醒消费线程取消费。

总结:
LinkedTransferQueue 因为是 1.7 才出的,集合了前面几个队列的优秀的地方,设计上感觉很别扭。并且极难理解。

碍于个人能力有限,如有错误希望大家能够指正,谢谢~!

如有兴趣,可以关注公众号一起学习~!
面试准备 -- 线程池队列LinkedTransferQueue详解_第13张图片

你可能感兴趣的:(面试准备)