• @Aysnc注解其实也就这么回事!


    你好呀,我是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 的基于注解开发的编程理念。

    最后说一句

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

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

  • 相关阅读:
    用户验证之自定义身份验证
    再谈CLR:查看程序集的依赖关系
    关于私钥加密、公钥加密、签名在生活中的场景
    MOSS 2010服务器对象模型(Object Model)
    用户身份验证之Windows验证
    由object不能比较引发的问题
    再谈CLR: .NET 4.0新功能:Mscoree.dll + Mscoreei.dll=更少的Reboot (上)
    再谈CLR:事件定义
    WPF:如何为程序添加splashScreen?
    通过反射得到类型的所有成员
  • 原文地址:https://www.cnblogs.com/thisiswhy/p/15233243.html
Copyright © 2020-2023  润新知