从FutureTask源码看“自旋锁”与CAS思想的应用

3 篇文章 0 订阅

“自旋锁”和CAS是什么

自旋锁

自旋锁是为高效并发而产生的一种锁优化的思想,要实现线程互斥同步往往需要对某些线程进行阻塞,而线程的挂起和恢复都要转入到内核态去完成。作为Java程序员,笔者也不太懂什么是内核态,但是需要知道的是线程的挂起和恢复是一种重量级的操作,也就是比较消耗CPU性能。
如果共享数据的锁定时间很短,那么为了这很短的时间去进行挂起和恢复线程这个重量级操作就划不来。对于多核CPU来说,可以多个线程并行,让需要请求锁的线程“稍等一下”比直接阻塞要好得多,这样线程大概率可以在等待中获取其他线程释放的锁,就不用放弃CPU的执行时间。
于是产生了自旋锁,自旋锁就是为了让线程等待获取锁而让线程去执行一个有限的循环,这种循环又叫做线程的自旋。

CAS

这个网上很多资料,大家自己百度吧

FutureTask源码与实现原理

要知道FutureTask源码中如何应用了“自旋锁”的思想,首先需要对FutureTask的源码和实现原理有一定的了解。

FutureTask实现了RunnableFuture接口

在Thread类的源码中,Thread只能接受Runnable类型的示例并执行Runnable的run( )方法,带有返回值的Callable接口类型的实例并不能直接被Thread接受并执行。
因此,如果想让Callable的call( )方法被线程Thread执行并获取到返回结果,只能将Callable类型的对象作为一个属性封装在某类里面,并且这个类需要实现Runnable接口,然后在其Runnable接口的run( )方法里面再间接的调用Callable的call( )方法最后得到返回结果,这个类就是Java里面的FutureTask。

FutureTask实现了RunnableFuture接口

public class FutureTask<V> implements RunnableFuture<V> {
           /*代码省略*/
}

RunnableFuture接口就是Runnable和Future的结合体,Runnable提供了让线程执行的实际任务,Future提供了获取任务执行结果等各种方法。

public interface RunnableFuture<V> extends Runnable, Future<V> {
    void run();
}

FutureTask的属性和作用

1、上文说到,FutureTask封装了一个Callable类型的属性,以便在自身的run( )方法中执行Callable对象的call( )方法。
2、定义了一个属性outcome标记返回结果,正常情况下,outcome保存的是任务的返回结果,不正常情况下,outcome保存的是任务抛出的异常。
3、为了能获取线程执行的状态,FutureTask中定义了一些final型整数来标记线程执行的状态,执行状态包括新建、即将结束、正常结束、异常、取消等。
4、因为可能会有多个线程去调用FutureTask的get( )方法来获取线程的执行结果,但是因为线程没有执行结束导致多个线程被阻塞。FutureTask为此定义了一个内部类WaitNode来封装等待的线程,并且WaitNode里面包含指向指向下一个等待线程的指针,多个WaitNode构成一个栈结构的数据。
5、同时也有waiters属性指向栈顶的节点,即栈顶指针。

    //表示当前任务的状态
    private volatile int state;
    //表示当前任务的状态是新创建的,尚未执行
    private static final int NEW          = 0;
    //表示当前任务即将结束,即线程已经执行完任务,但是还未把返回结果赋值给outcome时的状态
    private static final int COMPLETING   = 1;
    //表示当前任务正常结束
    private static final int NORMAL       = 2;
    //表示当前任务执行过程中出现了异常,内部封装的callable.call()向上抛出异常了
    private static final int EXCEPTIONAL  = 3;
    //表示当前任务被取消
    private static final int CANCELLED    = 4;
    //表示当前任务中断中
    private static final int INTERRUPTING = 5;
    //表示当前任务已中断
    private static final int INTERRUPTED  = 6;

    /** The underlying callable; nulled out after running */
    private Callable<V> callable;

    // 正常情况下,outcome保存的是任务的返回结果
    // 不正常情况下,outcome保存的是任务抛出的异常
    private Object outcome;

    //用来执行此任务的线程
    private volatile Thread runner;

    //因为会有很多线程去get结果,这里把线程封装成WaitNode,一种数据结构:栈,头插头取
    //这里的waiters指向Treiber stack的栈顶元素
    private volatile WaitNode waiters;

    //用来封装等待获取返回结果线程的WaitNode节点
    static final class WaitNode {
        //等待获取结果的线程
        volatile Thread thread;
        //指向下一个节点的指针
        volatile WaitNode next;
        WaitNode() { thread = Thread.currentThread(); }
    }

执行任务的run( )方法

FutureTask的run( )方法的目的就是执行Callable的call( )方法并且得到返回结果,需要注意的是FutureTask为了防止任务同时被多个线程执行,采用了CAS机制来解决实现线程安全。即FutureTask中用来表示执行线程的属性runner在任务执行之前为null,在任务执行时会通过CAS来赋值,CAS的期望值为null,如果发现runner的实际值不是null则表示任务已经被其他线程执行。

    public void run() {
        //在任务被线程执行之前,任务的状态是new,并且表示执行任务的线程的属性runner是null
        //当前任务状态不为new或者runner的值不为null,说明已经有线程执行任务了,直接返回
        //如果任务没有被执行的话,将runner的值设为当前执行的线程
        //compareAndSwapObject有交换功能,会直接将runnerOffset位置的null置换成实际thread对象
        if (state != NEW ||
                !UNSAFE.compareAndSwapObject(this, runnerOffset,
                        null, Thread.currentThread()))
            return;
        try {
            Callable<V> c = callable;
            // 当任务不为null并且当前任务状态为新建时才会往下执行
            // 条件1:防止空指针异常
            // 条件2:防止外部线程cacle掉当前任务,此处有疑问
            if (c != null && state == NEW) {
                //存储返回结果
                V result;
                //存储执行是否成功
                boolean ran;
                try {
                    //调用Callable对象的call( )方法
                    result = c.call();
                    ran = true;
                } catch (Throwable ex) {
                    //如果发生异常,现在的执行结果为null
                    result = null;
                    ran = false;
                    //设置异常
                    setException(ex);
                }
                if (ran)
                    //设置任务执行结果,并且将因获取执行结果而
                    //阻塞的线程唤醒,源码下文分析。
                    set(result);
            }
        } finally {
            //任务执行结束,将执行线程设为null,以便此任务可以被再次执行
            runner = null;
            int s = state;
            if (s >= INTERRUPTING)
                handlePossibleCancellationInterrupt(s);
        }
    }

获取执行结果的get( )方法

当有线程调用get( )方法来获取执行结果时,如果任务执行结束,则可以直接返回执行结果。如果任务还没有结束,则会将线程阻塞,直到任务结束为止,将线程阻塞方法为awaitDone(boolean timed, long nanos)。当多个线程因为调用get( )方法被阻塞时,会被封装成WaitNode节点,然后将节点放入栈中并重置栈顶指针。

get( )方法的源码:

    //get()方法获取的是任务执行完后返回的结果。对于空参的get()方法来说,
    //如果任务还没有执行完就有线程调用get()方法获取结果,
    //则该线程会陷入阻塞,阻塞的具体方法是awaitDone方法
    public V get() throws InterruptedException, ExecutionException {
        //检查任务的状态,
        int s = state;
        //如果成立说明当前任务的状态要么为新建状态要么为临界状态
        //说明没有执行完成,获取结果的线程需要等待
        if (s <= COMPLETING)
            //调用awaitDone方法自旋或阻塞等待任务完成
            s = awaitDone(false, 0L);
        //走到这里,说明任务已经结束,直接返回任务结果
        return report(s);
    }
    //任务执行结束用来封装执行结果的report(int s)方法
    private V report(int s) throws ExecutionException {
        Object x = outcome;
        //如果任务是正常结束,返回执行结果outcome
        if (s == NORMAL)
            return (V)x;
        //如果任务是取消或者被中断,抛出异常
        if (s >= CANCELLED)
            throw new CancellationException();
        throw new ExecutionException((Throwable)x);
    }

等待执行结果的awaitDone(boolean timed, long nanos)方法

awaitDone方法开始会再次检查任务的状态,如果任务没有完成并且也不是COMPLETING(即将结束)状态,就会创建一个WaitNode节点封装线程放入栈中,并且将线程阻塞。如果任务的状态是COMPLETING(即将结束),则让当前线程让出CPU,等线程重新执行时任务可能就完成了。

这里有个线程安全问题,如果执行任务的线程是A,调用get()方法获取任务结果的线程是B,B线程因为任务没有结束需要等待,q是封装线程B的节点,则线程B的入栈有3步:
(1)入栈节点中下一个节点的指针指向原栈顶节点(q.next = waiters);
(2)栈顶指针重定向为入栈节点(waiters值置为q,q正式入栈);
(3)线程阻塞(LockSupport.park(当前线程))。

