本文翻译自 Java Is Fast. Your Code Might Not Be.,版权归原作者所有。

Java 性能优化系列文章第一部分。第二部分和第三部分即将推出。
几周前,我为在 DevNexus 的一次演讲构建了一个 Java 订单处理应用。应用能运行,测试通过。我运行了负载测试并收集了 Java Flight Recording (JFR) 数据。
优化前: 1,198ms 耗时,每秒 85,000 订单,堆内存峰值略超 1GB,19 次 GC 停顿。
优化后: 239ms。每秒 419,000 订单。139MB 堆内存。4 次 GC 停顿。
同一个应用。同样的测试。同样的 JDK。没有架构变更。当考虑到这类代码在生产环境中并非运行在单机上,而是部署在整个集群时,这些数据就更有意义了。
在第二部分,我将逐步讲解这些数据背后的性能剖析信息:火焰图、哪些方法真正消耗 CPU,以及修复后发生了什么变化。在此之前,你需要了解我们实际修复了哪些问题。
这些问题是真实代码库中常见的模式。它们编译通过,能混过代码审查,而且如果没有性能剖析数据指引,很容易被忽略。以下是其中八个问题。
TL;DR
修复这类反模式将一个耗时 1,198ms 的 Java 应用优化到 239ms。以下是一些需要查找并修复的问题:
- 循环中的字符串拼接 —— 不可变性导致的 O(n²) 复制
- 循环内的 O(n²) Stream 迭代 —— 每个元素都遍历整个列表
- 热点路径中的 String.format() —— 最慢的字符串构建方式,每次调用都解析格式
- 热点路径中的自动装箱 —— 数百万个临时包装对象
- 用异常控制流程 —— fillInStackTrace() 遍历整个调用栈
- 过宽的同步范围 —— 单个锁成为瓶颈
- 重复创建可复用对象 —— 每次调用都创建 ObjectMapper、DateTimeFormatter、Gson
- 虚拟线程钉住(JDK 21–23) —— synchronized 加阻塞 I/O 钉住载体线程
修复后:吞吐量提升 5 倍,堆内存减少 87%,GC 停顿减少 79%。同一个应用,同样的测试,同样的 JDK。
1. 循环中的字符串拼接
String report = "";
for (String line : logLines) {
report = report + line + "\n";
}
这段代码看起来没问题,对吧?问题在于 String 不可变性在实际中意味着什么。
每次使用 + 时,Java 都会创建一个全新的 String 对象,完整复制所有之前的内容并附加新内容。旧对象被丢弃。每次迭代都会发生这种情况。
被复制的字符数量按 O(n²) 增长。如果你有 10,000 行,第 1 次迭代几乎不复制内容,第 5,000 次迭代复制 5,000 个字符的累积内容,第 10,000 次迭代复制所有内容。BellSoft 对此运行了 JMH 基准测试,结果显示当 n 增长 4 倍时,循环拼接版本的耗时增加超过 7 倍,远差于线性增长。
修复方法:
StringBuilder sb = new StringBuilder();
for (String line : logLines) {
sb.append(line).append("\n");
}
String report = sb.toString();
StringBuilder 基于单个可变字符缓冲区工作。一次分配。每次 append 写入该缓冲区。最后调用一次 toString()。
注意:从 JDK 9 开始,编译器足够智能,可以优化单行中的 "Order: " + id + " total: " + amount。但该优化不适用于循环内部。在循环内,你仍然会在每次迭代时创建并丢弃一个新的 StringBuilder。你必须像上面的修复方法那样,在循环之前声明它。
2. 循环内的 Stream 导致意外的 O(n²)
for (Order order : orders) {
int hour = order.timestamp().atZone(ZoneId.systemDefault()).getHour();
long countForHour = orders.stream()
.filter(o -> o.timestamp().atZone(ZoneId.systemDefault()).getHour() == hour)
.count();
ordersByHour.put(hour, countForHour);
}
这看起来很合理。你在按小时分组订单。但看看发生了什么:对每个订单,你都遍历整个列表来统计有多少订单属于该小时。如果你有 10,000 个订单,那就是 10,000 次迭代 × 10,000 个流元素。本该单次遍历的工作,现在需要 1 亿次比较。
在我的演示应用中,这个确切模式是单个最大的 CPU 热点。它占 JFR 录制中近 71% 的 CPU 栈采样。
修复方法:
for (Order order : orders) {
int hour = order.timestamp().atZone(ZoneId.systemDefault()).getHour();
ordersByHour.merge(hour, 1L, Long::sum);
}
单次遍历。O(n)。每个订单直接增加其小时的计数。你也可以使用 Collectors.groupingBy(... Collectors.counting()) 在单个流管道中完成,但 merge 方法更清晰,而且避免了创建流的开销。
如果你在循环体内看到 .stream() 调用,这是一个信号,应该停下来检查是否在做冗余工作。
3. 热点路径中的 String.format()
public String buildOrderSummary(String orderId, String customer, double amount) {
return String.format("Order %s for %s: $%.2f", orderId, customer, amount);
}
String.format() 常被推荐为构建字符串的简洁、可读方式。是的,它可读,但在频繁调用时,它也是 Java 中最慢的字符串构建选项。
Baeldung 对 Java 中每种字符串拼接方法运行了 JMH 基准测试。String.format() 在每个类别中都垫底。它每次调用都要解析格式字符串,运行基于正则的令牌匹配,并通过完整的 java.util.Formatter 机制分发。StringBuilder 始终是最快的。
修复方法:
return "Order " + orderId + " for " + customer + ": $" + String.format("%.2f", amount);
在需要数值格式化的地方使用 String.format(),让编译器优化其余部分。或者如果需要完全控制,就使用 StringBuilder。
String.format() 用于配置加载、启动代码、错误消息等不频繁运行的地方没问题。只要把它从性能剖析工具标记为热点的任何地方移出来即可。
4. 热点路径中的自动装箱
Long sum = 0L;
for (Long value : values) {
sum += value;
}
在 JVM 层面实际发生的是什么:
Long sum = Long.valueOf(0L);
for (Long value : values) {
sum = Long.valueOf(sum.longValue() + value.longValue());
}
每次迭代都会将 sum 拆箱获取 long,相加,然后将结果装箱回新的 Long 对象。对于一百万个元素,你创建了一百万个 Long 对象供 GC 清理。在 64 位 JVM 上,每个 Long 在堆上约占 16 字节。对于一个本该简单的加法循环,这产生了 16MB 的堆内存周转。
修复方法:
long sum = 0L; // 基本类型,不是包装类
for (long value : values) {
sum += value;
}
这通常悄悄潜入的地方:聚合和处理循环。求和指标、累加计数器、构建统计信息。包装类型混入是因为有人在上游某个集合签名中使用了 Long,而没人想到它在下游循环中的代价。这确实容易忽略。
注意 Integer、Long 或 Double 用作局部循环变量或累加器的情况。还要注意热点代码中的 List<Long> 和 Map<String, Integer>。每次 .get() 和 .put() 都涉及你默默付出的装箱/拆箱往返。
5. 用异常控制流程
public int parseOrDefault(String value, int defaultValue) {
try {
return Integer.parseInt(value);
} catch (NumberFormatException e) {
return defaultValue;
}
}
如果这个方法在紧密循环中被调用,且有相当比例的输入是非数字的,你就有一个可能看起来不像性能问题的性能问题。
昂贵的部分是 Throwable.fillInStackTrace(),它在每次创建异常时于 Throwable 构造函数内部运行。它通过本地方法遍历整个调用栈,并将其物化为 StackTraceElement 对象。调用栈越深,代价越高。想象一下在像 Spring 这样的框架中,调用栈可能非常深。Netty 项目的 Norman Maurer 对此进行了基准测试,差异显著。Baeldung 的 JMH 结果显示,抛出异常会使方法运行速度比正常返回路径慢数百倍。
这不是理论。有一个 Scala/JVM 模板系统的真实生产案例,在发现每个模板渲染的每个字段都会抛出 NumberFormatException 后,响应时间减少了 3 倍。每次测试字段名是否为数字索引时,它都会抛出异常。
修复方法:
public int parseOrDefault(String value, int defaultValue) {
if (value == null || value.isBlank()) return defaultValue;
for (int i = 0; i < value.length(); i++) {
char c = value.charAt(i);
if (i == 0 && c == '-') continue;
if (!Character.isDigit(c)) return defaultValue;
}
try {
return Integer.parseInt(value);
} catch (NumberFormatException e) {
return defaultValue;
}
}
或者如果类路径上已有 Apache Commons Lang,可以使用 NumberUtils.isParsable()。
更新: 几位 HN 评论者正确指出,上面的修复最初没有包含 try-catch,这意味着溢出值和像单独 “-” 这样的边界情况会抛出未处理的异常。已更新为在最终的 parseInt 周围保留 try-catch 作为安全网。预验证仍然避免了绝大多数无效输入的昂贵异常路径,这才是重点。
原则:如果无效输入是你应用中的常规情况(用户提供的数据、外部数据源、任何你不完全控制的内容),请显式预验证。异常用于真正意外的情况,而不是"这可能格式不对"。
6. 过宽的同步范围
public class MetricsCollector {
private final Map<String, Long> counts = new HashMap<>();
public synchronized void increment(String key) {
counts.merge(key, 1L, Long::sum);
}
public synchronized long getCount(String key) {
return counts.getOrDefault(key, 0L);
}
}
共享可变状态需要保护。但整个方法的 synchronized 意味着任何时候只能有一个线程调用任一方法。在处理真实并发的服务中,每个调用 increment() 的线程都会排队等待其他线程完成。锁本身成为瓶颈。
修复方法:
private final ConcurrentHashMap<String, LongAdder> counts = new ConcurrentHashMap<>();
public void increment(String key) {
counts.computeIfAbsent(key, k -> new LongAdder()).increment();
}
public long getCount(String key) {
LongAdder adder = counts.get(key);
return adder == null ? 0L : adder.sum();
}
ConcurrentHashMap 处理并发读写而无需锁定整个结构。LongAdder 专为高并发递增设计。它将计数器分布在内部单元上,在竞争下优于 AtomicLong。
值得单独指出:Collections.synchronizedMap() 包装器有同样的粗粒度锁问题,整个 Map 只有一个锁。ConcurrentHashMap 几乎总是正确的替代方案。
7. 重复创建"可复用"对象
public String serializeOrder(Order order) throws JsonProcessingException {
return new ObjectMapper().writeValueAsString(order);
}
ObjectMapper 是最常见的看起来创建成本低但实际不低的对象示例。创建一个涉及模块发现、序列化器缓存初始化和配置加载。这里每次调用都在做实际工作。
DateTimeFormatter.ofPattern("...")、new Gson()、new XmlMapper() 也是同样的模式。它们都被设计为创建一次并复用。在热点方法中创建它们意味着每次调用都要支付设置成本。
修复方法:
private static final ObjectMapper MAPPER = new ObjectMapper();
public String serializeOrder(Order order) throws JsonProcessingException {
return MAPPER.writeValueAsString(order);
}
ObjectMapper 配置完成后是线程安全的,所以共享 static final 实例没问题。DateTimeFormatter 内置常量如 DateTimeFormatter.ISO_LOCAL_DATE 已经是单例。如果你在热点方法中调用 DateTimeFormatter.ofPattern("..."),把它移到常量中。
经验法则:如果对象的构造函数做了大量设置工作,且对象在构造后是无状态的(或可安全共享的),它应该是字段或常量,而不是局部变量。
8. 虚拟线程钉住(如果你在使用 JDK 21–23)
如果你开始使用虚拟线程,这一点值得了解,它作为生产功能在 Java 21 中引入。
虚拟线程的工作原理是挂载到称为载体线程的小池平台(OS)线程上。当虚拟线程阻塞(例如等待 I/O)时,调度器将其从载体上卸载,释放该载体运行其他内容。这就是虚拟线程可扩展性的全部故事。
但有个陷阱。当虚拟线程进入 synchronized 块并在其中遇到阻塞操作时,它无法被卸载。它会钉住载体线程。该平台线程现在卡住等待,无法服务其他虚拟线程,持续时间与阻塞操作一样长。
// 这个模式在 JDK 21 上可能钉住载体线程
public synchronized String fetchData(String key) throws IOException {
return Files.readString(Path.of("/data/" + key)); // synchronized 内的阻塞 I/O
}
如果这种情况频繁发生,所有载体线程都会被钉住,应用会停滞,即使有数千个虚拟线程等待工作。Netflix 在生产环境中遇到了完全相同的问题,并写了一篇关于调试它的文章。
JFR 实际上会告诉你何时发生这种情况。jdk.VirtualThreadPinned 事件在虚拟线程被钉住时阻塞时触发,默认情况下仅在操作超过 20ms 时触发,所以它已经过滤到真正重要的情况。
JDK 21–23 的修复方法:
private final ReentrantLock lock = new ReentrantLock();
public String fetchData(String key) throws IOException {
lock.lock();
try {
return Files.readString(Path.of("/data/" + key));
} finally {
lock.unlock();
}
}
ReentrantLock 不使用 OS 级对象监视器,所以 JVM 可以在虚拟线程阻塞时正常卸载它,而不是将其钉住在载体上。
JDK 24 说明: JEP 491 随 Java 24 发布,很大程度上解决了这个问题。在 JDK 24+ 上,synchronized 在大多数情况下不再导致钉住。如果你仍在使用 21、22 或 23,这仍然相关,值得用 JFR 检查。如果你在 24 上,对于 synchronized 你大多不必担心,尽管本地方法调用仍可能导致钉住。
累积效应
这些模式中的任何一个都不会让你的应用崩溃。它们不会抛出异常或产生错误答案。它们只是让一切变慢一点,消耗更多内存,扩展性比应有的差。
让它们在没有性能剖析的情况下难以发现的是,其中任何一个在你的代码库中可能完全无害。启动时运行一次的循环中的字符串拼接不会让你付出任何代价。每天调用两次的工具类中的 String.format() 没问题。问题是当这些模式出现在热点路径上时——每个请求、每个事件、主处理循环每次迭代都运行的代码。
在我的演示应用中,这些模式和其他模式将一个 239ms 的操作变成了 1,198ms,并将堆使用量从 139MB 推高到超过 1GB。没有任何单一模式在孤立情况下是灾难性的。但修复堆压力后,GC 停顿从 19 次降到 4 次。修复竞争后,之前被噪音掩盖的新热点变得可见。性能剖析的形状发生了变化。
这些改进的累积效应还超越了单个应用。当你查看单个实例或在测试套件运行时间中看到小幅改进时,某些优化可能看起来微不足道。但通常现实世界的 Java 代码不在一台机器上运行。在生产环境中,应用运行在整个集群上,处理大量真实客户请求。在一个主机上节省几毫秒或减少堆压力的改进,同时在数千个主机上发生。在那种规模下,总体差异是惊人的。考虑到吞吐量改进和整个集群可能的实例降级,成本影响可能很显著。
这种连锁效应是我想在第二部分直接在 JDK Mission Control 中展示的。你将看到任何更改之前的火焰图,然后是第一轮修复后的样子,以及图片如何不断变化。在第三部分,我们将探讨自动化识别和实施性能改进的过程。
如果这些看起来熟悉,等着看火焰图的样子吧。我在 LinkedIn 上。第二部分即将推出:一个方法使用了 71% 的 CPU。这是火焰图。