为什么你的Java代码性能差?这些常见陷阱要注意
你刚上线一个功能用户反馈卡顿、响应超时、CPU飙升你检查代码逻辑都对了为什么性能还是这么差太多Java开发者把“能跑就行”当作标准却忽略了那些藏在代码角落里的性能杀手。今天我们就来扒一扒那些最常见、也最容易被忽视的性能陷阱。你踩过几个字符串拼接隐形的内存黑洞几乎每个新手都写过这样的代码String result ; for (String s : list) { result s; }在循环里使用拼接字符串是导致性能崩溃的头号罪犯。每次都会在堆中创建一个新的 StringBuilder 对象再调用 append 方法最后 toString 生成新字符串。如果你循环 1000 次就会产生 1000 个临时对象不仅浪费 CPU 时间做无谓的拷贝还触发频繁的 GC垃圾回收。GC 暂停时你的应用就像被按下了暂停键用户感知到的就是“卡死”。正确做法是在循环外声明 StringBuilder并在循环内直接 append。更极致一点预先估算最终字符串长度传入初始容量避免扩容时的数组复制。很多人觉得这只是微优化但在高并发下这种微优化能直接决定你的系统是撑住 1000 QPS 还是 10 QPS。不要相信“现代 JVM 会帮你优化”的鬼话——JVM 只优化无副作用的代码而字符串拼接的副作用就是创建了太多无用对象。异常处理不当把业务逻辑写进 Catch 块很多人喜欢用异常来控制流程比如try { String value map.get(key); Integer num Integer.parseInt(value); } catch (NumberFormatException e) { // 默认值 }使用异常来处理可预期的业务分支是性能上的自杀行为。当 JVM 抛出异常时需要生成完整的异常堆栈fillInStackTrace这涉及当前线程所有栈帧的快照代价极其昂贵。更可怕的是有些代码在循环里频繁捕获异常——比如遍历字符串列表并解析数字每个非数字字符串都会触发一次异常构造耗时可能是正常判断的数百倍。正确的做法是能用条件判断就绝不用异常。比如先检查字符串是否为 null 或空再用正则或Character.isDigit预检。如果非用异常不可至少把异常对象缓存成静态 final 变量并使用Exception(String message, Throwable cause, boolean enableSuppression, boolean writableStackTrace)禁止堆栈追踪需要你自己重写构造方法。但严肃建议永远别把异常当作 if-else 的替代品。集合初始容量你根本没把扩容当回事ArrayList 默认容量是 10HashMap 默认负载因子 0.75初始容量 16。当你往一个空的 ArrayList 里插入 1000 个元素时它会发生多少次扩容扩容 新数组创建 旧数据拷贝。ArrayList 每次扩容 1.5 倍从 10 → 15 → 22 → 33 → 49 → 73 → 109 → 163 → 244 → 366 → 549 → 823 → 1234实际取决于扩容阈值。算下来至少 13 次数组拷贝每次拷贝需要遍历旧数组。如果你插入 100 万条数据呢拷贝次数虽然减少指数增长但单次拷贝的数据量巨大依然可能造成毫秒级的暂停。任何你知道数据规模的集合都应该在构造时传入初始容量。比如new ArrayList(1000)直接分配足够空间完全避免扩容。HashMap 还要考虑负载因子new HashMap(size / 0.75f 1)来避免重新哈希。很多人在面试中背过这点但写代码时却总忘记。性能问题往往不是因为不知道而是因为“懒得写”。一个显式容量构造器能为你节省几十毫秒甚至上百毫秒的 GC 压力。循环中的重复计算你以为编译器会帮你移出去看这段代码for (int i 0; i list.size(); i) { process(list.get(i), someExpensiveMethod()); }如果someExpensiveMethod()每次返回的结果都一样比如获取配置、计算常量那它完全应该提到循环外面。但更隐蔽的是如果这个方法的计算结果依赖于循环变量但计算本身很重且重复多次你可能需要做局部缓存。例如在循环里多次调用String.format或SimpleDateFormat.parse每个都是重量级操作。JVM 的 JIT 编译器会做循环不变代码外提Loop Invariant Code Motion但前提是方法没有副作用且返回值完全可预测。大多数业务代码里方法足够复杂JIT 可能放弃优化。不要依赖 JIT 替你擦屁股。你应该手动把不变计算移到循环外比如int size list.size(); boolean flag someExpensiveMethod(); for (int i 0; i size; i) { process(list.get(i), flag); }性能优化的第一条原则就是“减少绝对调用次数”。把常量和循环无关的操作提取出来哪怕只减少一次方法调用在高频循环里都是巨大的收益。日志框架滥用打印一万次 debug 等于让应用暂停一万次生产环境里很多团队把日志级别设为 DEBUG然后疯狂打日志log.debug(用户 {} 执行操作 {}参数{}耗时{} ms, userId, op, params, cost);即使日志级别被设置为 INFO日志框架仍然会计算参数表达式比如拼接字符串、获取线程名称、调用toString方法。log4j2或logback如果使用占位符实际上会先构建消息对象再判断级别这本身就有开销。更严重的是如果params是一个很大的集合toString会遍历所有元素浪费 CPU。正确的做法是在调用日志前先判断级别if (log.isDebugEnabled()) { log.debug(用户 {} ..., userId, op, params, cost); }很多现代日志框架如 Log4j2 的异步日志虽然减少了 I/O 阻塞但参数计算的 CPU 消耗依然存在。高并发下日志是最大的性能杀手之一。你可能会惊讶于仅仅打印日志就能让 CPU 满负荷。所以不要在生产环境打开 DEBUG且对于高频日志路径务必使用条件判断或 Lambda 延迟求值log.debug(() - message buildString())。对象池 vs 轻量对象什么时候该重用什么时候该新建频繁创建和销毁重量级对象如数据库连接、线程、Socket时对象池非常有价值。但很多人误以为所有对象都应该池化结果搞出了对象池管理开销大于新建开销的窘境。例如一个简单的byte[1024]数组池化需要维护锁、双重检查、线程安全等反而比直接new慢。而另一个极端是很多人对轻量对象也做了池化或者对重量级对象完全不池化。比如频繁创建的SimpleDateFormat是线程不安全的很多人每次使用都new一个这就是典型的“不该重用却每次都新建”。实际上SimpleDateFormat的创建成本很低但它的parse和format内部有 Calendar 实例频繁创建会导致大量临时对象。更好的方案是用DateTimeFormatter线程安全或者用 ThreadLocal 缓存一个实例。判断是否需要池化的标准很简单对象的创建成本是否远高于管理成本如果是数据库连接、线程、大内存缓冲区必须池化如果是简单 DTO、VO、短期使用的集合直接新建让 JVM 快速分配和回收。不要为了复用而复用那往往是过度设计的开始。同步与锁的粗粒度你让所有线程排队等一把锁同步块的范围越大性能损失越成指数级增长。很多人为了图方便直接把整个方法用synchronized修饰public synchronized void processOrder(Order order) { validate(order); saveToDB(order); sendNotification(order); }如果validate不需要同步sendNotification也不需要那这段代码就导致所有线程在saveToDB之前排队等待而实际写数据库的操作只占了很小一部分。更糟的是sendNotification是远程调用可能需要几百毫秒这段时间其他线程全被阻塞了。你应该遵循“最小锁范围”原则只在需要保护的共享资源上使用锁。比如public void processOrder(Order order) { validate(order); // 无需同步 synchronized (this) { saveToDB(order); // 需要保护数据库写入一致性 } sendNotification(order); // 无需同步 }对于读多写少的场景考虑用ReadWriteLock、StampedLock或ConcurrentHashMap等并发容器。锁竞争是并发性能的最大瓶颈很多时候你需要的不是锁而是无锁数据结构或原子类。比如计数器用AtomicLong而不是synchronized可以提升数个数量级。IO 操作无缓冲你让系统调用了无数次内核每次调用read()或write()方法都会触发一次系统调用如果底层是原生 IO这意味着用户态到内核态的切换。对于文件 IO 或网络 IO你应该总是使用缓冲流BufferedInputStream、BufferedReader、BufferedWriter。它们内部维护一个 8KB 的缓冲区减少系统调用次数。但很多人写文件时直接用FileWriter一个字符一个字符地写性能惨不忍睹。另一个极端是有人用了缓冲流但忘记调flush()导致数据一直留在缓冲区。或者调用了flush()太多次等于没缓冲。正确习惯是一次写入尽量大的块然后调用一次flush。对于网络 IO使用NIO的ByteBuffer配合Channel能进一步减少拷贝比如FileChannel.transferTo()可以在操作系统层面直接发送数据完全绕过用户缓冲区实现零拷贝。最常见的陷阱是在循环里逐行读取并处理文本每读一行都做一次字符串操作然后写入另一个文件。这时候应该将文件一次性读入内存如果文件不大或者使用内存映射文件MappedByteBuffer来避免用户态和内核态的多次数据拷贝。IO 性能优化的核心就是尽量减少系统调用次数和数据拷贝次数。随机数生成你的 SecureRandom 可能阻塞整个 JVM很多人用Random生成随机数但Random在高并发下性能不佳CAS 竞争种子。他们转而使用ThreadLocalRandom这好很多。但另一个坑是SecureRandom在 Linux 下默认使用/dev/random这会阻塞直到熵池足够。特别是在云服务器或 Docker 容器中熵池可能很小当你的应用需要大量生成安全随机数如 token、密码盐时可能会卡住。如果你不需要加密级别强的随机数请使用ThreadLocalRandom.current().nextInt()。如果必须用SecureRandom显式指定算法和 Provider或者使用-Djava.security.egdfile:/dev/./urandom来避免阻塞虽然urandom安全性略低但大多数场景可接受。永远不要在生产环境直接用默认的new SecureRandom()而不做任何配置否则可能遇到莫名其妙的长时间停顿。序列化性能你还在用 Java 原生序列化Java 原生序列化ObjectInputStream/ObjectOutputStream是出了名的慢且不安全。它使用反射遍历对象图每次写入都会写全类名和字段描述数据膨胀严重。如果你需要序列化大量对象如缓存、RPC 调用请改用Kryo、Protobuf、Jackson二进制格式或Fastjson性能较差但易用。原生序列化的速度可能是这些工具的 10 倍以上。一个更常见的场景是很多人用JSON.toJSONString(obj)序列化然后又用JSON.parseObject(str, clazz)反序列化每次都在字符串和对象之间转换。如果对象内部有循环引用或大集合序列化时间会飙升。缓存序列化结果时应该缓存二进制字节数组而非 JSON 字符串因为二进制序列化体积小、解析快。同时注意避免框架的自动序列化如 Session 对象放入 Redis 时确认使用了高效序列化方式。正则表达式你的 Pattern 被反复编译正则表达式在 Java 中是昂贵的对象。Pattern.compile(regex)会进行语法分析、表达式树构建、优化等操作耗时通常在毫秒级。如果你在循环里调用String.matches(regex)它内部会每次调用Pattern.compile等于每次都重新编译正则。正确的做法是将Pattern编译为static final常量然后在循环中重用Pattern.matcher(str).matches()。更深入的陷阱是复杂正则可能导致灾难性的回溯Catastrophic Backtracking。例如(a)b在匹配长串失败时指数级回溯使程序假死。解决办法是使用原子分组(?...)或占有量词来防止回溯或者限制匹配长度或者换用更简单的字符串方法如startsWith、contains。永远不要在循环或频繁调用的路径中使用未经测试的正则否则一个恶意输入就能打垮你的应用。性能优化从来不是一蹴而就的事情它藏在每一行代码的细节里。写代码时多花一分钟思考“这个操作会执行多少次”就能在生产环境省下几小时的排查痛苦。以上这些陷阱你至少踩过三个吧从今天起把“能跑就行”改成“能高效跑才对”。你的用户会感谢你你的老板也会感谢你而你最该感谢的是那个愿意停下来审视代码的自己。
