Groovy OOM
前言
前段时间主导了系统整体的框架升级,包括但不限于 Spring、Spring Boot、Gradle、Groovy、Java 等,由于现在的 AI Coding 发展迅速,所以整体升级过程还是比较顺畅。将升级代码 Merge 之后,项目构建时会偶现 OOM 导致编译失败的现象,但奇怪的紧接着再点下构建又好了。起初还以为是偶尔服务器资源不够,经过一阵子观察之后发现没有那么简单,多半是升级带来的后遗症,故尝试解决。
分析过程
既然遇到的是 OOM,第一时间还是得想办法弄到内存文件和构建参数。
内存文件如下:
构建参数如下:
1 | org.gradle.daemon=true |
虽然注意到 ConcurrentSoftCache 的大小有点不对劲,但是构建参数中预留的内存空间是 2G , 而 320MB 离 2G 还有很大一段距离,所以第一时间没有怀疑这个类。
回想升级过程中可能会影响编译的人为操作,有且只有一点:Gradle 的版本从 6.8.3 升级到了 8.4,这两个版本对模块之间依赖的传递性有差异,6.8.3 中的 compile 关键字在 8.4 被细分为 api 和 implementation,implementation 代表依赖仅在当前模块内部可见,api 则相反,当时一股脑的将 compile 改成了 api。会不会是这个原因呢?答案肯定不是,如果是这个原因不可能出现时好时坏的情况,应该会一直失败才对。
那么现在只有一条路了,看下 ConcurrentSoftCache 的源码,经过一系列搜索之后,发现官方的 Jira 曾经记录过一个问题 GROOVY-11271] ConcurrentCommonCache causes memory leaks. - ASF Jira 大致就是说用读锁保护了 LRU 的读操作,从而导致了内存泄露。
简单回忆一下 LRU, 它是一种淘汰策略,每次淘汰的是最近最少使用的节点。 在 Java 中对采用 LRU 模式的 LinkedHashMap 来说,当一个元素被访问之后会被放到链表尾部,一直这样维护下去链表头部就一定就是最近最少访问的元素,从而达到 LRU 的目的。可以很明显确定对于用 LinkedHashMap 实现的 LRU 来说,它的读操作也伴随着写操作。
关键代码如下:
1 | // 缓存初始化确实只使用了 LinkedHashMap |
总所周知,读锁是可共享的,读锁保护了写操作自然而然就会引起线程安全问题。
知道根本原因之后问题就很好解决了,按照官方指引升级 Groovy 版本,检查一下相应位置的源码:
1 | // 缓存初始化确实只使用了 LinkedHashMap |
可以看到,官方用 ConcurrentLinkedHashMap 替换 LinkedHashMap 从而避免了线程安全的问题。经过长时间观察发现再也没有出现过编译期间 OOM 的问题,故而此次 BUG 修复成功。
思考
并不是用了锁和并发集合就不可能再遇到线程安全的问题,多线程环境下一定要对写操作一定要十分敏感!
想起来笔者曾经犯过的一个错误:
1 | ConcurrentHashMap<String, String> concurrentHashMap = new ConcurrentHashMap<>(); |
这段代码仔细一看就能发现问题,虽然使用了并发集合,但 containsKey 和 put 不是原子操作,那么自然是有可能遇到线程安全问题,要不然使用 putIfAbsent 代替,要不然就用锁保证操作区域的原子性。值得一提的是,从性能的方面考虑,如果选择锁的方案就没有必要再使用 ConcurrentHashMap。
另外,之所以 320MB 就发生了 OOM 是因为 Gradle 在开启并行编译之后会依次给每个模块再分配一下。