1. 写在前面,50个细节到底怎么用
前阵子给一个订单查询接口做Java性能优化,接口从最初的1.8秒优化到400毫秒左右,前前后后一共列了50个可以动手的细节。整理完之后我发现一件事:大部分性能问题根本不是什么高深莫测的底层黑科技,而是一些每天都在写、但没留神的“老毛病”——字符串拼多了、集合容量没给、锁的范围太大、IO不带缓冲、日志拦路抢劫。
这篇文章就把这50个优化细节按主题拆开讲。我的建议不是让你一条条背下来,而是把它当成一份排查清单:接口慢了、CPU飘了、GC频繁了,对照着找找自己项目里有没有对应的影子。每一条细节我都会说明“为什么能提速”“什么时候适用”“有没有副作用”,这样你才能决定在哪个场景下采纳它。
为什么强调“适用场景”?因为我见过太多人把网上抄来的优化套路硬套到项目里,结果没快多少,反而把代码搞复杂了。比如有人为了省一次new,把对象搞成全局复用,结果多线程并发下数据互相覆盖,线上出了P0故障。优化这事,方向比努力更重要。你首先要搞清楚瓶颈在哪里,再决定用哪一类的细节去处理。
这份清单主要面向写业务代码的后端开发者,也适合准备性能调优面试的同学。如果你正在做故障排查、接口响应时间过长、系统吞吐上不去这类问题,可以按字符串、集合、并发、IO、JVM这几个纬度逐一排查。下面进入正文。
2. 字符串处理里的10个性能细节
字符串是Java里使用频率最高的对象,也是性能陷阱最密集的地方。很多人写代码的时候根本没意识到,一行不起眼的字符串操作,在高频接口里可能吃掉几成的CPU。
2.1 细节1:循环里拼接字符串,别用加号
循环内用“+”拼接字符串是最常见的性能杀手。很多新手以为编译器会自动优化成StringBuilder,这句话只对了一半。
我直接说结论:javac确实会把单个“+”表达式编译成StringBuilder.append,但如果拼接发生在循环体内,编译器不会智能到把StringBuilder提取到循环外,而是每次循环都new一个StringBuilder和String对象。循环1000次,就创建1000个临时对象,最终引发GC压力。
正确姿势:在循环外声明StringBuilder,循环内只做append。实测在10万次拼接场景下,显式使用StringBuilder比循环内“+”快3到5倍,内存分配量大幅下降。
如果你的代码是Java 8以后的版本,在特定场景下可以直接用String.join或Collectors.joining,底层一样是StringBuilder,但代码更简洁。原则只有一个:循环外的拼接随便用“+”,循环内的拼接必须手动管好缓冲区。
2.2 细节2:String.split底层是正则,能不用就不用
“下单成功,订单号:12345,金额:99.00”这种字符串,很多人直接一行split按逗号切。方便是方便,但split的底层实现是正则表达式匹配,每一次调用都会涉及Pattern的编译和执行,代价比肉眼看到的要大得多。
如果你只是按固定的单个字符切割,比如逗号、竖线、冒号,完全可以用indexOf加substring手写一个轻量切割方法。我处理过一个业务日志解析的案例:日志字段就是“key=value;key=value”这种格式,200万行日志用split解析耗时8秒,改成indexOf循环后直接降到1.2秒,而且没有额外对象分配。
注意:如果切分规则比较复杂,比如多个分隔符或者需要忽略空串,那用split没问题,毕竟代码可读性也是性能的一部分。但要知道它的成本,不要无脑用。
2.3 细节3:字符串比较,把常量写在equals前面
“PAY_SUCCESS”.equals(status)和status.equals(“PAY_SUCCESS”)性能上几乎没差别,但有一个隐藏收益:前者永远不会抛空指针。
假设status从外部接口返回,你没有做空值校验,直接status.equals就炸了。为了防御这个空指针,你可能会写if(status != null && status.equals(...)),多了一次判空指令。但常量前置写法天然免疫null,少写一行判断,也就少了一次比较开销。
这属于零成本优化。不要小看这种细枝末节,在每天调用几百万次的规则引擎里,少一两个分支指令也有可观测的收益。
2.4 细节4:正则表达式一定要预编译
这是字符串处理里收益最明显的一条。很多人写工具类的时候,方法内部直接Pattern.matches(“\d+”, input),表面看是一行代码,实际上每次调用都会经历“编译正则→匹配”的全过程。正则编译挺贵的,比匹配本身贵一个数量级。
正确做法是在类加载时把Pattern编译成静态常量,复用Matcher。更讲究一点,如果同一个Pattern在单线程内频繁使用,可以复用Matcher并调用reset方法,避免每次重新创建Matcher对象。
我做过一个手机号校验的测试:用Pattern.matches跑100万次耗时约3.2秒,预编译后只要0.4秒。这是8倍的差距,而且改动极其简单,把Pattern声明成static final就行。
隐患提醒:Matcher对象内部有group、region等状态,多线程共享不安全。但Pattern本身是线程安全的,可以放心作为static常量。如果要在多线程场景复用Matcher,用ThreadLocal包一层。
2.5 细节5:String.format是个隐形富豪工具
String.format写起来舒服,读起来也清晰,但性能很差。它需要解析格式字符串、构造Formatter对象、处理各种参数类型,内部还会生成StringBuilder和多个临时对象。
在一个高频打点场景里,我统计过String.format比手动字符串拼接慢10倍左右。如果你只是拼几个变量,直接加号或者StringBuilder就好;如果硬要格式化,可以考虑MessageFormat(其实也不算快),或者干脆用占位符替换自己实现一个轻量方法。
最讽刺的是:很多人用String.format拼日志,结果日志框架后面还要做一次异步序列化,两层浪费。后面讲日志的时候我会再提到这个问题。
2.6 细节6:substring在JDK 6和JDK 7+的内存表现完全不同
这个细节属于历史遗留问题,但老项目里依然有。JDK 6的substring实现是共享原字符串的char数组,通过offset和count来标识子串。好处是快、零拷贝,坏处是:如果你截取了一个大字符串的一小段,只要持有这个子串,整个大字符串的char数组就无法被回收,造成“伪内存泄漏”。
JDK 7之后Oracle改成了复制新数组,不会共享底层数据了,内存泄漏问题解决了,但子串创建时需要一次数组拷贝。如果频繁对大字符串做截取,这个拷贝成本你要心里有数。
经验之谈:如果你维护的老项目还跑在JDK 6上,切大字符串后记得把子串赋值成null,或者主动new String(substring)断绝引用。JDK 8+的读者了解一下原理即可,更多是面试题价值。
2.7 细节7:频繁equals比较的字符串,考虑用intern
先声明:这不是让你无脑调用intern。String.intern的代价是需要在常量池里查找或插入,本身也有开销,还可能影响常量池的GC。但如果同一个字符串在系统里出现频率极高,而且你是拿它当枚举值或状态码用,intern可以减少重复对象。
实际案例:某个接口返回的状态值只有“SUCCESS”“FAIL”“PENDING”三种,但每次从JSON反序列化都会产生新的String对象。对状态字段调用intern之后,不仅后续equals可以直接比较引用,还能让JVM通过常量池复用同一个对象,堆内存里的字符串实例大幅减少。
注意:intern适合“有限取值集合”的字符串。如果是用户输入之类无限分布的字符串,千万别intern,池子会被撑爆,GC也要跟着遭殃。
2.8 细节8:高频小字符串操作,直接上StringBuilder专用缓冲区
如果一个线程在处理大量字符串拼接、替换、翻转操作,每次new StringBuilder默认初始容量是16,超过16就要扩容、复制数组。频繁扩容同样触发内存分配和GC。
正确做法是:如果预先知道目标字符串的大致长度,直接new StringBuilder(1024),省掉扩容的数组复制。我还见过一种技巧:用一个ThreadLocal的StringBuilder作为线程级缓冲区处理临时字符串,用完清空长度再复用,避免了反复创建销毁。
这个技巧不适合所有场景,因为一旦忘记清理残留数据,或者方法递归调用同一个缓冲区,就会出现脏数据。我只推荐在高频且纯拼接的场景使用,低频场景没必要折腾。
2.9 细节9:trim和replaceAll隐藏的正则陷阱
很多开发者不知道:String.trim()只处理ASCII字符集的小于等于U+0020的空白字符,对全角空格、不间断空格无效。如果你需要处理中文文本里的全角空格,用replaceAll(“\s+”, “”),但这又是一个正则。
性能上trim本身不慢,replaceAll则贵得多。说这个细节是想提醒:一旦需求涉及“清洗字符串”,先确认清理规则是否真的是正则级别,能用手写循环或替换字符就避免正则。中文文本场景里,我处理过一个“替换空白字符”的需求,把replaceAll改成基于Character.isWhitespace的手写遍历后,耗时降了70%。
2.10 细节10:常量字符串声明成static final,让编译器内联
这个优化你可能感知不到,但它是白捡的。private static final String SUCCESS = "SUCCESS";我见过太多人漏掉final,或者根本没用static。其实如果字符串在编译期就能确定,javac会直接把它内联到使用处,运行时不走字段读取,也不产生额外对象引用。
更重要的是:把它定义为常量后,所有使用点共享同一个String对象,JVM的字符串常量池也会做去重。这个细节小,但积少成多,几百个常量类里都这么做,字符串重复实例数量能显著减少。
3. 集合容器使用里最常见的12个性能问题
集合是业务代码里另一个重灾区。很多人用集合就像用数组一样随意,但集合底层有扩容、哈希、红黑树转换、视图引用等各种机制,用不好就是延迟和内存的双重损失。
3.1 细节11:HashMap初始化一定指定容量,默认16害人
Java的HashMap默认初始容量是16,负载因子0.75,也就是说元素达到12个就触发扩容。扩容不是简单的数组变长,而是要重新计算每个key的hash并搬到新数组,这一步是O(n)操作。
如果你明确知道要存500个元素,new HashMap()最终可能要扩容两三次。直接new HashMap(640)可以省掉所有这些扩容。计算公式:初始容量 = 预估元素数 / 0.75 + 1。我见过有人直接new HashMap(500),等于没用,因为500/0.75=667,HashMap内部会向上取整到1024,这没啥问题,但如果你只给了500,那么存500个元素时已经扩容一次了。
顺带一提:JDK 8以后HashMap在链表长度超过8且数组长度超过64时会转红黑树,转树本身也有开销。注意不要让太多key哈希碰撞,必要的时候提供一个好的hashCode实现。
3.2 细节12:ArrayList同理,预估容量一次性给到位
ArrayList的扩容机制是1.5倍增长,每次扩容都是一次Arrays.copyOf。假设你要add 10000个元素,默认情况下可能要扩容十几次,累计复制的元素个数远超10000,做了一堆无用功。
最佳实践:如果能预估size,new ArrayList<>(10000)。如果无法精确预估,也可以先new ArrayList<>(预估上限),因为ArrayList扩容只会扩容到比你预估更大的容量,不会浪费太多。注意这里的坑是:ArrayList(0)这种写法只会给你一个空数组,首次add直接扩容到10,后面还得继续扩,别以为写了0就省内存。
3.3 细节13:ArrayList还是LinkedList,别只看“插入删除快”
经典面试题告诉你要频繁插入删除就选LinkedList,但其实这句话在现代业务代码里基本是毒药。LinkedList的插入删除确实O(1),前提是你已经持有了那个节点的引用;但如果你是按索引插入,它需要从头遍历找到位置,是O(n)。而ArrayList的随机访问是O(1),尾部插入均摊也是O(1)。
更致命的是:LinkedList每个节点都是一个独立Node对象,内存占用是ArrayList的数倍,缓存局部性差,遍历性能也远不如ArrayList。我实际测试过:10万元素的随机访问,ArrayList比LinkedList快两个数量级;尾部插入ArrayList略慢于LinkedList,但差距不大。
结论:99%的业务场景直接选ArrayList,LinkedList基本只有实现队列(ArrayDeque更好)或者需要频繁在头部插入时才考虑。不要为了理论上的O(1)付出实际上的代价。
3.4 细节14:批量添加用addAll,别在循环里add
这个细节和ArrayList扩容有关。已知数据源是一个List,要拷贝到另一个List,很多人的第一反应是for循环一个个add。但addAll有一个优化:它能一次性计算出需要扩容的容量,然后System.arraycopy整块搬过去,只扩容一次。
两个方法在数据量小的时候没差别,但数据量是几万、几十万时,addAll快了将近一倍。类似地,Map.putAll也有同样的效果。
3.5 细节15:去重用HashSet,不要用List.contains
很多人去重逻辑是这样:遍历原始列表,如果result.contains(item)就不加入。这个写法在数据量小的时候问题不大,但List.contains是O(n)遍历,加上外层循环就是O(n²)。数据量一旦上万,运行时间会指数级拉胯。
正确方案:用一个HashSet作为“已存在标记集”,再做一次遍历构建结果列表,整体O(n)。如果对顺序没要求,直接new HashSet<>(list)一步搞定;如果要求保持原始顺序,就用LinkedHashSet。
我处理过一个十万级数据去重的接口,List.contains跑了两分多钟还超时,换成HashSet秒回。这个优化几乎每本Java书都讲,但生产代码里总有人写错。
3.6 细节16:查找Map不要“先containsKey再get”
常见写法:
if (map.containsKey(key)) { Object value = map.get(key); }这执行了两次哈希查找。更高效的做法是直接get,然后判断null:
Object value = map.get(key); if (value != null) { ... }如果担心key存在但value原本就是null的情况,可以用getOrDefault或者computeIfAbsent,但总原则是:一次哈希查找能搞定的事别做两次。这在高频配置读取场景里收益特别明显。
3.7 细节17:遍历Map用entrySet,避免keySet加get
for (String key : map.keySet()) { Object v = map.get(key); }这种写法在遍历时每取一次value都多做一次哈希查找。直接遍历entrySet,key和value都在同一个对象里,省掉哈希计算。
数据量小的时候无所谓,但为map里上万条数据时,keySet+get比entrySet慢30%左右。别小看这个30%,Map遍历在很多报表导出、缓存刷新的场景里是主力循环。
3.8 细节18:Stream不一定比普通for循环快,甚至会慢
Java 8流式编程写起来很爽,但它引入了Lambda和更多中间对象,比如stream管道中的Sink链、reduce的包装对象。ArrayList遍历100万元素,普通for循环和增强for循环基本无差别,Stream的forEach反而要慢10%~15%。如果是parallelStream,数据量小的时候线程切换开销会吃掉并行收益。
我不反对用Stream,它表达力更强,易读、易维护。但在超高并发、每秒执行几十万次的遍历逻辑里,我会老老实实写for循环,提前跟你们交个底。
3.9 细节19:尽量用基本类型数组,少用List
List<Integer>里每个Integer都是一个对象,光内存占用就比int[]高4倍以上。如果你处理的是数字密集型数据,比如百万级别的评分、统计、坐标计算,用基本类型数组可以显著减少堆内存和GC压力。
数组的缺点是操作不够方便,但你可以把算法封装成一个工具类,对外暴露出友好的方法,内部用原始数组。我自己写过一套类似原语的数值计算工具,内存占用从300MB降到120MB,GC从每秒10次降到每秒2次。代价是代码量增加,但收益非常直观。
3.10 细节20:Arrays.asList返回的是固定大小视图,别抛异常才知道
Arrays.asList底层还是原来的数组,它不是真正的ArrayList,不支持add/remove。如果后续要给集合添加元素,很多人直接new ArrayList<>(Arrays.asList(...)),这等于多拷贝一次。
更高效的做法是:如果原始数据就是数组,直接用Arrays.asList的视图做只读遍历即可,压根不需要包装成ArrayList。只有当确实需要增删时,才二次包装。这个细节不仅是性能问题,更是减少无谓拷贝的编程习惯。
3.11 细节21:subList是视图,不是拷贝,别误解
list.subList(1, 3)返回的是原列表的视图,修改它会直接影响原列表。很多人以为subList做了拷贝,放心大胆地往里面加数据,结果原列表被改了,排查半天。
从性能角度讲,subList的优点是零拷贝、创建快,适合做分页和局部遍历;缺点是它不是独立副本,如果你只是想临时取几个元素来用,后续又可能修改原列表,视图的安全性没法保证,此时还是老老实实new ArrayList<>(subList)拷贝一份。两者用途不同,别混用。
3.12 细节22:常返回空集合时,用Collections.emptyList()
写一个查询方法,查不到数据时返回什么?很多人的第一反应是return new ArrayList<>();。如果这个方法被高频调用,频繁创建空ArrayList对象就是浪费。
Collections.emptyList()返回的是单例对象,不分配新内存,可以无限复用。同理还有emptyMap、emptySet。注意emptyList是不可变集合,如果你要求调用方可能对它做add操作,就不能用这个。配合Optional使用效果更好。
个人习惯:所有可能返回空集合的接口,一律默认emptyList,除非明确要求可变集合。这个改动的收益单纯在内存上,长期运行的服务表现尤其明显。
4. 并发编程里躲不开的10个性能细节
并发是Java性能优化里最有技术含量的一块,也是翻车概率最高的。线程池、锁、原子类、ThreadLocal,每一个选项背后都藏着权衡。
4.1 细节23:线程池参数别照抄网上的“经验公式”
网上流传的线程池参数设置公式:CPU密集型 = CPU核数+1,IO密集型 = CPU核数*2。这个公式在简单的压测场景可能适用,但真实业务往往混合了计算和等待,照搬容易出错。
正确的做法是:先区分任务类型。纯CPU计算的任务,线程数最好不超过CPU核数,多了反而因为上下文切换变慢;带IO等待的任务(HTTP调用、数据库查询、磁盘读写),线程数可以放大到CPU核数 *(1 + 等待时间/计算时间)。这个比例需要通过profile工具实测。
实操上我更建议:不要一开始就把核心线程数和最大线程数拉满,先压测,观察队列积压、线程利用率、响应时间三个指标,再逐步调整。比背公式靠谱得多。
4.2 细节24:锁的粒度能细就细,范围能小就小
一个误区是觉得synchronized没有什么成本,其实在竞争激烈时,锁的获取和释放涉及操作系统级别的上下文切换,非常贵。
实际案例:某支付模块的账户更新方法,一开始整个方法都加了synchronized,所有账户的操作都串行执行,吞吐量只有几百TPS。后来把锁范围缩小到“只锁账户余额变更这一段”,并且用账户ID做细粒度锁,每个账户一个锁,吞吐量直接翻了10倍。
技巧:如果必须用一个锁保护多个资源,优先使用更细粒度的锁或者分段锁,不要图省事用一个大锁把所有流量挡在门外。
4.3 细节25:读多写少的场景,优先CopyOnWrite和ReadWriteLock
集合里的CopyOnWriteArrayList、CopyOnWriteArraySet,读操作无锁,写操作复制底层数组。适合“读频率高、写很少”的场景,比如配置项缓存、黑白名单。它的代价是每次写都全量复制,如果写频繁,性能反而崩溃。
另一个选择是ReentrantReadWriteLock:多个读线程可以同时持有读锁,只有写线程会互斥。这比全局synchronized的并发度高出一个量级。
注意:ReadWriteLock在读多写少时收益明显,但如果写比例超过20%,读写锁的性能优势就会消失,这时候还不如用并发容器或者直接加synchronized。要按实际比例选,别凭感觉。
4.4 细节26:能用AtomicInteger就别用synchronized
计数器、序号生成、多线程状态标记等场景,用AtomicInteger比加锁快一个数量级。它基于CAS(比较并交换),在没有激烈竞争时基本没有阻塞。
CAS的缺点是会自旋重试,如果竞争过于激烈,CPU会一直空转,反而更浪费。此时考虑下面的LongAdder,或者在业务层面做分片计数。
4.5 细节27:高并发计数场景,LongAdder比AtomicLong更合适
AtomicLong在高度竞争下CAS重试次数暴增,性能急剧下降。LongAdder内部维护了一个base变量和一个Cell数组,不同线程更新时分散到不同的Cell上,最后求和时再汇总。这相当于分段锁思想的无锁版本。
我压测过20线程各累加100万次:AtomicLong耗时约800毫秒,LongAdder只要150毫秒左右。代价是LongAdder的内存占用稍大,统计也不如AtomicLong实时精确(sum遍历Cell时可能看到中间状态)。适合做指标计数、流量统计这种“精确一点或慢一点都无所谓”的场景。
4.6 细节28:ThreadLocal使用完务必remove
ThreadLocal的经典问题是内存泄漏。因为ThreadLocalMap的key是WeakReference,如果线程持续存活(比如线程池里的线程),value却始终被强引用链持有,就无法回收。日积月累,可能造成OOM。
性能层面,ThreadLocal的get/set在ThreadLocalMap里做线性探测查找,如果map里堆积了大量失效条目,查找效率也会下降。所以无论从内存还是性能角度,用完都该remove。
我的习惯:在finally块里做remove,跟关闭流一样养成肌肉记忆。尤其在线程池场景,线程复用让ThreadLocal的“残留数据”风险远远大于单线程。
4.7 细节29:并发Map选ConcurrentHashMap,别再用HashTable或同步Map
HashTable是全表加锁,所有线程串行访问,性能极差;Collections.synchronizedMap也是如此。ConcurrentHashMap在JDK 8以后用CAS + synchronized锁住单个桶,并发度大幅提升。
另一个细节:ConcurrentHashMap的size()并不是实时准确的,它可能是一个估算值,不在强一致场景下使用。同理,containsKey在并发修改时可能看到过期结果。这些都是为了性能做的妥协,理解即可。
4.8 细节30:避免伪共享,在高并发热数据上做缓存行填充
伪共享是一个偏底层的概念:多核CPU的缓存行大小通常是64字节,如果两个不同线程修改的变量落在同一个缓存行里,那么它们会互相“污染”对方的缓存,导致缓存一致性协议频繁触发,性能暴跌。
Java里类的字段默认会进行内存布局优化,但在高并发压力下,某些频繁写入的热字段可能被分配到同一个缓存行。Java 8以上提供了@Contended注解(需要JVM参数-XX:-RestrictContended开启),可以把字段填充到单独缓存行。
这个细节非常小众,一般业务代码不需要关注。只有当你用压测工具发现缓存一致性开销占比很高,且多线程写同一个对象的相邻字段时,才值得尝试。我之前在一个高频路由组件里用过类似手段,吞吐提升了约15%。
4.9 细节31:避免大范围同步方法,锁块代替锁方法
public synchronized void doSomething()这种写法,等于把整个方法的代码都放进临界区。如果方法里有耗时的IO操作、外部调用,锁的持有时间就会特别长,阻塞其他线程。
改进:只把真正需要原子操作的几行代码包在synchronized块里,其他耗时操作放到锁外执行。锁的持有时间越短,竞争概率越低,吞吐越高。这个优化几乎零成本,纯代码结构调整。
4.10 细节32:异步任务别傻等,用CompletableFuture和批量接口整合
现在很多性能问题其实不是CPU不够,而是线程在等IO。比如一个订单详情接口,要查用户信息、商品信息、优惠信息,三次HTTP调用串行执行,耗时是三者之和。
改成CompletableFuture并行发起三个请求,总耗时变成三者最大值,接口响应时间直接减少三分之一。注意:并行不是没有代价的,需要额外的线程资源,所以只适合IO型场景。同时不要忘了给CompletableFuture指定线程池,直接用默认的ForkJoinPool.commonPool容易被其他任务拖垮。
进阶提示:能用批量接口解决的就不要用异步。一次RPC查出100个商品的信息,比发100次单查再拼装强太多了。具体后面第七部分详谈。
5. IO与网络层面容易被忽略的8个性能细节
大部分线上故障最终都跟IO有关:文件读写慢、网络请求超时、日志刷屏阻塞。但IO性能问题往往不在第一轮排查里被暴露,而是表现为接口偶尔变慢、线程大量阻塞。
5.1 细节33:文件IO和网络IO,缓冲区一定要给够
用FileInputStream读文件,如果一次读一个字节,系统调用次数等于文件字节数,速度慢到怀疑人生。加上BufferedInputStream之后,底层会用8K缓冲区批量读入内存,系统调用次数降低几个数量级。
网络IO同理。用BufferedReader读HTTP响应、用BufferedOutputStream写socket,都可以减少小包传输。这个细节简单有效,但很多人只在书上见过,实际编码里经常忘。
5.2 细节34:大文件传输用FileChannel或内存映射,别用普通流循环读
处理百MB甚至GB级别的文件时,普通FileInputStream + byte[]循环读拷贝依然有大量用户态到内核态的切换。FileChannel的transferTo/transferFrom可以直接在内核态完成文件到socket或文件到文件的拷贝,零拷贝,性能碾压。
另一种内存映射方式FileChannel.map,把文件映射到内存地址空间,读文件就像读数组一样。适合随机读、多次读的场景,但要注意映射后如果不主动释放,可能长期占用虚拟内存。
实操现场:处理一个2GB的日志文件做数据抽取,用InputStream逐行读耗时约25秒,换成FileChannel + 批量buffer后降到9秒左右。文件越大,差距越明显。
5.3 细节35:高并发网络服务,BIO换NIO或者干脆上Netty
一个连接一个线程的BIO模型,连接数多了线程数爆炸,线程上下文切换开销把CPU耗尽。NIO的Selector模型让少量线程管理成千上万个连接,IO多路复用,资源占用大幅下降。
如果你们的RPC框架或HTTP服务器已经在用Netty,那底层已经是NIO了,不用操心。但如果你自己在写网络通信组件,比如长连接推送、自定义协议网关,还停留在ServerSocket + 多线程,那就是下一个性能瓶颈。
提醒:NIO编码复杂度比BIO高一个台阶,不要为了“高性能”强行造轮子。能用成熟框架就用成熟框架,把精力放在业务逻辑上。
5.4 细节36:HTTP连接复用,别每次请求都新建连接
HTTP每次请求都经历TCP三次握手,加上TLS握手,一个HTTPS请求光是建连可能就要消耗几十毫秒。如果你的服务要调用外部接口,每次都新建HttpClient,这个开销在QPS稍高时会很明显。
正确做法是使用连接池:配置最大连接数、每个路由的最大连接数、空闲保活时间。连接池复用已建立的连接,后续请求省掉建连耗时。常见的HTTP客户端库都支持连接池,注意设置合适的超时和最大连接数,防止连接耗尽。
5.5 细节37:JSON序列化选型,别迷信“最快的那个”
JSON序列化是高并发Web服务的核心热点之一。不同的JSON库性能差距可以到十倍以上:反射式序列化在对象结构复杂时明显更慢,而基于手工生成字节码或直接操作字段的库要快很多。
我实测过的结果:同样一个包含20个字段的对象,序列化+反序列化100万次,主流JSON库里最快的比最慢的快了8倍。但如果你们的场景是低频配置读取,用啥差异不大,开发体验优先。
建议:做一次针对你们业务对象的基准测试再选型,别盲从网上的排行榜。排名是会随JDK版本和库版本变化的。另外,不要为了省序列化时间把对象设计得过于扁平,开发效率也是成本。
5.6 细节38:日志是隐藏的杀手,异步化+合理级别
日志框架如果同步刷盘,当磁盘IO变慢时,所有业务线程都会被阻塞在写日志这段代码上。很多线上“假死”事故的元凶就是日志拖垮了正常业务线程。
对策:使用异步日志,业务线程只负责把日志事件丢进队列,后台线程批量刷盘。同时生产环境务必把级别设为WARN或ERROR,避免打印过多DEBUG日志。还有一点经常被忽略:日志中的字符串拼接不要用“+”,用参数占位符{},这样在级别不满足时不会执行拼接逻辑,省下大量无效字符串构造。
5.7 细节39:频繁new小对象最后可能输在GC上
很多人只关心执行时间,忽略了对象分配率对GC的影响。一个接口如果每秒调一万次,哪怕每次都只new一个1KB的byte[],每秒也会产生10MB垃圾,累积起来能显著拉高GC频率。
解决手段有三个层次:代码层尽可能复用对象(比如byte[]用ThreadLocal在同一个线程内复用);设计层用池化技术(比如数据库连接池、对象池);框架层用堆外内存或直接内存,绕开堆GC管理(比如ByteBuffer.allocateDirect)。
注意:对象池也不是银弹,池化会带来锁竞争、池大小管理成本,只有创建对象代价大、对象复用概率高的场景才值得用。盲目池化反而降低性能。
5.8 细节40:压测时别只看CPU,GC和IO Wait同样重要
我见过一个团队排查CPU只用了20%但接口却很慢的问题。后来看GC日志才发现,系统在疯狂Full GC,STW时间占了总运行时间的一半。问题根源是堆内存太小,对象频繁晋升到老年代。
做性能分析至少要看四个指标:CPU利用率、GC暂停时间与频率、IO等待时间、线程Blocked状态。推荐用VisualVM、jstat -gc、jstack组合排查。先确认瓶颈属于计算型、GC型还是IO型,再选择上面相应的优化细节,事半功倍。
6. JVM参数与底层机制里容易见效的6个细节
JVM这块水很深,但有几个高频出现的优化点,普通业务开发者也能快速掌握。
6.1 细节41:-Xms和-Xmx设置相等,取消堆动态扩容
生产环境JVM启动时如果-Xms很小、-Xmx很大,堆会随着使用不断扩容。扩容需要向操作系统申请内存并重新分配对象,过程有开销,而且扩容瞬间可能出现卡顿。
把-Xms和-Xmx设为相同值,让JVM启动时一次性申请完整堆空间,后续不再扩容。看似浪费了初始内存,但实际上内存反正都要用到,提前申请反而减少了运行期抖动。另一个相关的参数是-XX:+AlwaysPreTouch,启动时把所有内存页touch一遍,减少运行期缺页错误,适合追求性能的服务。
6.2 细节42:垃圾收集器选型,先看延迟和吞吐的权衡
JDK 8默认是ParallelGC,吞吐优先,但STW时间可能较长;JDK 11+主流是G1,兼顾吞吐和延迟,适合大部分服务;如果堆内存上GB级且要求几十毫秒内的低延迟,ZGC或Shenandoah值得关注。
我经历过一个数据中台的迁移:堆内存32GB,原来用ParallelGC,平均单次Full GC约800毫秒,高峰期一天几十次,接口毛刺严重。改成G1并调好MaxGCPauseMillis后,毛刺显著减少。但也有反例:一个高吞吐计算服务,G1开销反而比ParallelGC高,导致吞吐下降。
结论:没有“最好的收集器”,只有“最适合场景的收集器”。先压测,再决定。
6.3 细节43:逃逸分析让局部小对象“栈上分配”,没那么可怕
前面说频繁new小对象会增加GC压力,但JVM的逃逸分析做了不少优化。如果一个对象只在方法内部使用,没有逃逸出方法,JIT编译器可能把它拆散成多个标量字段直接分配在栈上,甚至完全消除对象分配。
这就是为什么有时候你new了一个小对象,GC压力却没那么大。逃逸分析不需要手动开启,JVM默认会做,但配合-XX:+EliminateAllocations可以做得更彻底。理解这个机制后,你就不必为了“少new一个对象”写出反人类的代码——真正要小心的是那些逃逸到集合里、被长期持有的对象。
6.4 细节44:本地缓存是提升性能的大招,但注意一致性和容量
把数据库查询结果、远程接口返回值缓存在本地内存,可以省掉每次IO开销,性能提升立竿见影。但本地缓存有很多坑:多实例部署时缓存不一致、缓存容量太大会挤占堆内存、缓存过期策略不合理导致数据错乱。
我的建议:用成熟的缓存框架,而不是自己写Map+定时清理。设置合理上限、过期时间、淘汰策略。还要建立一个“缓存加载失败兜底”机制,避免缓存击穿时直接打爆数据库。
6.5 细节45:大字节数组复用,避免触碰到老年代
一个常见的大内存浪费场景:每次都需要new byte[1MB]作为传输缓冲区,用完丢弃。这个1MB对象如果超过TLAB大小,会直接在老年代分配,频次高了就会触发老年代GC,甚至Full GC。
更好的是用ThreadLocal维护一个byte[],同一个线程内反复复用。一般建议:小于100KB的对象分配没那么可怕,大于1MB的大对象要格外小心,因为老年代GC成本远比新生代高。
6.6 细节46:了解偏向锁、轻量级锁和重量级锁的升级路径
synchronized在低竞争时不是一上来就是重量级锁。JDK运行时会从偏向锁升级到轻量级锁,再到重量级锁。偏向锁会记录线程ID,后续同一线程进入不需要CAS;轻量级锁则用CAS在对象头中记录锁记录地址;竞争加剧才膨胀成重量级锁,调用操作系统的互斥量。
JDK 15以后偏向锁被废弃了,因为偏向锁的撤销本身有成本,在并发高的场景弊大于利。这个细节更多是帮助你理解锁的性能差异:为什么开始很顺,压测线程一多就急剧下降。了解升级路径,你才能判断“锁竞争优化”该从哪个方向入手。
7. 编码习惯与架构层面最后的4个细节
最后这四个细节已经不单纯是Java语法层面的问题了,而是“怎么做设计才能让系统更快”。很多时候,最大的性能提升不是靠微优化,而是靠减少无效的计算和调用。
7.1 细节47:循环体内不做无关计算,不创建无用对象
这是代码Review里最常见的低效点。循环体内的常量表达式、重复的数据库查询、重复创建相同对象,都在白白消耗CPU。
一个典型案例:
for (Order order : orders) { Date now = new Date(); // 每个循环都创建一次 if (order.getExpireTime().before(now)) { ... } }其实把Date now = new Date()移动到循环外,代码逻辑完全不变,但省了N次对象创建。类似这样的问题在真实代码里遍地都是。Review的时候重点看循环体,把它当“热点区域”来扣,收益最直接。
7.2 细节48:提前返回和分支前置,减少无效逻辑执行
一个方法里有多个if分支时,把“快速返回”的条件放在最前面,可以让大多数请求避免走到昂贵逻辑。比如查询订单详情,先判断订单号是否为空、订单是否存在,再去做权限校验、关联查询。
分支前置的另一个好处是避免多层嵌套。嵌套越深,代码越难读,也越容易引入额外判断。改成卫语句后,逻辑路径清晰,执行效率也稍微好一点——虽然编译器和CPU分支预测可以优化大部分,但可维护性提升是实打实的。
7.3 细节49:懒加载和延迟初始化,减少启动和无谓开销
有些服务启动时要初始化一堆资源,比如加载配置文件、建立外部连接、预加载数据。如果这些资源在启动阶段并不一定会被用到,完全可以延迟到第一次使用时再初始化。
Java里的实现方式:静态内部类单例模式、volatile+ double-check或直接用AtomicReference。注意不要过度设计——如果系统基本确定启动后马上会用到这些资源,懒加载反而会增加首个请求的等待时间。权衡清楚再动手。
7.4 细节50:减少远程调用次数,能批量就批量
到了架构层面,最有效的性能优化就是“不做或少做远程调用”。一个接口内部调用5个下游服务,每次都有网络往返,就算每个下游只有20毫秒,串行加起来也是100毫秒。优化空间不在单次调用,而在于调用次数。
思路有这么几个:提供批量查询接口合并请求,原本查100次ID变成一次查100个;将多个强相关调用聚合到网关层或BFF层做并行;把频繁读取的数据从远端缓存到本地;对容忍一定延迟的场景异步化。这一条看似最“虚”,但实际体验提升最明显。我胖这句原话:性能优化到后面,拼的不是JVM参数,而是系统设计。
8. 最后说几句掏心窝的话
我自己在实际项目中踩过不少坑,最深的一条体会是:这50个细节不用全记,甚至不用全用。每个项目都有自己的热点路径,热点之外的微优化都是在给代码上刑具。你真正要做的是先把线上问题度量清楚,再用这份清单对号入座。
另外,每次只改一到两处,然后压测对比,不要一口气把这些细节全铺上去。我见过有人一天之内改了十个优化点,出问题后根本不知道是哪一步引入的,回滚都困难。一步一步来,每个改动都能验证收益,才是一个健康的调优节奏。
最后一个实用小技巧:养成随手写微基准测试的习惯。不用复杂的JMH,哪怕是一段简单的循环计时,也能帮你验证直觉对不对。很多“公认”的优化方案,在自己项目里的真实收益可能完全不同,只有数据不会骗你。
这50个细节往大了说,是覆盖了字符串、集合、并发、IO、JVM、编码习惯、系统设计的一条完整路径;往小了说,就是一个个能落地执行的改进项。希望这份清单能成为你排查Java性能问题时的案头参考。慢慢用,用熟了,很多优化就会变成你写代码的本能。