Java 优化这条路,越往后走越有意思。上一篇基础篇我们把环境配置、数据类型、集合框架这些底子过了一遍,这期就直接进入更实战的环节:代码、JVM、SQL,以及现在 Java 生态里越来越常见的向量数据库集成场景。说句实在话,很多人在业务里写了两年代码,对性能优化的理解还停留在"改个 JVM 参数"或者"把 for 循环换成 stream"的层面,结果改了以后该慢还是慢,甚至越调越糟。这篇 JAVA 优化之路(二)不打算讲那种大而全的理论,只想分享那些我在生产环境里真正试过、踩过、验证过的调优方法和排查思路,适合刚进阶的开发者也适合有一定线上问题排查经验的工程师参考。
1. 优化到底优化什么:先理清思路再动手
1.1 不要一上来就调参数
很多朋友拿到"系统变慢"这个问题,第一反应就是改 JVM 内存、改线程池大小,或者看别人教程抄几个参数填上去。这个思路我特别不推荐。优化的前提是先定位,定位的前提是量化。就像你去看病,总不能啥检查不做直接让医生给你开刀。
我的习惯是三步走:
- 定指标:明确什么叫"慢"。接口平均响应时间、P99 响应时间、吞吐量、GC 停顿时间,这些指标含义完全不同,业务场景不同,关注的侧重点也不一样。比如一个后台批处理任务,吞吐优先;一个用户查询接口,延迟优先。
- 做采样:用工具拿到运行期数据。最常见的是 Arthas、async-profiler、JFR(JDK Flight Recorder),它们能告诉我 CPU 到底烧在哪、内存到底耗在哪、锁到底堵在哪。
- 定位瓶颈:数据出来了,再判断瓶颈在哪个环节。是热点方法太慢,还是锁竞争激烈,还是 JVM 堆分配压力过大,又或者是下游数据库慢。
我用过一个很典型的例子:某次接口晚上高峰期超时率飙升,所有人第一反应是"机器不够了,要扩容"。上了 Arthas 一看,CPU 曝露出来的热点是某个工具类里的String.split被高频调用,内部正则在每次调用时都会重新编译,直接成了性能黑洞。最后一行预编译缓存的代码解决了问题,跟扩容完全没关系。这类案例多了之后,我越来越确信:优化最忌讳的,就是跳过测量直接猜测。
1.2 优化的三层地图
做优化之前,我心里一般会画一张"三层地图":
- 代码层:算法选型、集合使用、字符串处理、IO 方式。这一层最容易出成绩,也是新手最能掌握的部分。
- JVM 层:内存分配策略、垃圾回收器选型、JIT 编译优化、线程池和锁。这一层见效慢,但踩坑也最多。
- 数据层:SQL 语句本身、索引设计、连接池配置,以及更现代的向量数据库检索检索参数。数据层的优化通常能带来数量级的提升。
日常开发中,如果链路是 Web 应用 + 数据库,那么 90% 的"慢"其实是慢在数据层,而不是代码层。所以这篇我会把数据库优化放在比较重要的位置,而不是像很多教程那样一笔带过。理解了这层地图,遇到问题的时候你至少能按照优先级逐个排查,而不是东一榔头西一棒子。
2. 代码层面的优化:从排序算法到集合选型
2.1 排序不是简单的 Arrays.sort()
讲代码优化,排序算法绕不开。就说关键字里反复出现的"冒泡排序Java",很多入门教程把它当作教学案例,这没问题——冒泡排序思想简单,适合理解算法原理。但你要是真在业务代码里写一个 O(n²) 的冒泡排序,去处理几万元素,那性能直接就没眼看了。
JDK 自带的排序其实已经很强了:
Arrays.sort(int[])底层是双基准快速排序(Dual-Pivot Quicksort),对基本类型性能很好;Arrays.sort(Object[])和Collections.sort()底层是 TimSort,它是稳定排序,时间复杂度最差情况下也能保持 O(n log n)。
日常业务里,绝大多数场景直接Collections.sort或者list.sort就够了。但你得知道一个很重要的点:对象排序用稳定排序,基本类型排序用快排,这个设计不是随便定的。基本类型只需要数值比较,快排快点无所谓;对象排序往往涉及多字段比较,稳定性非常关键——比如先按用户等级排,再按注册时间排,如果排序不稳定,第二轮的比较结果就会干扰第一轮的顺序。
如果数据量很小,比如只有几个到几十个元素,反而直接写选择排序或插入排序更快,因为快排的递归和分治开销在这个规模下是浪费。JDK 里的 TimSort 就是这么干的,分治到小规模时会自动切换成插入排序。所以我的建议是:别在业务代码里自己造排序轮子。你唯一需要手写排序的场景是数据量特别大且内存非常敏感,或者你需要某种自定义的局部有序策略。就算要手写,也请认真考虑复杂度边界和退化情况,别把最坏情况 O(n²) 的算法直接怼到生产上。如果你是在准备蓝桥杯这类算法竞赛,手写排序、分析复杂度是基本功,我很支持;但到了企业项目里,学会站在 JDK 的肩膀上,才是成熟工程师的表现。
2.2 集合类选型:ArrayList 未必浪费,LinkedList 未必高效
集合这块我见过太多反直觉的操作。很多人觉得 LinkedList 适合"频繁增删",结果在真实场景里用了 LinkedList 反而更慢。原因有三点:
- LinkedList 每个节点是一个独立对象,内存占用远高于 ArrayList 的连续数组;
- 它的内存地址不连续,缓存局部性差,CPU 遍历时的 cache miss 会非常严重;
- 实际代码里所谓的"频繁增删",往往是在末尾追加数据,而 ArrayList 的均摊追加成本是 O(1),反而最快。
所以我的选型经验是:绝大多数业务场景,直接用 ArrayList。遇到"频繁在中间插入"的需求,先问问自己这个列表多大——如果只有几十个元素,ArrayList 在中间插入虽然要移动元素,但那点移动成本真不算什么。如果列表真的很大、又必须中间插入,再考虑更对症的数据结构,比如ArrayDeque做双端操作,ConcurrentLinkedQueue做并发场景下的队列。
HashMap 也有一个高频踩坑点:初始化容量。new HashMap<>()不指定容量时默认是 16,当数据量超过负载因子阈值(默认 0.75)就会扩容,扩容时所有元素重新计算哈希、重新搬移。如果提前知道大概数据量,比如要放 5000 个元素,你可以设置初始容量为5000 / 0.75 ≈ 6667,HashMap 会向上取整到 8192,这样整个过程一次扩容都不发生。一个固定容量设置对了,HashMap 的性能和内存占用都会好看很多,这个细节面试里也经常问。
顺手再说一个字符串判断的小问题。很多人写"判断字符串是否纯字母数字"直接调String.matches("^[a-zA-Z0-9]+$"),这在低并发场景下没人管你,但在热点路径里,每一次调用都会重新做正则编译,性能损耗非常明显。更稳的做法是预编译 Pattern 或者直接循环Character.isLetterOrDigit判断。类似的"小操作"积累多了,代码层的优化空间其实比想象中大。
2.3 Stream 与 Lambda:用得好是糖,用不好是坑
Java 8 之后的 Stream 和 Lambda 极大提升了代码可读性,但性能上是有代价的。Lambda 本身不总是生成匿名类,但 Stream 管道的每一步都涉及较多的对象分配和迭代器封装。相比之下,传统的 for 循环在 JIT 编译器眼里非常简单,很容易被优化成高效的机器码;Stream 链路复杂,JIT 不一定能完全展开。
我不是让大家回到 for 循环时代,而是想说清楚什么时候会踩坑:
- 热点循环里,数据量几十万以上且频繁执行,尽量别用
stream().map().filter().collect()串一大串。优先考虑传统 for,或者用IntStream、LongStream、DoubleStream这种基本类型流,避免自动装箱拆箱产生大量临时对象; parallelStream()要慎用。它默认用的是ForkJoinPool.commonPool(),并发度是 CPU 核数减一,所有用了 parallelStream 的代码共享同一个线程池。在一个接口里两处都用并行流,它们会互相抢线程,可能比串行还慢。而且并行流的分割、合并也有开销,数据量不够大的时候,负优化是常事。
一个比较稳妥的做法是:先用 for 循环把功能跑通,再基于性能测试数据决定哪些热点要优化。性能优化必须建立在数据上。真正踩过坑的人才知道,一百万个元素的map+filter,用 for 和用 Stream 的差距能差两三倍,但十万元素以内几乎感觉不出来。所以别一听到 Stream 慢就全面否定,也别被"优雅代码"洗脑,一切以测量为准。
3. JVM 调优实操:内存、GC 与启动参数
3.1 堆内存设置的几个原则
JVM 调优第一个大坑,就是把-Xmx和-Xms设成完全相同的值。很多运维模板喜欢这么做,理由是"避免堆扩容导致的停顿"。这个说法有一定道理,但如果服务实际活跃数据只有 512MB,你却给它 4GB 堆,GC 扫描范围变大,停顿时间反而更长。
我的做法是三步:
- 先跑一个不加 JVM 参数的应用,用 JFR 或 jstat 观察稳定运行时的 Old 区占用量;
- 根据"Old 区稳定占用 + 50% 富余"来设定
-Xmx; - 把
-Xms设成这个值的 60% 到 80%,减少启动初期频繁扩容,又保留一定的弹性空间。
另外别忘了-XX:MaxMetaspaceSize。JDK 8 之后永久代被移除,类元数据放到元空间,使用本地内存。频繁热部署、动态生成代理类的应用(比如 CGLIB、JDK Proxy)元空间非常容易爆。之前帮朋友排查过一个每天定时 OOM 的支付服务,日志里根本没有堆溢出,看了 jstat 才发现 Metaspace 涨到近两 GB 然后崩了。原因就是某个第三方库每处理一笔交易就生成一批临时增强类。这类问题如果不对元空间做限制,很容易拖垮整台机器。
3.2 GC 选型:别再抱着 CMS 不放了
GC 选型是 Java 优化绕不开的话题。现在 JDK 8 的主力还是 ParallelGC 和 CMS,JDK 11+ 很多时候推荐 G1,JDK 17+ 或者 JDK 21 上,ZGC 已经很成熟了。
| 场景 | 推荐 GC | 理由 |
|---|---|---|
| 批处理、后台任务,对停顿不敏感 | ParallelGC | 吞吐优先,CPU 利用率最高 |
| Web 应用,业务高峰期停顿敏感 | G1 | 停顿时间可预期,是 CMS 的现代替代 |
| 超大堆(几十 GB)且要求低延迟 | ZGC | 停顿时间通常不超过几毫秒,适合大内存搜索服务 |
很多老项目还在用 CMS,一是改配置怕出事,二是习惯难改。但 CMS 在 JDK 9 就开始标记废弃,JDK 14 直接移除了。如果你还在 JDK 8 上跑 CMS,并且堆比较大、GC 停顿问题突出,我建议尽快评估迁移到 G1。迁移时重点看两个参数:
-XX:MaxGCPauseMillis=200:目标停顿时间,G1 会以此为目标做增量回收。但别设得太小,比如设成 50ms,G1 会把大量 CPU 花在回收上,造成吞吐量下降。-XX:ConcGCThreads:并发标记线程数,通常默认即可,不要盲目调大,太多线程反而干扰业务线程。
如果你想把 G1 的调优经验快速落地,可以参考下面这个 JDK 17 的启动参数模板,具体堆大小请按实际资源和监控数据来改:
java -Xms4g -Xmx4g \ -XX:+UseG1GC \ -XX:MaxGCPauseMillis=200 \ -XX:MaxMetaspaceSize=512m \ -XX:+HeapDumpOnOutOfMemoryError \ -XX:HeapDumpPath=/var/log/app/heapdump.hprof \ -jar app.jar+HeapDumpOnOutOfMemoryError这个参数一定要加。不要等 OOM 之后才临时挂 jmap,那时候进程可能已经被系统回收了。之前排查线上内存问题,全靠这个参数留下的 hprof 文件,后面用 MAT 打开很快就能看到到底是谁占着堆不放。
3.3 用 JFR 和 Arthas 定位热点
JVM 层优化最容易遇到的困境是:你知道慢,但不知道慢在哪。这时候 JFR(Java Flight Recorder)真的神器。JDK 11+ 可以直接用jcmd <pid> JFR.start duration=60s filename=/tmp/rec.jfr开始录制,不需要额外探针。相比之下,jstack只能抓瞬时的线程栈,而 JFR 能持续采样 CPU、内存分配、锁等待,把问题还原得比较完整。
Arthas 更是国内 Java 开发者的老朋友。dashboard可以实时看到全局 CPU 和内存分布;thread -n 3可以列出 CPU 最繁忙的三个线程;trace命令能直接定位某个方法内部各步骤的耗时分布。我举一个实际发生过的例子:有一次接口 P99 延迟从 80ms 涨到 1.2 秒,现象很诡异,不是稳定变慢,而是偶发变慢。用 Arthas 观察后发现,一个工具类在计算哈希时,内部对一个Map做get(),而这个 Map 是synchronized的,偶尔出现头部锁竞争。换成ConcurrentHashMap之后,P99 立刻降到 120ms 以内。这种问题如果靠猜,一辈子猜不出来。
4. 数据库与慢 SQL 优化:从定位到并行化
4.1 慢 SQL 的定位:一行 EXPLAIN 胜过十次猜测
SQL 优化是 Java 工程师最容易忽视、也最容易拿到大回报的部分。慢 SQL 的排查套路我总结成一句话:先看慢查询日志,再看 EXPLAIN,再结合业务和数据分布确认。
MySQL 慢查询日志默认关闭,一般开发环境的配置是:
slow_query_log = ON slow_query_log_file = /var/log/mysql/mysql-slow.log long_query_time = 1long_query_time代表超过多少秒会被记录。业务高峰期如果日志太多,可以先调到 2 秒或 5 秒,把最严重的问题抓出来,再用pt-query-digest或者mysqldumpslow聚合,找出 Top N 的慢语句。
拿到慢语句之后,就是执行计划出场了。EXPLAIN SELECT ...出来之后,我一般只看这几列:
type:常见的有ALL(全表扫描)、range(走索引但范围很大)、ref(非唯一索引等值匹配)、const(主键或唯一索引等值查询)。看到ALL基本可以断定这条 SQL 的扫描量非常大。rows:预估扫描行数。如果这个数接近表的总行数,说明索引失效或者没建索引。key:实际用到的索引。如果为 NULL,说明优化器没找到可用索引。Extra:重点看有没有Using filesort、Using temporary提示。Using filesort意味着排序没走索引,这通常是大查询超时的隐性杀手。
我见过最典型的案例:一个订单列表接口,用户查最近一百单,结果每次执行 3 秒多。EXPLAIN 显示type=ALL,rows 是 80 万。加了一个KEY idx_user_order(user_id, order_time DESC)的联合索引之后,查询时间直接降到 30 毫秒——将近 100 倍的提升。
4.2 索引设计的三条铁律
索引优化不是建越多越好,索引写多反而拖慢插入和更新。我总结了三句话:
- 区分度高的列放前面。联合索引
(a,b,c)里,a 的区分度要最高,因为 MySQL 先按 a 筛选,a 区分度高才能快速缩小范围。如果 a 只有 0/1 两种取值,b 才具有高区分度,那应该把 b 放前面。 - 尽量做覆盖索引。让 SELECT 的字段全部包含在索引里,就能直接从索引树拿到数据,免去回表。比如
SELECT id, name FROM user WHERE status=1,如果建了(status, name)联合索引,那么查询只扫索引就够了,比只建(status)索引快非常多。 - 小心索引失效的常见操作。在索引列上使用函数,比如
LEFT(name,2)='ab';隐式类型转换,比如字段是字符串,传参传 int;LIKE 以%开头;OR 条件中存在无索引列。这些都会让索引白建。
另一个高频的坑是分页深翻。业务里常见的LIMIT 1000000, 20这种写法,MySQL 要先把前 100 万行找出来再丢掉。更高效的做法是"延迟关联":
SELECT t.id, t.name, t.amount FROM ( SELECT id FROM order_log WHERE user_id = 123 ORDER BY create_time DESC LIMIT 1000000, 20 ) temp JOIN order_log t ON t.id = temp.id;这个技巧的关键是:内层子查询只从索引里取主键,不回表;找到目标 20 个 id 之后,再回表拿全字段。因为前 100 万次扫描都没有回表,性能提升一到两个数量级是常见的。
4.3 并行 SQL 和批量操作的调优
关于"并行 SQL 优化",我在实际项目里的态度是:数据仓库的读场景可以并行,OLTP 在线交易场景要极其谨慎。你给主库一个PARALLEL 8,单个查询占 8 个线程不是问题,但多用户并发时,总线程数很快被打满,整体吞吐反而雪崩。
在线业务里我更推荐的是逻辑层面的并行:比如把一个大的统计任务拆成按时间分片,多个线程分别查不同时间段,再在内存里聚合。这种方式可控、易回滚、对数据库压力也小。
再有一个 Java 侧最常见的性能杀手:逐条 INSERT。很多老代码是这么写的:
for (Order order : orderList) { orderMapper.insert(order); }一万条订单就是一万次网络往返和一万次事务提交。改成批量插入之后,如果用的是 MySQL JDBC,记着加两个连接参数:
rewriteBatchedStatements=true useServerPrepStmts=truerewriteBatchedStatements=true能让 JDBC 把多条 INSERT 重写成一条多行 INSERT 语句,网络往返次数直接从一万降到几十次。我做过的改造里,导入五万条初始数据,耗时从 40 秒降到 2 秒左右,效果非常直观。
5. 向量数据库集成与优化:Java 新场景的实测经验
5.1 为什么 Java 项目要碰向量数据库
最近"向量数据库集成与优化"这个热词频繁出现,不是没有原因的。Java 后端在传统上跟关系型数据库绑定得比较紧,但这两年 AI 相关的业务大量落地,语义搜索、推荐召回、知识库问答(RAG)这些场景都需要把文本、图片、音频变成向量,再用向量检索做近似查找。作为后端主力语言的 Java,不可能绕开这个趋势。
我举个例子:一个电商搜索团队想实现"根据用户描述的商品特征召回相关商品",传统的关键词检索对"保暖又不太贵的长款羽绒服"这种自然语言处理得很差。方案是把商品描述和用户 query 都 embedding 成向量,然后在向量数据库里做top-K近似检索,Java 服务只需要负责调度和写业务逻辑。这种架构现在已经成为很多 AI 应用的标配。
5.2 选型与接入参数
向量数据库选型,我接触过的几个主流方案各有特点:
| 方案 | 特点 | 适合场景 |
|---|---|---|
| Milvus / Zilliz Cloud | 分布式能力最强,支持亿级向量 | 大规模生产检索 |
| Qdrant | 轻量,Rust 编写,性能好,接口简单 | 中小规模,快速落地 |
| Weaviate | 内置模块化丰富,和语言模型集成好 | RAG 类知识库 |
| pgvector | 基于 PostgreSQL 扩展,运维成本低 | 已经在用 PG 的团队 |
Java 接入时,我踩过一个大坑是连接数和超时。向量数据库的 SDK 底层常常走 gRPC,默认连接池、超时配置都比较克制。如果你的搜索请求里有多个向量的批量检索,比如一段文本被切成了五个分句,每个都要单独召回,并发一上来,gRPC 连接非常容易被打满。
我建议在初始化客户端的时候,根据 QPS 和单次检索耗时估算连接池大小。比如单次检索 P99 是 50ms,目标 QPS 是 200,那一秒内单连接最多完成1000 / 50 = 20次请求,需要的连接数是200 / 20 = 10个,再留余量,设成 32 到 64 比较稳。超时时间不要照抄默认值,检索超时最好设置成比 P99 略高的值,比如 300ms,这样既不会让大量请求卡死,也不会频繁误报超时。
5.3 检索调优和避坑清单
向量数据库的性能调优,主要落在两个地方:索引参数和过滤条件。
HNSW 是最常用的近似最近邻索引,核心参数有M和efConstruction:
M:每个节点的最大连接数。M 越大,索引越精确,但内存占用也越大;efConstruction:构建时的动态列表长度。越大构建越慢,但检索质量更好。
在实际项目里,我一般从M=16, efConstruction=100起步,然后根据召回率实测调整。如果只是做语义召回,不需要 100% 精确,别把参数推太高,否则内存吃不消。
还有一个特别容易被忽略的点:元数据过滤要尽量前置。你可以在向量库里同时存一些标量字段,比如商品分类、上架状态、价格区间,检索时用过滤条件先把候选集缩小,再做向量相似度计算。如果不做过滤,一个原本几十毫秒的检索可能翻番。向量检索不是单纯比距离,先把无关数据挡在门外,性能才能稳。
最后记录一个踩坑:向量数据写入时的 batch size 问题。有人一次性把几十万条向量通过一个 session 灌进去,结果内存直接撑爆、写入超时。解决办法是分批次写入,单批次建议 512 到 2048 条,边写边观察吞吐。别迷信大 batch,吞吐不一定线性增长,内存倒是实实在在会炸。
6. 常见问题与排查技巧实录
6.1 接口变慢、进程 OOM、启动失败的排查思路
我整理了一份高频问题速查表,都是实际运维和排查中反复用到的:
| 现象 | 可能原因 | 建议排查步骤 |
|---|---|---|
| 接口偶发超时 | 锁竞争 / GC 停顿 / 下游抖动 | Arthasthread -n 3、JFR 录 10 分钟、查连接池监控 |
启动失败且日志有OutOfMemoryError | 堆内存不足 / 元空间不足 | 看堆栈是 Heap 还是 Metaspace,调整对应参数,开启 HeapDump |
| 进程假死但没崩 | 线程池耗尽 / 死锁 | jstack抓取线程栈,看 BLOCKED / WAITING 状态 |
| 数据库 CPU 90%+ | 慢 SQL / 全表扫描 | 开慢日志,EXPLAIN 排查,优先处理type=ALL |
| 接口响应快但吞吐上不去 | 单接口内串行等待太多 | 分析调用链路,把可并行的 IO 用独立线程池做并发 |
排查的时候有一个习惯特别推荐:一次只改一个变量。很多人遇到性能问题就同时改 JVM 参数、换线程池、调 SQL,结果问题确实没了,但不知道是哪步起的效果,下次复发依然是盲人摸象。我自己的做法是,所有参数变更记录到排查笔记里,每改一项压测或观察一段时间,稳定之后再改下一项。
6.2 一些值得坚持的优化习惯
最后分享几个从踩坑中总结出来的习惯:
- 线上环境务必开启 GC 日志。JDK 8 用
-XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:/path/gc.log;JDK 11+ 使用统一日志-Xlog:gc*:file=/path/gc.log:time,uptime,level,tags。有了 GC 历史,才能判断偶发停顿到底和 GC 有没有关系。 - 接口压测别只看平均值。平均值会被极少数慢请求平均掉,P99、P999 才是真实体验的晴雨表。优化目标是让 P99 曲线平稳,而不是把平均响应时间从 50ms 优化到 40ms 就交差。
- 代码层面的优化,永远放到性能和可维护性平衡之后。你有把握说"这里确实是热点、确实是瓶颈"再动手改。否则,把时间花在业务正确性和代码可读性上,性价比高得多。
我个人在实际操作中的体会是:Java 优化不是某一项神操作,而是把测量、归因、验证这一套流程不断重复。从代码写法到 JVM 参数,从 SQL 执行计划到向量检索配置,每解决一个真实瓶颈,系统能稳定的时间就越长。这篇 JAVA 优化之路(二)里写的这些方法,有的看起来不起眼,但每一个都是我在生产环境试错试出来的;如果你也正好遇到类似问题,不妨按"先量化、再定位、后优化"的顺序走一遍,大概率比直接抄参数靠谱。