前言

前段时间主导了系统整体的框架升级,包括但不限于 Spring、Spring Boot、Gradle、Groovy、Java 等,由于现在的 AI Coding 发展迅速,所以整体升级过程还是比较顺畅。将升级代码 Merge 之后,项目构建时会偶现 OOM 导致编译失败的现象,但奇怪的紧接着再点下构建又好了。起初还以为是偶尔服务器资源不够,经过一阵子观察之后发现没有那么简单,多半是升级带来的后遗症,故尝试解决。

分析过程

既然遇到的是 OOM,第一时间还是得想办法弄到内存文件和构建参数。

内存文件如下:
img

构建参数如下:

1
2
3
4
org.gradle.daemon=true
org.gradle.jvmargs=-Xmx2048m -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp -Dfile.encoding=UTF-8
org.gradle.parallel=true
org.gradle.configureondemand=true

虽然注意到 ConcurrentSoftCache 的大小有点不对劲,但是构建参数中预留的内存空间是 2G , 而 320MB2G 还有很大一段距离,所以第一时间没有怀疑这个类。

回想升级过程中可能会影响编译的人为操作,有且只有一点:Gradle 的版本从 6.8.3 升级到了 8.4,这两个版本对模块之间依赖的传递性有差异,6.8.3 中的 compile 关键字在 8.4 被细分为 apiimplementationimplementation 代表依赖仅在当前模块内部可见,api 则相反,当时一股脑的将 compile 改成了 api。会不会是这个原因呢?答案肯定不是,如果是这个原因不可能出现时好时坏的情况,应该会一直失败才对。

那么现在只有一条路了,看下 ConcurrentSoftCache 的源码,经过一系列搜索之后,发现官方的 Jira 曾经记录过一个问题 GROOVY-11271] ConcurrentCommonCache causes memory leaks. - ASF Jira 大致就是说用读锁保护了 LRU 的读操作,从而导致了内存泄露。

简单回忆一下 LRU, 它是一种淘汰策略,每次淘汰的是最近最少使用的节点。 在 Java 中对采用 LRU 模式的 LinkedHashMap 来说,当一个元素被访问之后会被放到链表尾部,一直这样维护下去链表头部就一定就是最近最少访问的元素,从而达到 LRU 的目的。可以很明显确定对于用 LinkedHashMap 实现的 LRU 来说,它的读操作也伴随着写操作。

关键代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 缓存初始化确实只使用了 LinkedHashMap
public CommonCache(final int initialCapacity, final int maxSize, final EvictionStrategy evictionStrategy) {
this(new LinkedHashMap<K, V>(initialCapacity, DEFAULT_LOAD_FACTOR, EvictionStrategy.LRU == evictionStrategy) {
private static final long serialVersionUID = -8012450791479726621L;

@Override
protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
return size() > maxSize;
}
});
}

// 读取逻辑
private <R> R doWithReadLock(EvictableCache.Action<K, V, R> action) {
this.readLock.lock();

Object var2;
try {
var2 = action.doWith(this.commonCache);
} finally {
this.readLock.unlock();
}

return (R)var2;
}

总所周知,读锁是可共享的,读锁保护了写操作自然而然就会引起线程安全问题。

知道根本原因之后问题就很好解决了,按照官方指引升级 Groovy 版本,检查一下相应位置的源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 缓存初始化确实只使用了 LinkedHashMap
private static <K, V> Map<K, V> createMap(int initialCapacity, int maxSize, EvictionStrategy evictionStrategy) {
final boolean lru = EvictionStrategy.LRU == evictionStrategy;

if (lru) {
return new ConcurrentLinkedHashMap.Builder<K, V>()
.initialCapacity(initialCapacity)
.maximumWeightedCapacity(maxSize)
.build();
}
return new LinkedHashMap<K, V>(initialCapacity, DEFAULT_LOAD_FACTOR, lru) {
private static final long serialVersionUID = -8012450791479726621L;

@Override
protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
return size() > maxSize;
}
};
}

可以看到,官方用 ConcurrentLinkedHashMap 替换 LinkedHashMap 从而避免了线程安全的问题。经过长时间观察发现再也没有出现过编译期间 OOM 的问题,故而此次 BUG 修复成功。

思考

并不是用了锁和并发集合就不可能再遇到线程安全的问题,多线程环境下一定要对写操作一定要十分敏感!

想起来笔者曾经犯过的一个错误:

1
2
3
4
ConcurrentHashMap<String, String> concurrentHashMap = new ConcurrentHashMap<>();
if (concurrentHashMap.containsKey("test")) {
concurrentHashMap.put("test", "test");
}

这段代码仔细一看就能发现问题,虽然使用了并发集合,但 containsKeyput 不是原子操作,那么自然是有可能遇到线程安全问题,要不然使用 putIfAbsent 代替,要不然就用锁保证操作区域的原子性。值得一提的是,从性能的方面考虑,如果选择锁的方案就没有必要再使用 ConcurrentHashMap

另外,之所以 320MB 就发生了 OOM 是因为 Gradle 在开启并行编译之后会依次给每个模块再分配一下。