Java高质量编程【二、高性能】
在Java开发中,高性能编码不仅仅是写出跑得快的代码,更关乎资源利用率(CPU、内存、I/O)和系统稳定性。高性能往往意味着减少垃圾回收(GC)压力、降低锁竞争、优化数据结构以及避免不必要的对象创建。
以下是Java高性能编码的核心方法、规范详解及正反示例,分为内存与对象管理、集合与数据结构、并发编程、I/O与流处理、字符串处理五个维度。
一、内存与对象管理 (Memory & Object Management)
核心原则:减少对象分配频率,降低GC压力,避免内存泄漏。
1. 避免在循环中创建临时对象
- 规范:将对象创建移出循环,或复用对象。
- 原理:循环内创建对象会导致短时间内产生大量“朝生夕死”的对象,触发频繁的 Minor GC,甚至导致 Stop-The-World (STW)。
❌ 反面示例:
public long sumValues(List<Integer> list) {
long sum = 0;
for (Integer i : list) {
// 每次循环都自动装箱/拆箱,且如果逻辑复杂可能创建临时对象
// 这里的 new BigDecimal 是典型的循环内创建大对象
BigDecimal val = new BigDecimal(i);
sum += val.longValue();
}
return sum;
}
✅ 正面示例:
public long sumValues(List<Integer> list) {
long sum = 0;
// 如果需要复杂计算,尽量在循环外初始化可复用对象,或使用基本类型
for (Integer i : list) {
// 直接使用基本类型运算,避免对象创建
sum += i;
}
return sum;
}
// 如果必须用BigDecimal,考虑是否在循环外复用(注意线程安全),或者接受必要的开销但避免其他多余操作
2. 使用基本类型代替包装类
- 规范:在计算密集型场景,优先使用
int,long,double而非Integer,Long,Double。 - 原理:包装类包含对象头开销(约12-16字节)+ 引用开销,且涉及自动装箱/拆箱的CPU指令。
❌ 反面示例:
// 使用包装类列表,内存占用大,且有拆箱开销
List<Long> ids = new ArrayList<>();
for (long i = 0; i < 1000000; i++) {
ids.add(i); // 自动装箱:new Long(i)
}
✅ 正面示例:
// 使用基本类型数组或第三方库(如 fastutil, HPPC)
long[] ids = new long[1000000];
for (long i = 0; i < 1000000; i++) {
ids[(int)i] = i; // 无装箱开销
}
// 或者使用 fastutil 的 LongArrayList
3. 警惕隐式内存泄漏
- 规范:长生命周期的对象(如静态Map、缓存)持有短生命周期对象的引用时,必须手动清理。
- 原理:GC Roots 链未断开,导致对象无法回收。
❌ 反面示例:
public class Cache {
private static final Map<String, Object> store = new HashMap<>();
public void put(String key, Object value) {
store.put(key, value);
// 忘记 remove,随着时间推移,store 无限膨胀,导致 OOM
}
}
✅ 正面示例:
import java.util.WeakHashMap; // 方案A:使用弱引用
// 或者
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
public class SafeCache {
// 方案B:使用带有淘汰策略的专业缓存库(推荐)
private final Cache<String, Object> store = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(java.time.Duration.ofMinutes(5))
.build();
public void put(String key, Object value) {
store.put(key, value);
}
}
二、集合与数据结构 (Collections & Data Structures)
核心原则:预分配容量,选择合适的数据结构,减少扩容和哈希冲突。
1. 初始化集合时指定容量
- 规范:如果知道集合的大致大小,务必在构造函数中指定
initialCapacity。 - 原理:避免数组多次扩容(resize)和元素拷贝(System.arraycopy)。HashMap 扩容还会导致 rehash。
❌ 反面示例:
// 默认容量16,负载因子0.75。当放入1000个元素时,会经历多次扩容 (16->32->64...->1024)
// 每次扩容都伴随着数组创建和数据迁移,性能损耗极大
List<String> list = new ArrayList<>();
for (int i = 0; i < 10000; i++) {
list.add("item" + i);
}
✅ 正面示例:
// 预估大小,一次性分配足够内存
List<String> list = new ArrayList<>(10000);
for (int i = 0; i < 10000; i++) {
list.add("item" + i);
}
// HashMap 同样需要计算容量:expectedSize / loadFactor + 1
Map<String, String> map = new HashMap<>((int)(10000 / 0.75f) + 1);
2. 选择正确的 Map 实现
- 规范:
- 键值对少且Key简单:
HashMap。 - 高并发读多写少:
ConcurrentHashMap(JDK8+ 性能很好)。 - 只需要Key不需要Value:
HashSet(底层是HashMap) 或 专用位图。 - 范围查询/排序:
TreeMap(O(logN)),但如果只需排序一次,建议ArrayList+Collections.sort(O(NlogN) 但常数更小)。
- 键值对少且Key简单:
❌ 反面示例:
// 场景:只需要判断元素是否存在,却用了 HashMap<String, Boolean>
Map<String, Boolean> existsMap = new HashMap<>();
existsMap.put("key", true);
// 浪费了一个 Boolean 对象的空间
✅ 正面示例:
// 使用 HashSet 或者更极致的 BitSet (如果Key是整数)
Set<String> existsSet = new HashSet<>();
existsSet.add("key");
三、并发编程 (Concurrency)
核心原则:减少锁粒度,利用无锁算法,避免上下文切换。
1. 优先使用 LongAdder 而非 AtomicLong (高并发计数)
- 规范:在高并发写场景下,使用
LongAdder。 - 原理:
AtomicLong基于 CAS,竞争激烈时自旋消耗大量 CPU;LongAdder采用分段累加(Cell数组),仅在求和时合并,大幅降低竞争。
❌ 反面示例:
private final AtomicLong count = new AtomicLong(0);
public void increment() {
// 高并发下,CAS 失败率高,CPU 空转
count.incrementAndGet();
}
✅ 正面示例:
private final LongAdder count = new LongAdder();
public void increment() {
// 线程更新不同的 Cell,几乎无冲突
count.increment();
}
public long getCount() {
return count.sum(); // 读取时稍慢,但写入极快
}
2. 缩小锁的范围 (Lock Striping / Fine-grained Locking)
- 规范:只锁定必要的代码段,避免锁定整个方法或大块逻辑。
- 原理:减少线程等待时间,提高吞吐量。
❌ 反面示例:
public synchronized void process(List<Data> list) {
// 锁住了整个方法,包括耗时的 I/O 操作和无关的计算
Data data = fetchFromDB(); // I/O 阻塞,锁被长时间占用
transform(data);
save(data);
}
✅ 正面示例:
private final Object lock = new Object();
public void process(List<Data> list) {
Data data = fetchFromDB(); // 在锁外执行 I/O
synchronized (lock) {
// 仅锁定共享资源修改的临界区
transform(data);
save(data);
}
}
3. 使用线程池,严禁手动 new Thread()
- 规范:使用
ThreadPoolExecutor或Executors(需注意坑) 创建线程池,并合理配置参数。 - 原理:线程创建和销毁开销大,无限创建线程会导致 OOM 或 CPU 过度切换。
❌ 反面示例:
// 每次请求都新建线程,系统资源迅速耗尽
public void handleRequest() {
new Thread(() -> {
doHeavyWork();
}).start();
}
✅ 正面示例:
// 预定义线程池,复用线程
private static final ExecutorService executor = new ThreadPoolExecutor(
10, 50, 60L, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(1000),
new ThreadPoolExecutor.CallerRunsPolicy() // 拒绝策略防止任务丢失
);
public void handleRequest() {
executor.submit(() -> doHeavyWork());
}
四、I/O 与 流处理 (I/O & Streams)
核心原则:减少系统调用次数,使用缓冲,利用 NIO。
1. 使用缓冲流 (Buffered Streams)
- 规范:包装
InputStream/OutputStream/Reader/Writer时使用Buffered版本。 - 原理:减少磁盘/网络系统调用(syscall)的次数。一次读1KB比读1000次1字节快得多。
❌ 反面示例:
// 每次 read() 都可能触发一次系统调用
try (FileInputStream fis = new FileInputStream("large.txt")) {
int data;
while ((data = fis.read()) != -1) { // 逐字节读取,极慢
process(data);
}
}
✅ 正面示例:
// 内部维护缓冲区,批量读取
try (FileInputStream fis = new FileInputStream("large.txt");
BufferedInputStream bis = new BufferedInputStream(fis)) {
byte[] buffer = new byte[8192];
int len;
while ((len = bis.read(buffer)) != -1) { // 批量读取
process(buffer, len);
}
}
2. 谨慎使用 Java Stream API (在极致性能场景)
- 规范:Stream API 代码优雅,但在极度敏感的热点路径(Hot Path),传统
for循环通常更快。 - 原理:Stream 涉及 Lambda 对象创建、接口调用开销、流水线构建开销。JDK 优化后差距缩小,但在简单遍历累加中,
for仍胜一筹。
❌ 反面示例 (微基准测试敏感场景):
// 在每秒调用百万次的场景中,Stream 的开销不可忽视
long sum = list.stream()
.filter(x -> x > 0)
.mapToLong(Long::valueOf)
.sum();
✅ 正面示例:
long sum = 0;
for (long x : list) {
if (x > 0) {
sum += x;
}
}
// 注:对于业务逻辑复杂的非热点代码,Stream 的可读性优势大于微小的性能损失,应优先保证可读性。
五、字符串处理 (String Handling)
核心原则:利用不可变性,避免频繁拼接,注意编码转换。
1. 循环中使用 StringBuilder
- 规范:在循环或频繁修改字符串时,必须使用
StringBuilder(单线程) 或StringBuffer(多线程,较少用)。 - 原理:String 是不可变的,
+操作符在循环中会生成大量中间 String 对象。
❌ 反面示例:
String result = "";
for (int i = 0; i < 1000; i++) {
result += i; // 编译为 new StringBuilder().append(result).append(i).toString()
// 每次循环都丢弃旧的 StringBuilder 和 String
}
✅ 正面示例:
StringBuilder sb = new StringBuilder(1000 * 4); // 预估容量
for (int i = 0; i < 1000; i++) {
sb.append(i);
}
String result = sb.toString();
2. 字符串常量池与 intern() 的慎用
- 规范:不要随意调用
string.intern(),除非你非常清楚自己在做什么(如节省大量重复长字符串的内存)。 - 原理:
intern()会将字符串放入 JVM 的永久代/元空间中的常量池,操作涉及同步锁,且容易导致 Metaspace OOM。
❌ 反面示例:
// 解析大量日志时,对每个字段都 intern,导致元空间爆满且性能下降
String category = parseCategory(line).intern();
✅ 正面示例:
// 正常创建字符串,依靠 JVM 的 G1/ZGC 处理重复字符串(现代 GC 对短字符串优化很好)
// 或者使用自定义的字典映射 (Map<String, Integer>) 来枚举化
String category = parseCategory(line);
六、总结与最佳实践清单
| 类别 | 关键动作 | 性能收益 |
|---|---|---|
| 对象 | 循环外创建对象,复用对象池 | 减少 GC 频率,降低 STW 时间 |
| 类型 | 基本类型 > 包装类 | 减少内存占用,消除装箱拆箱 |
| 集合 | 初始化指定容量 (capacity) |
避免数组扩容拷贝和 Rehash |
| 并发 | LongAdder > AtomicLong (高并发写) |
降低 CAS 自旋,提升 CPU 利用率 |
| 并发 | 缩小 synchronized 范围 |
减少线程阻塞等待 |
| I/O | 必须使用 Buffered 流 |
减少系统调用次数 (Syscalls) |
| 字符串 | 循环拼接用 StringBuilder |
避免 $O(N^2)$ 的对象创建复杂度 |
| 日志 | 使用占位符 log.info("id={}", id) |
避免在不打印日志时构造字符串 |
七、如何验证性能?
不要盲目优化。测量优于猜测。
- JMH (Java Microbenchmark Harness):编写微基准测试,科学地对比两种写法的性能差异。
- Profiler 工具:使用 Async Profiler, JVisualVM, Arthas 查看 CPU 热点方法和内存分配情况。
- GC 日志分析:观察 GC 频率和停顿时间,判断是否因对象创建过多导致。
示例:使用 JMH 测试
@State(Scope.Thread)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
public class StringBenchmark {
@Param({"100", "1000"})
public int size;
@Benchmark
public String testConcat() {
String s = "";
for (int i = 0; i < size; i++) {
s += i;
}
return s;
}
@Benchmark
public String testBuilder() {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < size; i++) {
sb.append(i);
}
return sb.toString();
}
}
运行结果通常会显示 testBuilder 比 testConcat 快几个数量级。
遵循这些规范和模式,能够显著提升 Java 应用的响应速度和吞吐量,同时保持系统的稳定性。
本文来自博客园,作者:蓝迷梦,转载请注明原文链接:https://www.cnblogs.com/hewei-blogs/articles/19714740

浙公网安备 33010602011771号