写于2017-10-11
背景:面试时我喜欢问候选人的一个问题是:是否有性能优化的经历与案例可以分享。大多数候选人一上来就说sql优化,甚至直接谈起如何建索引。诚然多数的性能问题是由于不合适的sql/索引引起,但是代码级别的优化,就真的没有可挖之处了吗?
本文笔者将根据实际项目中碰到的部分案例浅析代码优化那点事
1、Map实现Code2Name,减少时间复杂度
案例:已有学生信息列表,班级信息列表,翻译每个学生(只知道班级ID,不知道班级名称)所在的班级名
@Data
public class Student {
private int name;
private int classId;
//扩展属性
private String className;
}
@Data
private class ClassInfo{
private int classId;
private String ClassName;
}
public void translateClassName(List<Student> studentList, List<ClassInfo> classInfoList){
for(Student student : studentList){
for(ClassInfo classInfo : classInfoList){
if(student.getClassId() == classInfo.getClassId()){
student.setClassName(classInfo.getClassName());
}
}
}
}
改进后的代码如下:
public void translateClassNameImprove(List<Student> studentList, List<ClassInfo> classInfoList){
Map<Integer, String> classId2Name = new HashMap<>();
for(ClassInfo classInfo : classInfoList){
classId2Name.put(classInfo.getClassId(), classInfo.getClassName());
}
for(Student student : studentList){
student.setClassName(classId2Name.get(student.getClassId()));
}
}
相比之下,前者的时间复杂度是N*N,后者的时间复杂度接近2N。
2、循环或多次字符串拼接
循环或多次String变量相加,请使用StringBuilder,通过append()方法拼接生成字符串。每次字符串相加类似于new StringBuilder().append(a).append(b).append(c).toString(); 如果在循环中多次相加,相当于new 了很多次临时的StringBuilder对象。
3、Log输出时字符串拼接
当年使用log4j时有很多类似isInfoEnable()这种当前日志启用级别的判断,为的就是避免多余的字符串拼接操作
示例:
//假如当前的级别为ERROR,也会先进行a + b + c拼接运算
logger.info(a + b + c);
//假如当前的级别为ERROR,不会进行a + b + c拼接运算
if(logger.isInfoEnable()){
logger.info(a + b + c);
}
缺点显而易见,就是到处充斥着isInfoEnable()这种冗余代码,这个问题在Slf4j时已得到解决,例如:
String url = “moext.com”;
//假如当前的级别为ERROR,不会进行拼接运算
logger.info(“article from url {}”, url);
但是即使用了Slf4j,仍有不少开发的童鞋是这样写的:
//假如当前的级别为ERROR,也会先进行a + b + c拼接运算
logger.info(a + b + c);
这样仍然有同样的问题。
4、循环进行跨进程调用合并成批量操作
例如,循环里调用数据库或redis查询不同用户信息,可以合并成一到N次批量查询
循环里调用数据库或redis存储用户信息,可以合并成一到N次批量存储
5、公共信息或高成本对象使用cache空间换时间
即将复用程度很高的数据缓存,以空间换时间的常用手法。
6、[可选] httpClient与URLConnection
相比之下HttpClient更像是Http客户端操作,比如对cookie支持,对https支持,易用、通用、扩展性更高。而URLConnection更像是一个半成品,需要做定制化开发。
但后者的性能更高,如果你的项目中只是简单的通过http调用一些其他的项目(可控的),而且对性能的损失也很在意,不妨考虑在URLConnection基础上做些少量封装。本条由于提高性能的同时,会降低扩展性,故实际根据项目需要可选。
7、If判断时,将最常见的条件放最前面
减少分支判断,让CPU以最快的速度命中条件。
8、ArrayList这种内部采用元素采用数组类型的,条件允许的情况下尽量指定初始大小
当元素数量达到默认size时,会进行扩容,重新分配一段更大的连续内存 ,涉及到旧对象的copy操作,代价不小。
HashMap同理,所不同的时HashMap引入了加载因子,默认为0.75,即元素数量达到0.75 * 默认size时,会发生扩容,即使这样,指定大小也可以减少扩容次数,一定程度上提高了性能。
9、事务过长
尤其是使用了Spring默事务支持方式@Transactional,经常可以发现在事务方法范围里有其他复杂逻辑运算,甚至是数据库查询操作,优化方法为尽量只保持最简洁的事务操作代码在此方法里,将其余的查询及运算操作放到方法外。(需要注意的是,如果使用Spring默认的AOP事务,在是同一个类里方法调用子方法,而声明子方法为@Transactional,事务是不会生效的)
10、多余对象
某项目中的代码片段
TDailyTask record = new TDailyTask();
if (CollectionUtils.isEmpty(tDailyTaskList)) {
record.setNote("文章来自http://moext.com");
tDailyTaskMapper.insert(record);
}
如果if条件不成立,也new TDailyTask(),属于多余对象,改进后如下:
if (CollectionUtils.isEmpty(tDailyTaskList)) {
TDailyTask record = new TDailyTask();
record.setNote("文章来自http://moext.com");
tDailyTaskMapper.insert(record);
}
11、善用BufferedReader和BufferedWriter
带缓存的输入输出流。一般情况下IO的成本是较高的,如果每次只读取或写入一个字符,那效率可想而知,而带缓冲的输入输出流则可以通过分批读取和写入字符提高效率。
12、使用最有效率的方式去遍历HashMap
Set<Object> keySet = map.keySet();
for(Object key : keySet){
Object value = map.get(key);
//do something...
}
循环里多了一次map.get(key)操作,最好情况下时间复杂度接近N,最坏接近N*N,最优遍历方式如下如下:
Set<Map.Entry<Object, Object>> entrySet = map.entrySet();
for(Map.Entry<Object, Object> entry : entrySet){
Object key = entry.getKey();
Object value = entry.getValue();
//do something...
}
时间复杂度稳定为N
12、慎用反射,如BeanUtils.copyProperties()
BeanUtils提供对Java反射和自省API的包装。其主要目的是利用反射机制对JavaBean的属性进行处理。我知道在很多Java项目中PO对象和VO对象属性基本是相同的,开发童鞋为了减少get/set的书写,通过BeanUtils.copyProperties()来减少代码量,但这个方法的性能实在不高,对象属性越多则越明显。通常来说使用反射可以减少代码量,但对性能的损失也要有清醒的认识,折衷根据项目需要做出最优选择。
13、对比之后选择json/xml序列化和反序列化框架
不同的json和xml解析包的解析性能差距有时候相差甚远,请对比后做出选择。这里就不广告了
14、大文件读与写
大文件的“大”主要体现在(容易)超出内存容易限制,简单又好用的处理方式为按行处理,这就要求大文件定协议时要考虑按行处理的性能需求。
15、ConcurrentHashMap
Map的线程安全是很常见的需求,有用HashTable的,也有对HashMap进行包装后将方法加synchronized的。建议直接采用ConcurrentHashMap,其内部采用多“桶”机制,不同的key根据hash算法落到某个“桶”,理论上写并发能力提高了N倍 (N为桶数量),同时降低了读一致性。除非对强读写一致性有很严格的需求,否则ConcurrentHashMap是适用大多数场景的。