线程安全的对象你用对了吗?

2020/04/24 14:11 下午 posted in  Java

HashMap是线程不安全的,如果我们需要线程安全的使用场景,通常会使用ConcurrentHashMap来代替HashMap来保证线程安全。可是这样就够了吗?

我们用Mybatis源码中的一个例子来说明。

BlockingCache.java

private final ConcurrentHashMap<Object, ReentrantLock> locks;

// MyBatis的这个blocking cache保证同一个时间对于一个查询key只有一个线程可以获得锁。锁放在了locks这个ConcurrentHashMap中。每个线程要操作前需要尝试获取锁。以下就是获取锁的核心逻辑。注意这个方法本身是没有加锁保护的。
private ReentrantLock getLockForKey(Object key) {
    ReentrantLock lock = new ReentrantLock();
    ReentrantLock previous = locks.putIfAbsent(key, lock);
    return previous == null ? lock : previous;
    return locks.computeIfAbsent(key, (k) -> new ReentrantLock());
}

关键是这一句

locks.putIfAbsent(key, lock);

putIfAbsent() 是啥作用呢? 按照字面意思,就是如果key不存在,则set,否则不插入。为啥会用这样一个看起来有点复杂的接口呢?如果我们没有熟读ConcurrentHashMap提供的接口,我们可能会这么写:

private ReentrantLock getLockForKey(Object key) {
    ReentrantLock lock = locks.get(key); //  从map里读取一下是否key已经存放了
    if (lock == null) { // 不存在,则新建一个锁,加入到map中
      lock = new ReentrantLock();
      locks.put(key, lock);
    }
    return lock;
}

这段代码是我们日常很有可能写出的逻辑,逻辑清楚,代码也还算简洁。那这段代码会有什么问题呢?

一个有点挫的流程图,来解释一下。这段代码没有同步锁的保护,所以可能有多个线程同一时间都进入了这段代码。A线程先执行了1,2步骤,判断了map中没有key,准备创建线程并添加。这时候发生了线程切换,B线程开始执行这段代码。B线程也去判断map中有没有key,显然也是没有的。然后B线程走完了整个逻辑。然后A线程再一次获取到时间片。最终结果,A线程和B线程都往map中插入了他们自己创建的锁。很明显B线程的锁被覆盖了。

回过头来看一下源码中是怎么写的。

locks.putIfAbsent(key, lock);

源码使用了ConncurrentHashMap的一个接口来达到两个目的,1. 查询一下map中是否包含某个key值。2.不存在则插入。而调用这个接口,ConncurrentHashMap保证了两个步骤的原子性,即要么都做要么都不做。

总结

ConncurrentHashMap虽然是线程安全的,但是用不好一样也会有线程不安全的问题。像这种线程安全的类,他们的单个接口是可以保证线程安全的,但是如果调用了多个接口,又没有用锁来保护,则仍然有可能发生线程不安全的问题。

引申一下,这个问题和Redis用lua脚本来保证多个redis命令的线程安全是有类似的地方。Redis执行单个命令是线程安全的。执行多个组合命令则要使用Lua脚本。这是因为执行Lua脚本命令是安全的。因此可以用Lua脚本来执行多个Redis命令来达到对多个Redis命令的原子性操作。