对代码优化的一些总结
优化可以分为三部分:第一是数据库优化、第二是代码优化、第三是算法优化。
数据库优化
因为我们平时对数据库操作最多的是查询操作,所以数据库的优化重点就体现在如何优化我们的查询操作。
主要有几点需要考虑:
-
循环操作改为批量操作
循环查询可以改为批量查询,再封装为map使用。循环插入可以改为批量保存。
-
需要什么查什么
尽量不要无脑
SELECT *
,无脑查询全部字段会给网络和内存带来额外的压力。这是不必要的。 -
创建索引提高查询速度
使用索引也有几点需要注意:
- 要满足最左前缀匹配原则。要让查询条件尽可能走索引。
- 不在索引列上做计算:不在索引列上做任何操作(计算、函数、(自动 or 手动)类型转换「尤其注意 varchar 和 int」),会导致索引失效而转向全表扫描。
- 索引列上不能有范围查询:将可能做范围查询的字段的索引顺序放在最后。比如时间范围条件尽可能放到最后。
- 尽量使用覆盖索引:查询列和索引列一致,不写 select *。
- 尽量不用select*,需要用到哪些属性就查哪些属性,需要增加返回属性的时候由该开发人员负责增加。尤尤其对于使用MybatisPlus 的项目,单表查询变得十分方便,如果没写查询的字段,默认就是查询SELECT *。
-
优化特定的查询
-
优化COUNT()查询,统计行数时尽量使用COUNT(*),mysql对COUNT(*)做了优化,它会忽略所有的列而直接统计所有的行数。
-
优化联接查询:优化联接查询有几个注意点
- 确保ON或者USING子句中的列上有索引。在创建索引的时候就要考虑到联接的顺序。
- 确保任何GROUP BY和ORDER BY中的表达式只涉及一个表中的列,这样MySQL才有可能使用索引来优化这个过程。
-
优化LIMIT和OFFSET子句
在偏移量非常大的时候,例如,可 能是LIMIT 1000,20这样的查询,这时MySQL需要查询10020条记录然 后只返回最后20条,前面10 000条记录都将被抛弃,这样的代价非常高。可以通过延迟联接进行优化,充分利用索引的优势。
比如:
SELECT id, name, age FROM ORDER BY age LIMIT 10000, 10;
可以通过延迟联接进行优化:
SELECT id, name, age FROM user INNER JOIN ( SELECT id FROM user ORDER BY age LIMIT 10000, 10 ) AS temp USING(id);
-
这种方式在查询字段较多且字段长度较长时有明显的性能提升。
推荐阅读: 《高性能MySql(第4版)》
作为开发人员,对Mysql优化可以先看第7章(创建高性能的索引)和第八章(查询性能的优化)。
代码优化
不要太过信任自己的代码,多推敲一下,往往问题就出现在我们深信不疑的地方。
下面列举一下平时使用的时候不注意,却可以锦上添花的地方。
1. 尽量不用属性拷贝
Spring 的 BeanUtils.copyProperties
方法,是一个很好用的属性拷贝工具方法。当需要对两个对象的属性进行拷贝时,使用一行代码就能完成,十分简洁。BeanUtils.copyProperties(Object source, Object target)
。
但是,代码简洁的前提是以牺牲性能和安全为代价的。
不推荐的原因有二:
- 属性拷贝比直接调用属性Get和Set方法性能更低。
- 属性名称改了开发人员需要手动赋值,很容易造成忽略。因为属性拷贝只对两个属性类型和名称相同的属性进行拷贝。
2. 当成员变量值无需改变时,尽量定义为静态常量
在类的每个对象实例中,每个成员变量都有一份副本,而成员静态常量只有一份实例。
反例:
public class Test {
String mac = "xxxls" + "abcdefg";
}
正例:
public class Test {
private static final String MAC_PREFIX = "xxxls";
String mac = MAC_PREFIX + "abcdefg";
}
3. 尽量使用基本数据类型,避免自动装箱和拆箱
JVM 支持基本类型与对应包装类的自动转换,被称为自动装箱和拆箱。装箱和拆箱都是需 要 CPU 和内存资源的,所以应尽量避免使用自动装箱和拆箱。
反例:
Integer num = 0;
for(int i = 0; i < 10; i++) {
num = num + 1; //相当于num = Integer.valueOf(num.intValue() + value);
}
正例:
int num = 0;
for(int i = 0; i < 10; i++) {
num = num + 1;
}
直接使用基本类型的包装类进行运算会造成大量的拆箱装箱代码,消耗资源,然而这种消耗完全是不必要的。而且,在超过包装类对象在内存中的缓存范围后,每次装箱都会创建对象。
在循环中直接用包装类进行计算性能损耗尤为严重。
4. 尽量使用函数内的基本类型临时变量
在函数内,基本类型的参数和临时变量都保存在栈(Stack)中,访问速度较快,随着方法出栈,对应栈中的变量就清除;对象类型的参数和临时变量的引用都保存在栈(Stack)中,内容都保存在堆(Heap) 中,访问速度较慢,而且创建的对象需要经过gc进行回收。
5. 尽量使用基本数据类型作为方法参数类型。
避免不必要的装箱、拆箱和空指针判断。尽量使用基本数据类型进行计算。返回值也最好用基本类型。在 JDK 类库的方法中,很多方法返回值都采用了基本数据类型。比如: Collection.isEmpty()
和 Map.size()
。
反例:
public static double sum(Double v1, Double v2) {
v1 = Objects.isNull(v1) ? 0.0D : v1;
v2 = Objects.isNull(v2) ? 0.0D : v2;
return v1 + v2;
}
正例:
public static double sum(double v1, double v2) {
return v1 + v2;
}
6. 尽量减少方法的重复调用
反例:
List<Integer> list = ...;
for( int i = 0; i < list.size(); i++) {
//每次循环都会调用list.size()方法
...
}
正例:
List<Integer> list = ...;
int size = list.size();
for( int i = 0; i < size; i++) {
...
}
7. 位运算代替正整数乘除
用移位操作可以极大地提高性能。对于乘除 2^n(n 为正整数)的正整数计算,可以用移位操作来代替。对于计算频次非常高的计算可以用位运算封住函数使用,使用频率不高的话性能差别不大。
int num1 = a * 4; //等价于 int num1 = a << 2;
int num2 = a / 4; //等价于 int num2 = a >> 2;
8. 尽量不在条件表达式中用!取反
使用!取反会多一次计算,如果没有必要则优化掉。
9. 对于多常量选择分支,尽量使用 switch 语句而不是 if-else 语句
if-else 语句,每个 if 条件语句都要加装计算,直到 if 条件语句为 true 为止。switch 语句进行了跳转优化,Java 中采用 tableswitch
或 lookupswitch
指令实现,对于多 常量选择分支处理效率更高。
经过试验证明:在每个分支出现概率相同的情况下, 低于 5 个分支时 if-else 语句效率更高,高于 5 个分支时 switch 语句效率更高。
10. 尽量不要使用String类进行大量字符串拼接。
因为String是final类,每一次拼接都会在堆内存中产生一个新对象。可以使用StringBuilder
拼接字符串。
正例:
StringBuilder builder = new StringBuilder();
for (int i = 0; i < 100; i++) {
builder.append(i).append(" ");
}
11. 不要使用""+转化字符串
使用""+
进行字符串转化,使用方便但是效率低,建议使用 String.valueOf
.
反例:
int i = 12345;
String s = "" + i;
正例:
int i = 12345;
String s = String.valueOf(i);
12. 尽量预编译正则表达式
Pattern.compile()
方法的性能开销很大。如果多次用到同一个正则表达式进行匹配,可以先构建Pattern对象,重复利用。
compile()
方法中有一个循环,所以把正则编译放到循环里面执行,实际的时间复杂度要比预估的高一个数量级。
// Convert all chars into code points
for (int x = 0; x < patternLength; x += Character.charCount(c)) {
c = normalizedPattern.codePointAt(x);
if (isSupplementary(c)) {
hasSupplementary = true;
}
temp[count++] = c;
}
正例:
String regex = "\\w+";
Pattern PATTERN = Pattern.compile(regex);
String[] ss = {"aaa", "aba", "cba"};
for (String s : ss) {
boolean isMatched = PATTERN.matcher(s).matches();
}
类似的方法还有:
// 循环中调用多次
String source = "*********";
boolean isMatched = source.matches("regex");
String target = source.replaceAll("regex","result");
String[] targets = source.split("regex");
这些方法都使用了Java的正则匹配,如果在循环中调用,都会进行重复编译对应的正则表达式。正确的做法是把正则表达式的编译放到循环的外面,一次编译多次使用。
正确使用:
final Pattern PATTERN = Pattern.compile("regex");
//以下代码执行多次
String source = "*********";
boolean isMatched = PATTERN.matcher(source).matches();
String target = PATTERN.matcher(source).replaceAll("regex","result");
String[] targets = PATTERN.matcher(source).split(source);
13. 初始化集合时,尽量指定集合大小
Java 集合初始化时都会指定一个默认大小,当默认大小不再满足数据需求时就会扩 容,每次扩容的时间复杂度有可能是 O(n)。所以,尽量指定预知的集合大小,就能避免或减少集合的扩容次数。
推荐使用Google提供的类库中创建集合的工具类。lists、Sets、Maps
。
对于Map和Set初始化集合大小的注意事项。因为Map设置了装填因子,当元素大于(装填因子 x 容量)时就会进行扩容。所以初始化Map和Set的大小时new HashMap(int size)
是错误的操作。
Maps工具类中提供了Maps.newHashMapWithExpectedSize()
初始化Map。其中实现了计算容积的方法,使得已知元素个数的情况下,创建的Map刚好不会进行扩容。
static int capacity(int expectedSize) {
if (expectedSize < 3) {
CollectPreconditions.checkNonnegative(expectedSize, "expectedSize");
return expectedSize + 1;
} else {
return expectedSize < 1073741824 ? (int)((float)expectedSize / 0.75F + 1.0F) : Integer.MAX_VALUE;
}
}
13. 空集合尽量不要直接返回new的对象。
返回空集合时,尽量使用Collections工具类中的空集合对象Collections中专门定义了空集合的静态常量,比如
-
Collections.emptyMap();
-
Collections.emptyList();
-
Collections.emptySet();
因为new出来的集合会占用一定内存空间。比如 new ArrayList()
会默认开辟大小为10的对象地址空间,new HashMap()
会创建初始大小为16的数组空间然而,返回空对象时这段空间就没有任何使用的意义,浪费了。
1.8之后是延迟加载,插入第一个元素时才创建空间,直接创建空集合性能影响不大。但是本来就有静态类可以用,又何必创建空集合的对象呢?
14. 不要使用循环拷贝集合,尽量使用 JDK 提供的方法拷贝集合。
JDK 提供的方法可以一步指定集合的容量,避免多次扩容浪费时间和空间。同时, 这些方法的底层也是调用本地方法 System.arraycopy ()
方法实现,直接从内存上进行复制,进行数据的批量拷贝效率更高。比如List中的addAll()
方法。但是Map中的putAll()
方法还是循环调用的put()
方法,要注意区分。
15. 集合作为参数时,根据其作用选择其类型
比如用到了List的顺序性,参数类型传List;用到了Set的唯一性,则将Set作为参数。如果只是用到了集合存储数据的特性,则直接用Collection即可。好处是拓展性强。
值得一提的是,我们的项目中引入了MabatisPlus,MabatisPlus的接口中用到了大量的泛型、Collection、Object定义参数,这就是其拓展性很强的原因。我们用MabatisPlus写的CRUD方法其实就是对MabatisPlus接口的延伸,一样可以用类似的方法,让我们的代码有更好的兼容性。
比如:集合用Collection类型作为参数,这样Set和List都能调用这个查询、数字用Number作为参数,这样整形和浮点型都能调用,方法的复用性就提升了很多。
反例:
public void sout(List<Integer> list) {
for(Integer i: list) {
//遍历...
}
}
public void sout(Set<Integer> set) {
for(Integer i: set) {
//遍历...
}
}
正例:
public void sout(Collection<?> coll) {
for(Object i: coll) {
//遍历...
}
}
16. 查询某个表数据的方法尽量写在对应的Service中
Service继承了BaseMapper后,虽然可以直接在别的注入了这个Service 的地方使用Mabatis进行查询,但是这种写法耦合度太高。
比如:我在ServiceA里写了一个查询ServiceB数据的方法 f()
,此时ServiceC刚好有相同的查询逻辑,如果ServiceC知道ServiceA里有 f()
,则需注入ServiceA进行调用,此时的调用链是ServiceC -> ServiceA -> ServiceB。另外,如果ServiceC不知知道ServiceA里有f(),那么ServiceC则需要重新写一个相同逻辑的查询接口在ServiceB中或者ServiceC中。
正确的方法应该是把 f()
写在ServiceB中,这样ServiceA和ServiceB都能复用f(),而且写在ServiceB中 f()
能更容易被发现。
17. 根据id进行删除的接口,可以改写成通过id集合批量删除,兼容性更高。
18. 每个模块可以新增一个字符串常量接口
在我们的代码中用到了大量的字符串拼接,定义的字符串却是直接写在代码中。这样做会导致复用性降低,且每一次调用相同的代码都会创建相同字符串对象进行拼接。可以新增一个接口保存这些使用频率非常高的字符串常量,进行使用。
算法优化
算法优化一般来讲是最难处理的。因为算法的设计一般在程序设计阶段就已经确立了,当出现了性能问题要从算法上进行优化时一般来讲都会对之前的架构会有影响。这种情况下,优化的代价基本上就是重构代码。
一般来讲,算法优化的思路一般就两个:时间复杂度和空间复杂度。从这两个方向综合考虑优化,这往往需要丰富的经验和知识作支撑。