####ReetrantWriteReadLock读写锁
Java的同步器,都基于AQS自定义同步器,那么如果设计一个自定义同步器。需要去适应不同的场景,例如我们在讲到ReetrantLock的实现时,它是基于AQS实现的一个可重入,可中断的锁,获取到锁的线程可以在同步块进行竞争对象的读写操作。但是我们可以设想,如果当前请求的线程多为读操作,那么加锁,释放锁的操作就会非常频繁,A线程在读的时候,B线程只能循环等待A线程释放锁(实际上此时此刻并没有线程进行写操作,并不会造城脏读),这样的话实际上是极大的牺牲了程序响应读的性能。
等待/通知机制
在介绍读写锁的实现之前,介绍下java的线程通信机制wait/notify机制。之前有一篇博客,对比了CountDownLatch和CyclicBarrier,其中一个区别便是CountDownLatch 的主线程需要所有等待子线程完成。而CyclicBarrier建立在等待/通知机制上,实现了线程在等待之后重新被唤醒。
这里我们主要介绍synchronized+object的等待通知机制,建立在两个线程(等待线程和通知线程),可以类比为消费者线程和生产者线程。等待线程(消费者)在生产者完成生产操作之后从wait处继续执行。这里提供一种实现。定义一个消费者线程:
1 | package NotifyWait; |
定义一个生产者线程:
1 | package NotifyWait; |
开启主线程启动:
1 | package NotifyWait; |
主线程启动之后看下执行的结果,消费线程休眠10s,生产者线程先获得锁:
1 | NotifyThread开始生产数据 |
消费线程先获得锁:
1 | 消费者线程进入同步块,flag:false |
这里需要注意的一点是,在java的等待通知机制中是必须结合synchronized关键字的(锁的获取和释放)。调用object.wait(),object.notify(),object.notifyAll(),这些方法需要先对object对象进行加锁。下面总结一个经典的等待通知机制范式,等待方(消费者)需要遵循如下原则:
1.获取对象的锁。
2.如果条件不满足,那么调用对象的wait方法,被通知到再次进行条件检查。
3.条件满足之后执行相应的逻辑。
1 | synchronized(对象){ |
通知方需要遵循如下原则:
1.获得对象的锁。
2.改变判断条件。
3.通知所有等待在对象上的线程重新开始执行
1 | sychronized(对象){ |
我们可以看到java的等待通知机制完成了单个线程与单个线程的通信,控制代码的执行顺序。这在我们设计一个读写锁的时候,写的时候不能读,读的时候不能写,在写操作完成之后是可以通过这种方式通知读线程可以继续读的。但是基于这种范式的局限性,实际上通过sychronized关键字去修饰的代码块已经是串行执行。而我们的读写锁最终需要保证的是读锁(共享锁)多个线程可重入,而写锁(独占锁)不可重入。获取读锁需要判断当前没有写线程,获取写锁需要保证当前无读线程。
ReetrantWriteReadLock的应用
根据ReetrantWriteReadLock的特性,我们可以封装一个线程不安全的hashMap。
1 | package ReetrantWriteAndReadLock; |
读写锁的设计
读写锁需要在一个int类型的变量(AQS中的state变量)上维护读锁的状态和写锁的状态。按位分割,高十六位表示读锁的状态,低十六位表示写锁的状态,这两部分在判断读锁和写锁状态的时候需要按位去取。这样只需要保证整个int变量的线程可见性即可。可以看下jdk源码是如何去获取读锁和写锁的,获取读锁:
1 | protected final int tryAcquireShared(int unused) { |
获取写锁:
1 | protected final boolean tryAcquire(int acquires) { |
总结
读写锁通过将读写分离,读锁作为一个共享锁,写锁作为一个独占锁,两个锁同时由一个volatile的int state维护,高位维护读锁,低位维护写锁。同时读锁和写锁都可以设置为公平和非公平。通过运用读写锁,可以在并发过程中保证读的正确性以及响应速度。读写锁非常适用于读的频率很高,但是写的频率很小的场景。