Poison

Striped

在业务中曾有为不同的 key 的操作实现锁语义的需求,常见的实现为构建一个线程安全的 Map 实现然后为每个 key 创建对应的对象用于互斥锁,但是该实现存在内存泄漏的问题,即随着不同 key 不停地出现,该 Map 实例会越来越大,之前为每个 key 创建的对象一直驻留在内存中,即使 key 永远不再出现。比如 JDK 7 引入的支持并行类加载的特性 Multithreaded Custom Class Loaders in Java SE 7 中,引入了如下代码:

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
26
27
28
29
30
31
32
33
34
35
36
37
// Maps class name to the corresponding lock object when the current
// class loader is parallel capable.
// Note: VM also uses this field to decide if the current class loader
// is parallel capable and the appropriate lock object for class loading.
private final ConcurrentHashMap<String, Object> parallelLockMap;

/**
* Returns the lock object for class loading operations.
* For backward compatibility, the default implementation of this method
* behaves as follows. If this ClassLoader object is registered as
* parallel capable, the method returns a dedicated object associated
* with the specified class name. Otherwise, the method returns this
* ClassLoader object.
*
* @param className
* The name of the to-be-loaded class
*
* @return the lock for class loading operations
*
* @throws NullPointerException
* If registered as parallel capable and {@code className} is null
*
* @see #loadClass(String, boolean)
*
* @since 1.7
*/
protected Object getClassLoadingLock(String className) {
Object lock = this;
if (parallelLockMap != null) {
Object newLock = new Object();
lock = parallelLockMap.putIfAbsent(className, newLock);
if (lock == null) {
lock = newLock;
}
}
return lock;
}

以上代码在本文编写时依然存在于最新的 JDK 中,该实现会导致 parallelLockMap 持续增长。比如,我们编写以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package me.tianshuang;

public class Test {

public static void main(String[] args) {
int i = 0;
while (true) {
try {
ClassLoader.getSystemClassLoader().loadClass(String.valueOf(i++));
} catch (ClassNotFoundException e) {
// ignore
}
}
}

}

当设置一个较低的 -Xmx 将会很快观察到 OOM 抛出,如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Exception in thread "main" java.lang.OutOfMemoryError: GC overhead limit exceeded
at java.util.zip.ZipCoder.getBytes(ZipCoder.java:80)
at java.util.zip.ZipFile.getEntry(ZipFile.java:317)
at java.util.jar.JarFile.getEntry(JarFile.java:261)
at java.util.jar.JarFile.getJarEntry(JarFile.java:244)
at sun.misc.URLClassPath$JarLoader.getResource(URLClassPath.java:1070)
at sun.misc.URLClassPath.getResource(URLClassPath.java:250)
at java.net.URLClassLoader$1.run(URLClassLoader.java:366)
at java.net.URLClassLoader$1.run(URLClassLoader.java:363)
at java.security.AccessController.doPrivileged(Native Method)
at java.net.URLClassLoader.findClass(URLClassLoader.java:362)
at java.lang.ClassLoader.loadClass(ClassLoader.java:418)
at sun.misc.Launcher$AppClassLoader.loadClass(Launcher.java:355)
at java.lang.ClassLoader.loadClass(ClassLoader.java:351)
at me.tianshuang.Test.main(Test.java:9)

关于该问题曾有用户提交过 bug,参见:JDK-8037342 parallelLockMap keeps growing - Java Bug System,但是并未被修复,而后 David Holmes 写了一篇博文 Parallel Classloading Revisited: Fully Concurrent Loading (David Holmes’ Weblog) 来阐述该问题并提供了几种潜在的修复方案,但是并未在 JDK 中得以实现,具体讨论可以参考:Proposal: Fully Concurrent ClassLoading,此处不再赘述。

在我们之前的工程中,也存在类似的代码。同样地,在长时间运行后,随着不同 key 的累积,导致了内存的不停增长,只不过在 OOM 前我们收到了监控告警,提前修复了该问题。我们使用 Striped 限制锁的个数来避免为每个 key 创建锁导致的内存持续上升的问题,当然,并发度不再是单个 key 级别,但是,在大部分场景下,Striped 已经能够满足需求。关于其介绍可以参考 Striped (Guava: Google Core Libraries for Java 19.0 API),即我们可以在内存占用与并发度之间取得平衡,同时我们还可以使用弱引用、懒加载能进一步控制内存占用。Striped 的代码实现不算复杂,主要提供了两种实现,一种为基于数组的实现,一种为基于 Map 的实现,具体可参考 guava/Striped.java at v31.1 · google/guava · GitHub

也有观点认为可以使用 String.intern() 的返回值作为锁对象,但是关于该用法存在争议。因为 String 对象实例是 JVM 范围内共享的,那么就存在与其他代码共用一把锁的风险,即使可以通过命名空间等措施来降低冲突的概率,但是风险依然存在。还有观点认为 String.intern() 的性能取决于 JVM 底层实现,存在不可控的因素,相关讨论可以参考:Synchronizing on String objects in Java - Stack Overflow

Hive

在 Hive 的 HMSHandler 类中即使用了 Striped 对指定数据库的指定表进行加锁。

Reference

How to acquire a lock by a key - Stack Overflow
StripedExplained · google/guava Wiki · GitHub
Problems with padding · Issue #3195 · google/guava · GitHub