Java并发之搞懂读写锁
reentrantreadwritelock
我们来探讨一下java.concurrent.util包下的另一个锁,叫做reentrantreadwritelock,也叫读写锁。
实际项目中常常有这样一种场景:
比如有一个共享资源叫做some data,多个线程去操作some data,这个操作有读操作也有写操作,并且是读多写少的,那么在没有写操作的时候,多个线程去读some data是不会有线程安全问题的,因为线程只是访问,并没有修改,不存在竞争,所以这种情况应该允许多个线程同时读取some data。
但是若某个瞬间,线程x正在修改some data的时候,那么就不允许其他线程对some data做任何操作,否则就会有线程安全问题。
那么针对这种读多写少的场景,j.u.c包提供了reentrantreadwritelock,它包含了两个锁:
- readlock:读锁,也被称为共享锁
- writelock:写锁,也被称为排它锁
下面我们看看,线程如果想获取读锁,需要具备哪些条件:
- 不能有其他线程的写锁没有写请求;
- 或者有写请求,但调用线程和持有锁的线程是同一个
再来看一下线程获取写锁的条件:
- 必须没有其他线程的读锁
- 必须没有其他线程的写锁
这个比较容易理解,因为写锁是排他的。
来看下面一段代码:
public class reentrantreadwritelocktest { private object data; //缓存是否有效 private volatile boolean cachevalid; private reentrantreadwritelock rwl = new reentrantreadwritelock(); public void processcacheddata() { rwl.readlock().lock(); //如果缓存无效,更新cache;否则直接使用data if (!cachevalid) { //获取写锁前必须释放读锁 rwl.readlock().unlock(); rwl.writelock().lock(); if (!cachevalid) { //更新数据 data = new object(); cachevalid = true; } //锁降级,在释放写锁前获取读锁 rwl.readlock().lock(); //释放写锁,依然持有读锁 rwl.writelock().unlock(); } // 使用缓存 // ... // 释放读锁 rwl.readlock().unlock(); } }
这段代码演示的是获取缓存的时候,判断缓存是否过期,如果已经过期就更新缓存,如果没有过期就使用缓存。
可以看到我们先创建了一个读锁,判断如果缓存有效,就可以使用缓存,使用完之后再把读锁释放。如果缓存无效,就更新缓存执行写操作,所以先把读锁给释放掉,然后创建一个写锁,最后更新缓存,更新完缓存后又重新获取了一个读锁并释放掉写锁。
从这段代码里可以看出来,一个线程在拿到写锁之后它还可以继续获得一个读锁。
小结
我们来总结一下reentrantreadwritelock的三个特性:
- 公平性
reentrantreadwritelock也可以在初始化时设置是否公平。
- 可重入性
读锁以及写锁也是支持重入的,比如一个线程拿到写锁后,他依然可以继续拿写锁,同理读锁也可以。
- 锁降级
要想实现锁降级,只需要先获得写锁,再获得读锁,最后释放写锁,就可以把一个写锁降级为读锁了。但是一个读锁是没有办法升级为写锁的。
最后我们来对比一下reentrantlock与reentrantreadwritelock
-
reentrantlock
:完全互斥 -
reentrantreadwritelock
:读锁共享,写锁互斥
因此在读多写少的场景下,reentrantreadwritelock的性能、吞吐量各方面都会比reentrantlock要好很多。但是对于写多的场景reentrantreadwritelock就不那么明显了。
stampedlock
上面我们已经探讨了reentrantreadwritelock能够大幅度提升读多写少场景下的性能,stampedlock是在jdk8引入的,可以认为这是一个reentrantreadwritelock的增强版。
那么大家想,既然有了reentrantreadwritelock,为什么还要搞一个stampedlock呢?
这是因为reentrantreadwritelock在一些特定的场景下存在问题。
比如写线程的“饥饿”问题。
举个例子:假设现在有超级多的线程在操作reentrantreadwritelock,执行读操作的线程超级多,而执行写操作的线程很少,而如果这个执行写操作的线程想要拿到写锁,而reentrantreadwritelock的写锁是排他的,要想拿到写锁就意味着其他线程不能有读锁也不能有写锁,所以在读线程超级多,写线程超级少的情况下就容易造成写线程饥饿问题,也就是说,执行写操作的线程可能一直抢不到锁,即使可以把公平性设置为true,但是这样又会导致性能的下降。
那么我们看看stampedlock怎么玩:
首先,所有获取锁的方法都会返回stamp,它是一个数字,如果stamp=0说明操作失败了,其他的值表示操作成功。
其次就是所有获取锁的方法,需要用stamp作为参数,参数的值必须和获得锁时返回的stamp一致。
其中stampedlock提供了三种访问模式:
-
writing模式
:类似于reentrantreadwritelock的写锁r -
eding(
悲观读模式):类似于reentrantreadwritelock的读锁。 -
optimistic reading
:乐观读模式
悲观读模式:在执行悲观读的过程中,不允许有写操作
乐观读模式:在执行乐观读的过程中,允许有写操作
通过介绍我们可以发现,stampedlock中的悲观读与乐观读和我们操作数据库中的悲观锁、乐观锁有一定的相似之处。
此外stampedlock还提供了读锁和写锁相互转换的功能:
我们知道reentrantreadwritelock的写锁是可以降级为读锁的,但是读锁没办法升级为写锁,而stampedlock它提供了读锁和写锁之间互相转换的功能。
最后,stampedlock是不可重入的,这也是和reentrantreadwritelock的一个区别。
读过源码的同学可能知道,在stampedlock源码里有一段注释:
我们来看一下这段注释,他写的非常经典,演示了stampedlock api如何使用。
class point { private double x, y; private final stampedlock sl = new stampedlock(); void move(double deltax, double deltay) { // an exclusively locked method //添加写锁 long stamp = sl.writelock(); try { x += deltax; y += deltay; } finally { //释放写锁 sl.unlockwrite(stamp); } } double distancefromorigin() { // a read-only method //获得一个乐观锁 long stamp = sl.tryoptimisticread(); // 假设(x,y)=(10,10) // 但是这是一个乐观读锁,(x,y)可能被其他线程修改为(20,20) double currentx = x, currenty = y; //因此这里要验证获得乐观锁后,有没有发生写操作 if (!sl.validate(stamp)) { stamp = sl.readlock(); try { currentx = x; currenty = y; } finally { sl.unlockread(stamp); } } return math.sqrt(currentx currentx + currenty currenty); } void moveifatorigin(double newx, double newy) { // upgrade // could instead start with optimistic, not read mode long stamp = sl.readlock(); try { while (x == 0.0 && y == 0.0) { long ws = sl.tryconverttowritelock(stamp); if (ws != 0l) { stamp = ws; x = newx; y = newy; break; } else { sl.unlockread(stamp); stamp = sl.writelock(); } } } finally { sl.unlock(stamp); } } }
这个类有三个方法,move方法用来移动一个点的坐标,instancefromorigin用来计算这个点到原点的距离,moveifatorigin表示当这个点位于原点的时候用来移动这个点的坐标。
我们来分析一下源码:
move方法是一个纯粹的写操作,在操作之前添加写锁,操作结束释放写锁;
instanceorigin首先获得一个乐观锁,然后开始读数据,我们假设(x,y)=(10,10),但是这是一个乐观读锁,(x,y)可能被其他线程修改为(20,20),所以他会验证获得乐观锁后,有没有发生写操作,如果validate结果为true的话,表示没有发生过写操作,如果发生过写操作,那么就会改用悲观读锁重读数据,然后计算结果,当然最后要把锁释放掉。
最后moveifatorigin方法也比较简单,主要演示了怎么从悲观读锁转换成写锁。
小结
stampedlock主要通过乐观读的方式提升性能,同时也解决了写线程的饥饿问题,但是有得必有失,我们从示例代码中不难看出,stampedlock使用起来要比reentrantreadwritelock复杂很多,所以使用者要在性能和复杂度之间做一个取舍。
总结
本篇文章就到这里了,希望能够给你带来帮助,也希望您能够多多关注的更多内容!