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

可以很明显得注意到 ConcurrentSoftCache 的大小有点诡异,但是从数据上来说确实也没到离谱的程度,因为当时预留的空间还是比较大的,更怀疑的是自己没有用对“新版本”的 Gradle。
这里简单介绍下两个 Gradle 的不同,差异主要集中在模块之间的可见性,简单的理解就是新版本的 Gradle 对模块之间的各个依赖之间的可见性控制更加严格,是不是因为我可见性没控制好导致的?经过一番完整的梳理发现并不是,因为改前和改后的模块之间的可见性是一致的。反过来想,如果真是可见性控制得不对,那也不应该出现一会成功一会失败的现象,应该一直失败才对,故而这条排查思路不对。
那么现在只有一条路了,看下 ConcurrentSoftCache 的源码,在经过一系列的搜索发现官方的 Jira 曾经记录过一个问题 GROOVY-11271] ConcurrentCommonCache causes memory leaks. - ASF Jira 大致就是说用写锁保护了 LRU 的读操作,点进源码一看还真是,官方虽然初始化了一把 ReentrantReadWriteLock,但是底层的 Map 实现用的是 LinkedHashMap。这是一个非常经典的错误,LRU 的读并不仅仅是“读”操作,它还伴随了一定的“写”操作,对于 LinkedHashMap 来说,如果采用 LRU 模式的话,当一个元素被访问之后会被放到链表尾部,那么链表头部一定就是最近最少访问的元素,从而达到 LRU 的目的。
知道根本原因之后问题就很好解决了,按照官方指引升级 Groovy 版本,检查一下相应位置的源码,发现用 ConcurrentLinkedHashMap 替换了 LinkedHashMap。升级之后没有再出现过构建编译的时候 OOM。
思考
并不是用了锁和并发集合就不可能再遇到线程安全的问题,多线程环境下一定要对“写”操作一定要十分敏感!
想起来笔者曾经犯过的一个错误:
1 | ConcurrentHashMap<String, String> concurrentHashMap = new ConcurrentHashMap<>(); |
这段代码仔细一看就能发现问题,虽然使用了并发集合,但 containsKey 和 put 不是原子操作,那么自然是有可能遇到线程安全问题,要不然使用 putIfAbsent 代替,要不然就用锁保证操作区域的原子性。值得一提的是,从性能的方面考虑,如果选择锁的方案就没有必要再使用 ConcurrentHashMap。