工业级 JVM 调优这活儿,很多人一听就觉得是高深莫测的黑科技。尤其是当你面对的不是自己熟悉的 HotSpot,而是被戏称为“躺着吃资源”的 Oracle JRockit,以及企业级应用里最让人头疼的 WebLogic 集群时,那种“明明知道有问题,却不敢乱动”的憋屈感,我太懂了。多年前我在某金融类项目的核心账务系统上,就经历过一次由于 GC 长暂停导致交易卡顿的深夜大作战,那会儿 JRockit 的调优资料少得可怜,全靠一点一点喂参数、看曲线试出来的。这篇东西既是一份复盘,也是一套可以直接抄作业的实战路径,适合那些正在维护传统企业级 Java 应用,尤其是 WebLogic + JRockit 组合的兄弟们参考。
1. 项目背景与总体优化思路
1.1 为什么还在用 JRockit + WebLogic
先别急着问“为什么不换 HotSpot + Spring Boot”。在银行、电信、制造业这些系统里,WebLogic 集群的存量是非常庞大的。这些系统动辄运行了十年以上,核心交易链路、周边接口、定时批处理全都跑在 WebLogic 之上,最关键的是,底层 JVM 大多绑定的是 JRockit。换平台的风险评估成本和停机审批成本,根本不是技术层面能解决的,所以现实就是——你得在现有框架下把性能榨干。
JRockit 和 HotSpot 完全是两套思路。JRockit 没有分代堆这一说,它更强调“运行时决策”,也就是说,JVM 自己会根据当前的运行状态和你的目标(延迟还是吞吐),动态调整各种内部策略。它内部的优化器(JIT)和 GC 策略,本质上更像一个“自适应控制器”,而不是一堆静态参数堆砌出来的机器。这就意味着,调 JRockit 不能完全照搬 HotSpot 的经验,你得换一套思维模式。
1.2 工业级优化究竟要解决什么问题
工业级调优绝对不是把几个参数调大调小那么简单。我一般会把目标拆成三层:
- 稳定性:消除长暂停、避免 OOM、防止线程死锁,这是最底线的要求。
- 延迟:把交易的平均响应时间、TP99 压到业务可接受的范围。对于金融核心系统,TP99 超 500ms 基本就会被投诉。
- 吞吐:在保证延迟的前提下,尽量提高单节点每秒处理的事务量,减少应对峰值流量所需的节点数。
这三层目标在参数上的诉求可能完全相反。比如,为了降低响应时间,你可能希望 GC 尽可能用并发模式,但并发模式会抢占 CPU,导致吞吐下降。所以调优的核心是“权衡”,而不是“拉满”。实际项目里,我们做性能测试时,一定会在压测环境上先跑一轮基线,把当前各项指标(堆内存、GC 频率、响应时间、吞吐量)都记录下来,再对照这三层目标去定位瓶颈在哪一层。
1.3 一套能落地的调优方法论
在动手改任何参数之前,先搞清楚顺序,否则等着你的就是线上事故。我自己的调优流程基本固定如下:
- 监控先行:用 JRockit Mission Control(JMC)连着线上或者压测环境,先把 GC 实时曲线、堆内存使用曲线、线程状态、类加载情况录下来。
- 找出异常点:观察是 GC 频繁导致 CPU 抖动,还是堆内存持续上涨导致老年代压力大,或者是锁竞争导致线程 Blocked。
- 单点试验:每次只改一个参数,压测后对比数据,绝不一次改七八个参数。
- 回归验证:性能指标达标后,再放到完整的业务流程里做回归,确认没有影响其他接口。
这套流程虽然慢,但最稳。我记得当时处理那个账务系统卡顿问题时,就是因为直接在线上调了堆大小和 GC 模式,结果一个小时内交易超时率不降反升。后来老老实实回退,在压测环境一步步验证,才找到真正的临界点。
2. JRockit 调优的核心逻辑,和 HotSpot 完全不一样
2.1 从“分代”到“面向延迟”的思维转变
HotSpot 的 GC 思路是分代,年轻代、老年代各管各的,Eden、Survivor、Old 三个区职责分明。但 JRockit 对内存的管理思路是“我不管你是新对象还是老对象,我只关心你能不能达到我设定的停顿目标”。它的堆只有一个,不分代,靠的不是给对象贴年龄标签,而是通过并发标记、并发清理,配合一个极其聪明的“动态分代”机制(老版本里叫 Generational Concurrent,简写 gencon)来实现低延迟。
这套设计带来的好处是,调优参数少,但变量之间的关系更密切。在 HotSpot 上你可能需要调 -Xmn、-XX:SurvivorRatio、-XX:MaxTenuringThreshold 这一大堆,在 JRockit 上你主要关心的就是两件事:堆大小和 GC 模式带来的停顿目标。它的自适应优化器会拿你设定的目标当“KPI”,动态地调节内部行为去完成这个 KPI。
打个比方:HotSpot 像一辆手动挡的车,司机得自己根据路况换挡;JRockit 更像自动挡,你告诉它多久要到目的地(停顿目标),它会自己选挡位,但它有时候会因为“过度自信”而选错挡,这时候你就要手动去干预它。
2.2 三种 GC 模式选型:吞吐、暂停还是可预测
JRockit 里最核心的 GC 模式就三种,选错了后面就算堆调得再大也没用:
- 吞吐优先模式( -Xgc:throughput ):追求单位时间内完成的业务量最大,它会牺牲暂停时间,适合批处理、报表类任务。
- 暂停时间优先模式( -Xgc:pause ):通过并发GC把每次停顿控制在一个目标值内,适合在线交易系统。
- 确定性模式( -Xgc:deterministic ):保证每次 GC 的暂停时间基本恒定,适合对停顿极端敏感的场景,比如实时风控、高频交易。
我们的核心账务系统属于在线交易,肯定选 pause 模式。但这里有个坑:pause 模式在堆变大之后,并发标记阶段会占用更多 CPU,导致吞吐下降。如果你们的机器 CPU 核数不足,比如只有 4 核,那 pause 模式可能反而会更糟。所以选型前一定先看硬件资源。
2.3 关键参数与目标停顿时间的计算逻辑
JRockit 里最关键的参数是 -XpauseTarget 。它直接告诉 JVM:我希望任何一次 GC 停顿都不超过多少毫秒。注意,这是“目标”,不是“保证”,JVM 会尽力去达成,但数据量大到一定程度时只能妥协。
我通常的做法是根据业务的 TP99 来反推停顿目标。比如业务要求 TP99 在 300ms 以内,那 GC 停顿最好控制在 80ms 以下,留出足够的余量给代码执行、网络传输和数据库调用。如果业务允许 500ms,那停顿目标设 150ms 也完全没问题。计算公式很简单:
GC停顿目标 = 业务允许最长响应时间 - 平均业务处理时间 - 网络耗时 - 数据库耗时
举个例子:线上监控显示某笔交易平均耗时 180ms,网络 30ms,数据库 60ms,那剩下的 90ms 就是 JVM 内部消耗的预算。如果你希望总耗时压在 300ms 以内,GC 停顿目标就得小于 30ms,这在并发模式下真的很难。所以我们会适当放宽 TP99 的目标,只对 TP999 强制要求低停顿。
堆大小的设定也不能拍脑袋。我一般先看监控里的峰值存活对象大小(Live Set Size),在压测环境下观察 Full GC 之后堆还能压缩到多少。理论上堆大小最少得是存活对象的 3 到 4 倍,才不至于频繁触发 GC。我们那个系统,压测时存活对象在 2GB 左右,所以最小堆就设了 6GB,最大堆 8GB。再往上堆内存超过物理内存的一半时,就要小心操作系统本身的页缓存压力。
2.4 JRockit Mission Control 的正确打开方式
JRockit Mission Control(JMC)是调优最重要的工具,没有之一。很多人只知道用它看 GC 曲线,其实它最强大的功能是飞行记录器(Flight Recorder),有点像飞机上的黑匣子,可以让 JVM 在不停机的情况下记录一段时间的详细运行信息,包括线程、锁、堆分配、IO、代码热点。
我的经验是,遇到诡异性能问题时,先在 JMC 里开一段 30 分钟的飞行记录,然后去“内存标签页”看对象的分配统计,重点盯那些大量分配的对象是哪个类创建的。有一次我们排查内存泄漏,就是通过飞行记录发现某个定时任务每次执行都会创建海量中间对象,而这些对象被一个静态的 Map 引用着无法回收,才定位到了根因。这种问题用 VisualVM 看堆转储是能看出来,但 JMC 直接在记录里就能找到分配点和调用栈,效率高了一个量级。
提示:JMC 远程连接需要开启 JMX 管理,启动参数加 -Xmanagement:ssl=false,authenticate=false,port=7099 注意生产环境千万别开 authenticate=false,最好用隧道把端口打出来,或者用控制台跳板机,防止被外部扫描到。
3. WebLogic 集群侧的配合改动
3.1 集群会话复制策略的坑
调完 JVM 之后,集群层面还有一堆事情要做。首当其冲的就是会话复制。WebLogic 集群默认把 Session 复制到集群内其他节点,一般是主-备复制模式。但如果你的应用是集群部署,而且启用了粘性会话(Sticky Session),那每次请求其实只会落到同一个节点上,另一个节点的 Session 完全是被动备份。
问题来了:如果你的应用在 Session 里放了大量对象,比如某个查询结果列表,那每次 Session 复制都会触发序列化和网络传输,这玩意儿对 GC 的影响非常大——它会瞬间产生大量生命周期极短的对象,打在年轻代上,导致 Minor GC 的频率暴增。
我们的解决方案比较简单直接:用 weblogic.xml 把 persistent-store-type 设为 replicated,同时把 Session 里存放的数据量控制在最小。对于大对象,要么改成放数据库,要么用分布式缓存。这一点在调优时特别容易被忽略,因为 GC 只看数据,不看你存的是什么,但源头就在这里。
3.2 JDBC 连接池与事务超时参数
在任何企业级应用里,数据库连接池都是高频瓶颈。WebLogic 的 JDBC DataSource 默认配置很保守,初始容量只有 1,最大容量 30,这在高并发下基本就是找死。我们当时把核心数据源的最大容量调到了 100,并且把 Statement Cache Size 调到 50,让常用 SQL 的游标可以复用,避免反复解析。
事务超时也要注意。weblogic 里的事务超时时间默认是 30 秒。如果某个数据库操作慢,连接池会被占满,要么等到超时,要么直接抛异常。我建议先压测时把事务超时调到 60 秒,观察慢 SQL,再回头优化 SQL 本身,而不是靠无限拉高超时来掩盖问题。
连接池还有一个隐藏陷阱:连接泄漏。应用里如果异常路径没关连接,连接池会被慢慢吃光,表现到 JVM 上就是线程大量 Blocked,等待获取连接。排查时要看 WebLogic 控制台里的数据源监控,active 连接数持续增长的话,基本就是泄漏了。
3.3 Work Manager 与线程池的匹配
WebLogic 的默认线程池是自调整的,它不让你直接指定线程数,而是通过 Work Manager 来间接控制。这个设计本意是好的,但实际运行时,如果底层 JVM 的 GC 停顿时间过长,线程池会对“可用线程”的判断失真,导致流量拥入时任务排队严重。
我自己习惯的做法是,把默认的 Work Manager 的线程数下限调高一点,把最大线程数控制在 CPU 核数的 4 到 8 倍之间。比如 8 核的机器,最大线程数调到 32 到 64。注意,这个值不能太高,否则大量线程竞争锁,反而会把 CPU 耗在上下文切换上。
3.4 Node Manager 的配置细节
很多运维兄弟容易忽略 Node Manager,其实它对集群稳定性很重要。Node Manager 负责启动、停止、重启受管服务器,和 JVM 的交互很频繁。如果 Node Manager 的 JVM 本身内存没调好,会导致受管服务器的启动超时。
我建议单独给 Node Manager 跑一个 256MB 到 512MB 的 JVM,别让它和受管服务器共用资源。还有,Node Manager 的 SSL 证书过期可能会导致它无法远程管理节点,我见过不少线上事故就是重启失败后才发现证书失效了。
4. 实战参数模板:一套可以直接抄的配置
4.1 完整启动参数逐项解析
这里给出一套我们在某保险渠道系统上用的 JRockit 启动参数,这套参数经历了大促压测的考验,适用于 8 核 16GB 内存的 WebLogic 受管服务器,跑 Web 应用和交易接口都够用。
MEM_ARGS="-Xms6g -Xmx8g -Xgc:pause -XpauseTarget=100 -XX:UseLargePages=auto -XX:UseCompressedOops=true -Xmanagement:ssl=false,authenticate=false,port=7099 -XX:NativeMemoryPrinting=summary"逐项解释一下:
- -Xms6g -Xmx8g:初始堆 6GB,最大堆 8GB。初始堆定得高一点,避免启动后频繁扩容触发 GC。
- -Xgc:pause:启用暂停时间优先模式。
- -XpauseTarget=100:目标 GC 停顿 100ms,配合业务 TP99 500ms 的指标。
- -XX:UseLargePages=auto:自动开启大页面。大页面能减少 TLB Miss,对吞吐有好处,但开启前要确认操作系统是否支持,没有配置好的话反而会失败。
- -XX:UseCompressedOops=true:压缩对象指针,堆在 32GB 以下时推荐开启。能显著减少对象头大小,省内存。
- -Xmanagement:ssl=false,authenticate=false,port=7099:开启 JMX 远程监控端口。
- -XX:NativeMemoryPrinting=summary:JVM 退出时打印本地内存使用摘要,排查堆外内存泄漏时非常有用。
注意:同一个物理机上如果部署了多个 WebLogic 节点,堆内存总和不要超过物理内存的 60%。JRockit 还有不少堆外内存占用,比如线程栈、代码缓存、直接内存,留足余量才能避免操作系统开始 swap。
4.2 关键 XML 配置与思路
WebLogic 的配置分两层,一个是域级别的 config.xml,另一个是应用级别的 weblogic.xml。域级别主要调整 servlet 线程池和网络超时。我们重点关注应用级别的 weblogic.xml:
<session-descriptor> <persistent-store-type>replicated</persistent-store-type> <timeout-secs>3600</timeout-secs> </session-descriptor>这个配置把 Session 复制模式设置为集群复制,超时时间 3600 秒。会话复制频繁时,会显著增加网络 IO 和序列化开销,所以如果你对粘性有把握,也可以设置成 none,但一旦节点宕机,未复制的会话会丢失,业务上要能接受。
数据源的配置在 weblogic 控制台里操作,或者直接改 config.xml:
<max-capacity>200</max-capacity> <min-capacity>20</min-capacity> <statement-cache-size>50</statement-cache-size> <connection-reserve-timeout>30</connection-reserve-timeout>这里的 connection-reserve-timeout 是拿连接的超时时间,压测时如果频繁报“无法获取连接”,就看这个值太小还是连接池耗尽。statement-cache-size 对提升高频 SQL 的执行效率立竿见影,但也占用内存,别设太大。
4.3 压测验证与关键性能指标
配置改完后,一定要跑一轮压测。我用 JMeter 模拟了 100 并发用户,持续压了 30 分钟,重点看三个指标:
| 指标 | 基线值(未调优) | 调优后(压测值) | 状态 |
|---|---|---|---|
| GC 平均暂停 | 180ms | 45ms | 流畅 |
| GC 频率 | 每分钟 12 次 | 每分钟 3 次 | 大幅下降 |
| 业务 TP99 | 720ms | 310ms | 达标 |
这个结果说明参数生效了,但也要注意 TP99 下降不完全是 JVM 的功劳,会话复制策略、数据源缓存的改动都对响应时间有贡献。所以压测时最好每次只改一个变量,记录结果,再改下一个,这样你才能明确知道哪个改动带来了多少收益。
压测时额外留意系统 CPU:JRockit 并发 GC 会吃掉 1 到 2 个核的资源。如果 CPU 已经 90% 以上,再调 GC 参数也没意义,加机器才是正路。
5. 常见问题与排查实录
5.1 GC 长暂停导致交易卡顿
现象:压测时交易响应时间突然飙升,JMC 显示一次 GC 停顿超过了 1.5 秒。原因:堆中存活对象太多,并发标记阶段无法在目标时间内完成,JRockit 被迫进入 Full GC 模式。
排查步骤:
- 看 JMC 的 GC 时间线,确认长暂停发生的时间和堆使用量。
- 压测时把堆转储(通过 JMC 的 dump 功能),分析到底是哪些对象占用了大量内存。
- 如果发现大量 SQL 结果集对象,考虑是否在业务层翻页不全、造成全量加载。
这种问题最容易踩的坑是:一上来就无脑加堆。堆加大会降低 GC 频率,但单次暂停时间可能变得更长,反而适得其反。正确做法是找到真正的内存大头,改成流式查询或分页加载,然后再决定要不要加堆。
5.2 集群 Failover 后 Session 丢失
现象:某节点宕机后,该节点上的用户全部被迫重新登录。原因:weblogic.xml 里 session 复制策略还是默认值,或者虽然配了 replicated,但对象不可序列化。
排查要点:
- 看部署包里的 weblogic.xml 是不是真的更新上去了。
- 用 JMC 或者系统日志确认复制时有没有 NotSerializableException。
- 如果某个对象是第三方库的,没法改,可以考虑把它转成 DTO 或者字符串再存 Session。
这个问题普遍到令人发指,很多老系统配置完从来没验证过 Failover 场景。我建议大促前一定要做节点杀进程演练,别怕麻烦,真出事的时候再后悔就晚了。
5.3 堆外内存不断上涨
现象:JVM 堆内存很稳定,但操作系统显示 java 进程 RSS 持续上涨,最后被 OOM Killer 杀掉。原因:多半是直接内存(DirectBuffer)泄漏,或者 JNI 代码申请的内存没有释放。
这个问题的排查比较难。先看启动参数里有没有 -XX:MaxDirectMemorySize,没显式设置的话,默认等于堆大小,很容易被吃满。再在 JMC 的飞行记录里看本地缓冲区的分配情况。如果发现某个组件大量使用直接内存,比如 Netty,就要检查它的池化开关。
我遇到过一次比较诡异的情况,是 WebLogic 的 JSP 编译器在大量部署页面时产生了临时文件句柄泄漏,RSS 涨到 12GB 才被系统杀掉。这种只能用 NativeMemoryPrinting 的日志看线程栈,一步步核对谁在申请内存。
5.4 日常排查速查表
| 现象 | 可能原因 | 排查入口 |
|---|---|---|
| 节点频繁无响应 | GC 长暂停、线程死锁 | JMC GC 时间线 + 线程 dump |
| CPU 占用高但业务吞吐低 | 并发 GC 抢 CPU、锁竞争 | 飞行记录器看热点线程 |
| 连接池爆满 | 连接泄漏、SQL 慢 | 控制台数据源监控 + SQL 日志 |
| 定时任务 OVERLAP | 上一轮没跑完,下一轮又启动 | Quartz 日志 + 线程栈 |
| 堆内存持续上涨 | 内存泄漏、缓存无限增长 | 堆转储 + 对象统计 |
结尾
调完这套 JRockit + WebLogic 集群,最大的体会就是:真正花时间的不是拧参数那一刻,而是前期的监控分析和后面的压测回归。技术上很多文章都会写“把 -Xgc 改成什么什么”,但很少有人告诉你改完之后怎么验证自己真的对了。我个人的习惯是,每次调完参数就在压测环境里跑一宿,第二天早上看飞行记录,确认长时间运行后GC曲线依然平滑,才敢把参数推到生产。另外还有一个小技巧:JRockit 有个隐藏开关叫 -XX:SelfTuning,默认是开启的,它会根据运行时表现自动调整一些内部策略。如果你做了大量手动配置,可以考虑把它关掉,避免 JVM 自己乱改你的设定。但这是一把双刃剑,关掉之后 JVM 就不会帮你兜底了。我自己是先保留自调优,实在不行再手动干预。
最后再分享一个感触:JRockit 因为和 HotSpot 合并之后,Oracle 官方已经不怎么更新了,社区里的资料也在慢慢变少,但这套东西在当前的企业环境里还存着大量的部署。对还在维护这类系统的兄弟们来说,与其天天琢磨怎么迁移,不如先把现有环境的配置吃透,让业务跑得稳稳当当的。真到了非迁移不可的那天,你调优过程中积累的那些监控、压测、排查的功夫,放在任何 JVM 上都照样管用。