作者简介:大家好,我是smart哥,前中兴通讯、美团架构师,现某互联网公司CTO
联系qq:184480602,加我进群,大家一起学习,一起进步,一起对抗互联网寒冬
为什么要介绍FutureTask呢?原因有两点:
- Java中但凡和“线程池”、“异步线程”、“异步结果”有关的API,很大概率会出现FutureTask,是个很重要的类
- FutureTask本身很有意思
FutureTask为什么有意思呢?
假设一个场景,要求设计一个方法,专门用来执行耗时操作。但不同点在于:
- 调用时必须能够立即返回result
- 拿到result后,我可以在随后任意时刻通过result.get()获取耗时任务的最终结果(异步结果)
这个需求的诡异之处在于:刚开始立即返回的result肯定不包含结果(毕竟耗时任务),但随后通过result.get()却又能得到结果!如果让我们从零开始实现这个需求,是比较困难的,好在JDK已经设计出FutureTask,它专门为这种场景而生。FutureTask就像个魔术盒,刚开始是空的,你抱着它走了几步,再打开时里面竟然出现了一个苹果,amazing!
希望上面的介绍能勾起大家对FutureTask的兴趣,然后带着好奇心阅读本文。
FutureTask:Thread大佬别生气,这两货我包了
大部分人既不认识Future也不知道Task,合在一起更是不知所云。不急,先来看看FutureTask是怎么用的。
初识FutureTask
public class AsyncAndWaitTest {
public static void main(String[] args) throws ExecutionException, InterruptedException {
// 第一次看到FutureTask时,相信大家会震惊:啥玩意,怎么把Callable往FutureTask里塞呢?!
FutureTask<String> futureTask = new FutureTask<>(new Callable<String>() {
@Override
public String call() throws Exception {
System.out.println(Thread.currentThread().getName() + "========>正在执行");
try {
Thread.sleep(3 * 1000L);
} catch (InterruptedException e) {
e.printStackTrace();
}
return "success";
}
});
// 看到这,你再次震惊:啥玩意,怎么又把FutureTask塞到Thread里了呢?!
new Thread(futureTask).start();
System.out.println(Thread.currentThread().getName() + "========>启动任务");
// 可以获取异步结果(会阻塞3秒)
String result = futureTask.get();
System.out.println("任务执行结束,result====>" + result);
}
}
上面代码带给我们两个疑问:
- 为什么能把Callable塞到FutureTask中?
- 为什么能把FutureTask塞到Thread中?
其实归根到底,是我们还不了解FutureTask到底是啥。先来看一下FutureTask的继承体系:
我们发现FutureTask实现了Runnable,所以它能塞到Thread中也就不奇怪了,毕竟也算是任务类嘛。但把Callable塞到FutureTask,然后又把FutureTask塞到Thread中,这是搞什么名堂啊,想玩俄罗斯套娃吗?This is China ya!
以上种种疑问,后面分几个小节回答。就目前来看,FutureTask有以下2个特征:
- 能包装Runnable/Callable(上面案例代码只演示包装了Callable),但本身却又实现了Runnable接口,即本质是Runnable
- 既然是Runnable,所以FutureTask能作为任务被Thread执行,但诡异的是FutureTask#get()可以获取结果
也就是说,FuntureTask本质上和Runnable/Callable一样是任务类,但却能通过它获取执行结果。所以FutureTask = 任务(Task) + 异步结果(Future),正如它的名字一样,FutureTask具有双重属性。不过需要注意的是,FutureTask并不直接包裹任务代码,而是通过接收Runnable/Callable来实现任务的封装。
FutureTask如何包装Runnable/Callable
我们先来看FutureTask如何包装Runnable/Callable。但在此之前,需要先验证FutureTask确实可以包装Runnable/Callable。
通过上面的验证,我们大胆猜测FutureTask内部有两个成员变量分别接收Runnable和Callable类型的任务。
然而,不好意思,只有一个Callable,你要不要?
于是你的心态炸了,Runnable确实传进去了呀。难道Runnable继承了Callable?
我去,这两货完全没关系,而且Callable是JDK1.5出来的,Runnable从JDK1.0就在了。
事情开始变得有意思起来了...
上面是通过FutureTask构造器传入Runnable/Callable的,解铃还须系铃人,所以我们去看看FutureTask的构造器吧:
不愧是大佬,原来是这样啊!
FutureTask内部维护Callable类型的成员变量,对于Callable任务,直接赋值即可:
而对于Runnable任务,需要先调用Executors#callable()把Runnable先包装成Callable:
Executors#callable()用到了适配器模式:
而RunnableAdapter实现了Callable接口,所以包装后的RunnableAdapter可以赋值给FutureTask.callable。
也就是说:
- Callable --> 直接赋值给FutureTask内部的callable字段
- Runnable --> 通过Executors.callable(runnable) 把Runnable塞到RunnableAdapter --> 把包装后的RunnableAdapter赋值给callable字段
就这样,Runnable和Callable都被装进了FutureTask!
但是,你有没有思考过,FutureTask为什么要大费周章去包装传统的任务类呢?阅读下面小节,然后尝试得出答案。
Runnable和Callable的返回值问题
众所周知,Callable#call()是有返回值的,而Runnable#run()没有。它们都包装成FutureTask后,一个有返回值,一个没返回值,怎么处理呢?
如果我是FutureTask的设计者,我肯定要设计成有返回值的,毕竟Callable.call()明明有返回值,你总不能硬生生丢掉吧。至于Runnable.run()确实没返回值,但也好办,搞个假的返回即可。
这一步是在RunnableAdapter做的:
所以等到Thread执行FutureTask时,会先取出FutureTask.callable,然后调用callable.call():
- 如果是真的Callable,调用Callable.call()会返回真实的result
- 如果是Runnable包装的RunnableAdapter,会返回事先传入的result
这也是上面的程序中,我为什么要多传一个参数的原因:
FutureTask在包装Runnable时允许传入一个默认值,用来作为“假的result”
没办法,姑且就叫补偿性兼容吧。
需要注意的是,整个过程Thread完全不知道自己执行的是什么,它只知道有个Runnable类型的任务(FutureTask)通过构造器丢进来了,至于FutureTask底层包装的是Callable还是Runnable,who cares?
所以说,FutureTask在这方面设计得确实巧妙,完全屏蔽了Runnable和Callable的区别,甚至统一了返回值问题...大大提高了通用性,一个字,绝!
回答上一节问题:为什么FutureTask要大费周章去包装Runnable和Callable呢?为了一统这混乱的武林呀!不管你是黑猫(Runnable)白猫(Callable),不管你有没有尾巴(返回值),能抓老鼠(执行任务)就行,这样就可以在更高的层面把任务类统一起来!
你会在后面线程池章节看到这种做法的好处!
FutureTask是如何被Thread执行的
我相信大家最关心的还是FutureTask如何被执行。
比如Runnable的执行过程是:
总之,Thread申请到系统线程后会从自己的run()开始执行。
上面说过,FutureTask也是Runnable类型的,所以可以传入Thread执行。那么,它的执行流程应该也符合上图,只不过target变成了FutureTask,所以target.run()就是futureTask.run():
结果最终存哪呢?
这个outcome,也是FutureTask的一个成员变量,翻译过来就是“执行结果”:
难怪上面说 FutureTask = 任务 + 结果。
至此,讲完了FutureTask怎么来的,怎么被执行的,也知道了执行结果被存到它内部的outcome字段。剩下最后一个问题:怎么获取结果呢?
FutureTask#get()即可,这是一个阻塞方法。
为什么get()是阻塞的?
在FutureTask中定义了很多任务状态:
- 刚创建
- 即将完成
- 完成
- 抛异常
- 任务取消
- 任务即将被打断
- 任务被打断
这些状态的设置意义在哪?
一个任务,有时可能非常耗时。而当用户使用futureTask.get()时,必然是希望获取最终结果的。如果FutureTask不帮我们阻塞,就有可能获取空结果。此时为了获取最终结果,用户不得不在外部自己写阻塞程序...哦?你觉得你写得出来?
所以,get()内部会判断当前任务的状态,只有当任务完成才返回,否则FutureTask替你完成阻塞操作。
但是线程可不能一直阻塞在那,而且从实际表现看,我们最终得到了结果,所以中间必然经历类似唤醒的操作。怎么做到的?
秘密就在awaitDone():
具体流程就不带大家读了,挺麻烦的,懂了也没有太大意义,有兴趣可以再百度百度。
核心的就是 for循环 + LockSupport。
public class ParkTest {
@Test
public void testPark() throws InterruptedException {
// 存储线程
List<Thread> threadList = new ArrayList<>();
// 创建5个线程
for (int i = 0; i < 5; i++) {
Thread thread = new Thread(() -> {
System.out.println("我是" + Thread.currentThread().getName() + ", 我开始工作了~");
LockSupport.park(this);
System.out.println("我是" + Thread.currentThread().getName() + ", 我又活过来了~");
});
thread.start();
threadList.add(thread);
}
Thread.sleep(3 * 1000L);
System.out.println("====== 所有线程都阻塞了,3秒后全部恢复了 ======");
// unPark()所有线程
for (Thread thread : threadList) {
LockSupport.unpark(thread);
}
// 等所有线程执行完毕
Thread.sleep(3 * 1000L);
}
}
也就是说,调用get()后,如果任务状态不是NORMAL(已完成),线程就会在一个死循环中park(),等有了结果再unpark()并往下走:
取出outcome返回:
网上有很多分析FutureTask源码的博客,大家感兴趣可以自行了解
FutureTask魔术解密
文章开头举了个例子:
FutureTask就像个魔术盒,刚开始是空的,你抱着它走了几步,再打开时里面竟然出现了一个苹果。
乍一听确实神奇,但深入理解源码后就会发现这是一个小把戏。
接下来,就是见证奇迹的时刻。
线程池和Thread本质是一样的,这里用线程池演示,大家不要觉得陌生。
亦真亦假的异步结果
往线程池submit一个Callable,返回了一个Future(本质是FutureTask),我们可以通过Future#get()获取线程池的执行结果:
仔细观察:
- 返回的FutureTask包含了刚才丢进去的Callable,所以FutureTask本质是对任务类的包装
- 由于是耗时任务,此时result.outcome目前仍为null
你之所以觉得FutureTask神奇,有一大半原因在于你被错误引导了。在你眼里,Executor#submit()必然是这样的:
public Result submit(Task t) {
// 这是耗时操作
...
// 返回结果
return result;
}
你笃信的真理是,得到结果 = 任务结束。只有任务结束才会返回结果,如果结果一开始为null,便不会再发生改变。
真的是这样吗?
实际上,返回的futureTask并不是真正的结果,它内部持有的outcome才指向真正的结果。
大家都知道Java中存在值引用,假设我在堆内存创建了一个对象 User user = new User(); 并设置 User userCopy = user,随后把user返回给你,userCopy我自己保存着。
一开始user.getName()确实为null,但是一分钟后我调用userCopy.setName("bravo1988"),随后你再次调用user.getName()就会发现name字段凭空出现了。
你可以把“一分钟后userCopy.setName('bravo1988')”当做耗时操作,这个操作恰恰是在返回user后我通过userCopy做的。同理,返回futureTask后,任务并没有结束,在不确定的未来会产生结果,这个结果就是outcome执行的对象。
所以,开启线程后立即返回的futureTask是真的结果吗?肯定不是啊,因为耗时任务还在执行中呢,你咋就出结果了?
但能说它是假的吗?显然也不能啊,因为过一会儿你就能通过futureTask.get()得到最终结果了...
何时调用futureTask.get()
之前说过了,用户调用get()必然是想到得到最终结果的,所以为了保证一定能得到结果,JDK把FutureTask#get()设计成阻塞的。如果运气好,get()的时候可能任务刚好结束于是立即返回。如果运气不好,可能要等很久。所以,什么时候调用FutureTask#get()呢?
个人建议不要立即调用get(),否则程序完全没有发挥异步优势,由异步阻塞变成同步阻塞。
好不容易开启多线程,当然应该发挥多线程的优势:
public class AsyncAndWaitTest {
public static void main(String[] args) throws ExecutionException, InterruptedException {
// 单线程的线程池
ExecutorService executorService = Executors.newSingleThreadExecutor();
// 为了看清楚点,我把Callable提出来赋值
Callable<String> callable = () -> {
System.out.println(Thread.currentThread().getName() + "========>正在执行");
Thread.sleep(3 * 1000L);
System.out.println("3秒");
return "success";
};
// 提交Callable任务
Future<String> result = executorService.submit(callable);
// Thread.sleep(1000L);
// System.out.println("1秒");
// Thread.sleep(1000L);
// System.out.println("2秒");
// Thread.sleep(1000L);
System.out.println("result=" + result.get());
}
}
关于同步与异步、阻塞与非阻塞的概念解释,后面再解释。
FuturTask是一个非常重要的类,后面在线程池章节还会发挥巨大作用。另外,JDK1.8提供了CompletableFuture,在异步任务编排方面更加实用,我们会在后面章节介绍。
作者简介:大家好,我是smart哥,前中兴通讯、美团架构师,现某互联网公司CTO
进群,大家一起学习,一起进步,一起对抗互联网寒冬