概述
曾经我们介绍AQS时,基本都是排它锁(互斥锁),这些锁在同一时刻只允许一个线程进行访问,而读写锁在同一时刻允许多个线程访问。
当读操作远远高于写操作时,这时候使用读写锁让读-读可以并发,提高性能。写操作肯定要互斥,因为要防止数据的脏读。类似于数据库中的select ... from ... lock in share mode,
读写锁是基于共享锁实现的,因为多个读的线程可以同时的获取到锁,锁的Owner有多个。
ReentrantReadWriteLock
类大致如下。
public class ReentrantReadWriteLock implements ReadWriteLock {
private final ReentrantReadWriteLock.ReadLock readerLock;
private final ReentrantReadWriteLock.WriteLock writerLock;
final Sync sync;
//写锁
public ReentrantReadWriteLock.WriteLock writeLock() { return writerLock; }
//读锁
public ReentrantReadWriteLock.ReadLock readLock() { return readerLock; }
}
首先我们来自己创建一个可以读写的类:
@Slf4j
class DataContainer {
private ReentrantReadWriteLock readWriteLock = new ReentrantReadWriteLock();
//获取写锁对象
private ReentrantReadWriteLock.WriteLock writeLock = readWriteLock.writeLock();
//获取读锁对象
private ReentrantReadWriteLock.ReadLock readLock = readWriteLock.readLock();
private String s = "date";
//读取数据,只要不涉及到写数据只要使用读锁即可
public String getData() throws InterruptedException {
String data = null;
readLock.lock();
try {
data = s;
log.debug("读取数据...");
Thread.sleep(2000);
} finally {
readLock.unlock();
}
return data;
}
public void setData(String data) throws InterruptedException {
writeLock.lock();
try {
s = data;
log.debug("写入数据...");
Thread.sleep(200);
} finally {
writeLock.unlock();
}
}
}
读写用的不是同一个锁。
测试类:
public static void main(String[] args) throws InterruptedException {
DataContainer dc = new DataContainer();
new Thread(() -> {
log.debug("{}", dc.getData());
}, "t1").start();
new Thread(() -> {
log.debug("{}", dc.getData());
}, "t2").start();
//10:35:01.435 [t2] 读取数据...
//10:35:01.435 [t1] 读取数据...
//10:35:03.445 [t2] date
//10:35:03.445 [t1] date
}
可以看到两次重读的读取操作都同时得到锁了。而写读或者是写写操作只能是互斥的得到锁。
特点:
- 支持公平锁和非公平锁
- 支持重入
- 支持锁降级(即获取写锁,获取读锁再释放写锁,当前线程就被降级为了读锁,获取到写锁的时候是可以获取读锁的)
- 不支持锁升级(持有读锁的情况下去获取写锁,会导致获取写锁永久等待)
缓存应用
我们先来做一个单线程下的缓存。入戏,具体的dao层就是普通的查找和修改,直接略过
@Slf4j
//装饰者模式,给dao层加一个缓存功能
class StudentDaoCache extends StudentDao {
private StudentDao dao = new StudentDao();
//缓存容器
private Map<QueryObject, Student> cache = new HashMap<>();
@Override
public Student getStudent(String sno) throws SQLException {
QueryObject queryObject = new QueryObject("getStudent", sno);
//1.先从缓存中找
Student student = cache.get(queryObject);
if (student != null) {
log.debug("从缓存中拿到{}数据", sno);
return student;
}
//2.如果缓存没有,再从数据库找
student = dao.getStudent(sno);
//把查到的数据加入到缓存
cache.put(queryObject, student);
return student;
}
@Override
public boolean updateStudent(Student student) throws SQLException {
//需要先清空缓存
cache.clear();
return dao.updateStudent(student);
}
//把查询的sql语句和参数整体作为key
class QueryObject {
private String String;
private Object args;
public QueryObject(java.lang.String string, Object args) {
String = string;
this.args = args;
}
//重写hashcode和equals
@Override
public boolean equals(Object o) {}
@Override
public int hashCode() {}
}
}
测试类:
public class AQSTest {
public static void main(String[] args) throws InterruptedException, SQLException {
StudentDao dao = new StudentDaoCache();
Student student = dao.getStudent("2019139001");
Student student1 = dao.getStudent("2019139004");
Student student2 = dao.getStudent("2019139001");
Student student3 = dao.getStudent("2019139001");
student.name = "张三";
dao.updateStudent(student);
Student student4 = dao.getStudent("2019139001");
//12:40:04.661 [main] 查找数据库---找到结果Student{sno='2019139001', name='王五', age=20}
//12:40:04.680 [main] 查找数据库---找到结果Student{sno='2019139004', name='欧阳冲', age=19}
//12:40:04.681 [main] 从缓存中拿到2019139001数据
//12:40:04.681 [main] 从缓存中拿到2019139001数据
//12:40:04.967 [main] 更新2019139001的身份信息
//12:40:04.982 [main] 查找数据库---找到结果Student{sno='2019139001', name='张三', age=20}
}
}
可以看到缓存在单线程下是能够正确的工作,但如果有多个用户同时操作数据库,同时维护该缓存,那么会出现如下的问题:
- 在刚开始查询一个相同数据时可能有多个线程同时检测到缓存没有数据,然后这几个线程都跳过缓存去查数据库了,这个问题比较小
- 如果更新数据的线程把缓存清空了,正准备去数据库更新数据,但被一个查询线程抢先一步,查询线程看到缓存中没有值,这时候就去数据库查到值并放进缓存,然后更新才开始,下次所以线程读到的数据都是一个脏数据
- 如果代码中是先更新数据库再清空缓存,修改数据的线程先更新好了数据库,还没清空缓存,又被读线程抢先一步,读到了缓存中的脏数据,虽然才清空缓存,这样也有问题,但是比上一个问题小
我们需要加锁来解决问题:
@Slf4j
class StudentDaoCache extends StudentDao {
private ReentrantReadWriteLock readWriteLock = new ReentrantReadWriteLock();
private ReentrantReadWriteLock.ReadLock readLock = readWriteLock.readLock();
private ReentrantReadWriteLock.WriteLock writeLock = readWriteLock.writeLock();
private StudentDao dao = new StudentDao();
//缓存容器
private Map<QueryObject, Student> cache = new HashMap<>();
@Override
public Student getStudent(String sno) throws SQLException {
QueryObject queryObject = new QueryObject("getStudent", sno);
Student student = null;
//加读锁
readLock.lock();
try {
//1.先从缓存中找
student = cache.get(queryObject);
if (student != null) {
log.debug("从缓存中拿到{}数据", sno);
return student;
}
} finally {
readLock.unlock();//释放读锁,因为不支持锁的升级
}
//加写锁,因为涉及到写操作了
writeLock.lock();
try {
//双重检查,再检查一遍缓存是否已经有了,反之多个读线程
student = cache.get(queryObject);
if (student != null) {
log.debug("从缓存中拿到{}数据", sno);
return student;
}
//再从数据库找
student = dao.getStudent(sno);
//把查到的数据加入到缓存
cache.put(queryObject, student);
return student;
}finally {
writeLock.unlock();
}
}
@Override
public boolean updateStudent(Student student) throws SQLException {
writeLock.lock();
try {
//清空缓存和更新库这两个操作变成整体的了,原子的
cache.clear();
return dao.updateStudent(student);
} finally {
writeLock.unlock();
}
}
}
加了读写锁后那几个问题也就相应的解决了。以上的缓存写的比较low,不要太在意。
StampedLock
JDK8加入,是为了进一步优化读性能,它的特点是在使用读锁,写锁时必须配合【戳】使用。如:
加解读锁:
long stamp= stampedLock.readLock();
stampedLock.unlockRead(stamp);
加解写锁:
long stamp = stampedLock.writeLock();
stampedLock.unlock(stamp);
乐观读,StampedLock支持tryOptimisticRead()方法(乐观读),读取完毕后需要做一次戳校验如果校验通过,表示这期间确实没有写操作,数据可以安全使用,如果校验没通过,需要重新获取读锁,保证数据安全。
public long tryOptimisticRead() {
long s;
return (((s = state) & WBIT) == 0L) ? (s & SBITS) : 0L;
}
//验戳
public boolean validate(long stamp) {
U.loadFence();
return (stamp & SBITS) == (state & SBITS);
}
使用方式:
long stamp = stampedLock.tryOptimisticRead();
//验戳
if (stampedLock.validate(stamp)) {
//锁升级
}
该锁不支持重入