Java开发中十个常见的性能陷阱及解决办法
你的代码可能并没有你想象中那么快。哪怕你用着最新的JDK 21,写着漂亮的Stream API,做着优雅的Lambda,只要踩中一个常见的性能陷阱,几十毫秒的延迟、几M的内存泄漏,就会像温水煮青蛙一样一点一点吞噬你的系统吞吐量。别急着甩锅给JVM或者GC,先看看下面这十个你很可能每天都在犯的错误,以及解决它们最简单直接的方法。
无脑使用String +拼接,让亲儿子 StringBuilder 当场失业
无数教科书和博客都警告过:String是不可变的,你用“+”拼接字符串,编译器会悄悄把它翻译成new StringBuilder().append(...).toString()。在循环体内这么做,每次迭代都会新建一个StringBuilder,循环1000次就多产生999个临时对象,GC压力直接飙升。更隐蔽的是,你写String s = "a" + "b" + "c";编译器会直接优化为 "abc",但一旦换成变量,比如s = prefix + ":" + suffix,就变成了三段式拼接。
解决办法:只要在循环内拼接,老老实实自己new一个StringBuilder,然后append。如果循环超过几十次,这种手动预分配缓冲区长度的收益非常可观。哪怕你用了JDK 15+,也别指望编译器能包办一切,显式使用 StringBuilder 仍然是最可控的性能选择。
ArrayList 无初始容量扩容,每次搬家都翻倍搬家费
ArrayList默认初始容量是10,当你持续添加元素超过10个时,系统会创建一个新数组,容量扩展为原来的1.5倍(JDK 8+),然后把旧数组所有元素搬过去。频繁扩容导致大量的数组复制和垃圾产生。如果你事先知道数据量大概在1万左右,却从10开始慢慢扩容,中间会触发大约 log(1.5, 10000/10) ≈ 14 次扩容复制。每次复制都涉及CPU密集的System.arraycopy。
解决办法:预估初始容量,直接 new ArrayList<>(10000)。别偷懒,宁愿多估一点也不要少估。同理,HashMap也有类似问题,甚至更严重(默认负载因子0.75,扩容翻倍,还会导致rehash)。创建集合时指定初始大小,是为数不多不改变逻辑却能立竿见影的优化手段。
无脑使用stream.parallel()反被并行拖垮
并行流看起来很美,但很多场景下它比串行还慢。原因之一是拆箱装箱带来的大量对象创建:比如IntStream.range(0, 100000).parallel().sum()这种,因为每个元素都需要从原始int转换为Integer(如果中间做了其他操作导致装箱),再加上ForkJoinPool的线程调度开销,最终结果可能比串行慢几倍。另一个常见坑是共享可变状态:在并行流里对同一个数组写操作,或者用并行流处理ArrayList这样的非线程安全容器,会导致大量锁竞争甚至数据错误。
解决办法:并行流只用于计算密集型、无状态、数据量巨大(至少几十万以上)且不涉及频繁拆箱的操作。实际工作中,多线程用线程池显式控制,比偷懒用parallel流可靠得多。记住:并行不是银弹,它只是让你更容易写出bug且性能更差的代码。
捕获全家桶异常:catch (Exception e)的隐性成本
捕获异常会带来栈回溯填充、对象创建、性能损耗。如果捕获范围过大,比如把所有异常都catch住,那么任何不符合预期的小问题都会触发这个开销。更可怕的是,一旦在循环内部catch异常,每次迭代都可能产生完整异常栈。曾经有项目把NumberFormatException当作逻辑判断的一部分,循环处理几百万行数据时,应用直接卡死。
解决办法:只捕获你明确要处理的异常,而且永远不要在循环内捕获无关异常。如果必须处理,先预判输入合法性(比如用正则或类型检查),把异常留给真正意外的情况。异常处理的成本比你想象的高至少两个数量级,别拿它当if-else用。
日志打印正酣,磁盘IO成为隐形杀手
很多人习惯在业务代码里写log.debug("订单处理完成,参数:" + param),哪怕日志级别设为INFO,这条字符串拼接依然会被执行,因为参数已经在调用log方法前计算完毕。如果调用频繁,这会产生大量StringBuilder对象。更致命的是,日志框架的同步写磁盘操作(比如Logback的RollingFileAppender默认是同步的)会直接拖慢业务线程。
解决办法:使用占位符式日志:log.debug("订单处理完成,参数:{}", param),这样参数只在相应级别启用时才被格式化。同时,生产环境日志级别设为WARN或ERROR,不要因为调试日志没关就把磁盘写残。对于高并发应用,异步日志Appender(比如Logback的AsyncAppender)必须用上,否则日志就是性能杀手。
反射、动态代理滥用,让JVM开销翻倍
Spring和MyBatis大量使用CGLIB或JDK动态代理,这是框架层面的权衡。但如果你在业务代码里频繁调用Method.invoke(),或者用反射获取/设置字段,那就惨了。反射调用比直接调用慢几十倍,因为它需要安全检查、方法查找和参数包装。即使是动态代理,如果每次请求都生成新的代理类(比如用Proxy.newProxyInstance每次都创建),也会因为类加载和验证消耗资源。
解决办法:能用接口调用就用接口,能使用Lambda或方法引用就别用反射。如果一定要用反射(比如框架自省),缓存Method和Field对象,避免重复查找。另外,Spring的AOP尽量切在接口上,用JDK代理而非CGLIB,能避免很多不必要的开销。
使用System.currentTimeMillis()记录耗时,高并发下变慢
System.currentTimeMillis()内部调用gettimeofday(或类似系统调用),在Linux上这是一个快速操作,大约几十纳秒。但它会引发一次用户态到内核态的切换。如果在高并发场景下频繁调用(比如每个请求都要记录开始和结束时间乘以N次),那么这些系统调用的累计开销会非常可观。曾经有团队在每秒几万QPS的接口里,用这种方式做日志记录,结果系统时间花费占用CPU达到5%。
解决办法:对于高并发下的时间戳,使用System.nanoTime()测量时间差(它更精确且开销略低),对于绝对时间,考虑用Netty的Epoch缓存或者定期从NTP拉取一次。如果只是需要日志里带时间,留给日志框架自己去填充比你自己调用好得多。
大对象直接进入老年代,GC频发
当你创建了一个几十MB的大数组或大对象,并且它很快就用完了(比如作为临时缓冲区),但JVM的“直接进入老年代”机制(通过-XX:PretenureSizeThreshold控制)可能会导致这个临时大对象直接分配到老年代。然后GC时发现老年代多了一个几十MB的垃圾,触发Full GC。或者因为对象太大,在TLAB(线程本地分配缓冲区)放不下,直接在堆上分配,引发锁竞争。
解决办法:尽量避免创建临时大数组,尤其是网络IO中,用buffer pool(如Netty的ByteBuf池)复用缓冲区。如果无法避免,考虑调大年轻代,或者调整-XX:PretenureSizeThreshold让自己可控。对大对象说“不”,是Java程序员对GC最起码的尊重。
线程池参数拍脑袋,导致任务排队死锁
创建线程池时,很多人图省事直接newFixedThreadPool(10),然后提交任务。但如果任务之间形成了依赖(比如TaskA需要等待TaskB完成,而TaskB却在队列里排队,线程池已满),就会形成线程饥饿死锁。更常见的是,你用线程池执行异步IO操作,结果IO等待占用了线程,后续任务全部排队,CPU却空转。这种场景下,线程池的队列长度设置不当,拒绝策略用错,都会导致性能雪崩。
解决办法:明确线程池使用场景:IO密集型任务用大线程池(如2CPU核心数+1),计算密集型用小线程池(CPU核心数+1),异步任务考虑用额外的线程池隔离。使用有界队列并设置合理的拒绝策略,比如调用者运行策略(CallerRunsPolicy)能缓解压力而不是直接丢弃。最关键的是,不要在一个线程池里同时混用CPU密集阻塞和IO密集等待的任务。
自动拆装箱引发的“微型垃圾海”
List<Integer>里加1000万个int,每个元素都要从原始int装箱成Integer对象,这会产生1000万个对象。遍历时再拆箱成int,又产生大量临时对象。在短时间循环里,这些对象的创建和回收会给GC带来巨大压力。很多人以为“Java自动处理了”,其实它只是帮你写了Integer.valueOf(i)和i.intValue(),而valueOf虽然有缓存(-128到127),但超出范围还是会新建对象。
解决办法:对于大量数值处理,使用IntArrayList(第三方库如Eclipse Collections)、TIntArrayList或直接使用原始类型数组。JDK的IntStream和LongStream也避免了大部分装箱,但要注意使用原始流,不要和对象流混用。记住:自动拆装箱是糖,吃多了会蛀牙。
你遇到的问题可能远不止这十个。性能优化从来不是一蹴而就的,从代码规范的层面提前规避陷阱,比事后用Profiler抓热点更高效。下一次当你写完一段代码,不妨反问自己:这里面有没有隐性的对象分配?有没有不必要的同步?有没有滥用高级特性?如果有,改掉它,你的生产环境会感谢你。
