Java性能优化完全指南

15 阅读1分钟

去年双十一大促前夜,我们的订单服务在压测时突然出现长达 5 秒的 GC 停顿,TPS 直接从 2 万跌到 200。你猜最后定位到什么?一个同事在循环里随手写的 SimpleDateFormat.parse()。今天我们就从 时间格式化 这个高频操作切入,聊聊那些看似无害却暗藏杀机的性能陷阱。

1. 时间格式化的代价:为什么SimpleDateFormat是性能杀手?

真实场景

订单服务需要解析上游推送的 10w+/分钟的促销时间戳,最初代码长这样:

// 错误示范:每次循环都new SimpleDateFormat
for (String timestamp : timestampList) {
    SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
    Date date = sdf.parse(timestamp); // 耗时操作
    // 业务逻辑...
}

压测时发现:当 QPS > 5000 时,CPU 利用率飙升到 90%,解析耗时从平均 2ms 暴涨到 50ms。

根因分析

SimpleDateFormat 的问题在于:

  1. 非线程安全:每次使用必须新建实例,而 new SimpleDateFormat() 会触发 CLDR 时区数据加载(约 2ms/次)
  2. 正则表达式编译:模式字符串会被编译为正则表达式,这部分开销占解析时间的 60% 以上
  3. 同步锁竞争:即使复用实例,内部的 Calendar 对象仍存在锁竞争(JDK8 之前更严重)

正确解法

  • 方案1:线程局部变量*(适用中等吞吐量)
private static final ThreadLocal<SimpleDateFormat> threadLocalSdf = 
    ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));

// 使用方式
Date date = threadLocalSdf.get().parse(timestamp);
  • 方案2:DateTimeFormatter*(JDK8+ 首选)
private static final DateTimeFormatter formatter = 
    DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");

// 使用方式(比SimpleDateFormat快3倍)
LocalDateTime datetime = LocalDateTime.parse(timestamp, formatter);

性能对比

方案10w次解析耗时线程安全内存占用
每次new SimpleDateFormat3200ms❌高
ThreadLocal缓存210ms✅中
DateTimeFormatter90ms✅低

2. 集合选择陷阱:为什么你的HashMap在吞吐量高时会突然卡死?

真实案例

某风控系统使用 HashMap 缓存规则数据,平时运行正常,但在规则热更新时出现 长达 15 秒的服务不可用。

根因分析

问题出在 HashMap.resize():

  1. JDK7 的扩容采用头插法,多线程操作可能引发 环形链表(CPU飙升到100%)
  2. 即使使用 JDK8+ 的尾插法,并发扩容时仍会 阻塞所有读写线程

正确解法

// 错误:普通HashMap
Map<String, Rule> ruleCache = new HashMap<>();

// 正确:并发场景只用这两种
Map<String, Rule> ruleCache = new ConcurrentHashMap<>();  // 适合读多写少
Map<String, Rule> ruleCache = new ConcurrentSkipListMap<>(); // 需要排序时
  • 关键参数*:
  • 预估容量:new ConcurrentHashMap<>(512) 避免频繁扩容
  • 并发级别:new ConcurrentHashMap<>(16, 0.75f, 32) 最后一个参数是并发更新线程数

3. 避坑清单:Java性能优化高频雷区

  1. 字符串拼接
    • 禁止在循环内用 +=:String 的每次拼接都产生新对象
    • 改用 StringBuilder(单线程)或 StringBuffer(多线程)
  2. 日志打印
    • 避免 logger.debug("User:" + user + "..."),即使日志级别关闭也会执行拼接
    • 正确写法:logger.debug("User:{}...", user)(SLF4J 占位符延迟计算)
  3. 流式操作
    • list.stream().filter().collect() 比循环慢 3-5 倍,小集合直接用 for 循环
    • 并行流 parallelStream() 需要数据量 >1w 条才有收益,且要避免共享变量
  4. 自动装箱
    • Long sum = 0L; 的累加比 long sum = 0L; 慢 10 倍(实测 1亿次操作 800ms vs 80ms)

4. 终极武器:如何用JMH写出可信的Benchmark?

很多"优化"其实只是把误差当成了提升。分享一个真实对比案例:

@BenchmarkMode(Mode.Throughput)
@State(Scope.Thread)
public class StringBenchmark {
    
    @Benchmark
    public void testStringAdd() {
        String s = "";
        for (int i = 0; i < 100; i++) {
            s += i; // 被JIT优化后可能比StringBuilder还快!
        }
    }

    @Benchmark
    public void testStringBuilder() {
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < 100; i++) {
            sb.append(i);
        }
        String s = sb.toString();
    }
}
  • 实验结果*:
  • 循环次数 <100 时,+= 反而更快(JIT 做了优化)
  • 循环次数 >1000 时,StringBuilder 开始显现优势

这说明:没有基准测试的优化都是玄学。JMH 的关键要点:

  1. 用 @Warmup 预热 3-5 轮消除 JIT 影响
  2. 通过 @Measurement 控制实际测量次数
  3. 始终在同一个 JVM 进程中比较(避免跨进程对比失真)

性能优化的本质是 用确定性替换随机性。当你真正理解 JVM 的内存模型、GC 触发条件和 JIT 优化规律时,就能从"偶然调优成功"进阶到"必然写出高效代码"。

你在项目中最意想不到的性能坑是什么?欢迎分享你的实战案例。