最近在优化XX性能的时候,考虑根据产品维度进行并发处理,以便能够提升性能。
单个产品的业务处理是相互独立的,故可以很好利用并发的优势来提升执行速度。
但是由于之前在业务实现时,并没有过多考虑多线程的问题,故在改造时遇到了一些问题。
主要问题描述:
在子线程中调用scope=request级别的bean对象会报错,如下:
Scope 'request' is not active for the current thread
问题分析:
XX业务现在是根据日期(递增顺序)循环处理待处理的产品,而处理过程则是逐个产品进行处理。在复杂的业务处理中,为了减少数据库查询次数,对于一些service进行了数据缓存处理(比如净值数据)。数据缓存又不能太复杂,因为涉及到同步的问题,有可能某些数据随时会被修改,此时如果搞一套缓存同步的方案就太麻烦。
考虑到单次request请求过程中,这些缓存数据不应该被修改的实际情况,将service bean的scope设置为request,然后再将一些缓存数据放在该bean对象中,就可以很好地解决单次request层面的数据缓存实现。
正常情况下,这些bean对象能够在主线程中被访问到,但是当引入多线程时,就出现了上面的问题。
因为在创建的子线程里面,context里是没有requestScope的bean对象的。(具体原理参考下面的参考资料,主要的机制就是因为使用的是ThreadLocal来进行的bean对象存储)
解决方案:
搞清楚报错的原因后,针对性的解决方案也就差不多有思路了。
首先,需要解决子线程无法获取bean对象的问题。查看资料后,发现spring除了通过ThreadLocal来保存bean对象外,还实现了InheritableThreadLocal的方式。
那如何开启这个设置呢?
目前看到有两种方案:
1、直接通过web.xml里的SpringMVC的DispatcherServlet进行设置,参考设置如下:
<servlet>
<servlet-name>springServlet</servlet-name>
<servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
<init-param>
<param-name>threadContextInheritable</param-name>
<param-value>true</param-value>
</init-param>
<init-param>
<param-name>contextConfigLocation</param-name>
<param-value>classpath*:/spring-mvc*.xml</param-value>
</init-param>
<load-on-startup>1</load-on-startup>
</servlet>
2、通过重写RequestContextListener类来实现
具体参考下面的CSDN里的那篇文章,讲的很详细
(除了重写类,同样需要将web.xml里的listener类改为自己实现的类)
3、将request属性传给子线程(此方法也比较推荐)
先获取request的ServletRequestAttributes对象,然后传递给子线程,在子线程里调用set方法,把相关数据放入到当前线程的Thread Local里即可。
RequestAttributes attributes = RequestContextHolder.getRequestAttributes();
RequestContextHolder.setRequestAttributes(attributes);
说明:对于quartz定时任务而言,本身就根本没有ServletRequest对象,此时如果调用设置了scope的bean也会有类似问题。
通过方案3的变种,也能够很好的解决这个问题:
在任务线程起始处,直接执行
RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(new MockHttpServletRequest()));
此处使用了springframework的spring-test包,可以直接mock一个httpServletRequest对象。
该方案具体参考:(说明,文章最后的DummyRequest类,在Tomcat6版本中存在,但是在之后的版本并不存在了,故建议使用MockHttpServletRequest类)
通过上面的修改后,子线程已经可以继承到主线程里的缓存对象,顺利获取到bean对象。
但是,又引入了一个新的问题:
问题描述:
为了方便使用多线程并发处理任务,直接使用了JAVA8的stream的parallel特性。具体代码如下:
Arrays.stream(funds).parallel().forEach(fundCode -> {// YOUR BUSINESS CODE });
通过stream可以很简单的实现并行处理任务,但是在实际运行过程中,第一次跑任务,顺利进行,没有报“Scope 'request' is not active for the current thread”的错误。但是之后再次跑任务,就又出现了这个错误。
问题分析:
研究了一下stream的parallel实现机制,大概了解到,其默认用到了forkjoin机制,通过forkjoin的线程池来做任务的并发处理。
然后又看了Stack Overflow上的那个帖子,基本上明白为啥会导致这个问题了。由于用到了线程池,那stream并发处理时所用的线程必然有可能是复用之前已经创建好的线程。在第一次跑任务的时候,新创建的线程会继承来自主线程的缓存对象(bean对象),此时能够正常运行。但是当第一次请求结束,由于缓存是request级别的,在结束的时候,自然会将bean对象销毁。此时线程池里的线程里保存的bean对象其实已经被销毁了。当第二次在跑任务的时候,复用到的线程里自然也就没有bean对象了,所以才会又报这个错误。
解决方案:
同样,搞清楚问题的原因后,方案也很有针对性了。既然线程复用导致了这个问题,那就想办法不复用线程,每次跑任务的时候,都重新创建一个新的线程即可。
此时也有两种方案:
1、自行实现并发任务的处理,自行创建和管理线程(或者自己实现一个线程池来保证单次request级别的线程复用)
但是这个实现就比较复杂了,失去了期初重构的初衷。
2、通过查看资料,发现stream默认使用的是forkJoin的common pool,那如果每次request的时候,都手动指定一个自定义的线程池,是不是就能保证线程不被跨request级别的复用了。于是研究了一下Stack Overflow里的帖子,发现给stream指定forkjoin线程池还是很简单的。参考代码如下:
final int parallelSize = 10;
ForkJoinPool forkJoinPool = null;
try {
forkJoinPool = new ForkJoinPool(parallelSize);
forkJoinPool.submit(() -> Arrays.stream(funds).parallel().forEach(code-> {
// YOUR BUSINESS CODE
})).get();
} catch (Exception e) {
// catch the exception
} finally {
if (forkJoinPool != null) {
forkJoinPool.shutdown();
}
}
指定自定义的线程池后,保证了每次request都会创建新的线程池,这些线程也都只会在单个request级别内进行reuse。至此,解决了本次重构所遇到的这些问题。
PS:需要注意的是,在使用forkjoin自定义线程池时,一定要在submit后面调用get方法,要不然主线程就不会等待子线程完成任务,就直接执行下面的代码,从而可能导致子线程还在跑任务,但是主线程已经返回了。(此时有可能后续的子线程执行时,又出现了找不到bean的错误)
PS2:需要注意forkjoinPool一定要在最后进行shutdown,释放资源,否则可能导致内存溢出的问题。