使用volatile实现乐观的线程同步控制
最近项目在进行并发测试的时候发现了一个问题,定位到这样一段代码:
//简化代码如下
private Map<String, Device> deviceMap = new ConcurrentHashMap<>();
/** 取缓存 */
public Device get(String key){
return deviceMap.get(key);
}
/** 数据发生变化时会更新缓存 */
public void cacheDevices(){
...
deviceMap.clear();
deviceMap.putAll(...);
...
}
核心逻辑很简单,就是读写一个简单的本地缓存。这里有一个线程负责更新缓存,多个线程会去取缓存。已知每次更新缓存时都能将所有可能取的数据缓存到上面的集合中,但是在几万的并发访问下还是发生了取不到目标缓存的现象。查到这段代码时不难发现问题所在,由于没有采取线程同步措施,缓存集合在调用clear而还没有完成新的数据的载入的时候,此时调用取缓存方法就会失败,因为此时缓存集合还是空的。
很容易想到使用syncronized关键字来粗暴地解决任何线程同步问题:
private Map<String, Device> deviceMap = new ConcurrentHashMap<>();
/** 取缓存 */
public Device get(String key){
syncronized(deviceMap) {
return deviceMap.get(key);
}
}
/** 数据发生变化时会更新缓存 */
public void cacheDevices(){
...
syncronized(deviceMap) {
deviceMap.clear();
deviceMap.putAll(...);
}
...
}
然而在多线程高并发地读取缓存的情况下,这种线程同步方式对性能的影响太大了。syncronized是“悲观”的,它假设更新很可能冲突,每次都要先获取锁才能去操作要同步的对象,其他线程要进入该同步块的时候就要被阻塞,进入锁等待队列。事实上,在这种读远多于写的场景下,我们应该“乐观”一点,AtomicInteger这样的原子类型就为我们提供了很好的示范:
//JDK1.7 AtomicInteger源码
//volatile保证真实值的内存可见性
private volatile int value;
public final int incrementAndGet() {
for (;;) {
int current = get();
int next = current + 1;
if (compareAndSet(current, next))
return next;
}
}
这是原子整型进行自增的方法。这里原子变量的更新逻辑是非阻塞式的,是乐观的,它假设更新冲突比较少,使用循环重试处理冲突,这种同步方式不会阻塞其他线程,在更新较少的情况下性能损失很小,我们可以利用这种思想改造上面的代码:
private Map<String, Device> deviceMap = new ConcurrentHashMap<>();
private volatile boolean updatingCache = false;
/** 取缓存 */
public Device get(String key){
while(true){
if(!updatingCache){
return deviceMap.get(key);
}
}
}
/** 数据发生变化时会更新缓存 */
public void cacheDevices(){
...
updatingCache = true;
deviceMap.clear();
deviceMap.putAll(...);
updatingCache = false;
...
}
这里增加updatingCache变量用于告诉线程当前集合是否正在更新。为了保证updatingCache变量的修改对其他线程立即可见,这里使用volatile进行修饰,volatile变量在更新时会被立即写入主内存(相对于线程私有的工作内存),其他线程在使用该变量时,也必须先从主内存更新该值,由此保证变量的内存可见性。这里要注意只有在更新线程只有一个的情况下这么做才有效,因为volatile只能保证可见性,无法保证操作的原子性,如果有多个线程修改volatile变量,还是要通过加锁来保证操作原子性。
如果更新操作的耗时较长,还可以改造一下,以使读线程适当让出CPU时间分片:
/** 取缓存 */
public Device get(String key){
while(true){
if(!updatingCache){
return deviceMap.get(key);
}
try {
Thread.sleep(10);
} catch (InterruptedException e) {
...
}
}
}
以上方法只是针对当前代码事实利用简单的Java知识快速解决问题的一种思路,时间充足的情况下还是应该好好研读一下像Guava cache这样主流的cache类库的源码,必要的话直接引入成熟的缓存产品。