5

别问了,我真的不喜欢这个注解!

 2 years ago
source link: https://segmentfault.com/a/1190000040636475
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

你好呀,我是why。

我之前写过一些关于线程池的文章,然后有同学去翻了一圈,发现我没有写过一篇关于 @Async 注解的文章,于是他来问我:

是的,我摊牌了。

我不喜欢这个注解的原因,是因为我压根就没用过。

我习惯用自定义线程池的方式去做一些异步的逻辑,且这么多年一直都是这样用的。

所以如果是我主导的项目,你在项目里面肯定是看不到 @Async 注解的。

那我之前见过 @Async 注解吗?

肯定是见过啊,有的朋友就喜欢用这个注解。

一个注解就搞定异步开发,多爽啊。

我不知道用这个注解的人知不知道其原理,反正我是不知道的。

最近开发的时候引入了一个组件,发现调用的方法里面,有的地方用到了这个注解。

既然这次用到了,那就研究一下吧。

首先需要说明的是,本文并不会写线程池相关的知识点。

仅描述我是通过什么方式,去了解这个我之前一无所知的注解的。

搞个 Demo

不知道大家如果碰到这种情况会去怎么下手啊。

但是我认为不论是从什么角度去下手的,最后一定是会落到源码里面的。

所以,我一般是先搞个 Demo。

Demo 非常简单啊,就三个类。

首先是启动类,这没啥说的:

然后搞个 service:

这个 service 里面的 syncSay 方法被打上了 @Async 注解。

最后,搞个 Controller 来调用它,完事:

Demo 就搭建好了,你也动手去搞一个,耗时超过 5 分钟,算我输。

然后,把项目启动起来,调用接口,查看日志:

我去,从线程名称来看,这也没异步呀?

怎么还是 tomcat 的线程呢?

于是,我就遇到了研究路上的第一个问题:@Async 注解没有生效。

为啥不生效?

为什么不生效呢?

我也是懵逼的,我说了之前对这个注解一无所知,那我怎么知道呢?

那遇到这个问题的时候会怎么办?

当然是面向浏览器编程啦!

这个地方,如果我自己从源码里面去分析为啥没生效,一定也能查出原因。

但是,如果我面向浏览器编程,只需要 30 秒,我就能查到这两个信息:

失效原因:

  • 1.@SpringBootApplication 启动类当中没有添加 @EnableAsync 注解。
  • 2.没有走 Spring 的代理类。因为 @Transactional@Async 注解的实现都是基于 Spring 的 AOP,而 AOP 的实现是基于动态代理模式实现的。那么注解失效的原因就很明显了,有可能因为调用方法的是对象本身而不是代理对象,因为没有经过 Spring 容器管理。

很显然,我这个情况符合第一种情况,没有添加 @EnableAsync 注解。

另外一个原因,我也很感兴趣,但是现在我的首要任务是把 Demo 搭建好,所以不能被其他信息给诱惑了。

很多同学带着问题去查询的时候,本来查的问题是@Async 注解为什么没有生效,结果慢慢的就走偏了,十五分钟后问题就逐渐演变为了 SpringBoot 的启动流程。

再过半小时,网页上就显示的是一些面试必背八股文之类的东西...

我说这个意思就是,查问题就好好查问题。查问题的过程中肯定会由这个问题引发的自己更加感兴趣的问题。但是,记录下来,先不要让问题发散。

这个道理,就和带着问题去看源码一样,看着看着,可能连自己的问题是什么都不知道了。

好了,说回来。

我在启动类上加上该注解:

再次发起调用:

可以看到线程名字变了,说明真的就好了。

现在我的 Demo 已经搭好了,可以开始找角度去卷了。

从上面的日志我也能知道,在默认情况下有一个线程前缀为 task- 的线程池在帮我执行任务。

说到线程池,我就得知道这个线程池的相关配置才放心。

那么我怎么才能知道呢?

其实正常人的思路这个时候就应该是去翻源码,找对应的注入线程池的地方。

而我,就有点不正常了,我懒得去源码里面找,我想让它自己暴露到我的面前。

怎么让它暴露出来呢?

仗着我对线程池的了解,我的第一个思路是先压一压这个线程池。

压爆它,压的它处理不过来任务,让它走到拒绝逻辑里面去,正常来说是会抛出异常的吧?

于是,我把程序稍微改造了一下:

想的是直接来一波大力出奇迹:

结果...

它竟然...

照单全收了,没有异常?

日志一秒打几行,打的很欢乐:

虽然没有出现我预想的拒绝异常,但是我从日志里面还是看出了一点点端倪。

比如我就发现这个 taks 最多就到 8:

朋友们,你说这是啥意思?

是不是就是说这个我正在寻找的线程池的核心线程数的配置是 8 ?