因为线程B阻塞之后需要执行任务的线程A在任务结束后将其唤醒,如果B线程执行完(1)或(2)后因为时间片用完而被挂起。然后A线程此时将任务执行结束,并且A线程也随之结束了。那么B线程重新获得时间片后,完成第(3)步进入阻塞就无法被唤醒,于是成了僵尸线程。

步骤(2)和(3)之间的线程安全是在finishCompletion()方法来实现的,执行任务的A线程在开始唤醒栈中的阻塞线程之前会通过CAS检查栈顶指针有没有变化进而判断有没有新线程入栈,如果没有新线程入栈会将栈顶指针通过CAS置为null,然后开始唤醒工作。如果发现栈顶指针发生变化了,则说明有新的线程入栈阻塞,则另行处理。这个在下文的finishCompletion()方法中会具体分析。

步骤(1)和(2)之间的线程安全也是通过CAS来实现的,实现机制就在本方法内。在将当前节点q与原栈顶元素相连(q.next = waiters)之后,重置栈顶指针之前,会使用CAS机制比较现在的实际栈顶指针是不是waiters原先的值,如果是的话就说明栈顶元素在执行q.next = waiters之后没有发生改变,就可以将栈顶指针设为当前节点q,最后阻塞线程;如果发现栈顶指针waiters的实际值发生了改变,说明执行任务的线程A在任务结束时将栈顶指针置为null了(详见finishCompletion()方法),此时就需要重新检查任务状态。

    //当有其他线程调用get()方法获取任务执行结果,但是任务状态处于尚未运行
    //结束时,调用awaitDone方法让线程自旋或阻塞
    //timed:阻塞是否有超时时间,nanos超时时间的值
    
    private int awaitDone(boolean timed, long nanos)
            throws InterruptedException {
        //计算超时到达的时间点,如果没有超时时间限制,则时间点记为0
        final long deadline = timed ? System.nanoTime() + nanos : 0L;
        WaitNode q = null;
        //标记线程是否入栈
        boolean queued = false;
        for (;;) {
            //如果线程被中断了,则移除waiter,并抛出InterruptedException
            if (Thread.interrupted()) {
                //移除等待节点
                removeWaiter(q);
                throw new InterruptedException();
            }
            //获取任务状态
            int s = state;
            //任务已经结束了(正常完成、抛出异常、被取消)则返回state
            if (s > COMPLETING) {
                if (q != null)
                    q.thread = null;
                return s;
            }
            //如果任务即将完成,让其他线程先执行
            else if (s == COMPLETING) // cannot time out yet
                Thread.yield();
            //如果任务没有完成,也不处于即将完成的中将状态COMPLETING,则进入自旋
            //第一次循环,如果当前WitNode为null,new一个WaitNode结点
            else if (q == null)
                q = new WaitNode();
            //第二次循环,如果当前WaitNode节点没有入队,则尝试入队
             /*
                * 下面的代码有3步:
                * 1、将当前节点q与原栈顶元素相连(q.next = waiters)
                * 2、比较现在的实际栈顶元素是不是q.next
                * 3、阻塞线程或重新检查任务状态;
                *
                * 在第2步中,如果实际栈顶元素是q.next就说明栈顶元素在执行q.next = waiters
                * 之后没有发生改变,就可以进行阻塞线程;如果发现实际的栈顶元素已经不是
                * q.next,说明栈顶元素在q.next = waiters之后发生了改变,是因为执行任务
                * 的线程在任务结束后开始唤醒阻塞线程前重置了栈顶指针为null,此时就需要重
                * 新检查任务状态了。
                 * */
            else if (!queued)
                queued = UNSAFE.compareAndSwapObject(this, waitersOffset,
                        q.next = waiters, q);
            //第三次循环,根是否有超时时间来挂起线程
            else if (timed) {
                //计算当前时间离超时时间点还有的时间差
                nanos = deadline - System.nanoTime();
                //超出了指定时间,就移除当前节点并返回任务状态
                if (nanos <= 0L) {
                    removeWaiter(q);
                    return state;
                }
                //挂起线程
                LockSupport.parkNanos(this, nanos);
            }
            else
                //挂起线程
                LockSupport.park(this);
        }
    }

