前言
本篇是java多线程系列教程之实战篇 -- 在使用redis缓存的代码中考虑多线程问题,并使用双重校验锁DCL进行优化,为DCL正名
正文
像许多“重复发明的轮子” 一样,缓存看上去都非常简单。然而,但是是否是线程安全的使用,那就要看开发者对于线程的了解程度和追求程度了
本节我们将开发一个高效且可伸缩的缓存,用于改进一个高计算开销的函数。
我们首先从简单的案例开始,然后分析它的并发性映陷,并讨论如何修复它们。
初始版
在下列程序清单1-1中,传统的web项目中,这是再也寻常不过的一段使用redis缓存的代码,一个再普通不过的service调用dao的写法
包含三个类,一个是用于直接从从数据库中取得数据的ItemMapper类,其中有一个可以根据id获取item的方法;
ItemServiceImpl则需要调用ItemMapper的获取item方法,并且要将其结果缓存到redis中
import com.selton.mall.goodsentity.model.po.Item;
import com.selton.mall.goodsinters.service.IItemService;
import com.selton.mall.goodsservice.mapper.ItemMapper;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
@Service
public class ItemServiceImpl1 implements IItemService {
@Resource
private ItemMapper itemMapper;
//注意 这里使用@Resource注解和redisTemplate变量名称 缺一不可
@Resource
private RedisTemplate redisTemplate;
@Override
public Item getTbItem(final Long id) {
//日志 ...
//校验 ...
ValueOperations cacheOps = redisTemplate.opsForValue();
Item item = (Item) cacheOps.get(id);
if (item == null) {
Item itemFromDb = itemMapper.selectById(id);
item = itemFromDb;
}
return item;
}
}
优势
程序清单 1-1 中的 ItemServiceImpl1 用 RedisTemplate 作为缓存存储的代理者。由于RedisTemplate,其通过opsForValue得到的对象也是是线程安全的,因此在调用其单个方法的时候就不需要同步
扩展: RedisTemplate声称, once configed, thread-safe
ItemServiceImpl1有着更好的并发行为:多线程可以并发地使用它。
劣势
但它在使用缓存时仍然存在一些不足——当两个线程同时调用getTbItem时存在一个漏洞,可能会导致计算得到相同的值。
这只会带来低效,因为缓存的作用是避免相同的数据被计算多次
劣势的原因
ItemServiceImpl1的问题在于,如果某个线程启动了一个开销很大的计算,而其他线程并不知道这个计算正在进行,那么很可能会重复这个计算,如图1-1所示。
图1-1 当使用ItemServiceImpl1时,两个线程计算相同的值
最终版
ItemServiceImpl1的实现看似是没有问题的:它表现出了非常好的并发性(基本上是源于 RedisTemplate和Redis本身高效的并发性).但是仍有一个缺陷,即仍然存 在两个线程计算出相同值的漏洞。
这个漏洞的发生概率也会由于具体的逻辑计算过程的时长的增大而变高,这是由于getTbItem方法中的if代码块仍然是非原子(nonatomic)的“先检查再执行”操作
因此两 个线程仍有可能在同一时间内调用itemMapper.selectById来计算相同的值,即二者都没有在缓存中找到期望的值,因此都开始计算。这个错误的执行时序如图1-2所示。
图1-2
ItemServiceImpl1中存在这个问题的原因是,复合操作(“若没有则添加”)
程序清单1-2中的ItemServiceImpl使用了DLC双重校验锁,避免了 ItemServiceImpl1 的漏洞
注意: 现在DCL被喷的一无是处,但是大家不要忘了,双重校验锁从来都不仅仅是用于初始化资源的, 这就是最佳证明 !
import com.selton.mall.goodsentity.model.po.Item;
import com.selton.mall.goodsinters.service.IItemService;
import com.selton.mall.goodsservice.mapper.ItemMapper;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
@Service
public class ItemServiceImpl implements IItemService {
@Resource
private ItemMapper itemMapper;
//注意 这里使用@Resource注解和redisTemplate变量名称 缺一不可
@Resource
private RedisTemplate redisTemplate;
private final Object lock = new Object();
@Override
public Item getTbItem(Long id) {
//日志 ...
//校验 ...
ValueOperations cacheOps = redisTemplate.opsForValue();
Item item = (Item) cacheOps.get(id);
if (item == null) {
synchronized (lock) {
if (item == null) {
Item itemFromDb = itemMapper.selectById(id);
cacheOps.set(id, itemFromDb);
item = itemFromDb;
}
}
}
return item;
}
}