首页 > 基础资料 博客日记

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

2026-05-14 10:00:03基础资料围观1

文章JVM缓存对象对GC的影响与优化方案分享给大家,欢迎收藏极客资料网,专注分享技术知识

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

背景

当大量缓存对象长时间驻留堆内存时,JVM 的垃圾回收会被明显拖累。问题不在于对象多,而在于这些对象大多晋升到老年代,并持续引用年轻代对象——这直接破坏了分代 GC 的核心假设。

GC性能问题分析

YGC耗时增长的原因

分代垃圾回收基础原理

JVM 采用分代垃圾回收策略,堆内存分为年轻代(Young Generation)和老年代(Old Generation)。这套设计基于"弱代假说"(Weak Generational Hypothesis):

  • 大多数对象都是短命的
  • 存活时间长的对象引用存活时间短的对象的情况很少

YGC标记过程详解

YGC 阶段,垃圾回收器从 GCRoot(全局引用、栈引用、静态变量等)开始做可达性分析。由于 YGC 只回收年轻代,JVM 在扫描上做了一个优化:

扫描中断机制:

  • 扫描引用链时,一旦遇到老年代对象就中断该路径
  • 老年代对象在 YGC 期间不会被回收,继续往下扫描它引用的年轻代对象没有意义
  • 这个机制缩小了扫描范围,YGC 效率因此提升