什么,你问我为什么不能是最大线程数?

有可能吗?

当然有可能。但是我 10000 个任务发过来,没有触发线程池拒绝策略,刚好把最大线程池给用完了?

也就是说这个线程池的配置是队列长度 9992,最大线程数 8 ?

这也太巧合了且不合理了吧?

所以我觉得核心线程数配置是 8 ,队列长度应该是 Integer.MAX_VALUE

为了证实我的猜想,我把请求改成了这样:

num=一千万。

通过 jconsole 观察堆内存使用情况:

那叫一个飙升啊,点击【执行GC】按钮也没有任何缓解。

也从侧面证明了:任务有可能都进队列里面排队了,导致内存飙升。

虽然,我现在还不知道它的配置是什么,但是经过刚刚的黑盒测试,我有正当的理由怀疑:

 默认的线程池有导致内存溢出的风险。

但是,同时也意味着我想从让它抛出异常,从而自己暴露在我面前的骚想法落空。

前面的思路走不通,老老实实的开始怼源码吧。

我是从这个注解开始怼的:

点进这个注解之后,几段英文,不长,我从里面获取到了一个关键信息:

主要关注我画线的地方。

In terms of target method signatures, any parameter types are supported.

在目标方法的签名中,入参是任何类型都支持的。

多说一句:这里说到目标方法,说到 target,大家脑海里面应该是要立刻出现一个代理对象的概念的。

上面这句话好理解,甚至感觉是一句废话。

但是,它紧跟了一个 However:

However, the return type is constrained to either void or Future.

constrained,受限制,被约束的意思。

这句话是说:返回类型被限制为 void 或者 Future。

啥意思呢?

那我偏要返回一个 String 呢?

WTF,打印出来的居然是 null !?

那这里如果我返回一个对象,岂不是很容易爆出空指针异常?

看完注解上的注释之后,我发现了第二个隐藏的坑:

如果被 @Async 注解修饰的方法,返回值只能是 void 或者 Future。

void 就不说了,说说这个 Future。

看我划线的另外一句:

it will have to return a temporary {@code Future} handle that just passes a value through: e.g. Spring's {@link AsyncResult}

上有一个 temporary,是四级词汇啊,应该认识的,就是短暂的、暂时的意思。

temporary worker,临时工,明白吧。

所以意思就是如果你要返回值,你就用 AsyncResult 对象来包一下,这个 AsyncResult 就是 temporary worker。

就像这样:

接着我们把目光放到注解的 value 属性上:

这个注解,看注释上面的意思,就是说这个应该填一个线程池的 bean 名称,相当于指定线程池的意思。

也不知道理解的对不对,等会写个方法验证一下就知道了。

好了,到现在,我把信息整理汇总一下。

  • 我之前完全不懂这个注解,现在我有一个 Demo 了,搭建 Demo 的时候我发现除了 @Async 注解之外,还需要加上 @EnableAsync 注解,比如加在启动类上。
  • 然后把这个默认的线程池当做黑盒测试了一把,我怀疑它的核心线程数默认是 8,队列长度无线长。有内存溢出的风险。
  • 通过阅读 @Async 上的注解,我发现返回值只能是 void 或者 Future 类型,否则即使返回了其他值,不会报错,但是返回的值是 null,有空指针风险。
  • @Async 注解中有一个 value 属性,看注释应该是可以指定自定义线程池的。

接下来我把要去探索的问题排个序,只聚焦到 @Async 的相关问题上:

  • 1.默认线程池的具体配置是什么?
  • 2.源码是怎么做到只支持 void 和 Future 的?
  • 3.value 属性是干什么用的?

具体配置是啥?

我找到具体配置其实是一个很快的过程。

因为这个类的 value 参数简直太友好了:

五处调用的地方,其中四处都是注释。

有效的调用就这一个地方,直接先打上断点再说:

org.springframework.scheduling.annotation.AnnotationAsyncExecutionInterceptor#getExecutorQualifier

发起调用之后,果然跑到了断点这个地方:

顺着断点往下调试,就会来到这个地方:

org.springframework.aop.interceptor.AsyncExecutionAspectSupport#determineAsyncExecutor

这个代码结构非常的清晰。

编号为 ① 的地方,是获取对应方法上的 @Async 注解的 value 值。这个值其实就是 bean 名称,如果不为空则从 Spring 容器中获取对应的 bean。

如果 value 是没有值的,也就是我们 Demo 的这种情况,会走到编号为 ② 的地方。

这个地方就是我要找的默认的线程池。

最后,不论是默认的线程池还是 Spring 容器中我们自定义的线程池。

都会以方法为维度,在 map 中维护方法和线程池的映射关系

也就是编号为 ③ 的这一步,代码中的 executors 就是一个 map:

所以,我要找的东西,就是编号为 ② 的这个地方的逻辑。

这里面主要是一个 defaultExecutor 对象:

这个玩意是一个函数式编程,所以如果你不知道这个玩意是干什么的,调试起来可能有点懵逼:

我建议你去恶补一下, 10 分钟就能入门。

最终你会调试到这个地方来:

org.springframework.aop.interceptor.AsyncExecutionAspectSupport#getDefaultExecutor

这个代码就有点意思了,就是从 BeanFactory 里面获取一个默认的线程池相关的 Bean 出来。流程很简单,日志也打印的很清楚,就不赘述了。

但是我想说的有意思的点是,我不知道你看到这份代码,有没有看出一丝丝双亲委派内味。

都是利用异常,在异常里面处理逻辑。

就上面这“垃圾”代码,直接就触犯了阿里开发规范中的两大条:

在源码里面这就是好代码。

在业务流程里面,这就是违反了规范。

所以,说一句题外话。

就是阿里开发规范我个人感觉,其实是针对我们写业务代码的同事一个最佳实践。

但是当把这个尺度拉到中间件、基础组件、框架源码的范围时,就会出现一点水土不服的症状,这个东西见仁见智,我是觉得阿里开发规范的 idea 插件,对于我这样写增删查改的程序员来说,是真的香。

不说远了,我们还是回来看看获取到的这个线程池:

这不就找到我想要的东西了吗,这个线程池的相关参数都可以看到了。

也证实了我之前猜想:

我觉得核心线程数配置是 8 ,队列长度应该是 Integer.MAX_VALUE。

但是,现在我是直接从 BeanFactory 获取到了这个线程池的 Bean,那么这个 Bean 是什么时候注入的呢?

朋友们,这还不简单吗?

我都已经拿到这个 Bean 的 beanName 了,就是 applicationTaskExecutor,但凡你把 Spring 获取 bean 的流程的八股文背的熟练一点,你都知道在这个地方打上断点,加上调试条件,慢慢去 Debug 就知道了:

org.springframework.beans.factory.support.AbstractBeanFactory#getBean(java.lang.String)

假设你就是不知道在上面这个地方打断点去调试呢?

再说一个简单粗暴的方法,你都拿到 beanName 了,在代码里面一搜不就出来了嘛。

简单粗暴效果好:

org.springframework.boot.autoconfigure.task.TaskExecutionAutoConfiguration

都找到这个类了,随便打个断点,就可以开始调试了。

再说一个骚一点的操作。

假设我现在连 beaName 都不知道,但是我知道它肯定是一个被 Spring 管理的线程池。

那么我就获取项目里面所有被 Spring 管理的线程池,总有一个得是我要找的吧?

你看下面截图,当前这个 bean 不就是我要找的 applicationTaskExecutor 吗?

这都是一些野路子,骚操作,知道就好,有时候多个排查思路。

返回类型的支持

前面我们卷完了第一个关于配置的问题。

接下来,我们看另外一个前面提出的问题:

源码是怎么做到只支持 void 和 Future 的?

答案就藏在这个方法里面:

org.springframework.aop.interceptor.AsyncExecutionInterceptor#invoke

标号为 ① 的地方,其实就是我们前面分析的从 map 里面拿 method 对应的线程池的方法。

拿到线程池之后来到标号为 ② 的地方,就是封装一个 Callable 对象。

那么是把什么封装到 Callable 对象里面呢?

这个问题先按下不表,我们先牢牢的围绕我们的问题往下走,不然问题会越来越多。

标号为 ③ 的地方,doSubmit,见名知意,这个地方就是执行任务的地方了。

org.springframework.aop.interceptor.AsyncExecutionAspectSupport#doSubmit

其实这里就是我要找的答案。

你看这个方法的入参 returnType 是 String,其实就是被 @Async 注解修饰的 asyncSay 方法。

你要不信,我可以带你看看前一个调用栈,这里可以看到具体的方法:

怎么样,没有骗你吧。

所以,现在你再看 doSubmit 方法拿着这个方法的返回类型干啥了。

一共四个分支,前面三个都是判断是否是 Future 类型的。

其中的 ListenableFuture 和 CompletableFuture 都是继承自 Future 的。

这个两个类在 @Async 注解的方法注释里面也提到了:

而我们的程序走到了最后的一个 else,含义就是返回值不是 Future 类型的。

那么你看它干了啥事儿?

直接把任务 submit 到线程池之后,就返回了一个 null。

这可不得爆出空指针异常吗?

到这个地方,我们也解决了这个问题:

源码是怎么做到只支持 void 和 Future 的?

其实道理很简单,我们正常的使用线程池提交不也就这两个返回类型吗?

