前言
并发编程是一项非常重要的技术,无论在面试,还是工作中出现的频率非常高。
之前我发表的一篇《聊聊并发编程的10个坑》,在全网广受好评。说明了这类文章还是比较有价值的,接下来,打算继续聊聊并发编程这个话题。
并发编程说白了就是多线程编程,但多线程一定比单线程效率更高?
答:不一定,要看具体业务场景。
毕竟如果使用了多线程,那么线程之间的竞争和抢占cpu资源,线程的上下文切换,也是相对来说比较耗时的操作。
下面这几个问题在面试中,你必定遇到过:
你在哪来业务场景中使用过多线程?怎么用的?踩过哪些坑?
今天聊聊我之前在项目中用并发编程的12种业务场景,给有需要的朋友一个参考。
1.简单定时任务
各位亲爱的朋友,你没看错,Thad类真的能做定时任务。如果你看过一些定时任务框架的源码,你最后会发现,它们的底层也会使用Thad类。
实现这种定时任务的具体代码如下:
publicstaticvoidinit(){nwThad(()-{whil(tru){try{Systm.out.println("下载文件");Thad.slp(*60*5);}catch(Excption){log.rror();}}}).start();}
使用Thad类可以做最简单的定时任务,在run方法中有个whil的死循环(当然还有其他方式),执行我们自己的任务。有个需要特别注意的地方是,需要用try...catch捕获异常,否则如果出现异常,就直接退出循环,下次将无法继续执行了。
但这种方式做的定时任务,只能周期性执行,不能支持定时在某个时间点执行。
特别提醒一下,该线程建议定义成守护线程,可以通过stDamon方法设置,让它在后台默默执行就好。
使用场景:比如项目中有时需要每隔5分钟去下载某个文件,或者每隔10分钟去读取模板文件生成静态html页面等等,一些简单的周期性任务场景。
使用Thad类做定时任务的优缺点:
优点:这种定时任务非常简单,学习成本低,容易入手,对于那些简单的周期性任务,是个不错的选择。缺点:不支持指定某个时间点执行任务,不支持延迟执行等操作,功能过于单一,无法应对一些较为复杂的场景。
2.监听器
有时候,我们需要写个监听器,去监听某些数据的变化。
比如:我们在使用canal的时候,需要监听binlog的变化,能够及时把数据库中的数据,同步到另外一个业务数据库中。
如果直接写一个监听器去监听数据就太没意思了,我们想实现这样一个功能:在配置中心有个开关,配置监听器是否开启,如果开启了使用单线程异步执行。
主要代码如下:
SrvicpublicCanalSrvic{privatvolatilboolanrunning=fals;privatThadthad;
AutowidprivatCanalConnctorcanalConnctor;publicvoidhandl(){//连接canalwhil(running){//业务处理}}publicvoidstart(){thad=nwThad(this::handl,"nam");running=tru;thad.start();}publicvoidstop(){if(!running){turn;}running=fals;}}在start方法中开启了一个线程,在该线程中异步执行handl方法的具体任务。然后通过调用stop方法,可以停止该线程。
其中,使用volatil关键字控制的running变量作为开关,它可以控制线程中的状态。
接下来,有个比较关键的点是:如何通过配置中心的配置,控制这个开关呢?
以apollo配置为例,我们在配置中心的后台,修改配置之后,自动获取最新配置的核心代码如下:
publicclassCanalConfig{
AutowidprivatCanalSrviccanalSrvic;ApolloConfigChangListnrpublicvoidchang(ConfigChangEvntvnt){Stringvalu=vnt.gtChang("tst.canal.nabl").gtNwValu();if(BoolanUtils.toBoolan(valu)){canalSrvic.start();}ls{canalSrvic.stop();}}}通过apollo的ApolloConfigChangListnr注解,可以监听配置参数的变化。
如果tst.canal.nabl开关配置的tru,则调用canalSrvic类的start方法开启canal数据同步功能。如果开关配置的fals,则调用canalSrvic类的stop方法,自动停止canal数据同步功能。
3.收集日志
在某些高并发的场景中,我们需要收集部分用户的日志(比如:用户登录的日志),写到数据库中,以便于做分析。
但由于项目中,还没有引入消息中间件,比如:kafka、rocktmq等。
如果直接将日志同步写入数据库,可能会影响接口性能。
所以,大家很自然想到了异步处理。
实现这个需求最简单的做法是,开启一个线程,异步写入数据到数据库即可。
这样做,可以是可以。
但如果用户登录操作的耗时,比异步写入数据库的时间要少得多。这样导致的结果是:生产日志的速度,比消费日志的速度要快得多,最终的性能瓶颈在消费端。
其实,还有更优雅的处理方式,虽说没有使用消息中间件,但借用了它的思想。
这套记录登录日志的功能,分为:日志生产端、日志存储端和日志消费端。
如下图所示:
先定义了一个阻塞队列。
ComponntpublicclassLoginLogQuu{privatstaticfinalintQUEUE_MAX_SIZE=;privatBlockingQuublockingQuuquu=nwLinkdBlockingQuu(QUEUE_MAX_SIZE);//生成消息publicboolanpush(LoginLogloginLog){turnthis.quu.add(loginLog);}//消费消息publicLoginLogpoll(){LoginLogloginLog=null;try{loginLog=this.quu.tak();}catch(IntrruptdExcption){.printStackTrac();}turnsult;}}
然后定义了一个日志的生产者。
SrvicpublicclassLoginSrivc{
AutowidprivatLoginLogQuuloginLogQuu;publicintlogin(UsrInfousrInfo){//业务处理LoginLogloginLog=convrt(usrInfo);loginLogQuu.push(loginLog);}}接下来,定义了日志的消费者。
SrvicpublicclassLoginInfoConsumr{
AutowidprivatLoginLogQuuquu;PostConstructpublicvoitinit{nwThad(()-{whil(tru){LoginLogloginLog=quu.tak();//写入数据库}}).start();}}当然,这个例子中使用单线程接收登录日志,为了提升性能,也可以使用线程池来处理业务逻辑(比如:写入数据库)等。
4.xcl导入
我们可能会经常收到运营同学提过来的xcl数据导入需求,比如:将某一大类下的所有子类一次性导入系统,或者导入一批新的供应商数据等等。
我们以导入供应商数据为例,它所涉及的业务流程很长,比如:
调用天眼查接口校验企业名称和统一社会信用代码。写入供应商基本表写入组织表给供应商自动创建一个用户给该用户分配权限自定义域名发站内通知
等等。
如果在程序中,解析完xcl,读取了所有数据之后。用单线程一条条处理业务逻辑,可能耗时会非常长。
为了提升xcl数据导入效率,非常有必要使用多线程来处理。
当然在java中实现多线程的手段有很多种,下面重点聊聊java8中最简单的实现方式:paralllStam。
伪代码如下:
supplirList.paralllStam().forEach(x-importSupplir(x));
paralllStam是一个并行执行的流,它默认通过ForkJoinPool实现的,能提高你的多线程任务的速度。
ForkJoinPool处理的过程会分而治之,它的核心思想是:将一个大任务切分成多个小任务。每个小任务都能单独执行,最后它会把所用任务的执行结果进行汇总。
下面用一张图简单介绍一下ForkJoinPool的原理:
当然除了xcl导入之外,还有类似的读取文本文件,也可以用类似的方法处理。
温馨地提醒一下,如果一次性导入的数据非常多,用多线程处理,可能会使系统的cpu使用率飙升,需要特别