FutureTask原理介绍——简单实现

189 阅读13分钟

简单实现

从上述JoJoRunnable的实现可以看到,内部封装了一个Callable target和一个Object resultresult用于接收call()方法的返回值,在抛出异常时,它就是一个Exception,因此使用Object类型。如果对result提供一个get()方法,就可以拿到任务执行完之后的返回值。具体如下:

public class JoJoRunnable<V> implements Runnable {

    private Callable<V> target;

    private Object result;

    public JoJoRunnable(Callable callable) {
        target = callable;
    }

    @Override
    public void run() {
        try {
            result = target.call();
        } catch (Exception e) {
            result = e;
        }
    }
    
    public V get() throws ExcutionException {
        // result 是一个异常,则抛出异常
        if (result instanceof Exception) {
            throw new ExcutionException(result);
        } 
        // 否则返回
        return (V)result;
    }
    
    // 测试函数
    public static void main(String[] args) {
       JoJoRunnable runnable = new JoJoRunnable(() -> {
           System.out.println("call");
           return "str";
       });
       
       runnable.run(); // 打印"call"
       
       // 调用get()方法获取结果
       try {
           String result = runnable.get(); // "str"
       } catch(Exception e) {
           // 执行抛出异常
       }
    }
}

以上代码就是FutureTask的最基本实现,实现了任务的执行和结果获取方法。测试函数展示了其用法,main线程先调用run()执行任务,再调用get()方法获取结果。因为是同步调用,所以最终能正常拿到结果。

如果runnable.run()是放在子线程里调用呢?

public class JoJoRunnableTest {
    public static void main(String[] args) {
       JoJoRunnable runnable = new JoJoRunnable(() -> {
           System.out.println("call");
           return "str";
       });
       new Thread(runnable).start(); // 1 打印"call"
       try {
           String result = runnable.get();// 2 获取结果
       } catch (Exception e) {
           // 抛出异常
       }
    }
}

这里就要看线程调度的情况了。

第一种情况:如果子线程先执行了start(),主线程再执行get(),则结果与之前保持一致;

第二种情况:若主线程先执行了get(),此时result为空,然后子线程再执行start(),那么主线程是拿不到执行结果的。针对这种情况,我们需要用一种方式,让主线程调用get()方法获取结果时,若子线程任务没有执行完,等待其执行完毕。如何让主线程等待呢? 这里又可以引出相关八股了:

  • 自旋等待;
  • 阻塞等待(放弃cpu)。阻塞等待有两种方式,一个是调用Thread.sleep()睡几秒再起来;另一种就是等待/唤醒方式。

这里放一个线程Blocked和Waiting状态的区别,Blocked状态只会在使用synchronize拿锁失败时才会出现,一般情况下阻塞等待,线程都是进入Waiting状态。

image.png

由于任务执行一般都是一个很耗时的操作,自旋等待会无用的消耗大量cpu资源,因此选择Waiting等的方式,让线程放弃cpu。FutureTask本身采用的是Waiting等待方式,获取结果时,若任务没有执行完则进入Waiting状态,执行完后则由子线程将等待的线程唤醒。下面对该方式继续分析:

那么,如何判断一个任务是否执行完?这里很显然还需要一个类似于boolean isFinished这样的字段来进行标识。

这里,先约定几个概念:

  1. 执行线程:即执行任务的线程;
  2. 等待线程:即等待任务执行完后,获取结果的线程;这个线程既可以是执行线程本身,也可以是其他线程;另外,等待线程可以有多个。执行线程与等待线程是一对多的关系。
  3. 同步标识:即boolean isFinished字段,等待线程和执行线程通过对该字段的修改和查看来进行通信。从并发的角度来看,这是一个线程共享变量,对其读写通常需要加锁。

如:多个跑步员(等待线程)等着裁判员(执行线程)开枪(同步标识)起跑。