用 submit 的方式提交,返回一个 Future,把结果封装到 Future 里面:

用 execute 的方式提交,没有返回值:

而框架通过一个简单的注解帮我们实现异步化,它玩的再花里胡哨 ,就算是玩出花来了,它也得遵守线程池提交的底层原理啊。

所以,源码为什么只支持 void 和 Future 的返回类型?

因为底层的线程池只支持这两种类型的返回。

只是它的做法稍微有点坑,直接把其他的返回类型的返回值都处理为 null 了。

你还别不服,谁叫你不读注释上的说明呀。

另外,我发现这个地方还有个小的优化点:

当它走到这个方法的时候,返回值已经明确是 null 了。

为什么还用 executor.submit(task) 提交任务呢?

用 execute 就行了啊。

区别,你问我区别?

不是刚刚才说了吗, submit 方法是有返回值的。

虽然你不用,但是它还是会去构建一个返回的 Future 对象呀。

然而构建出来了,也没用上呀。

所以直接用 execute 提交就行了。

少生成一个 Future 对象,算不算优化?

有一说一,不算什么有价值的优化,但是说出去可是优化过 Spring 的源码的,装逼够用了。

接着,再说一下我们前面按下不表的部分,这里编号为 ② 的地方封装的到底是什么?

其实这个问题用脚指头应该也猜到了:

只是我单独拧出来说的原因是我要给你证明,这里返回的 result 就是我们方法返回的真实的值。

只是判断了一下类型不是 Future 的话就不做处理,比如我这里其实是返回了 hi:1 字符串的,只是不符合条件,就被扔掉了:

另外,idea 还是很智能的,它会提示你这个地方的返回值是有问题的:

甚至修改方法都给你标出来了,你只需要一点,它就给你重新改好了。

对于为什么要这么改,现在我们已经拿捏的非常清楚了。

知其然,也知其所以然。

@Async 注解的 value

接下来我们看看 @Async 注解的 value 属性是干什么的。

其实在前面我已经悄悄的提到了,只是一句话就带过了,就是这个地方:

前面说编号为 ① 的地方,是获取对应方法上的 @Async 注解的 value 值。这个值其实就是 bean 名称,如果不为空则从 Spring 容器中获取对应的 bean。

然后我就直接分析到标号为 ② 的地方了。

现在我们重新看看标号为 ① 的地方。

我也重新安排一个测试用例去验证我的想法。

反正 value 值应该是 Spring 的 bean 名称,而且这个 bean 一定是一个线程池对象,这个没啥说的。

所以,我把 Demo 程序修改为这样:

再次跑起来,跑到这个断点的地方,就和我们默认的情况不一样了,这个时候 qualifier 有值了:

接下来就是去 beanFactory 里面拿名字为 whyThreadPool 的 bean 了。

最后,拿出来的线程池就是我自定义的这个线程池:

这个其实是一个很简单的探索过程,但是这背后蕴涵了一个道理。

就是之前有同学问我的这个问题:

其实这个问题挺有代表性的,很多同学都认为线程池不能滥用,一个项目共用一个就好了。

线程池确实不能滥用,但是一个项目里面确实是可以有多个自定义线程池的。

根据你的业务场景来划分。

比如举个简单的例子,业务主流程上可以用一个线程池,但是当主流程中的某个环节出问题了,假设需要发送预警短信。

发送预警短信的这个操作,就可以用另外一个线程池来做。

它们可以共用一个线程池吗?

可以,能用。

但是会出现什么问题呢?

假设项目中某个业务出问题了,在不断的,疯狂的发送预警短信,甚至把线程池都占满了。

这个时候如果主流程的业务和发送短信用的是同一个线程池,会出现什么美丽的场景?

是不是一提交任务,就直接走到拒绝策略里面去了?

预警短信发送这个附属功能,导致了业务不可以,本末倒置的了吧?

所以,建议使用两个不同的线程池,各司其职。

这其实就是听起来很高大上的线程池隔离技术。

那么落到 @Async 注解上是怎么回事呢?

其实就是这样的:

然后,还记得我们前面提到的那个维护方法和线程池的映射关系的 map 吗?

现在,我把程序跑起来调用一下上面的三个方法,目的是为了把值给放进去这个 map:

看明白了吗?

再次复述一次这句话:

以方法维度维护方法和线程池之间的关系。

现在,我对于 @Async 这个注解算是有了一点点的了解,我觉得它也还是很可爱的。后面也许我会考虑在项目里面把它给用起来。毕竟它更加符合 SpringBoot 的基于注解开发的编程理念。

最后说一句

好了,看到了这里了,点赞、关注随便安排一个吧,要是你都安排上我也不介意。写文章很累的,需要一点正反馈。

给各位读者朋友们磕一个了:


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK