简单实现
从上述JoJoRunnable的实现可以看到,内部封装了一个Callable target和一个Object result,result用于接收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状态。
由于任务执行一般都是一个很耗时的操作,自旋等待会无用的消耗大量cpu资源,因此选择Waiting等的方式,让线程放弃cpu。FutureTask本身采用的是Waiting等待方式,获取结果时,若任务没有执行完则进入Waiting状态,执行完后则由子线程将等待的线程唤醒。下面对该方式继续分析:
那么,如何判断一个任务是否执行完?这里很显然还需要一个类似于boolean isFinished这样的字段来进行标识。
这里,先约定几个概念:
- 执行线程:即执行任务的线程;
- 等待线程:即等待任务执行完后,获取结果的线程;这个线程既可以是执行线程本身,也可以是其他线程;另外,等待线程可以有多个。执行线程与等待线程是一对多的关系。
- 同步标识:即
boolean isFinished字段,等待线程和执行线程通过对该字段的修改和查看来进行通信。从并发的角度来看,这是一个线程共享变量,对其读写通常需要加锁。
如:多个跑步员(等待线程)等着裁判员(执行线程)开枪(同步标识)起跑。
基于以上分析,我们尝试使用volatile、Object.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);
}
}
运行结果
思考3🤔:
这里访问isFinished字段时加锁,存在很大的性能问题。从当前场景来看,只有执行线程会写,只有等待线程会读(后续会增加等待线程写操作(取消))。加锁就是对了将多步封装为一次原子操作,而读和写都是原子操作,对该字段的读写在volatile的加持下,根本不需要加锁。
从上面代码来看,synchronized和Object.wait()/notify()包含了两层含义:一是访问并发资源时加锁,没获取到锁的线程进行锁等待,二是判断任务是否已完成,未完成则等待线程释放锁进行资源等待。这里分别对应的是锁池和资源池。
相关概念参考:blog.csdn.net/meism5/arti…
根据以上分析,去掉加解锁流程后,剩下资源等待。由于Object.wait()/notify()方法必须配合synchronize来使用,需要更换方法。
用什么呢?再次明确一下需求,在isFinished为false时,等待线程放弃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版本引入的,这里有两个问题:
- 虽然这个类可以实现上述功能,但这个类本身是怎么做到的呢?
await()和countDown()的背后到底做了什么? FutureTask也是用的这个类来实现其功能的吗?
通过阅读源码,这两个类不约而同的使用了同一个类LockSupport:一个工具类(不能被实例化,里面都是static方法)。不过这个类的注释上没有描述作者和版本。其实还是doug Lea和1.5版本。
再多看两眼源码,发现这个类也就是个傀儡,真正的起作用的类是UnSafe!
看到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
}
可以看到,LockSupport与Object#notifyAll()和Semapher#countDown()的一个最大不同时,后两者可以唤醒所有等待中的线程,而前者只能唤醒指定的线程,而且需要通过入参来指定。
那么,当执行线程的任务完成之后,它如何获取到所有的等待线程呢?这里就需要设计一个等待队列出来,等待队列作为FutureTask的成员变量,执行线程任务完成后,遍历这个队列,依次将等待线程唤醒。有几点需要注意:
- 等待队列又是一个临界资源,通过分析,等待线程可能会并发往队列里面添加节点,存在写写并发。
- 执行线程会去读这个临界资源,但由于它只会在任务完成之后才去读,此时该队列不会再新增节点,因此不存在读写并发。
- 虽然存在写写并发,但是入队是一个很快的时间,当产生并发冲突时,通过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()将不会被阻塞。。
看了源码,感觉也存在这样的问题。。
总结
本文的实现是参考源码进行的简单实现,后续再增加等待线程对任务进行cancel的逻辑。在实现过程中,有几个点想要总结一下:
- 垂死挣扎原则:等待线程虽然判断任务未完成,但并没有立刻就进入阻塞,而是在垂死挣扎:进入一波死循环,判断一次flag,new WaitNode()后判断一次flag,进入添加等待队列节点后再来一次判断,最后实在没办法了,才park自己。我称之为垂死挣扎原则。能不阻塞就尽可能不要阻塞。
- 快照原则:本章的参考了源码使用了该原则,但从本章的实现来看倒没有很大的感受,但在源码中多处可以发现。下面列举几处:
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 {
// ..
}
}
上述例子中:
- 当snapshot能够命中第一个条件时,那么最新的i也一定满足第一个条件;
- 所有的条件范围与i的变化成反方向。
不过虽然是这么解释,但感觉应该没有真正领悟快照原则的精髓,毕竟如果不用快照,这么分析下来也没有很大的问题。