JVM缓存对象对GC的影响与优化方案

发布时间:2026/5/15 14:42:10

JVM缓存对象对GC的影响与优化方案 背景当大量缓存对象长时间驻留堆内存时JVM 的垃圾回收会被明显拖累。问题不在于对象多而在于这些对象大多晋升到老年代并持续引用年轻代对象——这直接破坏了分代 GC 的核心假设。GC性能问题分析YGC耗时增长的原因分代垃圾回收基础原理JVM 采用分代垃圾回收策略堆内存分为年轻代Young Generation和老年代Old Generation。这套设计基于弱代假说Weak Generational Hypothesis大多数对象都是短命的存活时间长的对象引用存活时间短的对象的情况很少YGC标记过程详解YGC 阶段垃圾回收器从 GCRoot全局引用、栈引用、静态变量等开始做可达性分析。由于 YGC 只回收年轻代JVM 在扫描上做了一个优化扫描中断机制扫描引用链时一旦遇到老年代对象就中断该路径老年代对象在 YGC 期间不会被回收继续往下扫描它引用的年轻代对象没有意义这个机制缩小了扫描范围YGC 效率因此提升![](data:image/svgxml,%3C%3Fxml%20version1.0%20encodingUTF-8%3F%3E%3Csvg%20width1px%20height1px%20viewBox0%200%201%201%20version1.1%20xmlnshttp://www.w3.org/2000/svg xmlns:xlinkhttp://www.w3.org/1999/xlinktitle/titleg strokenone stroke-width1 fillnone fill-ruleevenodd fill-opacity0g transformtranslate(-249.000000, -126.000000) fill%23FFFFFFrect x249 y126 width1 height1%3E%3C/rect%3E%3C/g%3E%3C/g%3E%3C/svg%3E)old-gen scanning 的必要性但有一种情况需要单独处理被老年代对象引用的年轻代对象。这些对象没有被 GCRoot 直接或间接引用但被老年代中的对象所引用常规的 GCRoot 扫描会漏掉它们为此JVM 引入了 old-gen scanning专门扫描老年代中可能引用年轻代对象的区域保证这些对象不被误判为垃圾。跨代引用与记忆集Remembered Set为了高效实现 old-gen scanningJVM 用记忆集Remembered Set记录跨代引用核心组件是card table![](data:image/svgxml,%3C%3Fxml%20version1.0%20encodingUTF-8%3F%3E%3Csvg%20width1px%20height1px%20viewBox0%200%201%201%20version1.1%20xmlnshttp://www.w3.org/2000/svg xmlns:xlinkhttp://www.w3.org/1999/xlinktitle/titleg strokenone stroke-width1 fillnone fill-ruleevenodd fill-opacity0g transformtranslate(-249.000000, -126.000000) fill%23FFFFFFrect x249 y126 width1 height1%3E%3C/rect%3E%3C/g%3E%3C/g%3E%3C/svg%3E)老年代按固定大小切分为 card通常 512 字节每个 card 对应 card table 中的一个字节老年代对象引用年轻代对象时对应的 card 被标记为 dirtyold-gen scanning 只扫描 dirty card而不是整个老年代扫描时间与 dirty card 数量正相关老年代内存占用越大、跨代引用越多dirty card 数量也跟着涨YGC 时间就这样被拉长了。性能瓶颈分析old-gen scanning 的瓶颈old-gen scanning 是 YGC 耗时高的主要原因。这个阶段老年代被切成若干大小相等的区域stride每个工作线程处理其中一部分负责扫描对应的 card 数组和被标记为 dirty 的老年代空间。缓存数据大量堆积在老年代时跨代引用急剧增加dirty card 比例上升分代假设被打破扫描任务本身变得繁重调整并发参数只能优化任务分配解决不了扫描量本身的问题用 JDK API 诊断 Old Gen 压力排查 YGC 耗时问题时用代码直接观测 Old Gen 的占用变化比单看 GC 日志更直接。JDK 通过java.lang.management包提供了一组 MXBean 接口可以在运行时查询各内存池的实时状态import java.lang.management.*; import java.util.List; ListMemoryPoolMXBean pools ManagementFactory.getMemoryPoolMXBeans(); for (MemoryPoolMXBean pool : pools) { MemoryUsage usage pool.getUsage(); System.out.printf(%-30s used%dMB / max%dMB%n, pool.getName(), usage.getUsed() / 1024 / 1024, usage.getMax() / 1024 / 1024); }典型输出PS Eden Space used128MB / max1024MB PS Survivor Space used32MB / max128MB PS Old Gen used3584MB / max4096MBOld Gen 长期处于高水位如 80%时dirty card 数量通常也很高。配合GarbageCollectorMXBean一起看可以区分是 YGC 频率问题还是单次耗时问题ListGarbageCollectorMXBean gcBeans ManagementFactory.getGarbageCollectorMXBeans(); for (GarbageCollectorMXBean gc : gcBeans) { System.out.printf(%s: count%d, totalTime%dms%n, gc.getName(), gc.getCollectionCount(), gc.getCollectionTime()); }如果 Young GC 的totalTime / count持续走高但count没有明显增加往往就是 old-gen scanning 的负担在增加。优化解决方案JVM堆内存优化-XX:ParGCCardsPerStrideChunk控制 G1 old-gen scanning 的并行粒度指定每个工作线程每次处理的 card 数量。默认值 256对应每个区域大小 256 × 512B 128KB。以 4GB 堆为例区域数量约 32,768 个远超工作线程数。调大这个参数可以减少调度开销降低 GC 暂停时间。但在缓存量巨大的场景下调整并行粒度能做的有限。dirty card 数量本身过多时无论怎么切分任务总扫描量不变。-XX:AlwaysPreTouchJVM 启动时立即分配并初始化所有堆内存页面避免运行时因按需分页带来的延迟抖动。适合对启动时间不敏感、对运行时稳定性要求高的应用。-XX:-UseBiasedLocking禁用偏向锁减少锁撤销时的 STW 时间在多线程竞争激烈的场景下有用。代价是损失一些单线程性能。JDK 15 起偏向锁默认禁用JEP 374这个参数在高版本中已无意义。G1垃圾回收器配置原理-XX:UseG1GC启用 G1 垃圾回收器。-XX:G1HeapRegionSize16m设置 G1 区域大小为 16MB。较大的区域减少内部碎片但回收粒度也变粗。-XX:G1ReservePercent25预留 25% 的堆空间作为对象复制的 to-space防止晋升失败触发 Full GC。-XX:InitiatingHeapOccupancyPercent30堆使用率达到 30% 时启动并发标记。提前标记可以避免临近满堆时才触发内存压力会小很多。-XX:MaxGCPauseMillis指定最大 GC 暂停时间目标G1 会尽量满足但不做硬性保证。设置过小如 50ms时G1 会主动压缩年轻代来缩短暂停结果是年轻代频繁填满、Minor GC 次数激增整体吞吐反而下降。通常设在 100-200ms再结合 GC 日志调整。JDK 11内存优化原理指针压缩技术-XX:UseCompressedOops64 位系统下将对象指针从 64 位压缩为 32 位-XX:UseCompressedClassPointers压缩类指针减少元数据内存占用缓存场景下对象数量庞大这两个参数能压缩内存占用顺带提升 CPU 缓存命中率。ZGC低延迟回收器ZGC 是低延迟场景下的可选项最大暂停时间通常不超过 2ms适合对响应时间要求苛刻的应用。版本进度JDK 11实验性引入JDK 15正式生产可用稳定性大幅提升JDK 1646 个功能增强 25 个 bug 修复JDK 17成熟稳定Spring Boot 3.x 最低要求版本JDK 21引入分代 ZGC吞吐量大幅提升容器支持优化启用方式-XX:UseZGC堆外内存方案解决缓存 GC 问题的根本思路是把缓存数据从堆内移到堆外让 GC 不再需要感知这部分数据的存在。缓存的生命周期由应用自行管理不再委托给 JVM。堆外内存的监控迁移到堆外之后MemoryMXBean.getNonHeapMemoryUsage()看不到 Direct Buffer 的占用它只涵盖元空间、代码缓存等 JVM 内部区域。监控堆外缓存要用BufferPoolMXBeanimport com.sun.management.BufferPoolMXBean; import java.lang.management.ManagementFactory; import java.util.List; ListBufferPoolMXBean pools ManagementFactory.getPlatformMXBeans(BufferPoolMXBean.class); for (BufferPoolMXBean pool : pools) { System.out.printf(%s: count%d, used%dMB, capacity%dMB%n, pool.getName(), pool.getCount(), pool.getMemoryUsed() / 1024 / 1024, pool.getTotalCapacity() / 1024 / 1024); } // 典型输出 // direct: count1024, used512MB, capacity512MB // mapped: count0, used0MB, capacity0MB堆外内存的上限由-XX:MaxDirectMemorySize控制默认值等于-Xmx。超过上限会抛出OutOfMemoryError: Direct buffer memory上线前要根据缓存规模显式配置这个参数并通过BufferPoolMXBean持续监控。JDK 原生堆外内存 API在引入 OHC、EhCache 这类框架之前JDK 本身就提供了操作堆外内存的能力。了解底层 API 有助于理解框架的实现原理也能在简单场景下直接使用。ByteBuffer.allocateDirectJDK 1.4最基础的方式是通过ByteBuffer.allocateDirect()申请直接内存// 申请 64MB 堆外内存 ByteBuffer buf ByteBuffer.allocateDirect(64 * 1024 * 1024); // 写入数据 buf.putInt(0, 42); buf.putLong(4, System.currentTimeMillis()); // 读取数据 int val buf.getInt(0);allocateDirect分配的内存不在 JVM 堆内但对象头本身DirectByteBuffer实例还是在堆里。GC 回收这个对象头时会通过Cleaner机制触发堆外内存的释放。依赖 GC 来驱动释放有不确定性内存密集的场景下最好显式调用// 显式释放JDK 9 可用 sun.nio.ch.DirectBuffer ((sun.nio.ch.DirectBuffer) buf).cleaner().clean();ByteBuffer的 API 是字节级操作适合简单的二进制数据存取。用它实现一个带 LRU 淘汰、序列化、索引管理的缓存框架工作量很大这也是 OHC 存在的原因。MemorySegment ArenaJDK 21JDK 21 将 Foreign Function Memory APIFFM API转正JEP 454提供了更安全的堆外内存管理方式import java.lang.foreign.*; // Arena 管理内存的生命周期try-with-resources 退出时自动释放 try (Arena arena Arena.ofConfined()) { // 分配 1024 字节的堆外内存 MemorySegment segment arena.allocate(1024); // 通过 ValueLayout 以类型安全的方式读写 segment.set(ValueLayout.JAVA_INT, 0, 100); segment.set(ValueLayout.JAVA_LONG, 4, System.nanoTime()); int val segment.get(ValueLayout.JAVA_INT, 0); } // Arena 关闭后内存立即释放不依赖 GC相比ByteBufferMemorySegment的生命周期更明确Arena 关闭即释放不存在GC 还没跑到、内存还没还的问题。越界访问会抛出异常而不是静默读写错误地址。目前 OHC、EhCache 等框架底层仍使用ByteBuffer或UnsafeFFM API 更多出现在需要与 native 库交互的场景。OHC堆外缓存框架OHCOff-Heap-Cache最初为 Apache Cassandra 开发后来独立成为单独类库项目地址https://github.com/snazy/ohc管理的单机堆外内存可达 10G 左右缓存条目百万量级get 操作平均耗时约 20 微秒put 约 100 微秒可通过OHC#stats()获取命中率等指标Maven依赖配置dependency groupIdorg.caffinitas.ohc/groupId artifactIdohc-core/artifactId version0.7.0/version /dependency基本使用示例import org.caffinitas.ohc.Eviction; import org.caffinitas.ohc.OHCache; import org.caffinitas.ohc.OHCacheBuilder; OHCacheString, String ohCache OHCacheBuilder.String, StringnewBuilder() .keySerializer(new StringSerializer()) .valueSerializer(new StringSerializer()) .eviction(Eviction.LRU) .build(); ohCache.put(hello, world); System.out.println(ohCache.get(hello)); // worldOHC 提供两种实现org.caffinitas.ohc.chunked.OHCacheChunkedImpl为每个段分配堆外内存适合小型键值对目前仍处于实验阶段org.caffinitas.ohc.linked.OHCacheLinkedImpl为每个键值对单独分配堆外内存适合中大型键值对线上推荐使用这个两种实现都把条目缓存在堆外堆内只保存指向堆外的地址指针。EhCache堆外缓存框架EhCache 是老牌 Java 缓存框架支持堆外缓存和磁盘持久化。Maven依赖配置dependency groupIdorg.ehcache/groupId artifactIdehcache/artifactId version3.9.0/version /dependency使用示例CacheManagerConfigurationPersistentCacheManager persistentManagerConfig CacheManagerBuilder .persistence(new File(/users/kinbug/Desktop, ehcache-cache)); PersistentCacheManager persistentCacheManager CacheManagerBuilder.newCacheManagerBuilder() .with(persistentManagerConfig).build(true); // disk 第三个参数为 true 表示持久化到磁盘 ResourcePoolsBuilder resource ResourcePoolsBuilder.newResourcePoolsBuilder() .disk(100, MemoryUnit.MB, true); CacheConfigurationString, String config CacheConfigurationBuilder .newCacheConfigurationBuilder(String.class, String.class, resource).build(); CacheString, String cache persistentCacheManager.createCache(userInfo, CacheConfigurationBuilder.newCacheConfigurationBuilder(config)); cache.put(orderId, order序列化对象); System.out.println(cache.get(orderId)); persistentCacheManager.close(); // 确保数据 dump 到磁盘EhCache配置选项ResourcePoolsBuilder.heap(10)缓存最大条目数ResourcePoolsBuilder.newResourcePoolsBuilder().heap(10, MemoryUnit.MB)缓存最大空间withExpiry(ExpiryPolicyBuilder.timeToIdleExpiration(Duration.ofMinutes(10)))空闲过期时间withExpiry(ExpiryPolicyBuilder.timeToLiveExpiration(Duration.ofMinutes(10)))存活过期时间withSizeOfMaxObjectSize(10, MemoryUnit.KB)限制单个缓存对象大小存储介质建议使用堆外内存涉及磁盘存储时建议配 SSD。SSD 的 IOPS 和带宽比 HDD 高出数量级能明显降低 IO 延迟。哈希算法推荐 CRC32CCPU 占用低且分布均匀。混合存储策略需要排序功能时可以把数据和索引分开存堆外内存存完整数据堆内只维护用于排序的关键字段价格、数量等。old-gen scanning 扫描的对象数量大幅减少排序功能也不受影响。总结把缓存数据迁移到堆外之后Old Gen 占用降低dirty card 数量随之减少YGC 的扫描范围缩小耗时自然下来。FGC 压力也会跟着减轻OOM 的风险从 JVM 堆转移到了应用自行管理的堆外内存更可控。迁移后记得显式配置-XX:MaxDirectMemorySize并通过BufferPoolMXBean持续监控直接内存用量避免直接内存溢出。

相关新闻