![](data:image/svg+xml,%3C%3Fxml%20version='1.0'%20encoding='UTF-8'%3F%3E%3Csvg%20width='1px'%20height='1px'%20viewBox='0%200%201%201'%20version='1.1'%20xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink'><title></title><g stroke='none' stroke-width='1' fill='none' fill-rule='evenodd' fill-opacity='0'><g transform='translate(-249.000000, -126.000000)' fill='%23FFFFFF'><rect x='249' y='126' width='1' height='1'%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 scanning,JVM 用记忆集(Remembered Set)记录跨代引用,核心组件是 card table

![](data:image/svg+xml,%3C%3Fxml%20version='1.0'%20encoding='UTF-8'%3F%3E%3Csvg%20width='1px'%20height='1px'%20viewBox='0%200%201%201'%20version='1.1'%20xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink'><title></title><g stroke='none' stroke-width='1' fill='none' fill-rule='evenodd' fill-opacity='0'><g transform='translate(-249.000000, -126.000000)' fill='%23FFFFFF'><rect x='249' y='126' width='1' height='1'%3E%3C/rect%3E%3C/g%3E%3C/g%3E%3C/svg%3E)

  • 老年代按固定大小切分为 card(通常 512 字节)
  • 每个 card 对应 card table 中的一个字节
  • 老年代对象引用年轻代对象时,对应的 card 被标记为 dirty
  • old-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;

List<MemoryPoolMXBean> 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                  used=128MB / max=1024MB
PS Survivor Space              used=32MB  / max=128MB
PS Old Gen                     used=3584MB / max=4096MB

Old Gen 长期处于高水位(如 >80%)时,dirty card 数量通常也很高。配合 GarbageCollectorMXBean 一起看,可以区分是 YGC 频率问题还是单次耗时问题:

List<GarbageCollectorMXBean> 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:+AlwaysPreTouch

JVM 启动时立即分配并初始化所有堆内存页面,避免运行时因按需分页带来的延迟抖动。适合对启动时间不敏感、对运行时稳定性要求高的应用。

-XX:-UseBiasedLocking

禁用偏向锁,减少锁撤销时的 STW 时间,在多线程竞争激烈的场景下有用。代价是损失一些单线程性能。JDK 15 起偏向锁默认禁用(JEP 374),这个参数在高版本中已无意义。

G1垃圾回收器配置原理

-XX:+UseG1GC

启用 G1 垃圾回收器。

-XX:G1HeapRegionSize=16m

设置 G1 区域大小为 16MB。较大的区域减少内部碎片,但回收粒度也变粗。

-XX:G1ReservePercent=25

预留 25% 的堆空间作为对象复制的 to-space,防止晋升失败触发 Full GC。

-XX:InitiatingHeapOccupancyPercent=30

堆使用率达到 30% 时启动并发标记。提前标记可以避免临近满堆时才触发,内存压力会小很多。

-XX:MaxGCPauseMillis

指定最大 GC 暂停时间目标,G1 会尽量满足但不做硬性保证。

设置过小(如 <50ms)时,G1 会主动压缩年轻代来缩短暂停,结果是年轻代频繁填满、Minor GC 次数激增,整体吞吐反而下降。通常设在 100-200ms,再结合 GC 日志调整。

JDK 11内存优化原理

指针压缩技术
  • -XX:+UseCompressedOops:64 位系统下将对象指针从 64 位压缩为 32 位
  • -XX:+UseCompressedClassPointers:压缩类指针,减少元数据内存占用

缓存场景下对象数量庞大,这两个参数能压缩内存占用,顺带提升 CPU 缓存命中率。

ZGC低延迟回收器

ZGC 是低延迟场景下的可选项,最大暂停时间通常不超过 2ms,适合对响应时间要求苛刻的应用。

版本进度:

  • JDK 11:实验性引入
  • JDK 15:正式生产可用,稳定性大幅提升
  • JDK 16:46 个功能增强 + 25 个 bug 修复
  • JDK 17:成熟稳定,Spring Boot 3.x 最低要求版本
  • JDK 21:引入分代 ZGC,吞吐量大幅提升,容器支持优化

启用方式:-XX:+UseZGC

堆外内存方案

解决缓存 GC 问题的根本思路是把缓存数据从堆内移到堆外,让 GC 不再需要感知这部分数据的存在。缓存的生命周期由应用自行管理,不再委托给 JVM。

堆外内存的监控

迁移到堆外之后,MemoryMXBean.getNonHeapMemoryUsage() 看不到 Direct Buffer 的占用,它只涵盖元空间、代码缓存等 JVM 内部区域。监控堆外缓存要用 BufferPoolMXBean

import com.sun.management.BufferPoolMXBean;
import java.lang.management.ManagementFactory;
import java.util.List;

List<BufferPoolMXBean> 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: count=1024, used=512MB, capacity=512MB
// mapped:  count=0,    used=0MB,   capacity=0MB

堆外内存的上限由 -XX:MaxDirectMemorySize 控制,默认值等于 -Xmx。超过上限会抛出 OutOfMemoryError: Direct buffer memory,上线前要根据缓存规模显式配置这个参数,并通过 BufferPoolMXBean 持续监控。

JDK 原生堆外内存 API

在引入 OHC、EhCache 这类框架之前,JDK 本身就提供了操作堆外内存的能力。了解底层 API 有助于理解框架的实现原理,也能在简单场景下直接使用。

ByteBuffer.allocateDirect(JDK 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 + Arena(JDK 21+)

JDK 21 将 Foreign Function & Memory API(FFM 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 等框架底层仍使用 ByteBufferUnsafe,FFM API 更多出现在需要与 native 库交互的场景。

OHC堆外缓存框架

OHC(Off-Heap-Cache)最初为 Apache Cassandra 开发,后来独立成为单独类库:

  • 项目地址:https://github.com/snazy/ohc
  • 管理的单机堆外内存可达 10G 左右,缓存条目百万量级
  • get 操作平均耗时约 20 微秒,put 约 100 微秒
  • 可通过 OHC#stats() 获取命中率等指标

Maven依赖配置:

<dependency>
    <groupId>org.caffinitas.ohc</groupId>
    <artifactId>ohc-core</artifactId>
    <version>0.7.0</version>
</dependency>

基本使用示例:

import org.caffinitas.ohc.Eviction;
import org.caffinitas.ohc.OHCache;
import org.caffinitas.ohc.OHCacheBuilder;

OHCache<String, String> ohCache = OHCacheBuilder.<String, String>newBuilder()
        .keySerializer(new StringSerializer())
        .valueSerializer(new StringSerializer())
        .eviction(Eviction.LRU)
        .build();

ohCache.put("hello", "world");
System.out.println(ohCache.get("hello")); // world

OHC 提供两种实现:

  • org.caffinitas.ohc.chunked.OHCacheChunkedImpl:为每个段分配堆外内存,适合小型键值对,目前仍处于实验阶段
  • org.caffinitas.ohc.linked.OHCacheLinkedImpl:为每个键值对单独分配堆外内存,适合中大型键值对,线上推荐使用这个

两种实现都把条目缓存在堆外,堆内只保存指向堆外的地址指针。

EhCache堆外缓存框架

EhCache 是老牌 Java 缓存框架,支持堆外缓存和磁盘持久化。

Maven依赖配置:

<dependency>
    <groupId>org.ehcache</groupId>
    <artifactId>ehcache</artifactId>
    <version>3.9.0</version>
</dependency>

使用示例:

CacheManagerConfiguration<PersistentCacheManager> 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);

CacheConfiguration<String, String> config = CacheConfigurationBuilder
        .newCacheConfigurationBuilder(String.class, String.class, resource).build();

Cache<String, 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 延迟。哈希算法推荐 CRC32C,CPU 占用低且分布均匀。

混合存储策略

需要排序功能时,可以把数据和索引分开存:堆外内存存完整数据,堆内只维护用于排序的关键字段(价格、数量等)。old-gen scanning 扫描的对象数量大幅减少,排序功能也不受影响。

总结

把缓存数据迁移到堆外之后,Old Gen 占用降低,dirty card 数量随之减少,YGC 的扫描范围缩小,耗时自然下来。FGC 压力也会跟着减轻,OOM 的风险从 JVM 堆转移到了应用自行管理的堆外内存,更可控。

迁移后记得显式配置 -XX:MaxDirectMemorySize,并通过 BufferPoolMXBean 持续监控直接内存用量,避免直接内存溢出。


文章来源:https://www.cnblogs.com/wsss/p/20038443
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:jacktools123@163.com进行投诉反馈,一经查实,立即删除!

标签:

相关文章

本站推荐

标签云