四十四条性能优化实战总结
进行代码优化的核心价值之一在于:预防潜在的错误。在代码上线运行后,常常会出现各种预期之外的问题,因为生产环境与开发环境存在诸多差异。许多问题的根源往往非常微小。为了修复一个错误,我们通常需要本地验证、重新打包、替换
class
文件、暂停服务并重启。对于一个成熟且用户量大的系统而言,服务暂停意味着用户在此期间无法正常使用,影响显著。因此,在编码阶段就从源头关注细节,权衡并选择最优的实现方案,能够极大程度上规避未知错误,从长远看也显著减少了后续维护工作量。
代码优化的核心目标:
缩减代码体积
提升代码执行效率
具体优化细节
(1)尽可能为类与方法添加 final 修饰符
用 final 修饰的类无法被继承。在 Java 核心 API 中,存在大量 final 类的例子,例如 java.lang.String。为类指定 final
可防止其被继承,为方法指定 final 可防止其被重写。若一个类被声明为 final,其所有方法隐式地为 final。Java 编译器会尝试内联所有
final 方法,内联对提升 Java 运行效率作用显著,此举平均可提升约 50% 的性能。
(2)提倡对象复用
尤其在字符串操作中,进行字符串拼接时应优先使用 StringBuilder 或 StringBuffer。由于 JVM
创建对象和后续的垃圾回收都会消耗时间,过度创建对象会对程序性能造成负面影响。
(3)优先使用局部变量
方法调用时传递的参数以及方法内创建的临时变量都存储在栈中,访问速度较快;而静态变量、实例变量等则在堆中分配,访问相对较慢。此外,栈中的变量会随着方法执行结束而自动释放,无需额外的垃圾回收开销。
(4)及时关闭资源
在进行数据库连接、I/O 流操作时务必谨慎,使用完毕后应立即关闭以释放资源。因为这些大对象的操作会带来较大的系统开销,处理不当可能导致严重问题。
(5)避免重复计算
需要明确:方法调用本身是有开销的,包括创建栈帧、保护现场、恢复现场等。因此,像下面这样的写法:
java
for (int i = 0; i < list.size(); i++) {
// …
}
建议改为:
java
for (int i = 0, length = list.size(); i < length; i++) {
// …
}
这样,当 list.size() 很大时,能有效减少不必要的消耗。
(6)采用延迟加载策略,即需要时才创建对象
例如:
java
String str = “aaa”;
if (i == 1) {
list.add(str);
}
建议改为:
java
if (i == 1) {
String str = “aaa”;
list.add(str);
}
(7)谨慎使用异常
异常处理对性能有负面影响。抛出异常首先需要创建一个新对象。Throwable 的构造函数会调用名为 fillInStackTrace()
的本地同步方法,该方法会检查堆栈并收集调用跟踪信息。每当有异常抛出,JVM 都必须调整调用堆栈,因为在这个过程中创建了新对象。异常应当仅用于错误处理,而不应用于控制程序流程。
(8)避免在循环内部使用 try-catch,应将其置于最外层
根据社区反馈,这一点存在争议,可根据实际情况评估。
(9)预估内容大小,为基于数组实现的集合和工具类指定初始容量
例如 ArrayList、LinkedList、StringBuilder、StringBuffer、HashMap、HashSet 等。以 StringBuilder 为例:
StringBuilder():默认分配 16 个字符空间。
StringBuilder(int size):分配 size 个字符空间。
StringBuilder(String str):分配 16 个字符加上 str.length() 个字符空间。
通过构造函数设置合理的初始容量,可以明显提升性能。以 StringBuilder 为例,其内部存在扩容机制。当达到当前容量时,它会将容量增至原来的
2 倍再加 2。每次扩容都涉及创建新数组并复制旧数据,这是一个相对耗时的操作。假设预计要存放 5000 个字符但不指定初始长度,最接近的
2 的幂是 4096,那么:
在 4096 基础上,再申请 8194 大小的数组,总共相当于申请了 12290 大小的数组。若一开始指定 5000,则节省了一倍以上的空间。
还需要将原有 4096 个字符复制到新数组中。
这样既浪费内存又降低效率。因此,为基于数组实现的集合和工具类设置合理的初始容量能带来立竿见影的效果。但对于 HashMap
这种数组加链表的结构,初始容量不宜设置得与预估元素数完全一致,因为几乎不可能每个桶只放一个元素。建议设置为 2 的 N 次幂,例如预估有
2000 个元素,可设置 new HashMap(128) 或 new HashMap(256)。
(10)复制大量数据时,优先使用 System.arraycopy()
(11)乘除运算可考虑使用移位操作
例如:
java
for (int val = 0; val < 100000; val += 5) {
a = val * 8;
b = val / 2;
}
移位操作在底层效率更高,可改为:
java
for (int val = 0; val < 100000; val += 5) {
a = val << 3; // 乘以8
b = val >> 1; // 除以2
}
移位操作虽快,但可能降低代码可读性,建议添加适当注释。
(12)循环内避免重复创建对象引用
例如:
java
for (int i = 1; i <= count; i++) {
Object obj = new Object();
}
这会导致内存中存在 count 个 Object 对象的引用,若 count 很大则占用内存。建议改为:
java
Object obj = null;
for (int i = 0; i <= count; i++) {
obj = new Object();
}
这样内存中始终只有一份 Object 引用,每次循环指向新的对象,显著节省内存。
(13)在效率和类型检查允许的情况下,优先使用数组,不确定大小时才使用 ArrayList
(14)除非需要线程安全,否则优先使用 HashMap、ArrayList、StringBuilder,而非 Hashtable、Vector、StringBuffer,后三者因同步机制带来额外性能开销
(15)避免将数组声明为 public static final
这并无实际意义,它只定义了引用为 static final,数组内容仍可被修改。将数组声明为 public 更会带来安全风险,意味着外部类可以更改数组内容。
(16)在适当场景使用单例模式
单例模式有助于减少加载次数、缩短加载时间、提高加载效率。它主要适用于以下场景:
控制资源访问,通过线程同步实现资源的并发控制。
控制实例数量,以节约资源。
在不建立直接关联的情况下,实现多个不相关进程或线程间的数据共享。
(17)谨慎使用静态变量
当一个对象被静态变量引用时,垃圾回收器通常不会回收该对象所占用的堆内存。例如:
java
public class A {
private static B b = new B();
}
此时静态变量 b 的生命周期与类 A 相同。只要类 A 未被卸载,b 所引用的 B 对象将常驻内存,直至程序结束。
(18)及时清理无效会话
许多应用服务器为会话设置了默认的超时时间(通常为 30
分钟)。当服务器需要维护大量会话且内存不足时,操作系统可能将部分数据转移到磁盘,服务器也可能根据最近最频繁使用(MRU)算法将不活跃的会话转储到磁盘,甚至抛出内存不足异常。会话转储到磁盘前需要序列化,这在大型集群中代价高昂。因此,当会话不再需要时,应及时调用
HttpSession.invalidate() 进行清理。
(19)对于实现了 RandomAccess 接口的集合(如 ArrayList),应使用普通 for 循环而非 for-each 循环进行遍历
这是 JDK 的官方建议。RandomAccess 接口表明该类支持快速随机访问。使用普通 for 循环效率更高;而对于顺序访问的集合,使用迭代器(Iterator)效率更佳。判断方式如下:
java
if (list instanceof RandomAccess) {
for (int i = 0; i < list.size(); i++) {
// …
}
} else {
Iterator<?> iterator = list.iterator();
while (iterator.hasNext()) {
iterator.next();
}
}
for-each 循环底层基于迭代器实现,因此后半句“顺序访问使用迭代器效率更高”即指顺序访问的类实例使用 for-each 循环遍历。
(20)使用同步代码块替代同步方法
除非能确定整个方法都需要同步,否则尽量使用同步代码块,仅对需要同步的代码部分加锁,避免不必要的性能损耗。
(21)常量应声明为 static final 并以大写命名
这样在编译期即可将常量值放入常量池,避免运行期计算。同时,大写命名便于区分常量与变量。
(22)消除无用代码
不要创建不使用的对象,不要导入不使用的类。若出现“局部变量未使用”或“导入的类从未使用”等警告,应及时清理。
(23)程序运行时避免使用反射
反射功能强大但效率较低。不建议在程序运行过程中(尤其是频繁地)使用反射,特别是 Method.invoke()
方法。如果确实需要,建议在项目启动时通过反射实例化相关对象并放入内存。
(24)使用数据库连接池和线程池
两者都是为了重用对象。连接池可避免频繁地打开和关闭数据库连接,线程池可避免频繁地创建和销毁线程。
(25)使用带缓冲的输入输出流进行 I/O 操作
例如 BufferedReader、BufferedWriter、BufferedInputStream、BufferedOutputStream,这能显著提升 I/O 效率。
(26)根据场景选择集合类型
需要频繁随机访问或顺序插入时,使用 ArrayList;需要频繁删除元素或在中间插入时,使用 LinkedList。
(27)避免 public 方法参数过多
public 方法对外提供,参数过多有两个主要弊端:
违背面向对象思想,Java 强调一切皆对象,过多参数与之不符。
参数过多会增加方法调用的出错概率。
通常参数个数建议控制在 3-4 个以内。例如,一个插入学生信息的方法有 10 个字段,可将这些字段封装为一个学生实体类作为参数。
(28)字符串变量与常量比较时,将常量写在前面
例如:
java
String str = “123”;
if (str.equals(“123”)) {
// …
}
建议改为:
java
String str = “123”;
if (“123”.equals(str)) {
// …
}
这可以有效避免空指针异常。
(29)条件判断中,建议使用 if (i == 1) 而非 if (1 == i)
两者在 Java 中无区别,但从阅读习惯出发,前者更符合直觉。
(30)不要对数组直接调用 toString() 方法
对数组调用 toString() 输出的是数组的哈希表示,而非其内容。例如:
java
int[] arr = {1, 2, 3};
System.out.println(arr.toString()); // 输出类似 [I@18a992f
若要打印数组内容,应使用 Arrays.toString(arr)。但集合类(如 ArrayList)的 toString() 方法已被重写,可以正确输出内容。
(31)避免对超出范围的基本数据类型进行强制向下转型
例如:
java
long l = 12345678901234L;
int i = (int) l;
System.out.println(i); // 输出 1942892530,非预期值
这不会得到期望的结果,因为长整型被截断。
(32)及时清理公共集合中不再使用的数据
如果一个集合是公共的(非方法内局部变量),其内部的元素不会自动释放,因为有引用指向它们。若不及时移除无用数据,集合会不断增大,可能导致内存泄漏。
(33)基本数据类型转为字符串时,toString() 最快,String.valueOf() 次之,字符串拼接最慢
将基本数据类型转为字符串有三种常见方式。测试表明,Integer.toString(i) 最快,String.valueOf(i) 次之,i + “” 最慢。因为:
String.valueOf() 底层调用了 Integer.toString(),但多了空值判断。
Integer.toString() 直接转换。
i + “” 底层使用 StringBuilder 实现,涉及拼接和转换。
(34)高效遍历 Map
遍历 Map 的 EntrySet 是最高效的方式之一:
java
Map<String, String> map = new HashMap<>();
// 添加元素
for (Map.Entry<String, String> entry : map.entrySet()) {
System.out.println(entry.getKey() + “: “ + entry.getValue());
}
若只需遍历键,使用 keySet() 即可。
(35)资源关闭操作建议分开处理
例如:
java
try {
XXX.close();
YYY.close();
} catch (Exception e) {
// …
}
建议改为分别处理:
java
try {
XXX.close();
} catch (Exception e) {
// …
}
try {
YYY.close();
} catch (Exception e) {
// …
}
这样可以确保每个资源都被尝试关闭,避免因前一个资源关闭异常导致后续资源无法关闭。
(36)使用 ThreadLocal 后务必及时 remove
现代应用大多使用线程池,线程会被复用。ThreadLocal 中存储的数据在线程结束后不会自动清除,若下次复用该线程,可能会读到上次存储的数据,导致错误。因此,使用
ThreadLocal 后必须在适当位置调用 remove() 方法清理。
(37)使用常量定义替代魔法数字
魔法数字会严重降低代码可读性。字符串常量是否定义为常量可视情况而定。
(38)long 或 Long 类型赋值时,使用大写 L 而非小写 l
因为小写 l 易与数字 1 混淆,这是一个值得注意的细节。
(39)所有重写方法必须添加 @Override 注解
原因有三:
明确表明该方法是从父类继承并重写的。
避免因拼写错误导致未能正确重写(例如 getObject 与 get0bject)。
若父类方法签名改变,子类会立即编译报错。
(40)使用 JDK7 引入的 Objects.equals() 进行对象相等比较
直接使用 a.equals(b) 有 NullPointerException 风险,而 Objects.equals(a, b) 则安全。
(41)循环体内字符串拼接避免使用 “+”,应使用 StringBuilder
每次使用 “+” 拼接字符串,底层都会创建新的 StringBuilder 对象,效率低下。应在循环外创建 StringBuilder,在循环内使用 append()
方法。
(42)避免捕获可通过预检查规避的运行时异常
异常处理效率较低。许多 RuntimeException 的子类异常可以通过提前判断来避免,例如:
ArithmeticException:检查除数是否为零。
NullPointerException:检查对象是否为空。
IndexOutOfBoundsException:检查索引是否越界。
ClassCastException:使用 instanceof 进行类型判断。
ConcurrentModificationException:使用迭代器进行遍历操作。
(43)避免在多线程间共享 Random 实例
虽然共享 Random 实例是线程安全的,但竞争同一个 seed 会导致性能下降。JDK7 之后,建议使用 ThreadLocalRandom 来获取随机数。
(44)将工具类、单例类、工厂类的构造函数设为 private
这些类通常不需要外部实例化,将构造函数设为 private 可以防止误创建实例。
结语
卓越的代码源于对每一个细节的持续打磨。关注细微之处,不仅能提升程序运行效率,还能预防许多未知的问题。
