- 相关面试题
- 锁的演变
- ReentrantReadWriteLock
- 锁降级
- 案例演示一
- 案例演示二
- 总结
- 为什么要有锁降级
- 邮戳锁 StampedLock
- 邮戳锁的特点
- 案例演示一
- 案例演示二
- StampedLock 缺点
相关面试题
- 你说你用过读写锁,锁饥饿问题是什么?
- 有没有比读写锁更快的锁?
- StampedLock知道吗?(邮戳锁/票据锁)
- ReentrantReadWriteLock有锁降级机制策略你知道吗?
锁的演变
无锁 ——> 独占锁 ——> 读写锁 ——> 邮戳锁
无锁: 不用多说,存在线程安全问题
独占锁: ==(synchronized,ReentrantLock)==同一时刻只能有一个线程访问,在读多写少的场景下,显然效率并不高。
读写锁:ReentrantReadWriteLock , 适应在多读写少的场景下,读锁可以被线程共享(共享锁),写锁只能有一个线程获取(排它锁)。读的时候不允许写,写的时候不允许读。
读写锁缺点:
- 写锁饥饿问题
- 锁降级问题
邮戳锁:StampedLock 由于读写锁写锁饥饿问题,出现了邮戳锁。下面详细讲解…
ReentrantReadWriteLock
一个资源能够被多个读线程访问,或者被一个写线程访问,但是不能同时存在读写线程。
想象成菜刀,刀刃切菜,刀背拍蒜,但是你不能同时拍蒜又切菜
读写锁特点:
-
读写锁并不是真正意义上的读写分离,它只允许读读共存,而读写和写写依然是互斥的,大多实际场景是“读/读”线程间并不存在互斥关系,只有"读/写"线程或"写/写"线程间的操作需要互斥的。因此引入ReentrantReadWriteLock。
-
一个ReentrantReadWriteLock同时只能存在一个写锁但是可以存在多个读锁,但不能同时存在写锁和读锁(切菜还是拍蒜选一个)。
-
也即一个资源可以被多个读操作访问或一个写操作访问,但两者不能同时进行。
只有在读多写少情境之下,读写锁才具有较高的性能体现。
ReentrantReadWriteLock 架构图
实现了ReadWriteLock 接口,其中俩个方法,readLock获取读锁,writeLock 获取写锁
代码演示
模仿多个线程进行读写的场景。演示独占锁、读写锁的区别
使用 ReentrantLock 独占锁:
public class ReentrantReadWriteLockTest {
public static void main(String[] args) {
MapResource mapResource = new MapResource();
for (int i = 1; i <= 10; i++) {
// lambda 表达式使用变量应为 final 类型
final int finalI = i;
new Thread(() -> {
mapResource.put(finalI,finalI);
},String.valueOf(i)).start();
new Thread(() -> {
mapResource.get(finalI);
},String.valueOf(i)).start();
}
}
}
class MapResource {
private volatile Map<Integer,Integer> map = new HashMap<>();
// 独占锁==synchronized
ReentrantLock lock = new ReentrantLock();
// 读写锁
ReadWriteLock readWriteLock = new ReentrantReadWriteLock();
// 写操作
public void put(Integer key,Integer value) {
lock.lock();
try {
System.out.println(Thread.currentThread().getName() + " 正在进行写操作....");
TimeUnit.MILLISECONDS.sleep(200);
map.put(key,value);
System.out.println(Thread.currentThread().getName() + " 写操作完成....");
} catch (InterruptedException e) {
e.printStackTrace();
}finally {
lock.unlock();
}
}
// 读操作
public Integer get(Integer key) {
lock.lock();
Integer result = null ;
try {
System.out.println(Thread.currentThread().getName() + " 正在进行读操作....");
TimeUnit.MILLISECONDS.sleep(100);
result = map.get(key);
System.out.println(Thread.currentThread().getName() + " 读操作完成, 读取的值为: " + result);
return result ;
} catch (InterruptedException e) {
e.printStackTrace();
return null;
}finally {
lock.unlock();
}
}
}
输出结果:
无论是读还是写,都只允许一个线程进入。都是成双成对的出现。这样不仅效率降低 ,并且读操作没有达到共享
比如:电影院看电影,看电影相当于读操作,我们不能每一个电影只有一个人看,而是多个顾客都能够看,因此也就引入了读写锁
演示读写锁:
public class ReentrantReadWriteLockTest {
public static void main(String[] args) {
MapResource mapResource = new MapResource();
for (int i = 1; i <= 10; i++) {
// lambda 表达式使用变量应为 final 类型
final int finalI = i;
new Thread(() -> {
mapResource.put(finalI,finalI);
},String.valueOf(i)).start();
new Thread(() -> {
mapResource.get(finalI);
},String.valueOf(i)).start();
}
}
}
class MapResource {
private volatile Map<Integer,Integer> map = new HashMap<>();
// 独占锁==synchronized
ReentrantLock lock = new ReentrantLock();
// 读写锁
ReadWriteLock readWriteLock = new ReentrantReadWriteLock();
// 写操作
public void put(Integer key,Integer value) {
readWriteLock.writeLock().lock();
try {
System.out.println(Thread.currentThread().getName() + " 正在进行写操作....");
TimeUnit.MILLISECONDS.sleep(500);
map.put(key,value);
System.out.println(Thread.currentThread().getName() + " 写操作完成....");
} catch (InterruptedException e) {
e.printStackTrace();
}finally {
readWriteLock.writeLock().unlock();
}
}
// 读操作
public Integer get(Integer key) {
readWriteLock.readLock().lock();
try {
Integer result = null ;
System.out.println(Thread.currentThread().getName() + " 正在进行读操作....");
TimeUnit.MILLISECONDS.sleep(100);
result = map.get(key);
System.out.println(Thread.currentThread().getName() + " 读操作完成, 读取的值为: " + result);
return result ;
} catch (InterruptedException e) {
e.printStackTrace();
return null;
}finally {
readWriteLock.readLock().unlock();
}
}
}
输出结果
使用读写锁之后,读操作允许多个线程同时进入。
注意点:在读操作没有完成之前,写锁无法获得。
新增加三个线程执行写操作,并延迟读操作的完成时间.
输出结果:
只有当读操作完成后,写锁才会被获得。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ECzhrdeY-1670509253301)(https://images-1313160403.cos.ap-beijing.myqcloud.com/MarkDown/202212082219373.png)]
锁降级
将写入锁降级为读锁(类似Linux文件读写权限理解,就像写权限要高于读权限一样)
写锁的降级,降级成为了读锁
- 如果同一个线程持有了写锁,在没有释放写锁的情况下,它还可以继续获得读锁。这就是写锁的降级,降级成为了读锁。
- 规则惯例,先获取写锁,然后获取读锁,再释放写锁的次序。
- 如果释放了写锁,那么就完全转换为读锁。
获取写锁 ——> 获取读锁 ——> 释放写锁 ——> 释放读锁
案例演示一
演示锁降级: 写锁——> 读锁
main线程在获取写锁的同时,也能够获取读锁。
public class ReadWriteLockTest2 {
public static void main(String[] args) {
ReentrantReadWriteLock readWriteLock = new ReentrantReadWriteLock();
ReentrantReadWriteLock.ReadLock readLock = readWriteLock.readLock();
ReentrantReadWriteLock.WriteLock writeLock = readWriteLock.writeLock();
writeLock.lock();
System.out.println("写锁...");
readLock.lock();
System.out.println("读锁...");
readLock.unlock();
writeLock.unlock();
}
}
结果:
案例演示二
如果有线程在读,那么写线程是无法获取写锁的,是悲观锁的策略
public class ReadWriteLockTest2 {
public static void main(String[] args) {
ReentrantReadWriteLock readWriteLock = new ReentrantReadWriteLock();
ReentrantReadWriteLock.ReadLock readLock = readWriteLock.readLock();
ReentrantReadWriteLock.WriteLock writeLock = readWriteLock.writeLock();
readLock.lock();
System.out.println("读锁...");
writeLock.lock();
System.out.println("写锁...");
readLock.unlock();
writeLock.unlock();
}
}
结果:
写线程被阻塞
总结
- 线程获取读锁是不能直接升级为写入锁的。不可锁升级
在ReentrantReadWriteLock中,当读锁被使用时,如果有线程尝试获取写锁,该写线程会被阻塞。
所以,需要释放所有读锁,才可获取写锁,
即ReadWriteLock读的过程中不允许写,只有等待线程都释放了读锁,当前线程才能获取写锁,
也就是写入必须等待,这是一种悲观的读锁
为什么要有锁降级
锁降级 下面的示例代码摘自ReentrantWriteReadLock源码中:
ReentrantWriteReadLock支持锁降级,遵循按照获取写锁,获取读锁再释放写锁的次序,写锁能够降级成为读锁,不支持锁升级。
1 代码中声明了一个volatile类型的cacheValid变量,保证其可见性。
2 首先获取读锁,如果cache不可用,则释放读锁,获取写锁,在更改数据之前,再检查一次cacheValid的值,然后修改数据,将cacheValid置为true,然后在释放写锁前获取读锁;此时,cache中数据可用,处理cache中数据,最后释放读锁。这个过程就是一个完整的锁降级的过程,目的是保证数据可见性。
如果违背锁降级的步骤
如果当前的线程C在修改完cache中的数据后,没有获取读锁而是直接释放了写锁,那么假设此时另一个线程D获取了写锁并修改了数据,那么C线程无法感知到数据已被修改,则数据出现错误。
如果遵循锁降级的步骤
线程C在释放写锁之前获取读锁,那么线程D在获取写锁时将被阻塞,直到线程C完成数据处理过程,释放读锁。这样可以保证返回的数据是这次更新的数据,该机制是专门为了缓存设计的。
邮戳锁 StampedLock
StampedLock是JDK1.8中新增的一个读写锁,也是对JDK1.5中的读写锁ReentrantReadWriteLock的优化。邮戳锁也叫票据锁。
邮戳锁解决了ReentrantReadWriteLock 的写锁饥饿问题。什么是写锁饥饿?
ReentrantReadWriteLock实现了读写分离,但是一旦读操作比较多的时候,想要获取写锁就变得比较困难了
假如当前1000个线程,999个读,1个写,有可能999个读取线程长时间抢到了锁,那1个写线程就悲剧了
因为当前有可能会一直存在读锁,而无法获得写锁,根本没机会写.这就是写锁饥饿问题
如何缓解锁饥饿问题?
- 使用公平锁,但是公平锁是以牺牲系统吞吐量为代价的。
- 使用邮戳锁
ReentrantReadWriteLock
允许多个线程同时读,但是只允许一个线程写,在线程获取到写锁的时候,其他写操作和读操作都会处于阻塞状态,读锁和写锁也是互斥的,所以在读的时候是不允许写的,读写锁比传统 的synchronized速度要快很多,原因就是在于ReentrantReadWriteLock支持读并发
StampedLock横空出世
ReentrantReadWriteLock 的读锁被占用的时候,其他线程尝试获取写锁的时候会被阻塞。
但是,StampedLock采取乐观获取锁后,其他线程尝试获取写锁时不会被阻塞,这其实是对读锁的优化,所以,在获取乐观读锁后,还需要对结果进行校验。
邮戳锁的特点
- 所有获取锁的方法,都返回一个邮戳(Stamp),Stamp为零表示获取失败,其余都表示成功;
- 所有释放锁的方法,都需要一个邮戳(Stamp),这个Stamp必须是和成功获取锁时得到的 Stamp一致;
- StampedLock是不可重入的,如果一个线程已经持有了写锁,再去获取写锁的话就会造成死锁
邮戳锁的三种访问方式:
① Reading(读模式):功能和ReentrantReadWriteLock的读锁类似
② Writing(写模式):功能和ReentrantReadWriteLock的写锁类似
③ Optimistic reading(乐观读模式):无锁机制,类似于数据库中的乐观锁,支持读写并发,很乐观认为读取时没人修改,假如被修改再实现升级为悲观读模式
官方描述
案例演示一
使用传统读、写锁,演示: 读锁没有释放时,写锁无法获取
public class StampedLockTest {
public static void main(String[] args) {
MyResource myResource = new MyResource();
new Thread(myResource::read, "readThread").start();
try {Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}
new Thread(myResource::write, "writeThread").start();
}
}
class MyResource {
int num = 30 ;
StampedLock stampedLock = new StampedLock();
public void write() {
long stamp = stampedLock.writeLock();
try {
System.out.println(Thread.currentThread().getName() + " 正在进行写操作....");
num = 30 + 20 ;
System.out.println(Thread.currentThread().getName() + " 写操作完成....");
} finally {
// 通过邮戳释放锁
stampedLock.unlock(stamp);
}
}
public void read() {
long stamp = stampedLock.readLock();
try {
System.out.println(Thread.currentThread().getName() + " 正在进行读操作....");
// 将读操作睡眠4s,为了演示出结果
for (int i = 0; i < 4; i++) {
System.out.println(Thread.currentThread().getName() + " 正在读...");
try {Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}
}
System.out.println(Thread.currentThread().getName() + " 写操作完成....,读取的值: " + num);
} finally {
stampedLock.unlock(stamp);
}
}
}
结果:
只有当读操作释放锁之后,写操作才会获取锁
案例演示二
使用 StampedLock提供的乐观读
tryOptimisticRead
, 此种方式允许在读的过程中获取写锁。一般会通过
validate
方法判断 从获取 stamp 标记以来是否被其他线程获取,如果在读的时候有写操作介入,那么会返回 false,将乐观读 升级为 悲观读。
public class StampedLockTest {
public static void main(String[] args) {
MyResource myResource = new MyResource();
new Thread(myResource::byTryOptimisticRead, "readThread").start();
try {Thread.sleep(2000);} catch (InterruptedException e) {e.printStackTrace();}
new Thread(() -> {
System.out.println("writeThread want to come in ....");
myResource.write();
}, "writeThread").start();
}
}
class MyResource {
int num = 30 ;
StampedLock stampedLock = new StampedLock();
public void write() {
long stamp = stampedLock.writeLock();
try {
System.out.println(Thread.currentThread().getName() + " 正在进行写操作....");
num = 30 + 20 ;
System.out.println(Thread.currentThread().getName() + " 写操作完成....");
} finally {
// 通过邮戳释放锁
stampedLock.unlock(stamp);
}
}
public void read() {
long stamp = stampedLock.readLock();
try {
System.out.println(Thread.currentThread().getName() + " 正在进行读操作....");
// 将读操作睡眠4s,为了演示出结果
for (int i = 0; i < 4; i++) {
System.out.println(Thread.currentThread().getName() + " 正在读...");
try {Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}
}
System.out.println(Thread.currentThread().getName() + " 写操作完成....,读取的值: " + num);
} finally {
stampedLock.unlock(stamp);
}
}
// 乐观读
public void byTryOptimisticRead() {
// 获取乐观读的邮戳
long stamp = stampedLock.tryOptimisticRead();
try {
System.out.println(Thread.currentThread().getName() + " 正在进行读操作....");
for (int i = 0; i < 4; i++) {
try {Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}
System.out.println(Thread.currentThread().getName() + " 正在读..."+ "第 " + i + "秒, " + "stamp的状态: " + stampedLock.validate(stamp));
}
if (!stampedLock.validate(stamp)){
// 返回 false 表示有线程获取锁,乐观锁 --> 悲观锁
stamp = stampedLock.readLock();
try {
System.out.println("乐观读 升级为 悲观读");
System.out.println("升级到悲观读,重新读取到的值: " + num);
} finally {
stampedLock.unlock(stamp);
}
}
} finally {
System.out.println("最终读到的值: " + num);
}
}
}
结果:
首先读操作,沉睡4s,每过1s,都将 stamp 的状态输出。
当写操作沉睡2s中,stamp状态为true,没有线程获取写锁,唤醒后,stamp的状态为 false,表示写操作介入了,因此在 if 语句里 将乐观锁升级为了悲观锁,并重新读取值。
StampedLock 缺点
- StampedLock 不支持重入,没有Re开头
- StampedLock 的悲观读锁和写锁都不支持条件变量(Condition),这个也需要注意。
- 使用 StampedLock一定不要调用中断操作,即不要调用interrupt() 方法