基于以上分析,我们尝试使用volatileObject.wait()Object.notify()synchronized等java特性来进一步实现。

版本1

public class MyFutureTask<V> implements Runnable {

    private Callable<V> target;

    private Object result;

    // 标识任务是否完成,临界资源
    private volatile boolean isFinished;

    // 临界资源上锁
    private static Object lock = new Object();

    public MyFutureTask(Callable callable) {
        target = callable;
        isFinished = false;
    }

    @Override
    public void run() {
        try {
            result = target.call();
        } catch (Exception e) {
            result = e;
        }
        // 访问临界资源
        synchronized (lock) {
            isFinished = true;
            // 唤醒所有的等待线程
            lock.notifyAll();
        }
    }

    public V get() throws ExecutionException {
        if (!isFinished) {
            synchronized (lock) {
                while (!isFinished) {
                    try {
                        lock.wait();
                    } catch (InterruptedException e) {
                        // 被中断,此处暂时忽略
                    }
                }
            }
        }
        // result 是一个异常,则抛出异常
        if (result instanceof Exception) {
            throw new ExecutionException(result);
        }
        // 否则返回
        return (V)result;
    }
}

测试代码如下:

public class MyFutureTaskTest {
    public static void main(String[] args) throws ExecutionException {
        MyFutureTask runnable = new MyFutureTask(() -> {
            System.out.println("执行线程启动...");
            Thread.sleep(3000); //模拟执行3秒
            System.out.println("执行线程执行完毕...");
            return "str";
        });
        new Thread(runnable).start();

        // 等待线程-某个等待线程
        new Thread(() -> {
            try {
                System.out.println("等待线程"+Thread.currentThread().getName()+"等待结果");
                String result = (String) runnable.get();
                System.out.println("等待线程"+Thread.currentThread().getName()+"结果为"+result);
            } catch (ExecutionException e) {
                //
            }
        }).start();

        // 等待线程-主线程
        System.out.println("等待线程"+Thread.currentThread().getName()+"等待结果");
        String result = (String) runnable.get();
        System.out.println("等待线程"+Thread.currentThread().getName()+"结果为"+result);

    }
}

运行结果

image.png

思考3🤔:

这里访问isFinished字段时加锁,存在很大的性能问题。从当前场景来看,只有执行线程会写,只有等待线程会读(后续会增加等待线程写操作(取消))。加锁就是对了将多步封装为一次原子操作,而读和写都是原子操作,对该字段的读写在volatile的加持下,根本不需要加锁。

从上面代码来看,synchronizedObject.wait()/notify()包含了两层含义:一是访问并发资源时加锁,没获取到锁的线程进行锁等待,二是判断任务是否已完成,未完成则等待线程释放锁进行资源等待。这里分别对应的是锁池资源池

相关概念参考:blog.csdn.net/meism5/arti…

根据以上分析,去掉加解锁流程后,剩下资源等待。由于Object.wait()/notify()方法必须配合synchronize来使用,需要更换方法。

用什么呢?再次明确一下需求,在isFinishedfalse时,等待线程放弃cpu,进入waiting状态,当执行线程执行完毕,将其设置为true之后,唤醒等待线程。这里可以使用CountDownLatch来实现类似0-1信号量的功能。

版本2

class MyFutureTask2<V> implements Runnable {

    private Callable<V> target;

    private Object result;

    // 标识任务是否完成,临界资源
    private volatile boolean isFinished;

    // 同步工具
    private CountDownLatch countDownLatch;

    public MyFutureTask2(Callable callable) {
        target = callable;
        isFinished = false;
        countDownLatch =  new CountDownLatch(1);// 计数器为1
    }

    @Override
    public void run() {
        try {
            result = target.call();
        } catch (Exception e) {
            result = e;
        }
        // 访问临界资源,这里由于是原子操作,不需要锁的保护
        isFinished = true;
        // 唤醒所有等待线程,因为计数器为1
        countDownLatch.countDown();
    }

