1 读写锁的饥饿写问题
所谓的饥饿写是指在使用读写锁的时候,读线程的数量远远大于写线程的数量,导致锁长期被读线程霸占,写线程无法获得对数据进行写操作的权限从而进入饥饿的状态(当然可以在构造读写锁时指定其为公平锁,读写线程获得执行权限得到的机会相对公平,但是当读线程大于写线程时,性能效率会比较低下)。因此在使用读写锁进行数据一致性保护时请务必做好线程数量的评估(包括线程操作的任务类型)。
JDK1.8版本引入了StampedLock,该锁由一个long型的数据戳(stamp)和三种模型构成,当获取锁(比如调用readLock(),writeLock())的时候会返回一个long型的数据戳(stamp),该数据戳将被用于进行稍后的锁释放参数。如果返回的数据戳为0(比如调用tryWriteLock()),则表示获取锁失败,同时StampedLock还提供了一种乐观读的操作方式
需要注意的一点是,StampedLock是不可重入的,不像前文中介绍的两种锁类型(ReentrantLock、ReentrantReadWriteLock)都有hold计数器,每一次对StampedLock锁的获取都会生成一个数据戳,即使当前线程在获得了该锁的情况下再次获取也会返回一个全新的数据戳,因此如果使用不当则会出现死锁的问题。
2 StampedLock的使用
StampedLock被JDK1.8版本引入之后,成为了Lock家族的新宠,它几乎具备了ReentrantLock、ReentrantReadWriteLock这两种类型锁的所有功能(性能表现要看不同的使用场景)
2.1 替代ReentrantLock
在ReentrantLock锁中不存在读写分离锁,因此读写方法都是使用lock.writeLock()方法进行锁的获取,该方法会返回一个数据戳,在稍后的锁释放过程中需要用到该数据戳(stamp)
public class StampedLockExample1 {
/**
* 共享数据
*/
private static int shareData = 0;
/**
* 定义StampedLock
*/
private static final StampedLock STAMPED_LOCK = new StampedLock();
private static void inc() {
// 调用writeLock方法返回一个数据stamp
long stamp = STAMPED_LOCK.writeLock();
try {
shareData++;
} finally {
// 释放锁
STAMPED_LOCK.unlockWrite(stamp);
}
}
public static int get() {
// 获取锁并记录数据戳
long stamp = STAMPED_LOCK.writeLock();
try {
// 返回数据
return shareData;
} finally {
// 释放锁
STAMPED_LOCK.unlockWrite(stamp);
}
}
}
2.2 替代ReentrantReadWriteLock
与ReentrantReadWriteLock锁一样,StampedLock也提供了读锁和写锁这两种模式,因此StampedLock天生就支持读写分离锁的使用方式,下面的示例代码只是在Example1的基础上对get()方法稍作修改即可完成读写锁的实现方式。
public static int get() {
// 获取锁并记录数据戳
long stamp = STAMPED_LOCK.readLock();
try {
// 返回数据
return shareData;
} finally {
// 释放锁
STAMPED_LOCK.unlockRead(stamp);
}
}
2.3 乐观读模式
StampedLock还提供了一个模式,即乐观读模式,使用tryOptimisticRead()方法获取一个非排他锁并且不会进入阻塞状态,与此同时该模式依然会返回一个long型的数据戳用于接下来的验证(该验证主要用来判断共享资源是否有写操作发生)
public static int get() {
// 注释①
long stamp = STAMPED_LOCK.tryOptimisticRead();
// 注释②
if (!STAMPED_LOCK.validate(stamp)) {
stamp = STAMPED_LOCK.readLock();
try {
// 注释③
return shareData;
}finally {
STAMPED_LOCK.unlockRead(stamp);
}
}
// 注释④
return shareData;
}
-
首先调用tryOptimisticRead(),该方法为立即返回方法,并不会导致当前线程进入阻塞等待)方法进行乐观读操作,同样该方法也会返回一个long型的数据戳(stamp),如果获取成功,则数据戳为非0,如果失败,则数据戳为0。
如果仅仅进行了一次乐观读锁的获取并且立即返回一个数据戳(stamp),但是仅就这样的操作是不足以立即将共享数据返回 的,这会导致数据出现不一致的情况:- 假设调用乐观读返回的数据戳(stamp)为零,则代表其他线程正在对共享资源进行写操作,也就是说其他线程获取了对该共享资源的写权限。
- 假设调用乐观读返回的数据戳(stamp)为非零,紧接着又有其他线程立即获取了对共享资源的写操作。
-
基于以上两点,我们还需要对数据戳(stamp)进行校验之后才能决定对共享资源进行阻塞式的读还是将其立即返回,使用StampedLock的validate方法可以判断上述两种情况是否发生。
- 如果上述两种情况已经发生(validate返回false),则进行读锁的获取操作,此时若有其他线程对共享线程进行写操作,则当前线程会进入阻塞等待直到获取到读锁。
- 如果在注释①处获取的读锁通过验证,则直接返回共享数据(注释④处),不进行任何同步操作,这样的话就可以对共享数据进行无锁(Lock-Free)读操作了,即提高了共享资源并发读取的能力。
3 总结
StampedLock它更多地是提供了一种乐观读的方式供我们选择,同时又解决了读写锁中“饥饿写”的问题。作为开发人员要能够根据应用程序的特点来判断应该采用怎样的锁进行贡献资源数据的同步,以确保数据的一致性,如果你无法明确地了解读写线程的分布情况,那么请使用ReentrantLock,它的表现始终非常稳定,无论是读线程还是写线程。如果你的应用程序中,读操作远远多于写操作,那么为了提高数据读取的并发量,StampedLock的乐观读将是一个不错的选择,同时它又不会引起饥饿写的问题。