Java性能优化50个细节:从字符串到架构的实战清单
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 细节2String.split底层是正则能不用就不用“下单成功订单号12345金额99.00”这种字符串很多人直接一行split按逗号切。方便是方便但split的底层实现是正则表达式匹配每一次调用都会涉及Pattern的编译和执行代价比肉眼看到的要大得多。如果你只是按固定的单个字符切割比如逗号、竖线、冒号完全可以用indexOf加substring手写一个轻量切割方法。我处理过一个业务日志解析的案例日志字段就是“keyvalue;keyvalue”这种格式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 细节5String.format是个隐形富豪工具String.format写起来舒服读起来也清晰但性能很差。它需要解析格式字符串、构造Formatter对象、处理各种参数类型内部还会生成StringBuilder和多个临时对象。在一个高频打点场景里我统计过String.format比手动字符串拼接慢10倍左右。如果你只是拼几个变量直接加号或者StringBuilder就好如果硬要格式化可以考虑MessageFormat其实也不算快或者干脆用占位符替换自己实现一个轻量方法。最讽刺的是很多人用String.format拼日志结果日志框架后面还要做一次异步序列化两层浪费。后面讲日志的时候我会再提到这个问题。2.6 细节6substring在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 细节9trim和replaceAll隐藏的正则陷阱很多开发者不知道String.trim()只处理ASCII字符集的小于等于U0020的空白字符对全角空格、不间断空格无效。如果你需要处理中文文本里的全角空格用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 细节11HashMap初始化一定指定容量默认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.75667HashMap内部会向上取整到1024这没啥问题但如果你只给了500那么存500个元素时已经扩容一次了。顺带一提JDK 8以后HashMap在链表长度超过8且数组长度超过64时会转红黑树转树本身也有开销。注意不要让太多key哈希碰撞必要的时候提供一个好的hashCode实现。3.2 细节12ArrayList同理预估容量一次性给到位ArrayList的扩容机制是1.5倍增长每次扩容都是一次Arrays.copyOf。假设你要add 10000个元素默认情况下可能要扩容十几次累计复制的元素个数远超10000做了一堆无用功。最佳实践如果能预估sizenew ArrayList(10000)。如果无法精确预估也可以先new ArrayList(预估上限)因为ArrayList扩容只会扩容到比你预估更大的容量不会浪费太多。注意这里的坑是ArrayList(0)这种写法只会给你一个空数组首次add直接扩容到10后面还得继续扩别以为写了0就省内存。3.3 细节13ArrayList还是LinkedList别只看“插入删除快”经典面试题告诉你要频繁插入删除就选LinkedList但其实这句话在现代业务代码里基本是毒药。LinkedList的插入删除确实O(1)前提是你已经持有了那个节点的引用但如果你是按索引插入它需要从头遍历找到位置是O(n)。而ArrayList的随机访问是O(1)尾部插入均摊也是O(1)。更致命的是LinkedList每个节点都是一个独立Node对象内存占用是ArrayList的数倍缓存局部性差遍历性能也远不如ArrayList。我实际测试过10万元素的随机访问ArrayList比LinkedList快两个数量级尾部插入ArrayList略慢于LinkedList但差距不大。结论99%的业务场景直接选ArrayListLinkedList基本只有实现队列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然后判断nullObject value map.get(key); if (value ! null) { ... }如果担心key存在但value原本就是null的情况可以用getOrDefault或者computeIfAbsent但总原则是一次哈希查找能搞定的事别做两次。这在高频配置读取场景里收益特别明显。3.7 细节17遍历Map用entrySet避免keySet加getfor (String key : map.keySet()) { Object v map.get(key); }这种写法在遍历时每取一次value都多做一次哈希查找。直接遍历entrySetkey和value都在同一个对象里省掉哈希计算。数据量小的时候无所谓但为map里上万条数据时keySetget比entrySet慢30%左右。别小看这个30%Map遍历在很多报表导出、缓存刷新的场景里是主力循环。3.8 细节18Stream不一定比普通for循环快甚至会慢Java 8流式编程写起来很爽但它引入了Lambda和更多中间对象比如stream管道中的Sink链、reduce的包装对象。ArrayList遍历100万元素普通for循环和增强for循环基本无差别Stream的forEach反而要慢10%~15%。如果是parallelStream数据量小的时候线程切换开销会吃掉并行收益。我不反对用Stream它表达力更强易读、易维护。但在超高并发、每秒执行几十万次的遍历逻辑里我会老老实实写for循环提前跟你们交个底。3.9 细节19尽量用基本类型数组少用ListListInteger里每个Integer都是一个对象光内存占用就比int[]高4倍以上。如果你处理的是数字密集型数据比如百万级别的评分、统计、坐标计算用基本类型数组可以显著减少堆内存和GC压力。数组的缺点是操作不够方便但你可以把算法封装成一个工具类对外暴露出友好的方法内部用原始数组。我自己写过一套类似原语的数值计算工具内存占用从300MB降到120MBGC从每秒10次降到每秒2次。代价是代码量增加但收益非常直观。3.10 细节20Arrays.asList返回的是固定大小视图别抛异常才知道Arrays.asList底层还是原来的数组它不是真正的ArrayList不支持add/remove。如果后续要给集合添加元素很多人直接new ArrayList(Arrays.asList(...))这等于多拷贝一次。更高效的做法是如果原始数据就是数组直接用Arrays.asList的视图做只读遍历即可压根不需要包装成ArrayList。只有当确实需要增删时才二次包装。这个细节不仅是性能问题更是减少无谓拷贝的编程习惯。3.11 细节21subList是视图不是拷贝别误解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核数1IO密集型 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 细节28ThreadLocal使用完务必removeThreadLocal的经典问题是内存泄漏。因为ThreadLocalMap的key是WeakReference如果线程持续存活比如线程池里的线程value却始终被强引用链持有就无法回收。日积月累可能造成OOM。性能层面ThreadLocal的get/set在ThreadLocalMap里做线性探测查找如果map里堆积了大量失效条目查找效率也会下降。所以无论从内存还是性能角度用完都该remove。我的习惯在finally块里做remove跟关闭流一样养成肌肉记忆。尤其在线程池场景线程复用让ThreadLocal的“残留数据”风险远远大于单线程。4.7 细节29并发Map选ConcurrentHashMap别再用HashTable或同步MapHashTable是全表加锁所有线程串行访问性能极差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 细节36HTTP连接复用别每次请求都新建连接HTTP每次请求都经历TCP三次握手加上TLS握手一个HTTPS请求光是建连可能就要消耗几十毫秒。如果你的服务要调用外部接口每次都新建HttpClient这个开销在QPS稍高时会很明显。正确做法是使用连接池配置最大连接数、每个路由的最大连接数、空闲保活时间。连接池复用已建立的连接后续请求省掉建连耗时。常见的HTTP客户端库都支持连接池注意设置合适的超时和最大连接数防止连接耗尽。5.5 细节37JSON序列化选型别迷信“最快的那个”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压测时别只看CPUGC和IO Wait同样重要我见过一个团队排查CPU只用了20%但接口却很慢的问题。后来看GC日志才发现系统在疯狂Full GCSTW时间占了总运行时间的一半。问题根源是堆内存太小对象频繁晋升到老年代。做性能分析至少要看四个指标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性能问题时的案头参考。慢慢用用熟了很多优化就会变成你写代码的本能。