    public V get() throws ExecutionException, InterruptedException {
        while (!isFinished) {
            countDownLatch.await();
        }

        // result 是一个异常,则抛出异常
        if (result instanceof Exception) {
            throw new ExecutionException(result);
        }
        // 否则返回
        return (V)result;
    }
}

测试过程与运行结果与前面保持一致。

CountDownLatch也是1.5版本引入的,这里有两个问题:

  1. 虽然这个类可以实现上述功能,但这个类本身是怎么做到的呢?await()countDown()的背后到底做了什么?
  2. FutureTask也是用的这个类来实现其功能的吗?

通过阅读源码,这两个类不约而同的使用了同一个类LockSupport:一个工具类(不能被实例化,里面都是static方法)。不过这个类的注释上没有描述作者和版本。其实还是doug Lea和1.5版本。

image.png

再多看两眼源码,发现这个类也就是个傀儡,真正的起作用的类是UnSafeimage.png

看到Unsafe类不在juc包中,结果gpt一问,还是doug Lea。。汗流浃背了!

不扯远了,继续。

LockSupport提供了两个基本的线程等待/唤醒功能,并且不需要与锁绑定。

class LockSupport {
    /**
     * 1. 线程进入等待状态(WAITING/TIMED_WATING)
     * 2. 当interrupt时,线程会响应中断
     */
    public static void park() {
        UNSAFE.park(false, 0L);
    }
    
    // 入参为等待唤醒的线程
    public static void unpark(Thread thread) {
        if (thread != null)
            UNSAFE.unpark(thread);
    }
}

测试代码:

public class LockSupportTest {
    public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread(() -> {
            System.out.printf("thread %s start, state is %s, interrupt state is %s\n", Thread.currentThread().getName(), Thread.currentThread()
            .getState(), Thread.currentThread().isInterrupted());
            LockSupport.park();
            System.out.printf("thread %s wakeup, state is %s, interrupt state is %s", Thread.currentThread().getName(), Thread.currentThread()
                    .getState(), Thread.currentThread().isInterrupted());
        });
        t.start();
        
        Thread.sleep(1000);
        // 中断线程
        t.interrupt();
    }
    // 输出
    // thread Thread-0 start state is RUNNABLE, interrupt state is false
    // thread Thread-0 unparked state is RUNNABLE, interrupt state is true
}

可以看到,LockSupportObject#notifyAll()Semapher#countDown()的一个最大不同时,后两者可以唤醒所有等待中的线程,而前者只能唤醒指定的线程,而且需要通过入参来指定。

那么,当执行线程的任务完成之后,它如何获取到所有的等待线程呢?这里就需要设计一个等待队列出来,等待队列作为FutureTask的成员变量,执行线程任务完成后,遍历这个队列,依次将等待线程唤醒。有几点需要注意:

  1. 等待队列又是一个临界资源,通过分析,等待线程可能会并发往队列里面添加节点,存在写写并发
  2. 执行线程会去读这个临界资源,但由于它只会在任务完成之后才去读,此时该队列不会再新增节点,因此不存在读写并发
  3. 虽然存在写写并发,但是入队是一个很快的时间,当产生并发冲突时,通过while+cas(即自旋锁) 方式解决,没必要让线程阻塞再唤醒。

版本3

class MyFutureTaskV3<V> implements Runnable {

    private Callable<V> target;

    private Object result;

    // 标识任务是否完成,临界资源
    private volatile boolean isFinished;

    // 等待链表,头插法,临界资源
    private volatile WaitNode waiters;

    public MyFutureTaskV3(Callable callable) {
        target = callable;
        isFinished = false;
        waiters = null;
    }

    /**
     * 等待节点,执行线程唤醒节点中的线程
     */
    private static class WaitNode {
        private Thread thread;
        private WaitNode next;
        public WaitNode() {
            thread = Thread.currentThread();
        }
    }

