4.1 ReentrantReadWriteLock

多个线程读一份共享变量不涉及线程安全问题,只有读写交替才会可能产生问题。当读操作远远高于写操作时,这时使用 读写锁读-读 可以并发,提高性能。类似数据库中的共享锁,select … from … lock in share mode.

提供一个 数据容器类 内部分别使用读锁保护数据的 read() 方法,写锁保护数据的 write() 方法

  1. import java.util.concurrent.locks.ReentrantReadWriteLock;
  2. public class ReadAndWriteTest {
  3. public static void main(String[] args) {
  4. // 多个线程使用同一个dataContainer对象中的lock锁,这样才会实现互斥阻塞
  5. DataContainer dataContainer = new DataContainer();
  6. new Thread(()->{
  7. dataContainer.read();
  8. },"t1").start();
  9. new Thread(()->{
  10. dataContainer.read();
  11. },"t2").start();
  12. }
  13. }
  14. class DataContainer{
  15. private Object data;
  16. private ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
  17. private ReentrantReadWriteLock.ReadLock r = lock.readLock();
  18. private ReentrantReadWriteLock.WriteLock w = lock.writeLock();
  19. public Object read(){
  20. System.out.println("获取读锁");
  21. r.lock();
  22. try {
  23. System.out.println("读取锁");
  24. Thread.sleep(1000);
  25. return data;
  26. } catch (InterruptedException e) {
  27. e.printStackTrace();
  28. } finally {
  29. System.out.println("释放读锁");
  30. r.unlock();
  31. }
  32. }
  33. public void write(){
  34. System.out.println("获取写锁");
  35. w.lock();
  36. try{
  37. System.out.println("写入");
  38. }
  39. finally {
  40. System.out.println("释放写锁");
  41. w.unlock();
  42. }
  43. }
  44. }

read()方法读取数据,write()方法写入数据,多线程编程思路下,方法可能被多个线程调用,所以write方法加写锁,read方法加读锁。
经测试,加读锁与写锁后,多线程可以读-读 ,但 读-写写-写 均受到限制。

总结:读-读并发可以,读-写、写-写并发不可以

注意事项

  • 读锁不支持条件变量
  • 重入时升级不支持:即对于同一个线程,持有读锁的情况下去再去获取写锁,会导致获取写锁永久等待;升级是指,写锁等级高于读锁
  • 重入时降级支持:即持有写锁的情况下获取读锁是成立的,可理解为,拿到写的权力当然可以行使读的权力;

*读写锁应用之缓存

应用场景:当需要数据时,要从数据库中获取数据。如果每次获取的数据相同,或者相同的sql语句频繁执行,从数据库中取不是一个较好的方式。可以考虑增加缓存,如果缓存中有则从缓存中取即可;如果缓存没有,再从数据库中取出,并存放进缓存;当向数据库中执行插入等操作时,清空缓存,直接向数据库中更新,防止缓存数据与数据库数据不一致。

上述应用场景对应的框架为:Redis,实现代码如下:

  1. //装饰器模式,将待装饰的对象作为属性传入,套一层外壳。类似service、dao
  2. public class GenericDaoCached extends GenericDao{
  3. private GenericDao dao = new GenericDao();
  4. //定义缓存Map
  5. private Map<SqlPair,Object> map = new HashMap<>();
  6. @Override
  7. public <T> T queryOne(Class<T> beanClass,String sql,Object ... args){
  8. // 先从缓存中找,找到直接返回
  9. SqlPair key = new SqlPair(sql,args);
  10. T value = (T)map.get(key);
  11. if(value!=null){
  12. return value;
  13. }
  14. // 缓存中没有,查询数据库
  15. T t = dao.queryOne(beanClass,sql,args);
  16. map.put(key,value);
  17. return value;
  18. }
  19. @Override
  20. public int update(String sql,Object... args){
  21. //清空缓存,再去修改,否则会使得缓存中数据版本与数据库中数据版本不一致
  22. map.clear();
  23. return dao.update(sql,args);
  24. }
  25. //内部类
  26. class SqlPair{
  27. private String sql;
  28. private Object[] args;
  29. public SqlPair(String sql,Object[] args){
  30. this.sql = sql;
  31. this.args = args;
  32. }
  33. // SqlPair对象需要作为Map的key值,故需要重写hashCode、equals方法
  34. @Override
  35. public boolean equals(Object o) {
  36. if (this == o) return true;
  37. if (o == null || getClass() != o.getClass()) return false;
  38. SqlPair sqlPair = (SqlPair) o;
  39. return Objects.equals(sql, sqlPair.sql) && Arrays.equals(args, sqlPair.args);
  40. }
  41. @Override
  42. public int hashCode() {
  43. int result = Objects.hash(sql);
  44. result = 31 * result + Arrays.hashCode(args);
  45. return result;
  46. }
  47. }
  48. }

其中使用内部类SqlPair是常见写法,GenericDaoCached extends GenericDao,继承GenericDao类并对其封装,涉及设计模式中的装饰器模式。定义的Map集合对象用以实现缓存功能。

问题分析:

1、集合对象map是属于线程不安全对象,queryOne与update方法均涉及对map的读写操作,多线程环境下,会产生线程安全问题。
2、queryOne方法中,如果缓存为空(刚启动情形),多线程还是会执行dao.queryOne方法,这一步使得缓存的设置在多线程环境下变得无意义。

  1. @Override
  2. public <T> T queryOne(Class<T> beanClass,String sql,Object ... args){
  3. // 先从缓存中找,找到直接返回
  4. SqlPair key = new SqlPair(sql,args);
  5. T value = (T)map.get(key);
  6. if(value!=null){
  7. return value;
  8. }
  9. // 缓存中没有,查询数据库
  10. T t = dao.queryOne(beanClass,sql,args);
  11. map.put(key,value);
  12. return value;
  13. }

3、缓存更新策略存在问题

  • 先清缓存

    B线程执行update操作,A线程执行queryOne操作,由于无锁保证原子性,所以可能会产生方法间指令交错问题。如下所示,线程B清空缓存,这时发生线程上下文切换,线程A开始查询数据库,由于缓存被清空,线程B只能从数据库中查询旧值,并将查询到的旧值放入缓存中。线程切换回来,线程A将新数据存入数据库中,但由于缓存中存在数据,之后的查询均要从缓存中查询数据,可悲的是缓存中存放的是旧值,出大问题!
    image.png

  • 先更新数据库

    改用先更新数据库再清空缓存,B线程将新数据存入库中后。线程上下文切换,A线程从缓存中查询到旧值,但旧值只存在一段时间,线程再切换,B线程清空缓存,A线程再查询数据则是从数据库中查询新值,因为此时缓存已经清空。此种方法虽然存在错误,但只是瞬间的数据不一致。此种方法比”先清缓存”的更新策略好些。
    image.png