背景介绍:
最近技术调研,需要一次性解析大量的velocity模板,velocity解析做了大概的优化配置。其中,性能瓶颈主要涉及到批量Velocity模板解析和批量文件写入。为了满足效率要求,使用java.util.concurrent.Executors [JDK1.5 引入的线程池管理]管理并发,写了个工具类,采用固定数目的线程池,如下
public class ExecutorTaskHelper {
private ExecutorService executor;
private staticExecutorTaskHelper instance = new ExecutorTaskHelper();
private ExecutorTaskHelper() {
executor = Executors.newFixedThreadPool(AppConfig.getInstance().getIntValue("concurrence.threads.num", 10));
}
/**
* 添加给定的任务,当所有任务完成或超时期满时(无论哪个首先发生),返回保持任务状态和结果的 Future 列表。
* @param tasks 任务列表
* @param timeout 最长等待时间(单位:秒)
* @return表示任务的 Future 列表,列表顺序与给定任务列表的迭代器所生成的顺序相同。如果操作未超时,则已完成所有任务。如果确实超时了,则某些任务尚未完成。
* @throws InterruptedException 如果等待时发生中断,在这种情况下取消尚未完成的任务
*/
public static List> addTask(Collection extends Callable> tasks, int timeout) throws InterruptedException {
if(CollectionUtils.isNotEmpty(tasks)) {
return instance.executor.invokeAll(tasks, timeout, TimeUnit.SECONDS);
}
return null;
}
}
1. 并发批量解析Velocity模板
其中,Velocity文本模板的行数达到3000多行,需要解析的数量一次性达到1000个以上。暂时不考虑velocity的解析优化。先从并发数测试触发,看看如何控制并发数。首先,将大概的解析方式罗列如下:
/**
* 并发解析Velocity文件
* @param factor 并发乘数因子
* @param templateContent 模板String
* @param contextMap 模板解析上下文map
* @throws InterruptedException
* @throws ExecutionException
* @throws IOException
*/
public static long concurrencyParseContent(final float factor, final String templateContent, final List
如上,我们需要探讨的主要有三个参数,1. 解析数量,即contextMaps.size(),2. factor,这个是一个乘数因子,主要是为了方便能够在web应用中反复测试并发数,而不需要反复重启web工程,3. 线程池开启的固定线程数目poolNum。为了能找到性能优化的点,我采用物理中常见的控制变量法。
为了能够方便反复测试,写了个action,如下:
/**
* @author linjx
* @date 2014-8-21
* @version 1.0.0
*/
@ParentPackage("wsportal-default")
public class TestConcurrentParseTemplateAction {
private float factor;
private int testNum;
private String message;
@Action(value = "/test/concurrent-parse",
results= {@Result(name=BaseAction.SUCCESS, type="json")})
public String testParseTemplate() {
String template = null;
try {
template = FileUtils.readFile("F:/velocity/msconf.vm");
} catch (IOException e) {
e.printStackTrace();
}
ServerTemplateBo server = new ServerTemplateBo();
server.setIp("192.168.21.213");
server.setName("hello");
SoftwareTemplateBo software = new SoftwareTemplateBo();
software.setCacheType("4");
software.setConf("conf list");
software.setHasChildren(false);
software.setSquidId(1000000L);
software.setVersion("v1.0.0");
Map contentMap= new HashMap(2);
contentMap.put("server", server);
contentMap.put("software", software);
List>contextMaps = new ArrayList>(testNum);
for (int i = 1; i <= testNum; i ++) {
contextMaps.add(contentMap);
}
try {
long useTime = VelocityConcurrencyUtil.concurrencyParseContent(factor, template, contextMaps);
message = "总解析模板数: " + testNum + " "
+ "线程池线程数: " + poolThreadNum
+ "解析并发数: " + (factor * poolThreadNum) + " "
+ "The use time " + useTime + " ms";
} catch (InterruptedException | ExecutionExceptione) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
return BaseAction.SUCCESS;
}
public String getMessage() {
return this.message;
}
public void setFactor(float factor) {
this.factor = factor;
}
public void setTestNum(int testNum) {
this.testNum = testNum;
}
}
我们只是使用了同一组数据,但是衍生出足够多的解析测试。
① 首先,测试一下解析的耗时和解析的数据量的关系
直觉上告诉我们,待解析的数目越多,耗时肯定也越多,而且应该是线性关系。我们做个测试,其中poopNum=10:
1.{"message":"总解析数: 100 并发因子: 1.0 The use time 1941 ms"} 2.{"message":"总解析数: 1000 并发因子: 1.0 The use time 19700 ms"} 3.{"message":"总解析数: 10000 并发因子: 1.0 The use time 192719 ms"}
结果显示,我们的判断是正确的。好的,那接下来就将contextMaps.size()值固定为500,这样做是为了避免CPU时间片轮转对测试造成波动,也可以避免数据量太大,导致测试等待太久,毕竟时间还是很宝贵的。
②变动factor并发因子,改变并发数(即一次性交给Executor的任务数)
一开始,我们开辟固定线程池,池内线程数固定为10个,然后不断修改factor,测试耗时如下
1. {"message":"总解析模板数: 500 线程池线程数: 10 解析并发数: 1.0 The use time 8285 ms"}
2. {"message":"总解析模板数: 500 线程池线程数: 10 解析并发数: 2.0 The use time 6088 ms"}
3. {"message":"总解析模板数: 500 线程池线程数: 10 解析并发数: 3.0 The use time 7252 ms"}
4. {"message":"总解析模板数: 500 线程池线程数: 10 解析并发数: 4.0 The use time 8583 ms"}
5. {"message":"总解析模板数: 500 线程池线程数: 10 解析并发数: 5.0 The use time 9762 ms"}
6. {"message":"总解析模板数: 500 线程池线程数: 10 解析并发数: 8.0 The use time 10244 ms"}
7. {"message":"总解析模板数: 500 线程池线程数: 10 解析并发数: 10.0 The use time 10533 ms"}
8. {"message":"总解析模板数: 500 线程池线程数: 10 解析并发数: 20.0 The use time 10613 ms"}
9. {"message":"总解析模板数: 500 线程池线程数: 10 解析并发数: 200.0 The use time 8728 ms"}
10. {"message":"总解析模板数: 500 线程池线程数: 10 解析并发数: 500.0 The use time 8943 ms"}
通过上述数据,我们可以发现,任务的并发执行数并非越大越好,也不是越小越好,我反复测试发现,并发数为2是效率最高的。这是为什么呢?我仔细查看了,我的CPU是Intel i3,具体参数如下
核心类型 |
Arrandale |
核心数量 |
双核心 |
热设计功耗(TDP) |
35W |
制作工艺 |
32 纳米 |
晶体管数目 |
3.82亿 |
核心面积 |
81平方毫米 |
封装模式 |
rPGA 37.5×37.5mm, BGA 34×28mm |
我一直以为i3已经是四核了,可是不是,就双核而已;刚好和我们测试的结果是一致的。这样就不难解释了,虽然我们使用Executors开辟了10个固定的线程,如果我们一次性加载比较多的任务,根据操作系统的执行方式,使用Intel i3肯定是无法一次性就执行这么多任务,最多只能双核一起跑,即真正的同时执行两个线程(感觉,最终面向对象的执行还是很难逃脱面向过程的)。如果任务数大于实际真正的并发数,那在某个任务没有结束时,该任务会被挂起,等待下一个时间片轮转到它。这样,从某种程度上讲,CPU等于同时要处理多个任务,可是就只有两颗心,唯一能做的,就是分神,一边一边咯。自然,虽然CPU拥有很快的计算速度,但是还是会一定程度降低运行效率。除非CPU只做解析Velocity这件事,这样时间的耗散就理论上一致了——可惜,这是不可能的。
总结到这里,不禁联想到最近看的MapReduce。单机的计算能力终究是有限的,就像一个工人不可能单独一日完成一车间的任务,不管是横向的重复性任务还是纵向的流水线任务。MapReduce就像是集结一个车间的工人,分布式的使用了计算机集群。从这个优秀的框架我们可以看到车间流水线的影子。Map workers和Reduce workers分别负责分布式运算的流水任务,Reduce workers在Map workers完成第一轮的流水线任务之后就可以陆陆续续地开始自己的任务(如果有一节点既负责Map又负责Reduce任务,那还是需要串行执行的)。相同身份的workers是横向分布的,他们处理相同的任务,是单兵能力的有效扩展。备注:我对MapReduce算法解刨层面的理解是,对应数学上的微积分,一个先离散再聚合的过程,key对应的是微分参数。具体的理解,希望回头有机会实战后能给个有意义的讲解。至此打住,跑题太远。
回到正题,既然我们只能够真正同时执行两个线程,那线程池是不是也没有必要开辟那么大。毕竟我这里处理的方式是,执行完成一个任务集合后,在继续执行下一个任务集合,或者加入一堆的任务,由Executor自己分配。所以,如果这样,是不是直接把线程池开辟为2个就会提升效率呢?试试看(为了方便测试,poolNum也交由前端输入,具体代码小修改不做展示):
1.{"message":"总解析模板数: 500 线程池线程数: 1 解析并发数: 1.0 The use time 12461 ms"} 2. {"message":"总解析模板数: 500 线程池线程数: 2 解析并发数: 2.0 The use time 8717 ms"} 3. {"message":"总解析模板数: 500 线程池线程数: 4 解析并发数: 4.0 The use time 11437 ms"} 4. {"message":"总解析模板数: 500 线程池线程数: 10 解析并发数: 10.0 The use time 10623 ms"} 5. {"message":"总解析模板数: 500 线程池线程数: 100 解析并发数: 100.0 The use time 10233 ms"}
(这测试过程中,我发现,由于本机有时候某些后台程序释放了资源,计算能力会提升,所以有时候计算能力会提升,所以,数据比对只在一个时间段内做比对,不跨时段)
上述,我们不过内存损耗的开辟比较大的线程池,结果如预计的那样,并没有提高解析速度。就此,我们可以认为,单纯的提交解析效率,合理的并发数应该是CPU可真正同时执行的线程数。