    @Override
    public void run() {
        try {
            result = target.call();
        } catch (Exception e) {
            result = e;
        }
        // 当前由于只会有执行任务的线程修改这个字段,故不需要cas
        // 后续等待线程可以发送cancel命令后,存在多线程并发访问该字段,到时候再cas
        isFinished = true;
        afterFinish();
    }

    private void afterFinish() {
        /**
         * 这一层for循环,要不断的取waiters,直到waiters为空
         * 有可能执行线程将isFinished置为true之后,还有等待线程先判断isFinished为false然后入队
         * 共三种情况
         * 1. if失败,直接重新for循环
         * 2. if成功,还要重新for循环走一遍
         * 3. if成功,不需要for循环再走一遍
         */
        for (WaitNode p; (p=waiters)!=null;) {
            // 这里如果cas失败,说明队头又被更新了,要再次获取最新的队头
            if (UNSAFE.compareAndSwapObject(this, waitersOffset, p, null)) {
                while (p != null) {
                    // 唤醒线程
                    Thread t = p.thread;
                    if (t!=null) {
                        p.thread = null;
                        LockSupport.unpark(t);
                    }

                    WaitNode next = p.next;
                    p.next = null;// help GC
                    p = next;
                }
            }
        }
    }

    public V get() throws ExecutionException {
        // 如果未完成,且进队失败
        boolea f = isFinished;
        if (!f && !(f=await())) {
            // todo 抛出超时异常
        }

        // result 是一个异常,则抛出异常
        if (result instanceof Exception) {
            throw new ExecutionException(result);
        }
        // 否则返回
        return (V)result;
    }

    /**
     * todo 后续引入等待超时时间
     * @return 返回任务完成状态,目前一定是true
     */
    private boolean await() {
        WaitNode q = null;
        boolean queued = false;
        // 一步步来,通过多个if拆分,尽可能避免让线程进入waiting状态
        for (;;) {
            // 如果这个时候任务完成了,直接返回true
            boolean f = isFinished;
            if (f) {
                /**
                 * 1. 刚刚new WaitNode(),正好任务结束
                 * 2. 刚刚入队,正好任务结束
                 * 3. park()被唤醒后来到这里
                 */
                if (q.thread != null) {
                    q.thread = null;
                }
                return true;
            } else if (q == null) {
                q = new WaitNode();
            } else if (!queued) {
                WaitNode curWaiters = (q.next=waiters);
                // 始终是头插法,有冲突则for循环再来一次
                queued = UNSAFE.compareAndSwapObject(this, waitersOffset, curWaiters, q);
            } else {
                // 实在没办法,线程需要waiting了
                LockSupport.park();
            }
        }
    }


    // Unsafe mechanics
    private static final sun.misc.Unsafe UNSAFE;
    private static final long waitersOffset;
    static {
        try {
            UNSAFE = reflectGetUnsafe();
            Class<?> k = MyFutureTaskV3.class;
            waitersOffset = UNSAFE.objectFieldOffset
                    (k.getDeclaredField("waiters"));
        } catch (Exception e) {
            throw new Error(e);
        }
    }

    //引用Unsafe需使用如下反射方式,否则会抛出异常java.lang.SecurityException: Unsafe
    private static Unsafe reflectGetUnsafe() throws NoSuchFieldException, IllegalAccessException {
        Field theUnsafe = Unsafe.class.getDeclaredField("theUnsafe");
        theUnsafe.setAccessible(true);//禁止访问权限检查(访问私有属性时需要加上)
        return (Unsafe) theUnsafe.get(null);
    }

}

测试结果同样能够与前面保持一致。

但是,这里有个对thread进行唤醒的并发操作,似乎存在问题;

执行线程等待线程
isFinished = false
1. q = new WaitNode()
2. queued = cas(curWaiters,q)
3. isFinished = true
4. cas(p,null)
5. t=p.thread
6. if(t!=null)
7. if(finished)
8. if(q!=null)
9. q.thread=null
10. unpark(t)
11. return true

