资讯详情

资讯详情

Java Web性能排查:JVM内存、GC与参数调优实战

一个工作日的下午我手上维护的订单服务接口 P99 从 80ms 涨到了 1.2 秒监控上数据库耗时平稳、下游调用正常、错误率零日志里也翻不出任何异常堆栈。折腾了四十分钟才想起来去看一眼 GC 日志发现每两分钟一次的 Full GC 把整个进程停在那儿整整八百毫秒。这件事之后我把 JVM 相关的监控和参数模板重新梳理了一遍也就有了这篇东西。JVM 不是面试八股它更像汽车的发动机舱——平时不用天天打开看但一旦仪表盘亮灯你得知道哪个盖子下面是什么东西。这篇内容面向已经写过 Spring Boot 接口、能部署 jar 包、但每次看到 GC 日志就往右滑的 Javaweb 开发者我会把运行时数据区、类加载、垃圾回收、参数配置、线上排查这几块全部放回一个 Web 请求从进来到出去的真实链路里讲该给的参数模板、命令、排查顺序都会写清楚照着抄能跑起来。1. 写 Javaweb 的人为什么躲不开 JVM 这摊事1.1 一次接口变慢引出的连锁反应很多人对 JVM 的认知停留在内存分堆和栈垃圾回收自动跑。这套说法在写课后作业时够用放到线上就是另一回事了。Web 服务的典型特征是长驻进程 高并发 大量短生命周期对象这三个特征叠在一起会把 JVM 里那些平时看不见的行为放大成肉眼可见的性能问题。我复盘那次故障链条其实很清晰订单查询接口里拼了一个本地缓存 Map用的是静态字段键是用户 ID 时间戳本意是做几秒钟的去重结果时间戳精确到毫秒键几乎不重复缓存等于只写不读。这些对象被静态 Map 强引用着永远进不了回收范围堆被一点点吃掉老年代满了就 Full GC。而 Full GC 期间所有业务线程 STWStop The World请求全卡住卡住的请求又堆积了更多对象形成恶性循环。如果当时我知道两件事——静态集合是最典型的 GC Root 泄漏源、jstat 可以十秒钟看出老年代占用在单调上涨——这四十分钟能压缩到五分钟。所以 JVM 知识在 Web 场景里的价值不是让你去手写垃圾回收器而是让你在故障发生时有一条确定的排查路径而不是靠重启碰运气。1.2 JVM 知识在 Web 场景里的四个真实落点我把这些年真正用得上 JVM 知识的场景归了归类基本跑不出下面四类场景典型症状需要的 JVM 知识内存问题OOM 崩溃、频繁 Full GC、老年代缓慢上涨运行时数据区划分、可达性分析、GC Roots参数配错容器里被 OOMKilled、堆开太大导致宿主机抖动堆内存计算、容器感知参数类冲突同一个类两个版本、NoSuchMethodError类加载机制、双亲委派性能排查CPU 打满、线程阻塞、接口毛刺线程栈、栈帧结构、jstack这四类问题有个共同点报错信息和真实原因之间隔着一层。OutOfMemoryError: Java heap space只告诉你堆满了不告诉你谁占的NoSuchMethodError只告诉你方法找不到不告诉你加载了哪个版本的类。把这一层捅破靠的就是对 JVM 内部结构的基本理解。我不建议一上来就啃虚拟机规范那玩意儿是给实现者看的。更高效的路子是先把内存布局和对象生命周期搞清楚再去跑几个真实案例边跑边查文档两三轮下来就有手感了。2. 把运行时数据区塞进一个 HTTP 请求的生命周期里看2.1 五个区域各自管什么别再背成一段顺口溜标准的划分是程序计数器、虚拟机栈、本地方法栈、堆、方法区。死记硬背没意义我换个角度按线程私有还是共享来记一次就记住了。线程私有程序计数器、虚拟机栈、本地方法栈。每个线程一份线程结束就没了不需要垃圾回收。线程共享堆、方法区JDK 8 之后方法区的实现叫元空间挪到了本地内存。程序计数器是当前线程执行到哪条字节码的指示器它是唯一一个不会抛OutOfMemoryError的区域。虚拟机栈描述的是 Java 方法执行的内存模型每调用一个方法就压一个栈帧进去栈帧里装局部变量表、操作数栈、动态链接、方法返回地址。本地方法栈是给 native 方法用的HotSpot 里把它和虚拟机栈合并实现了所以调优时你只需要关心一个-Xss。堆是对象实例的主战场几乎所有的对象都在这里分配也是垃圾回收器唯一重点照顾的区域。方法区存的是类元信息、常量、静态变量、即时编译后的代码JDK 8 用元空间取代永久代之后它不再受-Xmx限制而是吃本地内存靠-XX:MaxMetaspaceSize兜底。提示很多人以为-Xmx就是进程能用的全部内存这是个高频误解。堆之外还有元空间、线程栈、直接内存、JVM 自身结构、JIT 编译产物这些统统算在容器内存限额里。2.2 一个请求进来内存里到底发生了什么假设一个GET /order/{id}请求打到 Tomcat走完 Spring MVC 的 DispatcherServlet最后到你的 Controller。这条链路上内存的动作大致是这样的Tomcat 的工作线程从连接器拿到请求字节解析成HttpServletRequest对象这些对象在堆上分配。线程调用DispatcherServlet#doService虚拟机栈压入一个栈帧参数和局部变量放在栈帧的局部变量表里。注意引用在栈上对象本体在堆上这个区分在排查问题时特别关键。方法内部new出来的 DTO、List、Map全部在堆的新生代 Eden 区分配。如果对象很小且逃逸分析判定它不会跑出方法外JIT 可能直接做标量替换把它拆成几个局部变量放在栈上压根不进堆——这就是为什么有些压测场景下堆增长比预期慢。方法返回栈帧弹出栈上的引用消失。堆上的对象变成不可达等着下一次 Minor GC 被清掉。如果这个对象在多次 Minor GC 后还活着比如被放进了单例的缓存它会被晋升到老年代之后只能等 Full GC 或者 Mixed GC 处理。这条链路里有两个高频踩坑点。第一是大对象直接进老年代一个几百 KB 的查询结果集如果超过-XX:PretenureSizeThreshold只对 Serial 和 ParNew 有效会跳过新生代直接进老年代反复创建这类对象会加速老年代填满。第二是TLAB 分配每个线程在 Eden 里有一块私有缓冲区小对象优先在 TLAB 里分配避免多线程争抢同一块内存。TLAB 太小会导致分配竞争太大又浪费空间默认值是 Eden 的 1%绝大多数情况不用动。2.3 元空间和直接内存两个最容易被忽略的口子我见过不止一次堆内存很健康但进程被杀的案例八成出在这两块。元空间存的是类的元数据。Spring 项目启动时扫描大量注解、生成大量代理类元空间占用会明显上升。默认情况下MaxMetaspaceSize是-1也就是不限制理论上可以一直吃到把本地内存耗光。这在容器里极其危险因为容器只看进程总 RSS超了就 OOMKilled你连堆 dump 都来不及留。我的做法是显式设一个上限一般 256MB 到 512MB 足够绝大多数 Web 应用超出说明有类加载泄漏这本身就是要修的问题。直接内存最典型的用户是 NIO 的DirectByteBufferNetty、Tomcat 的 NIO 连接器都在用。它不受-Xmx管靠-XX:MaxDirectMemorySize限制默认等于堆最大值。如果堆设了 4G直接内存理论也能用到 4G加起来 8G容器限额 4G 的话必然被杀。这个坑我在一个网关服务上真真切切踩过堆 dump 下来一切正常最后是靠Native Memory Tracking才定位到。注意容器环境里配置 JVM 参数永远要按进程总内存 堆 元空间 线程栈总和 直接内存 JVM 自身开销来算只盯着-Xmx一定会出事。3. 类加载机制框架越重这块越容易翻车3.1 双亲委派拦住的到底是什么类加载分五步加载、验证、准备、解析、初始化。前四步里准备这一步给静态变量分配内存并赋零值初始化才执行clinit也就是静态代码块和静态变量赋值这个区别在排查静态字段问题时很有用——一个静态字段在类初始化之前读到的是零值不是你以为的那个值。双亲委派说的是一个类加载器收到加载请求先甩给父加载器父加载器搞不定才自己上。这个机制拦住了两件事一是核心类被篡改你写一个java.lang.String放进 classpath启动类加载器一看自己已经加载过了直接拒绝不会让假的顶替真的二是同一个类被重复加载同一个类名在同一个加载器层级下只会有一份Class对象equals和类型转换才有意义。但这个父加载器优先在 Web 场景里经常需要打破因为 Web 应用的诉求恰恰相反同一个类库不同应用要能用不同版本。3.2 Tomcat 的类加载顺序为什么和标准不一样传统 Servlet 容器里一个 Tomcat 实例可能部署多个 Web 应用A 应用依赖某个工具库 1.0B 应用依赖 2.0。如果都委派给同一个父加载器后加载的那个就永远用不上自己的版本。所以 Tomcat 给每个 Web 应用配了一个WebappClassLoader加载顺序改成先看本地缓存有没有加载过再找 JDK 核心库这一层还是要委派的安全底线不能破然后先自己找WEB-INF/classes和WEB-INF/lib最后才委派给公共的CommonClassLoader。这个顺序反转解释了很多现象。比如你把一个日志实现打进WEB-INF/lib同时服务器lib目录下也有一个最终生效的是应用自己的那个两套日志框架同时存在就会出现配置不生效日志重复打印这类怪问题。再比如某些 SPI 实现JDBC 驱动是最典型的接口定义在核心库实现在应用侧核心库的加载器根本看不到应用的类只能靠线程上下文类加载器把请求逆向委派下去。这条链路第一次看会觉得很绕但理解了类加载器本质上是命名空间隔离这件事就都顺了。提示如果你在同一个 JVM 里看到同一个类instanceof却返回 false或者报ClassCastException但两边的类名完全一样不用怀疑人生百分之百是两个不同的类加载器各加载了一份。3.3 热部署带来的类加载泄漏开发期用热部署图省事线上如果也开着那就是给元空间埋雷。热部署的原理是重新部署时丢弃旧的ClassLoader新建一个。旧加载器加载的所有类、连带它们引用的静态字段和对象理论上都该被回收。问题在于只要有一个外部引用还指向旧加载器的某个类整条引用链就活下来了。常见的钉子户包括线程池里的线程线程的上下文类加载器指向旧加载器、JDBC 驱动的注册表、ThreadLocal 里挂着的对象、java.util.Timer线程、某些 JMX 注册项。这些钉子钉住一个类整个加载器连带它加载的几千个类全部无法卸载元空间就这么一点点漏。判断方法很直接jstat -gc pid看MC元空间容量和MU元空间使用量如果反复部署之后这两个值持续抬升、不回落就是类加载泄漏。修复思路是在应用关闭钩子里主动清理注销 JDBC 驱动、关闭线程池、清空 ThreadLocal、注销 MBean。写起来不复杂但绝大多数项目都缺这段代码。4. 垃圾回收不背名词只做策略选择4.1 分代假设和对象的真实寿命分代回收成立的前提是两条经验规律绝大多数对象朝生夕死熬过越多次回收的对象越难死。Web 请求处理恰好完美符合第一条——请求进来创建一堆 DTO请求结束就没人要了。所以 JVM 把堆分成新生代和老年代新生代再分 Eden 和两个 Survivor默认比例 8:1:1回收时把 Eden 和一块 Survivor 里的存活对象复制到另一块 Survivor然后整块清空。这个设计带来的直接结论是新生代越小Minor GC 越频繁新生代越大单次停顿越长。默认新生代是老年代的 1/3 左右很多场景下偏小。我的经验是如果监控显示 Minor GC 频率高于每秒一次或者每次晋升到老年代的对象量很大就该适当调大新生代-Xmn或者-XX:NewRatio都行前者更直观。对象晋升老年代的年龄阈值默认是 15-XX:MaxTenuringThreshold但真正起作用的往往是动态年龄判定如果 Survivor 里年龄 1 到 n 的对象总和超过 Survivor 空间的一半年龄大于等于 n 的对象直接晋升不等熬到 15。这个机制很隐蔽是很多对象莫名其妙进了老年代的原因。4.2 主流收集器的取舍对照收集器这块网上资料很多但大多是罗列我按什么场景该选谁整理一版收集器工作代停顿特征适用场景我的实际观感Serial / Serial Old新生代 / 老年代单线程停顿长单核小内存、客户端程序服务端基本不用考虑Parallel Scavenge / Parallel Old新生代 / 老年代多线程追求吞吐批处理、离线计算JDK 8 的默认堆 4G 以下够用CMS老年代并发标记停顿短老版本低延迟服务已被废弃新项目别碰G1整堆可预测停顿分 Region大部分在线服务堆 4G 以上的默认选择ZGC / Shenandoah整堆亚毫秒级停顿超大堆、极低延迟要求堆 32G 以上收益明显选型的逻辑其实就一个权衡吞吐量和延迟不可兼得。批处理任务希望单位时间处理尽可能多能容忍偶尔卡两秒选 Parallel。在线接口希望每次响应都在几十毫秒内宁可整体慢一点也别卡顿选 G1 或 ZGC。G1 从 JDK 9 开始是默认收集器它的核心是把堆切成大小相等的 Region按回收收益排序优先回收垃圾最多的那些所以可以设-XX:MaxGCPauseMillis200让它在 200ms 内尽量多回收一些。要注意这只是个目标值不是承诺G1 会尽力但对象存活率太高时照样会超。4.3 什么时候该怀疑回收器选错了有个很实用的判断方法把 GC 日志打开看停顿分布。如果绝大多数停顿在 50ms 以内、偶尔一次 300ms这是正常的属于 Mixed GC 或者晋升压力大的正常表现。如果出现规律的、每次都是秒级的停顿且不是 Full GC那可能是 Region 太小或者 Humongous 对象超过 Region 一半大小的对象太多。Humongous 对象直接进老年代 Region反复创建会触发连续的并发周期把 CPU 吃掉一大块。解决办法有两个方向一是把 Region 调大-XX:G1HeapRegionSize比如堆 16G 默认 Region 是 4MB改成 8MB 或 16MB二是从代码上避免大对象比如别把整个大结果集一次性toArray()改成分页或者流式处理。我个人更倾向第二个参数调优治标代码改对了治本。5. 参数配置一套能直接抄的模板5.1 堆大小到底该开多少这是被问得最多的问题答案是算出来的不是猜出来的。步骤是这样先确定容器或物理机给这个进程的内存上限比如 4G。扣掉非堆开销。线程栈按-Xss1m× 最大线程数计算500 个线程就是 500MB元空间按 256MB 打底直接内存如果用了 Netty按 512MB 预留JVM 自身结构、JIT 代码缓存、GC 辅助结构合起来按 300MB 到 500MB 算。剩下的才是堆。4G - 0.5G - 0.25G - 0.5G - 0.5G ≈ 2.25G取个整数设 2G 到 2.5G。把-Xms和-Xmx设成一样的值这个老规矩现在依然适用原因是避免运行期堆反复扩容缩容带来的额外开销和抖动。有人担心一启动就占满内存会不会浪费实际上 JVM 只是预留了虚拟地址空间物理内存还是按需触碰的不用纠结。5.2 容器里那几个必须显式写的参数JDK 8u191 之后JVM 默认开启-XX:UseContainerSupport能正确识别容器的内存和 CPU 限额不会再出现容器限制 2GJVM 却按宿主机 64G 来算堆的经典事故。但光靠自动识别还不够有两个地方必须显式配置第一是百分比参数。-XX:InitialRAMPercentage50.0 -XX:MaxRAMPercentage70.0让堆大小跟着容器限额自动走镜像在不同规格的机器上都能跑比写死-Xmx2g灵活得多。70% 是个比较稳的比例留 30% 给非堆和系统。第二是元空间上限。-XX:MetaspaceSize256m -XX:MaxMetaspaceSize512m注意第一个参数是首次触发元空间 GC 的阈值不是初始大小这个命名坑了不少人。注意如果你的镜像跑在 Kubernetes 上务必确认 JVM 版本对应容器感知的能力。老版本 JDK 需要用-XX:UnlockExperimentalVMOptions -XX:UseCGroupMemoryLimitForHeap而且对 cgroup v2 的支持还有问题升级 JDK 比打补丁划算。5.3 一份可以照着改的启动模板下面这套是我目前大部分 Web 服务在用的堆按 2G 算其他项目按比例缩放java -server \ -Xms2g -Xmx2g \ -Xss1m \ -XX:MetaspaceSize256m \ -XX:MaxMetaspaceSize512m \ -XX:MaxDirectMemorySize512m \ -XX:UseG1GC \ -XX:MaxGCPauseMillis200 \ -XX:G1HeapRegionSize8m \ -XX:InitiatingHeapOccupancyPercent45 \ -XX:HeapDumpOnOutOfMemoryError \ -XX:HeapDumpPath/data/dump/heap.hprof \ -Xlog:gc*:file/data/logs/gc.log:time,uptime,level,tags:filecount5,filesize50m \ -XX:ExitOnOutOfMemoryError \ -jar app.jar逐条说下为什么-server服务端模式JIT 编译更激进虽然现在默认就是写上无害。-Xss1m单线程栈大小。递归深的方法或者大量反射调用需要更大的栈但调太大等于变相减少可用线程数。默认值在 64 位 Linux 上通常就是 1M。-XX:InitiatingHeapOccupancyPercent45G1 触发并发标记周期的堆占用阈值。默认 45% 比较保守堆大的时候会导致标记启动太晚Mixed GC 来不及处理最后退化成一个 Full GC。堆超过 8G 时我会压到 35% 左右。-XX:HeapDumpOnOutOfMemoryError这条一定要加。OOM 发生瞬间自动 dump那一刻的现场是事后无论如何都复现不出来的。-XX:ExitOnOutOfMemoryErrorOOM 之后进程状态已经不可信了与其带病运行不如直接退出让编排系统拉起新的。这条在容器环境里尤其重要。-Xlog:gc*JDK 9 之后的统一日志写法替代了老版本的-XX:PrintGCDetails。滚动策略要加上否则日志能把磁盘写满这种事我见过两次。JDK 8 的等价写法是把最后一行换成-XX:PrintGCDetails -XX:PrintGCDateStamps -Xloggc:/data/logs/gc.log -XX:UseGCLogFileRotation -XX:NumberOfGCLogFiles5 -XX:GCLogFileSize50M。6. 线上出问题时的排查链路6.1 先分清是哪块内存炸了看到 OOM 别急着 dump 堆先看清楚异常的全名这决定了后面所有动作的方向异常信息炸的是哪块第一手动作Java heap space堆堆 dump 支配树分析Metaspace元空间查类加载数量jcmd pid GC.class_statsDirect buffer memory直接内存查 Netty 的 ByteBuf 释放、MaxDirectMemorySizeunable to create new native thread线程栈 / 系统线程数查线程数上限、线程泄漏GC overhead limit exceeded堆回收效率过低大概率是内存泄漏按堆问题处理Java heap space是最常见的也是最容易分析的。Metaspace前面说过多半是热部署或者动态生成类太多CGLIB 代理、Groovy 脚本、大量 JSP 编译。unable to create new native thread往往不是内存不够而是线程数到了系统限制ulimit -u和cat /proc/sys/kernel/threads-max这两个值得先看一眼再去看是不是有地方无限制地new Thread()。6.2 堆 dump 拿回来之后怎么读拿到 hprof 文件我一般用 MATMemory Analyzer Tool打开第一步不是看直方图而是看Leak Suspects报告——它会自动算支配树告诉你哪几个对象持有了大部分内存。经验上百分之七十的问题在那一页就能看出方向。具体读法看 Dominator Tree按 Retained Heap 倒序排。排第一的那个往往就是元凶比如一个几百 MB 的ConcurrentHashMap。找到这个 Map点开Path to GC Roots排除弱引用和软引用看是谁持有它。路径的终点通常是一个静态字段或者一个线程的ThreadLocalMap。如果支配树上看不出问题那就看直方图Histogram按实例数量排序某个类的实例数量几百万个基本就锁定方向了。有一个我踩过的坑值得说dump 文件本身可能很大而生产机磁盘往往不够。2G 的堆 dump 出来差不多就是 2G 文件再加上默认的.hprof是不压缩的磁盘满了之后 dump 失败现场就没了。所以-XX:HeapDumpPath一定要指向空间充足的盘并且提前配好磁盘告警。另外 dump 期间 JVM 会 STW几 G 的堆可能要停好几秒做这件事之前最好先把它从负载均衡上摘下来。6.3 CPU 打满的定位命令其实就那几条CPU 100% 的排查流程是固定的我把它写成可以直接复制的序列# 1. 找到最耗 CPU 的进程 top -c # 2. 找到该进程内最耗 CPU 的线程记下 PID top -Hp pid # 3. 把线程号转成十六进制jstack 里用的是 nid printf %x\n tid # 4. 抓线程栈按 nid 搜 jstack pid /tmp/stack.txt grep -A 30 nid0xhex /tmp/stack.txt抓到的那段栈通常就指向问题方法。常见的几种形态栈顶停在java.util.regex.Pattern或者String.split正则回溯爆炸检查有没有嵌套量词或者对超长字符串做匹配。栈顶停在HashMap.get且线程状态是RUNNABLE哈希冲突严重可能是 key 的hashCode实现有问题或者被人恶意构造了碰撞。栈顶停在GC相关或者大量线程在BLOCKED这其实不是 CPU 问题是 GC 或者锁竞争去翻 GC 日志和锁持有者。栈顶在Object.wait/park但 CPU 依然很高说明真正吃 CPU 的线程没抓到多抓几次间隔 5 秒连抓三次做对比。我习惯连抓三次因为单次快照可能恰好抓到线程在做无关的事。三次里都出现在RUNNABLE且栈顶一致的方法基本可以锁定。6.4 死锁和线程池耗尽的识别死锁用jstack一眼就能看出来输出末尾会直接打印Found one Java-level deadlock并给出两条互相等待的链。修复思路是统一加锁顺序或者用tryLock带超时。线程池耗尽更隐蔽症状是接口超时但 CPU 和内存都正常。抓栈会看到大量线程卡在LinkedBlockingQueue.take或者ThreadPoolExecutor.getTask说明任务都在排队。这时候要查三件事核心线程数是否合理、任务里有没有同步阻塞调用比如 HTTP 调用没设超时、队列是不是无界的LinkedBlockingQueue默认容量Integer.MAX_VALUE等于永不拒绝只会无限堆积。提示线程池用无界队列是线上最危险的配置之一。任务堆积不会报错只会让响应时间线性上升等到内存被排队任务撑爆就是 OOM 而不是拒绝了。给队列设个明确容量配一个说得清的拒绝策略。7. 几个反复踩到的坑和经验7.1 我认为最容易犯的三个错误第一个把-Xmx当成进程内存上限。前面说过堆之外还有元空间、线程栈、直接内存、JIT 代码缓存、GC 自身的辅助结构G1 的卡表和标记位图能占堆的百分之几。容器里设-Xmx等于限额一定会在某个流量高峰被 OOMKilled而且杀的时候往往连日志都来不及刷。第二个拿压测环境的 GC 数据推生产。压测的流量模型是均匀的生产的流量是有尖峰的压测的数据量小生产的缓存条目多。同一个参数在压测环境跑得飞快上生产就开始频繁 Mixed GC。我现在的做法是参数按生产规模的一半估算留出调整空间上线后观察一周再定稿。第三个看到 Full GC 就加内存。加内存能缓解但治不了根。如果老年代占用在每次 Full GC 后回落到一个明显高于上次的基线这就是典型的内存泄漏曲线加多少内存都是延迟爆炸时间而已。判断方法很简单连续记录几次 Full GC 后的老年代占用画条线看趋势。7.2 手上常备的工具清单不需要装一堆东西这几个够覆盖九成场景工具用途我用的频率jps -l快速列出 Java 进程和主类极高jstat -gcutil pid 1000每秒刷新 GC 统计看趋势极高jstack pid线程栈排查阻塞死锁 CPU高jmap -histo:live pid类实例直方图快速看谁多高jcmd pid VM.native_memory原生内存追踪查堆外中arthas在线诊断看方法耗时、反编译中jstat是我最常用的一个因为它是纯观测、对进程几乎零影响可以放心在生產上每秒刷。看什么看O老年代使用率和FGCFull GC 次数。如果O在持续上涨且FGC之后不回落直接进入内存泄漏排查流程不用犹豫。最后分享一个我觉得最值钱的小习惯给每个服务在启动日志里打印一行完整的 JVM 参数和运行时信息。用ManagementFactory.getRuntimeMXBean().getInputArguments()拿参数用Runtime.getRuntime().maxMemory()拿堆上限。这样任何时候排查先翻启动日志就能确认这个实例到底用的什么配置避免我明明改了参数怎么没生效这种浪费半小时的乌龙——顺带说一句参数没生效最常见的原因是改在了错误的启动脚本里或者被后面的JAVA_OPTS覆盖了。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →