线程的安全问题都是因为共享且可变的对象被多个线程访问引起的,所以我们必须通过加锁来保证数据的一致性。
在实际的开发过程中,大部分的操作都是读操作,只有少部分是写操作,就算是少量的写操作也会带来线程不安全问题。
synchronized和ReentrantLock都是一种标准的互斥锁,每次都只能一个线程持有锁,但是这种加锁方式并不那么友好。
在读明显多于写的场景中放宽加锁的限制,只要保证每个线程都能取到最新的数据,并且在读取数据时不会有其他的线程修改数据,那么就不会发生数据不一致问题。因此伟大的Doug Lea在JDK1.5版本引入了读写锁类。
一个共享资源可以被多个读操作访问或者被一个写操作访问时,但是两者不同时进行此时就可以考虑使用读写锁。
今天勾勾就和大家一起了解下读写锁。
目录
ReadWriteLock读写锁用法
ReadWriteLock接口定义了两个Lock对象,其中一个用于读操作,一个用于写操作。
public interface ReadWriteLock {
Lock readLock();
Lock writeLock();
}
在读写锁的加锁策略中,允许多个读操作同时进行,但每次只允许一个写操作,即读读不冲突,读写、写写冲突。
ReentrantReadWriteLock实现了ReadWriteLock接口,为这两种锁都提供了可重入的加锁语义。与ReentrantLock类似,ReentrantReadWriteLock在构造时也可以选择是一个非公平锁还是一个公平的锁,默认是非公平锁。
ReentrantReadWriteLock的构造函数:
public class ReentrantReadWriteLock
implements ReadWriteLock, java.io.Serializable {
public ReentrantReadWriteLock() {
this(false);
}
public ReentrantReadWriteLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
readerLock = new ReadLock(this);
writerLock = new WriteLock(this);
}
}
ReentrantReadWriteLock类派生出了ReadLock和WriteLock分别表示读锁和写锁。
ReadLock的加锁方法是基于AQS同步器的共享模式。
public void lock() {
sync.acquireShared(1);
}
WriteLock的加锁方法是基于AQS同步器的独占模式。
public void lock() {
sync.acquire(1);
}
我们用读写锁构建线程安全的Map来熟悉它的用法:
//新建ReentrantReadWriteLock对象
private final ReadWriteLock readWriteLock = new ReentrantReadWriteLock();
//派生读锁和写锁
private final Lock readLock = readWriteLock.readLock();
private final Lock writeLock = readWriteLock.writeLock();
//共享资源
private final Map<String, String> map = new HashMap<>();
public void put(String k, String v) {
writeLock.lock();
try {
map.put(k, v);
}finally {
writeLock.unlock();
}
}
public String get(String k) {
readLock.lock();
try {
return map.get(k);
}finally {
readLock.unlock();
}
}
从上面代码我们可能觉着读写控制分开了,提高了读的共享控制是不是读写锁的性能要比独占锁要高呢?
但是从一些性能测试上来说,读写锁的性能并不好,而且使用不当还会引起饥饿写的问题。
饥饿写即在使用读写锁的时候,读线程的数量要远远大于写线程的数量,导致锁长期被读线程持有,写线程无法获取写操作权限而进入饥饿状态。
因此JDK1.8引入了StampedLock。
StampedLock读写锁用法
StampedLock在获取锁的时候会返回一个long型的数据戳,该数据戳用于稍后的锁释放参数,如果返回的数据戳为0则表示锁获取失败。
StampedLock是不可重入的,即使当前线程持有了锁再次获取锁还是会返回一个新的数据戳,所以要注意锁的释放参数,使用不小心可能会导致死锁。
StampedLock几乎具备了ReentrantReadWriteLock和ReentrantLock的功能,我们将上面的测试代码替换为StampedLock来熟悉下它的用法。
//共享资源
private final Map<String, String> map = new HashMap<>();
//构建StampedLock锁
private final StampedLock lock = new StampedLock();
public void put(String k, String v) {
long stamp = lock.writeLock();
try {
map.put(k, v);
}finally {
lock.unlockWrite(stamp);
}
}
public String get(String k) {
long stamp = lock.readLock();
try {
return map.get(k);
}finally {
lock.unlockRead(stamp);
}
}
StampedLock还提供了乐观读模式,使用tryOptimisticRead()方法获取一个非排他锁并且不会进入阻塞状态。
该方法会返回long型的数据戳,数据戳非0表示获取锁成功,如果为0表示获取锁失败。
我们把上面测试用例的读操作换成乐观读模式。
public String optimisticGet(String k) {
//尝试获取读锁,返回获取的结果,线程不会进入阻塞
long stamp = lock.tryOptimisticRead();
//对上一步获取的结果进行验证
//如果验证失败,则此时可能其他线程加了写锁,那么此时线程通过加读锁进入阻塞状态直到获取到读锁
//如果验证成功,不进行任何加锁操作直接返回共享数据,这样的话就实现了无锁读的操作,提高了读访问性能。
if (!lock.validate(stamp)) {
stamp = lock.readLock();
try {
return map.get(k);
}finally {
lock.unlockRead(stamp);
}
}
return map.get(k);
}
总结
学到现在,勾勾已经掌握了好几种锁,之前就会一种的时候还好,用它就行,学的多了就不知道怎么选择了,结合知识的掌握和平时的开发实战,勾勾简单说下自己的理解。
synchronized和ReentrantLock是互斥锁只允许一个线程获取锁,如果是读写无法隔离的业务场景那就只能选择互斥锁了。如果读写能隔离但是读和写的线程数量不能明确,那就互斥锁吧。
勾勾在开发中用的最多的是互斥锁,读写锁至今还没有场景使用,但是多掌握一个知识准没错。万一以后的哪一天遇到了读写锁的场景我们还能找到一条好走的路。
那么什么时候选择synchronized,什么时候选择ReentrantLock呢?
如果你用到了ReentrantLock的高级性能,那么没办法只能ReentrantLock了。
如果不是高级性能可以优先选择synchronized,毕竟不用显示的加锁和解锁。但是我们需要考虑synchronized锁的升级不可逆的特点,如果线程并发存在比较明显的峰谷,则可以考虑选用ReentrantLock,毕竟重量级锁的性能确实不怎么好。
StampedLock的性能明显优于ReentrantReadWriteLock,且它还提供了乐观读的模式,优化了读操作的性能。StampedLock还提供了ReentrantLock的加锁解锁方式,因此如果遇到读写锁的场景那么可以考虑优先选择StampedLock。
2020年学习了很多知识但是发现自己懂的更少了,Java是一门神奇的语言,我用了这么多年却还是只知皮毛,前路漫漫,少年还需努力!!
我是勾勾,一直在努力的程序媛!感谢您的点赞、转发和关注!
参考资料:
《Java高并发编程详解》