1. 共享锁和排它锁
1.1 排它锁
排它锁又称独占锁,获得了以后既能读又能写,其他没有获得锁的线程不能读也不能写,典型的synchronized
就是排它锁
1.2 共享锁
共享锁又称读锁,获得了共享锁以后可以查看但无法修改和删除数据,其他线程也能获得共享锁,也可以查看但不能修改和删除数据
在没有读写锁之前,我们虽然保证了线程安全,但是也浪费了一定的资源,因为多个读操作同时进行并没有线程安全问题
ReentrantReadWriteLock
中 读锁就是共享锁,写锁是排它锁,在读的地方使用读锁,在写的地方使用写锁,灵活控制,如果不这样,读是无限阻塞的,这样提高了程序的执行效率
1.3 读写锁的规则
- 多个线程只申请读锁,都能申请到
- 如果有一个线程已经占用了读锁,则此时其他线程如果要申请写锁,则申请写锁的线程会一直等待释放该锁
- 如果有一个线程已经占用 写锁,则其他线程申请写锁或读锁都要等待它释放
- 也就是说,要么多读要么一写
下面的示例让两个线程去读,两个线程去写,使用读写锁读的线程是同时进行的,而写的线程等读的线程执行完再依次执行
public class RWLock {
private static ReentrantReadWriteLock readWriteLock = new ReentrantReadWriteLock();
//读锁
private static ReentrantReadWriteLock.ReadLock readLock = readWriteLock.readLock();
//写锁
private static ReentrantReadWriteLock.WriteLock writeLock = readWriteLock.writeLock();
private static void read() {
readLock.lock();
try {
System.out.println(Thread.currentThread().getName()+"得到了读锁,正在读取");
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
System.out.println(Thread.currentThread().getName()+"释放了读锁");
readLock.unlock();
}
}
private static void write() {
writeLock.lock();
try {
System.out.println(Thread.currentThread().getName()+"得到了写锁,正在写入");
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
System.out.println(Thread.currentThread().getName()+"释放了写锁");
writeLock.unlock();
}
}
public static void main(String[] args) {
new Thread(new Runnable() {
@Override
public void run() {
read();
}
},"t1").start();
new Thread(new Runnable() {
@Override
public void run() {
read();
}
},"t2").start();
new Thread(new Runnable() {
@Override
public void run() {
write();
}
},"t3").start();
new Thread(new Runnable() {
@Override
public void run() {
write();
}
},"t4").start();
}
}
/* t1得到了读锁,正在读取
t2得到了读锁,正在读取
t1释放了读锁
t2释放了读锁
t3得到了写锁,正在写入
t3释放了写锁
t4得到了写锁,正在写入
t4释放了写锁*/
1.4 读写锁我们需要思考的几个问题
① 选择规则: 假设我们现在一个线程持有读锁,后面有写锁在等待,这个时候又来了一个读线程,那这个读线程能不能插队,不管前面的排队的写线程和当前的线程一起读?
插队虽然可能提高了整体的效率,但是对写锁来说是不公平的
② 升降级: 能不能让一个持有写锁的线程直接降级获得读锁呢?或者反过来让一个持有读锁的线程直接升级获得写锁呢?
下面我们来看看ReentrantReadWriteLock
是怎么实现的
2. ReentrantReadWriteLock
- 在插队方面不允许读锁插队
- 在升降级的方面,允许降级不允许升级
2.1 插队策略
如果我们设置为公平的,那么自然不会允许插队
private static ReentrantReadWriteLock readWriteLock = new ReentrantReadWriteLock(true);
但是对于默认不公平的呢
假设现在有线程2和线程4同时读取,线程3想要写入,拿不到锁,于是进入等待队列,线程5 不在队列里,现在过来想要读取,这个时候有两种选择
① 让5插队
这样读取插队效率高,但是对于写操作的线程3会造成饥饿
② 不允许插队
这样可以避免饥饿
ReentrantReadWriteLock
ReentrantReadWriteLock
大体选用了策略2
- 写锁可以随时插队
- 读锁仅在等待队列的头节点不是想获取写锁的线程的时候可以插队
可以看一下源码
对于公平锁,看里面有两个方法,writerShouldBlock
和readerShouldBlock
就是用来判断当前进来的读写线程是否需要排队,可以看到他们的实现都是调用hasQueuedPredecessors
方法看看前面有没有人在等待
对于非公平的情况,可以看到writerShouldBlock
方法返回的是false,也就是写者永远不需要排队,而对于读者调用apparentlyFirstQueuedIsExclusive
方法,这个方法看名字就能看出来就是判断排队队列的第一个是不是读锁,如果是,那么返回true,要排队,如果不是,则可以插队
/**
* 描述: 演示非公平和公平的ReentrantReadWriteLock的策略
* 主要难模拟的是读线程的插队情况
*/
public class NonfairBargeDemo {
private static ReentrantReadWriteLock reentrantReadWriteLock = new ReentrantReadWriteLock(
true);
private static ReentrantReadWriteLock.ReadLock readLock = reentrantReadWriteLock.readLock();
private static ReentrantReadWriteLock.WriteLock writeLock = reentrantReadWriteLock.writeLock();
private static void read() {
System.out.println(Thread.currentThread().getName() + "开始尝试获取读锁");
readLock.lock();
try {
System.out.println(Thread.currentThread().getName() + "得到读锁,正在读取");
try {
Thread.sleep(20);
} catch (InterruptedException e) {
e.printStackTrace();
}
} finally {
System.out.println(Thread.currentThread().getName() + "释放读锁");
readLock.unlock();
}
}
private static void write() {
System.out.println(Thread.currentThread().getName() + "开始尝试获取写锁");
writeLock.lock();
try {
System.out.println(Thread.currentThread().getName() + "得到写锁,正在写入");
try {
Thread.sleep(40);
} catch (InterruptedException e) {
e.printStackTrace();
}
} finally {
System.out.println(Thread.currentThread().getName() + "释放写锁");
writeLock.unlock();
}
}
public static void main(String[] args) {
new Thread(()->write(),"Thread1").start();
new Thread(()->read(),"Thread2").start();
new Thread(()->read(),"Thread3").start();
new Thread(()->write(),"Thread4").start();
new Thread(()->read(),"Thread5").start();
new Thread(new Runnable() {
@Override
public void run() {
Thread thread[] = new Thread[1000];
for (int i = 0; i < 1000; i++) {
thread[i] = new Thread(() -> read(), "子线程创建的Thread" + i);
}
for (int i = 0; i < 1000; i++) {
thread[i].start();
}
}
}).start();
}
}
2.2 升降级
假设我现在有一个线程,线程的前半部分是对日志的写操作,而线程的后半部分是对一个资源的读操作,当然我一开始要获取写锁,但是当我写操作执行完了我还持有写锁不就是对资源的一种浪费么?(别的读线程不能读),对于这一点ReentrantReadWriteLock
对写锁可以降级为读锁,但是读锁不能升级为写锁
注意锁降级指的是写锁降级成为读锁,如果当前线程拥有写锁,然后将其释放,最后再获取读锁,这个过程是不能被称为锁降级的。锁降级是指把当前拥有的写锁,再获取到读锁,随后释放写锁的过程
class CachedData {
Object data;
volatile boolean cacheValid;
final ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
void processCachedData() {
// 获取读锁
rwl.readLock().lock();
if (!cacheValid) {
// 在获取写锁之前必须释放读锁,不释放的话下面写锁会获取不成功,造成死锁
rwl.readLock().unlock();
// 获取写锁
rwl.writeLock().lock();
try {
// 重新检查state,因为在获取写锁之前其他线程可能已经获取写锁并且更改了state
if (!cacheValid) {
data = ...
cacheValid = true;
}
// 通过在释放写锁定之前获取读锁定来降级
// 这里再次获取读锁,如果不获取,那么当写锁释放后可能其他写线程再次获得写锁,导致下方`use(data)`时出现不一致的现象
// 这个操作就是降级
rwl.readLock().lock();
} finally {
rwl.writeLock().unlock(); // 释放写锁,由于在释放之前读锁已经被获取,所以现在是读锁获取状态
}
}
try {
// 使用完后释放读锁
use(data);
} finally {
rwl.readLock().unlock(); //释放读锁
}
}
}}
为什么不能升级呢?
升级可能会造成死锁,因为获得写锁的前提是当前既没有读的也没有写的,假设现在有两个读线程A和B,AB都想升级为写锁,那必须要等待对方释放读锁,这个时候就会造成死锁