这篇看一下JUC包提供的读写锁(共享锁/独占锁)。
之前我们都知道在一个变量被读或者写数据的时候每次只有一个线程可以执行,那么今天我们来看一下读写锁,读写两不误ReadWriteLock。
这里有两个概念:
独占锁:
指该锁一次只能被一个线程所持有。(ReentrantLock和Synchronized都属于独占锁)。
共享锁:
指该锁可被多个线程所持有。
ReentrantReadWriteLock其读锁是共享锁,共写锁是独占锁。
读锁的共享锁可以保证并发读是非常高效的,读写,写读,写写的过程是互斥的。
直接使用ReentrantReadWriteLock写段代码看一下:
class CacheList{
private volatile ArrayList<Long> list = new ArrayList<>();
private ReadWriteLock lock = new ReentrantReadWriteLock();
public void put(Long value) {
try {
lock.writeLock().lock(); // 获取写锁
System.out.println(Thread.currentThread().getName() + " \t 开始写入数据: \t" + value);
TimeUnit.SECONDS.sleep(2); // 阻塞两秒
this.list.add(value);
System.out.println(Thread.currentThread().getName() + " \t 写入数据完成");
lock.writeLock().unlock(); // 释放写锁
}catch (Exception ex) {
ex.printStackTrace();
}
}
public void get() {
try {
lock.readLock().lock(); // 获取读锁
System.out.println(Thread.currentThread().getName() + " \t 开始读取数据");
TimeUnit.SECONDS.sleep(2); // 阻塞两秒
String collect = this.list.stream().map(String::valueOf).collect(Collectors.joining(","));
System.out.println(Thread.currentThread().getName() + " \t 读取数据完成: " + collect);
lock.readLock().unlock(); // 释放读锁
} catch (Exception ex) {
ex.printStackTrace();
}
}
}
public class ReadWriteLockDemo {
public static void main(String[] args) {
CacheList cacheMap = new CacheList();
IntStream.range(0, 5)
.forEach(i -> new Thread(() -> cacheMap.put(System.currentTimeMillis()),
"写线程:" + i).start());
IntStream.range(0, 5)
.forEach(i -> new Thread(cacheMap::get,
"读线程:" + i).start());
}
}
上方代码运行效果如下:
可以看到运行结果,红色圈住的地方我们可以看到当使用写锁的时候不管是哪个线程进来都会使其他线程在外等待,直到锁被释放才能拥有获取权限。而蓝色部分是使用了读锁,所有线程可以同时获取允许多个线程同时拥有锁。
注:
但是会出现写一个问题,就是写饥饿现象,上方我们是先运行了所有的写线程,读线程是在写线程后执行的,假如读线程的数量大于写线程数量的话,因锁的大概率都被读线程执行了,就会造成一种写饥饿现象,写线程无法满足大量读线程的读操作,因为写线程少的时候会抢不到锁。
然而在JDK1.8新增了一个锁叫做StampedLock锁,他是对ReadWriteLock的改进。
上边也说了ReadWrite锁可能会出现写饥饿,而StampedLock就是为了解决这个问题锁设计的,StampedLock可以选择使用乐观锁或悲观锁。
乐观锁:每次去拿数据的时候,并不是获取锁对象,而是为了判断标记为(stamp)是否又被修改,如果有修改就再去获取读一次。
悲观锁:每次拿数据的时候都去获取锁。
通过乐观锁,当写线程没有写数据的时候,标志位stamp并没有改变,所以即使有再多的读线程读数据,他都可以读取,而无需获取锁,这就不会使得写线程抢不到锁了。
stamp类似一个时间戳的作用,每次写的时候对其+1来改变被操作对象的stamp值。
通过代码来操作下看一看,先写一个出现写饥饿的情况,模拟19个读线程读取数据,1个写线程写数据。
class CacheList{
private volatile ArrayList<Long> list = new ArrayList<>();
private StampedLock lock = new StampedLock();
public void put(Long value) {
long stamped = -1; // 设置标记位
try {
stamped = lock.writeLock(); // 获取写锁
System.out.println(Thread.currentThread().getName() + " \t 开始写入数据: \t" + value);
TimeUnit.SECONDS.sleep(2); // 阻塞两秒
this.list.add(value);
System.out.println(Thread.currentThread().getName() + " \t 写入数据完成");
}catch (Exception ex) {
ex.printStackTrace();
}finally {
lock.unlockWrite(stamped); // 释放写锁
}
}
public void get() {
long stamped = -1; // 设置标记位
try {
stamped = lock.readLock(); // 获取读锁 -->这里是悲观锁实现 --> stamped重新赋值标记位
System.out.println(Thread.currentThread().getName() + " \t 开始读取数据");
TimeUnit.SECONDS.sleep(2); // 阻塞两秒
String collect = this.list.stream().map(String::valueOf).collect(Collectors.joining(","));
System.out.println(Thread.currentThread().getName() + " \t 读取数据完成: " + collect);
} catch (Exception ex) {
ex.printStackTrace();
}finally {
lock.unlockRead(stamped); // 释放读锁 --> 这里我们放入一个标记位
}
}
}
public class ReadWriteLockDemo2 {
public static void main(String[] args) {
CacheList cacheMap = new CacheList();
IntStream.range(0, 19)
.forEach(i -> new Thread(cacheMap::get,
"读线程:" + i).start());
IntStream.range(0, 1)
.forEach(i -> new Thread(() -> cacheMap.put(System.currentTimeMillis()),
"写线程:" + i).start());
}
}
上边使用了StampedLock做了一个读锁悲观锁的实现,模拟了20个线程,假设了写线程因不能及时写入数据造成写饥饿现象。我们看一下运行结果。
可以看到结果,读锁都可以同时获取锁,就算写线程没有写入数据所有读线程还是在抢占锁,使用ReadWriteLock也是会出现同样的现象,写饥饿。
下面我们使用 乐观锁,每次判断标记位是否被修改,如果有被修改就再进行上锁然后重新读取。
class CacheList{
private volatile ArrayList<Long> list = new ArrayList<>();
private StampedLock lock = new StampedLock();
public void put(Long value) {
long stamped = -1; // 设置标记位
try {
stamped = lock.writeLock(); // 获取写锁
System.out.println(Thread.currentThread().getName() + " \t 开始写入数据: \t" + value);
TimeUnit.SECONDS.sleep(2); // 阻塞两秒
this.list.add(value);
System.out.println(Thread.currentThread().getName() + " \t 写入数据完成");
}catch (Exception ex) {
ex.printStackTrace();
}finally {
lock.unlockWrite(stamped); // 释放写锁
}
}
public void get() {
// 这里使用了乐观锁,每次去判断标记位是否被改变,如果写线程有修改此值会被修改
long stamped = lock.tryOptimisticRead();
try {
System.out.println(Thread.currentThread().getName() + " \t 开始读取数据");
TimeUnit.SECONDS.sleep(2); // 阻塞两秒
} catch (Exception ex) {
ex.printStackTrace();
}
// 读取值
String collect = this.list.stream().map(String::valueOf).collect(Collectors.joining(","));
// 判断以下标记位是否被修改,被修改就会返回false,说明有写线程写入了新数据
// 那么重新获取锁并去读取值,否则直接使用上面读取的值
if (!lock.validate(stamped)){
try {
stamped = lock.readLock();
collect = this.list.stream().map(String::valueOf).collect(Collectors.joining(","));
}catch (Exception ex) {
ex.printStackTrace();
}finally {
lock.unlockRead(stamped);
}
}
System.out.println(Thread.currentThread().getName() + " \t 读取数据完成: " + collect);
}
}
public class ReadWriteLockDemo2 {
public static void main(String[] args) {
CacheList cacheMap = new CacheList();
IntStream.range(0, 19)
.forEach(i -> new Thread(cacheMap::get,
"读线程:" + i).start());
IntStream.range(0, 1)
.forEach(i -> new Thread(() -> cacheMap.put(System.currentTimeMillis()),
"写线程:" + i).start());
}
}
直接看运行结果:
主要看get方法,get方法开始调用StampedLock的tryOptimisticRead方法来获取标志位stamp,获取乐观锁那块并不是真的去上锁(所以不会阻塞写操作),然后直接去读数据。接着通过validate方法来判断标志位是否被修改了,修改了就在进行获取锁进行读取,没被修改则会返回true直接使用上边获取到的值。
StampedLock解决了在没有新数据写入时,由于过多读操作抢夺锁而使得写操作一直获取不到锁无法写入新数据的问题。