ehcache的clear()方法使用不当引起的gc

1,443 阅读9分钟

1. 问题:

线上使用 ehcache 做了一个缓存,缓存刷新时间间隔为 10s。

为了避免缓存刷新过程中的并发读写问题,设计了两个 Cache 缓存对象轮流使用的方式,即:

  1. 使用(读取)缓存 A 的过程中刷新缓存 B,10s 之后时间到,转而使用缓存 B;
  2. 使用缓存B的过程中再去刷新缓存 A,循环往复。
  3. 刷新缓存的时候,先调用 clear() 方法清空旧数据,再存入新数据

虽然这个设计不是很聪明的样子,但是用起来应该没有大问题。然而上线一段时间后,在服务遇到峰值期间,会在某个时间点开始 young GC 变得非常频繁,同时老年代占用空间快速增长,随后引发 mixed GC(使用的是G1)次数增长。
该问题在峰值过后会自愈,但峰值期间重启服务无效。

2. 问题分析

使用 jmap 得到 mixed gc 发生前后的直方图,发现 mixed gc 前有大量的缓存对象: org.ehcache.impl.internal.concurrent.ConcurrentHashMap$Node mixed gc 后被回收。显然问题确实出在 ehcache 缓存上。

因为是频繁的young gc 然后引发多次的 mixed gc 并且 mixed gc 能回收大量堆内存,所以肯定是因为某种原因持续不断的产生了大量对象,并且这种对象经过多次 young gc 仍然存活然后进入了老年代。顺着这个思路往下分析。

2.1 猜想,gc 太频繁导致缓存对象回收前达到晋升年龄进入老年代?

首先想到的是服务峰值期间 young gc 太频繁,导致 10s 缓存期间缓存对象的 gc 年龄达到了最大值,进入了老年代。那么观察两个指标:

  1. young gc 频率
  2. jvm 设置的老年代晋升年龄

对应如下:

  1. 10s刷新一次缓存,AB两个缓存轮流使用,缓存对象如果使用完被 clear() 方法释放然后被 gc 回收的话缓存对象最多被 ecache 引用20s;
  2. 发生问题时服务平均 30s 进行一次 young gc
  3. 如果 young GC 正常回收被释放的缓存对象,算上极限时间差,缓存对象最多生存60s,在这 60s 中 eden 区的缓存对象最多经历 gc 两次,不可能达到进入老年代的年龄(实际上在问题恶化后jvm是有可能把晋升年龄动态调整到2的,下面有讲)
  4. 使用的是默认晋升年龄 15,后面看 gc 日志发现发生了动态年龄调整

显然,这个猜想的原因造成不了线上的问题。

2.2 反复压测得到的一个宝贵结论

运维同学通过多次压测后得出结论:当缓存数量超过19.8万时才会出现这个问题。这说明这些对象在平常是能够被 young GC 回收掉的。
那么为什么数据到达 19.8 万之后这些对象就会进入老年代呢?

仔细看 gc 日志,偶然注意到发生 mixed gc之前开始出现多次:

[GC pause (G1 Humongous Allocation)
以及
[GC pause (G1 evacuation Pause)(young) Desired survivor size 127926272 bytes, new threshold 2 (max 15)

即 jvm 遇到了两个问题:

  1. 发生了大对象直接分配在老年代的问题;
  2. 老年代晋升年龄动态调整到了2,即2次 gc 后存活的新生代对象就晋升到了老年代。

3. 寻找两个问题的原因

3.1 大对象

在G1中,如果一个对象的大小超过分区大小的一半,该对象就被定义为大对象(Humongous Object)。大对象是直接分配到老年代的。
G1的分区大小对照表:

最小堆大小分区的大小
heap < 4GB1MB
4GB <= heap < 8GB2MB
8GB <= heap < 16GB4MB
16GB <= heap < 32GB8MB
32GB <= heap < 64GB16MB
64GB <= heap32MB
我们的服务是8G的堆,所以大于 2MB 就是大对象。
我们来算一下存放 19.8w 个 Node 的 ConcurrentHashMap 应该是多大:

HashMap 中 Node 数组大小应该是 2 的 n 次方,并且乘以承载因子 0.75 后需要大于19.8万,最后计算得到应该是 262144 个。
HashMap保存的是Node的引用,引用经过指针压缩之后是 4 byte,4 byte * 262144 = 1M 。
如果大于19.8w个,HashMap需要翻倍扩容,就大于 2M 了,确实是个大对象。

3.2 动态年龄调整

对象在新生代经历 young gc,每存活一次年龄加1,当年龄达到阈值时,就会晋升老年代。这个阈值最大15,可以通过 jvm 参数设置,但是 jvm 也会动态调整。
当一次 young gc 后,survivor 区放不下所有存活对象,这时候所有对象从最大年龄到最小年龄,相同年龄的对象作为一批,按照批次每批整体转移到老年代,直到剩下的对象可以放进 survivor 区。最后一批转移对象的年龄就会变成晋升老年代的年龄阈值。
我们从 gc 日志也能看出晋升年龄调整到了2。

3.3 但是这两个理论不足以解释上面遇到的问题

即使缓存对象集合使用的 HashMap 是一个大对象,分配在了老年代,但是存放具体数据的缓存 Node 只是在 HashMap 中有个引用,Node 本体还是在年轻代,而且这些 Node 既不是大对象,总体加起来占用空间也不大(服务峰值期间总大小也只有30M左右,下文有计算)不会占完新生代空间。当 clear() 之后这些 Node 被释放,此时照理就可以作为垃圾被 young gc 回收了。
实际观察到的现象却是 Node 一直没被 young gc 回收并且最终进入了老年代。
这时候我们的任务就变成了寻找为什么 Node 一直不能回收。
这是一个很曲折的过程,因为查问题查了很久,所以怎么发现的已经回想不清,这里只能给出结果。

4. ehcache的clear()方法的特殊之处

在切换AB缓存时,使用了 ehcache 的 clear() 方法,本意是清空缓存,释放所有缓存 Node。clear() 方法在 jdk 的集合类中使用的很多。 我们以常见的 java.util.ConcurrentHashMap 类为例来看一下它的 clear() 方法,是怎么清除 hashmap 当前所持有的所有 key-value 数据:

public void clear() {
    long delta = 0L; 
    int i = 0;
    Node<K,V>[] tab = table;
    while (tab != null && i < tab.length) {
        int fh;
        Node<K,V> f = tabAt(tab, i);
        if (f == null)
            ++i;
        else if ((fh = f.hash) == MOVED) {
            tab = helpTransfer(tab, f);
            i = 0; // restart
        }
        else {
            synchronized (f) {
                if (tabAt(tab, i) == f) {
                    Node<K,V> p = (fh >= 0 ? f :
                                   (f instanceof TreeBin) ?
                                   ((TreeBin<K,V>)f).first : null);
                    while (p != null) {
                        --delta;
                        p = p.next;
                    }
                    setTabAt(tab, i++, null);
                }
            }
        }
    }
    if (delta != 0L) {
        addCount(delta, -1);
    }
}

即使你看不懂这段代码,你应该也能看出来这段代码在把当前 map 中的数据去掉,即去掉 map 对 Node 的引用,这样 Node 就是无引用状态,就可以被回收了。
这个操作很符合我们对于 clear() 的认知,事实上 jdk 中集合类的 clear() 方法也基本是这个效果。
然而我们仔细去debug ehcache 的 clear() 方法(一定要debug,不然每个接口都有多个实现类根本不知道是哪个):
Ehcache.clear() -> EhcacheBase.clear() -> OnHeapStore.clear() -> SimpleBackend.clear()
SimpleBackend.clear() 就是最终的 clear 操作,它做了什么呢?

public void clear() {
    // 如果你去下载源码,就可以看到下面的注释,"这比清理map快"
    // This is faster than performing a clear on the underlying map
    this.realMap = (EvictingConcurrentMap)this.realMapSupplier.get();
}

是创建了一个新的 EvictingConcurrentMap 对象,让 SimpleBackend 的属性 realMap 指向这个新对象,并使用这个新对象来存储缓存数据。
而旧对象呢?旧对象的 HashMap 没有释放对 Node 的引用,而是直接和 Node 一起等待垃圾回收。
这时候问题就来了, HashMap 已经因为是大对象而分配在了老年代,mixed gc 之前不会被回收,而 Node 被 HashMap 引用,即使在年轻代,也不会被回收
每隔 10s 就调用一次 clear() 方法,意味着每隔 10s 就产生一批 young gc 无法回收的 Node 对象。 我们来算一下产生的 Node 对象的总大小:

HashMap 的 key-value 分别为 Long 和 CopiedOnHeapValueHolder,所以持有的对象数组是 Node<Long, CopiedOnHeapValueHolder<Object>>[] , 其中 Object 就是缓存的数据,一个Node 大概 136 byte,20w 个就是 27M。

定时任务每10s一次,每次29M(27+2=29),半小时 27M * 6 * 30 = 5.22G
这些 Node 对象无法被 young gc 回收 survivor 区又放不下,只能提前晋升老年代。提前晋升老年代使得动态年龄调整,后续更多 Node 提早进入老年代。
大量 Node 对象占满老年代最终触发了 mixed gc。

5. 总结

  1. 缓存 map 作为大对象直接分配在了老年代
  2. clear() 方法每次新建一个缓存 map,而不是复用旧的缓存 map
  3. 旧缓存 map 没有释放对 Node 的引用,导致 mixed gc 回收 map 之前,Node 也不能被回收
  4. 大量 Node 对象占满新生代,被迫提前晋升老年代,又引发了动态调低晋升年龄,使得更多 Node 提前进入老年代
  5. 老年代迅速增长,触发 mixed gc,mixed gc回收了 map,也就回收了 Node
  6. 重启机器并不能解决问题
  7. 服务高峰过后,gc 自我能恢复

6. 这里顺便解释一下 clear() 里面的 get() 方法为什么是创建一个新的 EvictingConcurrentMap 对象

这里使用的是函数式接口,如果不了解函数式接口可能会看不懂为什么 clear() 方法会新建一个 EvictingConcurrentMap 对象。
realMapSupplier 是 OnHeapStore 类的属性,它的类型是 Supplier<EvictingConcurrentMap<K, OnHeapValueHolder<V>>> 而 Supplier 是一个带泛型的函数式接口:

// 这个注解就表明是函数式接口,编译期会检查
// 没有这个注解也可以用作函数式接口,但是编译期不会检查
@FunctionalInterface
public interface Supplier<T> {
    T get();
}

所以realMapSupplier.get()方法得到的就是一个 EvictingConcurrentMap 对象。
为什么这个 EvictingConcurrentMap 对象每次都是新建的呢?我们来看一下代码。
realMapSupplier 的初始化在 OnHeapStore 的构造函数中:

OnHeapStore<K, V> onHeapStore = new OnHeapStore(storeConfig, timeSource, keyCopier, valueCopier, >sizeOfEngine, eventDispatcher, ConcurrentHashMap::new);

所以 realMapSupplier 的实现就是 ConcurrentHashMap::new 即 ConcurrentHashMap 的构造函数, 每次调用 realMapSupplier.get() 就相当于调用 ConcurrentHashMap 的构造方法创建一个新对象。
任意一个返回一个 java 对象的方法都是实现了这个 Supplier 函数式接口。 java 类的构造函数就是返回一个自身类的对象,恰好可以算作是实现了Supplier接口。所以构造函数可以赋值给 Supplier<EvictingConcurrentMap<K, OnHeapValueHolder>> 类型的 realMapSupplier 对象。