这里的问题在于,等待线程其实只进入等待队列,但并没有被park;而执行线程在遍历等待队列时,会unpark这个线程。对于LockSupport.unpark(Thread t)方法,源码的说明是,如果调用unpark时,t没有被park过,则下次调用park()将不会被阻塞。。

image.png 看了源码,感觉也存在这样的问题。。

总结

本文的实现是参考源码进行的简单实现,后续再增加等待线程对任务进行cancel的逻辑。在实现过程中,有几个点想要总结一下:

  1. 垂死挣扎原则:等待线程虽然判断任务未完成,但并没有立刻就进入阻塞,而是在垂死挣扎:进入一波死循环,判断一次flag,new WaitNode()后判断一次flag,进入添加等待队列节点后再来一次判断,最后实在没办法了,才park自己。我称之为垂死挣扎原则。能不阻塞就尽可能不要阻塞。
  2. 快照原则:本章的参考了源码使用了该原则,但从本章的实现来看倒没有很大的感受,但在源码中多处可以发现。下面列举几处:
public V get(long timeout, TimeUnit unit)
    throws InterruptedException, ExecutionException, TimeoutException {
    if (unit == null)
        throw new NullPointerException();
    int s = state; // 快照
    if (s <= COMPLETING &&
        (s = awaitDone(true, unit.toNanos(timeout))) <= COMPLETING)
        throw new TimeoutException();
    return report(s);
}
private void finishCompletion() {
    // assert state > COMPLETING;
    for (WaitNode q; (q = waiters) != null;) { // q是一个快照,后面都是对q进行操作
        if (UNSAFE.compareAndSwapObject(this, waitersOffset, q, null)) {
            for (;;) {
                Thread t = q.thread; // t是一个快照
                if (t != null) { // 这里有可能q.thread已经为空了,但t因为是快照,可能不为空
                    q.thread = null; 
                    LockSupport.unpark(t);
                }
                WaitNode next = q.next;
                if (next == null)
                    break;
                q.next = null; // unlink to help gc
                q = next;
            }
            break;
        }
    }
    // 。。。
}
private int awaitDone(boolean timed, long nanos)
    throws InterruptedException {
    final long deadline = timed ? System.nanoTime() + nanos : 0L;
    WaitNode q = null;
    boolean queued = false;
    for (;;) {
        if (Thread.interrupted()) {
            removeWaiter(q);
            throw new InterruptedException();
        }

        int s = state; // s为快照,这个地方的用法最为经典。
        // 分析一下这里的快照用法;用s的话,s的快照那一刻已经确定了,所以下面的if判断,首先s > COMPLETING,不成立则判断 s == COMPLETING,这是个合理的逐层判断。
        // 如果使用state判断的话,第一个if不成立,到第二个if时,有可能state变成COMPLETING了,但第二个条件也不成立,最终会走到第三个if,那么这几个if的层次就不合理了。
        if (s > COMPLETING) {
            if (q != null)
                q.thread = null;
            return s;
        }
        else if (s == COMPLETING) // cannot time out yet
            Thread.yield();
        else if (q == null)
            q = new 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);
    }
}

再举一个简单代码例子说明:

volatile int i = 0;

void inc() { // i只会增加,不会减少
    i++;
}

void judge() {
    
    int snapShot = i; // 获取snapshot
    
    // if判断的条件相反,递减
    if (snapShot > MAX) { 
        // ...
    } else if(snapShot > MIN) {
        // ..
    }else {
        // ..
    }
}

上述例子中:

  1. 当snapshot能够命中第一个条件时,那么最新的i也一定满足第一个条件;
  2. 所有的条件范围与i的变化成反方向。

不过虽然是这么解释,但感觉应该没有真正领悟快照原则的精髓,毕竟如果不用快照,这么分析下来也没有很大的问题。