用多线程将前东家项目效率提升了10倍
最近在看多线程方面的知识,突然想起前段时间前同事抱怨学员管理系统中报名表导出速度过慢的问题,我就想能否用多线程来解决呢?于是就有(shui)了这篇文章。
一、定位问题
首先根据描述,我找到问题发生在查询全部报名学员这个接口上,我试了一下,好家伙等了将近20秒才能查询出结果,记得之前写的时候没这么慢,应该是哪里出问题了。
1.1 使用AOP制作接口耗时工具
为了能够更加具体直观地认识这个接口速度有多慢,我必须要能够清楚地知道它的耗时是多少。所以我就想起了Spring中AOP这个功能,所谓的AOP,就是面向切面的缩写,其思想简单来说就是把运行中的项目任何地方视为一个个切点,开发者通过在这些切点动态地插入我们提前写好的功能,可以将相同的功能拿出来只写一遍,减少了重复操作,达到了解耦的目的。
AOP的思想刚好适合用来检测接口的耗时,只要提前写好检测类,然后在查找学员这个接口创建切点,将其织入即可。
MonitorExecTimeAspect 切面类
@Slf4j
@Aspect
@Component
public class MonitorExecTimeAspect {
@Around(value = "@annotation(com.superdebaters.sdoampService.Annotation.MonitorExecTime)")
public Object execTimeAround(ProceedingJoinPoint joinPoint) throws Throwable {
long startTime = System.currentTimeMillis();
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
String methodName = signature.getDeclaringTypeName() + "." + signature.getName();
MonitorExecTime monitor = signature.getMethod().getAnnotation(MonitorExecTime.class);
try {
Object[] args = joinPoint.getArgs();
return joinPoint.proceed(args);
} finally {
printExecTime(methodName, startTime, System.currentTimeMillis(), monitor);
}
}
private void printExecTime(String methodName, long startTime, long endTime, MonitorExecTime monitorExecTime) {
long execTime = endTime - startTime;
String template = "%s -- %s -- 耗时%d ms";
String printInfo = String.format(template, monitorExecTime.desc(), methodName, execTime);
System.out.println(printInfo);
}
}
MonitorExecTime 自定义接口
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface MonitorExecTime {
String desc() default "";
}
最后只需要将自定义接口附在要检测的接口上就大功告成了。

检测结果如下:

好家伙,一个接口开发环境下竟然要11秒,我发现这个接口是嵌套实现的,那到底是哪个方法拖慢了速度呢,于是我又分别在两个方法中织入了检测工具,结果如下:


到这里,真相大白了,问题出在关于新老学员的判定上
前东家的这个新老学员判定做的比较复杂,是根据openid+学员手机号+身份证号来识别的,一开始学员数据不多的情况下可以很快完成判定,但到现在,我看了一下(为了不透露具体数据,以下用x,y来代替)
学员参营数据有xk多条,报名表数据有yk多条,假设报名学员平均只要对比一半参营记录的情况下就可以完成判定,那也需要x/2K * yK = 300W 的计算量,难怪会花费10多秒的时间了。
二、解决问题
既然知道了问题出在哪,那就好办了,我首先想到的是可以修改数据库结构,增添新老学员项,用户在注册的时候系统后台会自动判定是否是老学员,然后写入,这样一来管理员在导出的时候就不需要再判定了。
然而我又仔细想了一下,Pass了这个方案,因为其实这并不是一个很好的方案,其本质上相当于把管理员要等待的时间转嫁到了用户身上,在高并发的情况下会占用大量的计算资源,可能会造成服务器停止响应。
难道就没有让计算机更快处理完300w的计算任务的方法了吗?
这时候我想到了最近在看的多线程与线程池方面的知识,对啊,可以利用多线程来执行任务,将300w的计算量平均分给多个线程来同时执行,这样就可以直接缩短计算时间!
2.1 编写线程池创建工具
在线程池创建方面,我利用了设计模式中的工厂模式,提前编写好创建线程池的工厂类CreateThreadUtil,在主方法中直接调用工厂类来创建线程池。
/**
* 使用工厂模式创建线程池
*/
public class CreateThreadUtil {
public static ThreadPoolExecutor createThread(int runSize)
{
ThreadFactory namedThreadFactory = new ThreadFactoryBuilder()
.build();//创建线程工厂
//创建线程池
ThreadPoolExecutor executor = new ThreadPoolExecutor(runSize,
runSize,
0L,
TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<>(1024),
namedThreadFactory,
new ThreadPoolExecutor.AbortPolicy());
return executor;
}
}
2.2 在主线程中创建多线程完成任务
我的思路是开启12个线程,将查询到的报名表数据拆分为12份,每个线程处理一份,12个线程同时并行运行,这样理论上来说,所花费的时间就可以
缩减为原来的1/12
下面是代码实现:
public List<SignupMp> identifyingNewUser(List<SignupMp> registers) {
// 开启的线程数
int runSize = 12;
// 一个线程处理数据条数
int count = registers.size() / runSize;
// 创建一个线程池,数量和开启线程的数量一样
ExecutorService executor = CreateThreadUtil.createThread(runSize);
for (int i = 0;i<runSize;i++){
int index = i * count;
final int finalI = i;
executor.submit(new Runnable() {
@Override
public void run() {
int num = count;
if (finalI == runSize-1){
num = count + (registers.size() - count * runSize);
}
try{
for (int j = index;j < index + num; j++){
if (isNewUser(registers.get(j).getId())){
registers.get(j).setNewold("新学员");
}else {
registers.get(j).setNewold("老学员");
}
}
}catch (Exception e){
}
finally {
}
}
});
}
return registers;
}
2.3 新的问题
写完后连忙用耗时检测工具测试了一下:

202ms?不可能这么快啊,根据理论值也应该在1000ms左右才对,哪里出错了吗?
果然,在我查看导出的数据时,发现新老学员那一栏基本都是空的,只有零零星星几个被计算出来。
究竟是哪里出问题了?我想起了之前看到的深拷贝、浅拷贝的知识
难道是因为传入多线程中的的List是深拷贝,多线程中只是把值赋予了List的备份,而没有传回主List中吗?
不对,我思考几秒后否定了这个假设,因为在Java中只有值传递,List对象是引用对象,传递的应该是对象的引用才对,也就是List 的内存地址,所以多线程中处理的是我们要传递给用户的List没有问题,最后结果中还有零零星星几个学员数据经过新老学员判定也可以佐证这个想法。
既然不是List对象的问题,那会不会是多线程的问题呢?
于是我在子线程完成和主线程完成时分别设置打印信息,结果如下:

结果竟然是,主线程中的线程池竟然在子线程执行完毕前提前执行完成
也就是说,之所以我们导出的数据中新老学员是空的,是因为
子线程还没来得及判定,就被心急的主线程把结果提前返回给用户了
2.4 让主线程等待子线程完成的三种方法
既然找到了原因,处理起来就很容易了
回忆了一下多线程的知识,可以有三种方法来让主线程等待子线程完成:
- 在主线程中判断线程池是否关闭,没有关闭则利用while来持续等待
- 利用CountDownLatch,线程完成时倒计时减一,直到倒计时归零主线程才继续执行
- 利用CyclicBarrier,子线程在执行完后进行等待,当全部的子线程等待后才返回主线程
我采用了最简单的方法1,在线程池的工厂方法中编写了判定方法:
public static void isCompleted(ThreadPoolExecutor threadPool) {
threadPool.shutdown();
while (!threadPool.isTerminated()) { // 如果没有执行完就一直循环
}
}
threadPool.shutdown()方法可以在线程池中的全部任务完成后关闭线程池。
最后在主线程中对线程池状态进行判定就大功告成了。

然后来看一下接口耗时:

1506ms,1.5秒左右,接近理论值,导出的结果也没有问题
至此,接口的性能优化算是全部完成了
从最开始的11s,到优化后的1.5s
性能提升了将近10倍

本文系作者 @小麓 原创发布在 小麓的博客。未经许可,禁止转载。