
如果你和我一样Java 写了几年翻过不止一遍那本经典大部头却总在线上出问题时发现自己“背了又不熟、看了又用不上”那这篇应该能帮你把 JVM 底层真正该吃透的部分串起来。以前我也喜欢从类加载讲到字节码但等你真遇到 Full GC 拖垮接口时真正救你的其实是三件事对象分配在哪儿、垃圾回收怎么判定、GC 日志怎么看。这篇不聊虚的只讲入户即用的 JVM 底层核心适合三类人准备跳槽想一次性打通知识点的人、线上出过 GC 故障却不知道下一步该查什么的人、以及实在没时间啃大部头但又不想在面试和排障现场露怯的普通开发。1. 对象的一生运行时数据区里最容易踩坑的细节1.1 从一行 new 开始对象创建完整流程很多人背运行时数据区背得滚瓜烂熟什么程序计数器、虚拟机栈、本地方法栈、堆、方法区但真问到“一个对象从 new 开始到底经历了什么”回答就开始含糊。其实对象创建远不是一句“分配内存”就完了它至少有五步类加载检查。JVM 遇到 new 指令时先在常量池里找到这个类的符号引用检查这个类是否已经加载、链接和初始化。没加载就触发类加载也就是常说的双亲委派模型那一套。这个过程最容易忽略的是“符号引用”你在 IDEA 里敲 new 和 JVM 常量池里存的符号引用是两回事面试官问到这个细节时能说出来会加分不少。分配内存。类加载完成后JVM 就从 Java 堆里给对象划一块大小确定的内存。这里有两种分配方式如果堆内存是规整的用指针碰撞移动一个指针就行如果内存是零散的就得用空闲列表记录哪些内存块可用再找一块足够大的分出去。堆是否规整取决于垃圾回收器用的是标记-复制还是标记-清除这个后面再说。内存空间初始化零值。这一步很多人根本不提但它很关键。JVM 要把分配到的内存空间全部初始化为零值这样对象的实例字段在没有任何赋值时默认值就已经是 0、null、false 了。这也是为什么 Java 的实体类字段不手动赋值也不会报错而不是“编译期默认给的”。设置对象头。对象头里存了两类信息一类是 Mark Word存放哈希码、GC 分代年龄、锁状态标志、线程持有的锁等另一类是类型指针指向方法区的类元数据JVM 靠它确定这个对象是哪个类的实例。如果数组对象对象头里还得多存一个数组长度。执行构造方法。init方法才是你写的构造函数到这里对象才算真正“造好了”。前面几个步骤完成之后从 JVM 的角度看它已经是一个完整的对象了但对你写的业务代码来说constructor 里的字段赋值才刚开始生效。平时我们讲“对象创建”大多只讲这五步但分配内存这一步里还有个高性能关键点TLAB。JVM 默认是线程安全的但如果每次分配都加锁那创建对象的效率就被拖垮了。所以 HotSpot 默认开启 TLAB即每个线程在 Eden 区预先申请一小块私有内存对象创建时优先在这块内存里分配不需要加锁。只有当 TLAB 不够用时才用 CAS 去公共 Eden 区抢内存。这个细节在生产调优里很重要线上对象创建特别频繁时可以适当调大-XX:TLABSize但别盲目调后面会说怎么看 TLAB 命中情况。1.2 Eden 区不是垃圾桶而是性能主战场对象创建后首先进入的几乎一定是新生代的 Eden 区。新生代被分成一块 Eden 和两块 Survivor比例默认是 8:1:1这个比例不是拍脑袋定的而是为了配合复制算法把浪费控制在 10% 左右。因为每次 Minor GC 后Eden 里存活的对象会被复制到 Survivor而 Eden 会被直接清空。两块 Survivor 交替使用始终有一块是空的等待下一次 GC 复制。这就是复制算法的核心思路不整理内存直接把存活对象搬到另一块预先留好的空闲区代价是必须浪费一块空间。8:1:1 就是在“浪费最少”和“复制成本可接受”之间找平衡。但有几个特殊情况值得注意。首先是“大对象直接进入老年代”。你可以在 JVM 参数里设置-XX:PretenureSizeThreshold比如把它设为 3MB那么大于 3MB 的对象就不会走 Eden直接进入老年代。这么做的原因很简单大对象在 Eden 和 Survivor 之间来回复制成本太高。大对象进入老年代后不会立刻触发 GC但如果这种对象特别多老年代很快就会被塞满。特别注意PretenureSizeThreshold其实对 Serial 和 ParNew 有效G1 和 Parallel 不保证严格遵守。G1 有自己的 Humongous 区域专门存大对象不能依赖这个参数。第二个是“长期存活对象进入老年代”。JVM 给每个对象一个年龄计数器每熬过一次 Minor GC 年龄加一默认到 15 就晋升老年代。这个 15 就是对象头 Mark Word 里分代年龄字段能表示的最大值4 位二进制最多 15。-XX:MaxTenuringThreshold可以调但不建议乱调弄低了对象过早晋升老年代弄高了 Survivor 空间不够放。第三个是“动态年龄判定”。这算一个容易被忽略的规则如果 Survivor 中相同年龄的所有对象大小总和大于 Survivor 空间的一半年龄大于等于该年龄的对象就可以直接进入老年代不需要等到 15 次。所以你在线上看到有几个对象晋升老年代但是 age 明明只有 3、4不一定是配置问题可能是动态判定生效了。第四个是“空间分配担保”。Minor GC 之前 JVM 会检查老年代最大可用连续空间是否大于新生代所有对象总空间如果小于且设置过允许冒险就会再检查历史晋升对象的平均大小如果还是小于那就直接触发一次 Full GC风险比较大。实际工作中真正要记的是老年代没空间担保时Minor GC 会升级成 Full GC这就是一些应用明明没到老年代 OOM却频繁 Full GC 的原因之一。1.3 逃逸分析与栈上分配没“逃”出去的对象可以不上堆知道对象大概率在堆上分配但还有一个例外逃逸分析。JVM 在 JIT 编译时会分析对象的作用域如果一个对象只在方法内部使用没有被返回、没有被赋给外部变量、没有被传到其他方法里它就算“未逃逸”。未逃逸的对象有两个优化手段栈上分配和标量替换。栈上分配很好理解对象可以直接在栈帧里分配方法结束栈帧销毁时对象就直接没了不需要 GC 接管。标量替换更彻底JVM 会把对象的字段拆成若干个局部变量来使用不生成真正的对象实例。比如你 new 了一个坐标对象只有 x、y 两个字段编译器优化后可能直接变成两个局部变量对象本身根本不存在。这个机制在实际生产里非常有用因为大量的临时对象本来就是用完即弃的。如果它们都去堆上溜一圈再等着 GCMinor GC 的压力会大很多。但要注意逃逸分析不是你想触发就触发的它依赖 JIT 的 C2 编译器并且要开启-XX:DoEscapeAnalysisHotSpot 默认是开的。在分层编译下逃逸分析的结果会直接影响锁消除等优化。我从实战中观察到的结论是代码写得越“局部化”越利于逃逸分析发挥反过来动不动就把临时对象塞进 List 或 Map 再返回逃逸分析基本就没戏了垃圾回收压力自然上来了。2. 垃圾回收入门到精通判定、算法与四种引用2.1 可达性分析是判定的灵魂引用计数法为什么被淘汰垃圾要能被回收前提是 JVM 得先认出谁是垃圾。有两种判定思路引用计数法和可达性分析。引用计数法听起来很直观对象每被引用一次计数器加一引用失效计数器减一计数为 0 就是垃圾。但它有一个致命缺陷循环引用。A 引用 BB 引用 A两者都不再被外部使用但引用计数永远不为 0GC 永远收不走它们。所以 HotSpot 几乎不用引用计数而是用可达性分析。可达性分析的思路是从一组称为 GC Roots 的根对象出发沿着引用链往下走所有能走到的对象都是“存活”的走不到的对象就是垃圾。关键在于 GC Roots 都包含什么。这里我直接列一份你面试和排障都要背的清单虚拟机栈中局部变量表里的引用也就是当前正在执行的方法引用的对象。方法区中的静态变量引用、常量引用。JNINative 方法引用的对象。被 synchronized 持有的对象锁对象可不能随便回收。Java 线程、活着的 Thread 对象。JVM 内部的一些基础对象比如系统类加载器、已加载的类等。实战中我们常会遇到一个困惑明明一个对象看起来已经没用了为什么 GC 后内存没降这通常是因为对象被某个 GC Root 间接引用了常见来源是静态集合、JDBC/连接池等框架层对象、以及缓存容器。我踩过最典型的坑就是用一个静态 Map 做全局缓存用法上觉得“不用的时候 set null”就行了但 Set 了业务层的引用Map 还在呢对象永远可达内存只增不减。搞清楚 GC Roots 之后你会发现大多数内存泄漏本质上就是“某个长生命周期的对象一直保存着短生命周期对象的引用”。2.2 四种引用你要掌握到能随口默写Java 在 JDK 1.2 之后把引用分成强引用、软引用、弱引用、虚引用四类回收时机各不相同。强引用就是最常见的Object obj new Object()只要强引用还存在GC 永远不会回收。软引用是内存不足时回收适合做缓存SoftReference指向的对象在发生 OOM 之前会被回收所以很多图片缓存和本地缓存框架会用它。弱引用是只要 GC 一发生不管内存够不够都会被回收典型代表是WeakHashMap和WeakReference。虚引用最特殊它不会决定对象生命周期唯一用处是在对象被回收时收到一个系统通知供堆外内存等资源做回收跟踪。我见过很多团队给缓存用强引用结果内存狂飙也有人反过来一律用弱引用结果缓存命中率低得离谱对象每次 GC 都丢等于没缓存。选择引用级别要看你兜底失败的代价弱引用适合“丢了也无所谓”的数据软引用适合“丢了能重建但有点贵”的数据强引用适合必须常驻的核心对象但要控制数量和生命周期。另外注意软引用在 JDK 对内存敏感度不同时被回收的时机是有差异的完全依赖软引用做缓存并不稳妥还是得配合过期策略和 LRU。2.3 标记-清除与标记-复制停车场的两种极端判定完谁是垃圾就要决定怎么回收。JVM 主流的回收算法是三大件标记-清除、标记-复制、标记-整理。标记-清除最朴素先标记所有存活对象再统一回收未标记对象。它的最大问题是内存碎片化回收完的对象之间会留下一个个不连续的空洞后续分配大对象时可能找不到连续空间又得提前触发 GC。CMS 就是用标记-清除所以它到了后期很尴尬碎片化严重时不得不 Full GC 来压缩整理。标记-复制就是我们前面提到的新生代方案它解决了碎片问题但要预留一块可复制的空间所以浪费了 10% 的内存。类比一下标记-清除就像停车场管理员给所有能停的车贴上条然后把不能停的车拖走但留下的空位彼此不相邻新来的大车停不进去复制算法则是把活着的车一辆辆开到另一个停车场原停车场清空后变成规整的一大片空地代价是多花了一个停车场的土地。老年代不能直接玩复制因为老年代存活对象多复制成本太高所以用标记-整理算法先标记存活对象然后把它们往一端移动不留碎片。移动对象是要停顿业务的所以老年代 GC 停顿往往比新生代长很多这是老年代调优的核心矛盾不做整理会碎片化做整理会停顿更久。理解了这三件套后面看 CMS 和 G1 的行为就顺理成章了。3. 七款垃圾收集器横评看完不被面试官问倒3.1 分代收集器Serial、ParNew、Parallel 与 CMS 的恩怨情仇新生代三兄弟先说清楚。Serial 是单线程收集器GC 时必须暂停所有业务线程现在只在单核或 Client 模式下有意义你在服务端基本不会用。ParNew 是 Serial 的多线程版本多个线程并行做 Minor GCJDK 8 时代配合 CMS 是很多互联网公司的黄金组合。Parallel Scavenge 则是吞吐量优先追求在单位时间内完成尽量多的业务它的独特之处是有一个自适应调节策略JVM 会根据运行情况自动调整新生代大小和晋升阈值。老年代里最值得说透的是 CMSConcurrent Mark Sweep名字就告诉你它是并发标记-清除。它的目标是尽量缩短停顿时间所以设计了四步初始标记、并发标记、重新标记、并发清除。初始标记很短只标记 GC Roots 直接可达的对象需要 Stop The World。并发标记最长业务线程和 GC 线程同时跑顺着引用链逐步标记。重新标记又要 STW用来修正并发标记期间业务线程产生的变化。最后并发清除业务线程和 GC 线程再次同时跑把垃圾清掉。CMS 看起来美实际用起来有三个老坑。第一个是 CPU 敏感并发阶段会占用一部分 CPU核心少的时候业务吞吐会掉。第二个是浮动垃圾并发清除阶段业务线程还在产生新垃圾这部分只能下次再清所以 CMS 不能等堆满了才启动它有一个触发比例-XX:CMSInitiatingOccupancyFraction一般设 70 到 80。第三个是碎片化标记-清除算法带来内存碎片严重时要触发 Full GCCMS 会退化成 Serial Old 单线程整理停顿时间惨不忍睹。配套参数还要记住-XX:UseCMSCompactAtFullCollection和-XX:CMSFullGCsBeforeCompaction但这俩在 JDK 9 之后被废弃了CMS 本身也在 JDK 9 标记弃用、JDK 14 正式移除。所以现在学 CMS 更多是为了维护老系统和过面试新项目就别往这个坑里跳了。3.2 GPU 之外的现代答案G1 到底赢在哪里G1Garbage First是 JDK 9 之后服务端默认收集器。它的核心设计是抛弃物理分代把堆划分成一个个 Region每个 Region 大小默认 1MB 到 32MB逻辑上仍分年轻代和老年代但年轻代和老年代不再是一整块连续内存而是各自一批 Region。G1 的目标是用有限的停顿时间处理尽可能多的垃圾所以它叫 Garbage First优先回收垃圾占比最高的 Region而不是全堆扫描。为了让各个 Region 之间互不干扰地回收G1 引入 RSet记录别的 Region 对自己的引用。写引用时JVM 用卡表维护脏卡信息这样 GC 时能快速找到谁引用了当前 Region。并发标记阶段用 SATB 快照在标记开始时为堆生成一个逻辑快照保证并发期间新增的引用也能被正确识别避免了漏标问题。G1 的实际运作分几个阶段Young GC 和普通新生代回收类似把 Eden 的存活对象复制到 Survivor然后是并发标记周期标记出老年代中值得回收的 Region之后是 Mixed GC回收部分年轻代 Region 和部分老年代 Region如果并发标记阶段空间不够、RSet 过大或者存活对象太多就会退化为 Full GCG1 的 Full GC 在 JDK 10 之前还是单线程JDK 10 之后并行化了但依然是最后的手段。调 G1 最核心的两个参数是-XX:MaxGCPauseMillis停顿目标默认 200 毫秒以及-XX:G1HeapRegionSizeRegion 大小。触发 G1 的回收不能光看堆占用它是基于停顿预测模型GC 时会根据历史统计判断每个 Region 的回收集合能否满足停顿目标。这里我要明确说G1 不是万金油它对大堆、多核场景表现很好但如果是 2GB 以下的小堆、对停顿极度敏感的应用选 ZGC 或干脆用 Parallel 也许更合适。不过现实中大多数服务用 G1 都没问题默认参数足够扛住日常业务。3.3 看一眼未来ZGC 与 Shenandoah 适合什么人ZGC 在 JDK 11 以 Experimental 形态出现JDK 15 转正目标是把停顿时间压缩到 10 毫秒以内甚至亚毫秒级别。它的秘密武器是染色指针和读屏障。染色指针把对象引用指针的高位用于 GC 状态标记GC 线程可以并行处理而读屏障让业务线程在读取对象指针时如果能感知到 GC 状态变化就协助修正不需要全部 STW。ZGC 天然适合超大堆、低延迟的场景比如几百 GB 堆且要求接口延迟抖动的系统优势非常明显。但 ZGC 对内存和 CPU 的开销也很大普通应用强行换 ZGC 未必能感受到好处反而可能增加 GC 线程占用。Shenandoah 的思路与 ZGC 类似是 Red Hat 主导的收集器在 OpenJDK 里能用到Oracle JDK 里没有。现阶段普通业务团队不用急着追新先搞清楚自己手里系统用的是哪一代、核心瓶颈是不是 GC 再决定要不要升。4. 调优三板斧不多但管用的参数组合4.1 先定目标再调参别上来就改内存很多开发一遇到 GC 频繁就调-Xmx这是典型的本末倒置。调优之前先问自己三个问题这个系统更看重延迟还是吞吐当前遇到了什么具体问题是 OOM、Full GC 频繁还是响应变慢改动后怎么验证有效而不是瞎猜心里有谱了再动参数。内存参数必须先说清楚-Xms和-Xmx建议设成一样大避免堆大小动态伸缩带来的性能波动。-Xmn是新生代大小它直接影响 Minor GC 频率和老年代可用空间。新生代太小对象频繁 Minor GC会拖慢吞吐新生代太大老年代变小Full GC 会更频繁而且单次 GC 停顿时间变长。我通常建议新生代占堆的 1/3 到 1/2具体看对象分配速率。Survivor 比例-XX:SurvivorRatio8是默认一般不用动。JDK 8 以后还要注意元空间-XX:MaxMetaspaceSize一定要设一个上限否则动态生成类太多时会把系统内存耗光。再次提醒这不是万能的很多应用调了半天内存才发现问题在代码层所以调参前先看一眼 GC 日志和对象分布比什么都重要。4.2 GC 日志看懂它才是真调优没有 GC 日志的调优等于闭眼开车。JDK 8 及以前常用的开关是-verbose:gc -XX:PrintGCDetails -XX:PrintGCDateStamps -XX:PrintGCApplicationStoppedTime -Xloggc:gc.log。JDK 9 之后统一日志系统上线原来的参数风格变了用-Xlog:gc*:filegc.log这一套。线上如果有条件一定要留 GC 日志而且要保留足够的历史文件否则出问题时你会满脸问号上次 GC 什么样改完参数前后对比数据在哪我随便给一段简化版的 GC 日志格式你对照着看就懂字段含义了线上真实日志字段会更多。单看一行你要抓住几个关键信息GC 类型Minor GC / Full GC、GC 前后堆大小、耗时、以及停顿时间。如果看到 Full GC 前后老年代占用几乎不降那不是垃圾回收的问题是内存泄漏如果老年代占用降了但下一次很快又涨回来那是对象分配或晋升速度过快。日志里还有一个隐藏宝藏GC 之前的老年代用量变化。它是判断“是否要调大堆、是不是大对象太多、该不该换收集器”的直接依据。4.3 三种典型的调优思路我把常见场景归成三类你直接对号入座。一是吞吐量优先常见于后台任务型系统。JDK 8 时代可以选 Parallel Scavenge 加 Parallel Old-XX:UseParallelGC -XX:UseParallelOldGC把吞吐量目标交给他它可以自适调节。JDK 11 之后 G1 也是吞吐量不错的选择重点是别把停顿时长设得太苛刻否则 GC 频率会上升。二是延迟优先常见于在线交易、接口服务。JDK 8 老旧系统用 CMS 配合 ParNew注意设置-XX:CMSInitiatingOccupancyFraction70左右并开启压缩整理。新系统直接用 G1适当设置-XX:MaxGCPauseMillis100或 200。如果堆很大且延迟要求很高评估 ZGC。三是“改动最少”方案适用于那些改任何参数都有风险的存量系统。只做两件事第一把-Xms和-Xmx设成一致并略调大给对象更多空间第二给-XX:MaxMetaspaceSize设上限并加上 GC 日志开关。做完这两步再观察往往比一顿操作猛如虎要可靠得多。因为很多时候 GC 频繁不是 JVM 的问题而是代码写得太离谱调参只会掩盖问题。5. 线上 Full GC 排查实录一次山重水复的诊断之旅5.1 事件回溯与初步诊断之前接手过一个某在线交易系统的排查现象是下午高峰期接口延迟从 30ms 一路飙升到 3 秒CPU 冲到 150% 以上业务方反馈订单接口超时。当时第一反应就是看 GC因为响应时间全面劣化一般逃不出三个方向GC 停顿、锁竞争、线程池打满。我让值班同学先跑jstat -gcutil pid 1000每秒打一次 GC 统计很快就看到两个关键数据Full GC 次数在快速上涨几乎每分钟一次老年代使用率从 30% 一路冲到 90% 以上而且每次 Full GC 之后回不到低位。单看这个现象内存泄漏嫌疑已经很大。顺手翻了 GC 日志发现每次 Full GC 前老年代都有大量对象晋升Survivor 永远放不下对象直接漏到老年代。这说明问题不在 GC 参数而在对象生命周期。为了进一步确认对象来源我先用jmap -histo:live pid | head -20看了一眼直方图确认有某个数据结构的实例数量和占用特别离谱。然后和业务团队确认高峰期正好有新功能上线大量用户同时触发了某个报表查询接口。线索基本指向就是这个接口往 heap 里塞了什么大对象。5.2 堆转储与 MAT 分析找出元凶要锁定具体代码路径直方图还不够得看引用链。我选择在低峰期用jmap -dump:live,formatb,file/tmp/heap.bin pid导出一份堆转储然后用 MAT 打开看 Dominator Tree也就是支配树找占用内存最大的对象路径。很快就看到了一个缓存组件的内部 MapKey 是某个业务维度而 Value 是一长串 List里面挂着大量报表查询结果对象数量远超预期。根因浮出水面业务代码在查询结果为空时会把一个默认值节点也塞进缓存而且重复操作不会更新导致 List 越积越长缓存本身又没有过期淘汰成了无限膨胀的静态持有者。修复方案也不算复杂给缓存加上容量上限和时间过期策略查询为空时不缓存空结果直接删除 Key对同一维度加并发锁防止重复构造大对象。上线后 Full GC 回归到半小时一次的常态化水平接口延迟恢复到 30ms。这个案例我拿来复盘总是提醒自己JVM 层能背的锅其实很小绝大多数 Full GC 是代码层对象生命周期错乱造成的。5.3 常见问题与排查技巧速查表我把自己踩过的坑和带团队时遇到的高频问题整理成一张速查表至少能帮你少走一半弯路。现象优先排查方向常用命令或手法Full GC 频繁接口变慢内存泄漏、大对象过多、堆太小、元空间溢出jstat -gcutil、jmap -histo:live、堆转储 MAT老年代占用高但 Full GC 次数不多对象自然增长、缓存容量膨胀、结构不合理观察 GC 日志中 Full GC 前后占用差CPU 高但 GC 不频繁死循环、正则回溯、锁竞争、序列化热点jstack多次采样看线程栈、perf 分析热点元空间 OOM动态生成类太多比如反射、大量代理类查看加载类日志确认是哪个框架或业务产生的频繁 Minor GC 但堆还有余量对象分配速率过高临时对象太多逃逸分析可能未生效检查代码是否大量创建集合副本System.gc() 被隐式调用显式 GC 触发、NIO 里某些框架会调用看 GC 日志是否存在 “Full GC (System)”排查技巧里我想特别强调一点别迷信-XX:DisableExplicitGC禁用System.gc()会把某些依赖显式 GC 做清理的框架坑到尤其是使用了堆外内存的组件。真要处理先定位是谁调的再决定是优化还是禁用。5.4 工具收藏夹jps、jstat、jinfo、jmap、jstack、jcmd 的正确打开姿势最后把 JDK 自带工具过一遍这几个命令值得每个 Java 工程师记牢。jps -l最快确认目标 Java 进程 PID 的方式排障第一步。jstat -gcutil pid 1000看 GC 频率和内存占比排查 GC 问题的头号武器。jinfo -flags pid查看进程实际生效的 JVM 参数排查“为什么我配的没生效”。jmap -histo:live pid快速看对象实例数排行能定位绝大多数内存异常。jmap -dump:live则用于导出堆转储大堆上执行会 STW低峰期再用。jstack pid抓线程栈配合多次采样能找死循环、线程阻塞、锁竞争。jcmd pid helpJDK 8 之后出现的多功能瑞士军刀很多jmap和jinfo的老用法在它这里都有替代。有一个使用心得线上排障先jstat再看栈别一上来就jmap -dump。堆转储文件动辄好几 GB传下来很费劲分析也要时间。先确认是不是 GC 问题再确认对象集中在哪一块最后才考虑要不要 dump。这是省时间的核心排序。前面聊了这么多最后我再给一条个人体会最深的小经验。很多人觉得 JVM 调优是玄学其实不是。把对象分配、GC 判定、收集器行为这三条主线理清了再看线上问题就会清晰很多。你不需要第一天就背下所有参数但至少要会看 GC 日志并且能从日志里判断出“现在的问题到底是代码问题还是参数问题”。我见过太多工程师把一个明显是缓存没清理的问题硬生生调了一个月的-Xmx最后该 Full GC 照样 Full GC。还有如果 GC 日志里每次 Full GC 之后老年代占用率都回不到低点那你基本可以断定有人在堆里藏了一堆不该长命的对象调 Java 参数治不好这种病去改代码才是正经路子。