任务完成后唤醒等待结果的线程

在执行任务的run( )方法中,任务执行完成以后会使用set(V v)方法来处理执行的结果。set(V v)方法不仅将执行结果赋值给outcome属性,还调用了finishCompletion()来对因等待任务执行结果而阻塞的所有线程进行唤醒。

这里有个线程安全问题需要解决,如果执行任务的线程是A,调用get()方法获取任务结果的线程是B:
(1)B线程调用了get()方法,并且检查到任务没有完成需要阻塞等待,但是还没有入栈,也没有执行阻塞方法,B线程就因为时间片结束而挂起;
(2)此时任务在A线程中执行完成,并且A线程执行完任务就去唤醒了栈中已经阻塞了的线程,B线程因为没有入栈所以没有得到唤醒,A线程随后结束;
(3)B线程又获得时间片开始执行,然后执行入栈和阻塞操作,进入阻塞状态。

如果发生以上的情况,因为执行任务的线程已经结束,不能再唤醒B线程,B线程将被永久阻塞,成为僵尸线程。

为了解决这个问题,A线程在执行完任务之后,开始唤醒栈中的线程之前,会使用CAS机制将栈顶指针置为null,如果CAS失败,则说明栈顶指针改变了,即有新的线程因为要阻塞而入栈,然后开始用新的栈顶来遍历栈元素和逐一唤醒阻塞线程。
如果CAS成功,说明栈顶指针在A线程开始进行唤醒工作之前没有变化,即没有新的线程入栈被阻塞,就可以开始进行唤醒工作了。

其实还有一个问题不知道读者有没有想到:
(1)B线程阻塞前重置栈顶元素(就是已经入栈了)然后时间片耗尽被挂起,但是还没有执行阻塞方法;
(2)A线程执行完任务开始唤醒阻塞线程,将栈中的阻塞的线程全部唤醒一次,也包括了已经入栈但是没有进入阻塞状态,只是因为时间片耗尽被挂起的B线程。A线程随后结束;
(3)B线程重新获得时间片,然后执行阻塞方法。

在这种情况下,B线程会不会因为先唤醒再阻塞而被阻塞住,此后也没有被唤醒的机会?

其实是不会的,这与LockSupport控制线程阻塞与唤醒的原理有关系:
LockSupport类使用了一种名为permit(许可)的概念来做到阻塞和唤醒线程的功能,每个线程都有一个permit,permit只有两个值0和1,默认是0。
调用park时,如果permit是0,则被阻塞;如果是1,则通过并且消耗掉permit,即permit置为0。调用unpark时,permit会置为1,unpark多次连续调用和调用一次是一样的效果,即不会累加。
因此LockSupport可以对一个线程进行先进行一次唤醒,然后让线程免于一次阻塞。

set(V v)方法源码:

    //设置任务的状态为EXCEPTIONAL(表示因为出现异常而非正常完成)
    //设置outcome(返回结果)为Callable对象的run()方法抛出的异常
    //执行finishCompletion()方法唤醒因为调用get()方法而陷入阻塞的线程。
    protected void setException(Throwable t) {
        if (UNSAFE.compareAndSwapInt(this, stateOffset, NEW, COMPLETING)) {
            outcome = t;
            UNSAFE.putOrderedInt(this, stateOffset, EXCEPTIONAL); // final state
            finishCompletion();
        }
    }

finishCompletion()方法源码:

      /*
    * 任务执行完成(正常结束和非正常结束都代表任务执行完成)会调用这个方法来唤醒.
    * 所有因调用get()方法而陷入阻塞的线程,同时将callable置为null。
    * */
    private void finishCompletion() {
        //开始唤醒之前,先获取栈顶指针,以便从栈顶指针开始遍历栈元素
        //如果将栈顶指针置为null失败,则说明有新的线程因阻塞而入栈,此时重新获取栈顶的节点进行重试
        //所以这里使用了for循环
        for (WaitNode q; (q = waiters) != null;) {
            /*
            * 这个方法的意义是:先比较栈顶指针的实际值还是不是q,如果是的话将栈顶指针置为null,然后开始逐一唤醒。
            * 如果栈顶指针不是q的话,说明栈顶指针改变了,即有新的线程因为阻塞入栈,此时不能从旧的栈顶开始唤醒。
            * */
            if (UNSAFE.compareAndSwapObject(this, waitersOffset, q, null)) {
                //遍历栈中的元素,唤醒所有阻塞的线程
                for (;;) {
                    Thread t = q.thread;
                    if (t != null) {
                        q.thread = null;
                        LockSupport.unpark(t);
                    }
                    //循环获取栈中的下一个线程
                    WaitNode next = q.next;
                    //当栈中已经没有下一个线程
                    if (next == null)
                        break;
                    //已经唤醒的线程出栈
                    q.next = null;
                    q = next;
                }
                break;
            }
        }
        done();
        //callable置为null
        callable = null;        // to reduce footprint
    }

总结FutureTask源码中的“自旋锁”思想

在等待任务执行结果的awaitDone方法中,线程在进入阻塞之前,经历了一波三折,即使在调用awaitDone方法之前,已经判断过一次任务没有执行结束。
身为菜鸟程序员的我,在第一次阅读awaitDone方法的源码时,感觉有些代码都是冗余的,比如线程进入阻塞栈时,进行了3步操作
1、创建WaitNode节点用来封装阻塞的线程;
2、判断线程是否入栈,没有的话进行入栈;
3、使用LockSupport工具类阻塞线程;
在FutureTask源码中,这3步每执行一步都要进行一次循环,并且在循环开始时重新检查任务状态:

        for (;;) {
             /*省略部分代码*/
            //获取任务状态
            int s = state;
            //任务已经结束了(正常完成、抛出异常、被取消)则返回state
            if (s > COMPLETING) {
                if (q != null)
                    q.thread = null;
                return s;
            }
            //如果任务即将完成,让其他线程先执行
            else if (s == COMPLETING)
                Thread.yield();
            //如果任务没有完成,也不处于即将完成的中将状态COMPLETING,则进入自旋
            //第一次循环,如果当前WitNode为null,创建一个WaitNode结点
            else if (q == null)
                q = new WaitNode();
            //第二次循环,如果当前WaitNode节点没有入队,则尝试入队
            else if (!queued)
                queued = UNSAFE.compareAndSwapObject(this, waitersOffset,
                        q.next = waiters, q);
            //第三次循环,根据是否有超时时间来挂起线程
            else if (timed) {
                //计算当前时间离超时时间点还有的时间差
                nanos = deadline - System.nanoTime();
                //超出了指定时间,就移除当前节点并返回任务状态
                if (nanos <= 0L) {
                    removeWaiter(q);
                    return state;
                }
                //挂起线程
                LockSupport.parkNanos(this, nanos);
            }
            else
                //挂起线程
                LockSupport.park(this);
        }

如果我写代码的话,肯定将这3个步骤放在一起,因为在我看来可以提高程序执行的效率:

        for (;;) {
              /*省略部分代码*/
             
             //创建节点
            if (q == null)
                q = new MyFutureTask.WaitNode();
            //节点入栈
            if (!queued)
                queued = UNSAFE.compareAndSwapObject(this, waitersOffset,
                        q.next = waiters, q);
            //根据是否有超时时间来阻塞线程
            if (timed) {
                //计算当前时间离超时时间点还有的时间差
                nanos = deadline - System.nanoTime();
                //超出了指定时间,就移除当前节点并返回任务状态
                if (nanos <= 0L) {
                    removeWaiter(q);
                    return state;
                }
                //挂起线程
                LockSupport.parkNanos(this, nanos);
            } else
                //挂起线程
                LockSupport.park(this);
        }

咋看,经过我改编后的代码要比FutureTask的JDK源码运行效率要高,因为省去了三次无意义的循环。
可事实上不是这样的,因为线程在进入阻塞之前的三次循环不是无意义的,而是为了等待任务执行结束而进行的自旋,也就是应用了自旋锁的思想。因为线程的阻塞和重启是比较消耗性能的重量级操作,所以JDK源码中尽量做到能不阻塞线程就不阻塞,故意让线程多进行了三次循环(自旋),为任务执行结束而“稍等一下”,如果三次自旋还等不来任务完成,再阻塞线程。

这也是为什么将任务状态细分为COMPLETING(即将结束)和NORMAL(正常结束)的原因,如果任务已经执行完成,但是还没有给返回结果赋值,就将任务状态设为COMPLETING,好让需要获取执行结果的线程知道任务已经快要结束了,可以使用Thread.yield()暂时让出CPU时间片,稍等片刻后就能直接获取到结果而不用阻塞。

  • 13
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值