Lock是java.util.concurrent(java并发包)中的接口,用于解决线程安全问题。
既然synchronized可以解决线程同步问题为什么还会有lock?
这是因为使用synchronized申请资源的时候,如果资源被占有,那么线程就进入阻塞状态,而且无法主动释放资源。
而Lock可以
- 带超时的尝试获取锁
- 非阻塞的获取锁,如果获取不到可以释放锁而不是阻塞
- 响应中断请求
这三种方案可以弥补 synchronized 的问题,体现在 API 上,就是 Lock 接口的三个方法。详情如下:
// 支持中断的 API
void lockInterruptibly() throws InterruptedException;
// 支持超时的 API
boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
// 支持非阻塞获取锁的 API
boolean tryLock();
下面是锁的类图:
可以看到并发包中有可重入锁和读写锁实现了lock接口。
先介绍一下ReentrantLock(可重入锁),指线程可以重复获取同一把锁,在使用 ReentrantLock 的时候,你会发现 ReentrantLock 这个类有两个构造函数,一个是无参构造函数,一个是传入 fair 参数的构造函数。fair 参数代表的是锁的公平策略,如果传入 true 就表示需要构造一个公平锁,反之则表示要构造一个非公平锁。
ReentrantLock fairLock = new ReentrantLock(true);
这里所谓的公平性是指在竞争场景中,当公平性为真时,会倾向于将锁赋予等待时间最久的线程。公平性是减少线程“饥饿”(个别线程长时间等待锁,但始终无法获取)情况发生的一个办法, 当然sychronized无法保证公平性。
简单介绍下使用方法:
ReentrantLock lock = new ReentrantLock();
lock.lock();
try {
// Todo
} finally {
lock.unlock();
}
这样做是为了保证锁的释放,每一个lock()动作,建议都立即对应一个try-catch-fnally。
Condition
条件变量(java.util.concurrent.Condition),如果说ReentrantLock是synchronized的替代选择,Condition则是将wait、notify、notifyAll等操作转化为相应的对象,将复杂而晦涩的同步操作转变为直观可控的对象行为。
先看下阻塞队列的源码理解一下condition的用法:
final ReentrantLock lock;
private final Condition notEmpty;
private final Condition notFull;
public ArrayBlockingQueue(int var1, boolean var2) {
this.itrs = null;
if (var1 <= 0) {
throw new IllegalArgumentException();
} else {
this.items = new Object[var1];
this.lock = new ReentrantLock(var2);
//通过锁获取条件变量
this.notEmpty = this.lock.newCondition();
this.notFull = this.lock.newCondition();
}
}
public E take() throws InterruptedException {
ReentrantLock var1 = this.lock;
var1.lockInterruptibly();
Object var2;
try {
//如果队列为空则等待
while(this.count == 0) {
this.notEmpty.await();
}
var2 = this.dequeue();
} finally {
var1.unlock();
}
return var2;
}
private void enqueue(E var1) {
Object[] var2 = this.items;
var2[this.putIndex] = var1;
if (++this.putIndex == var2.length) {
this.putIndex = 0;
}
++this.count;
//元素入队时唤醒阻塞在notEmpty条件变量上的线程
this.notEmpty.signal();
}
通过signal/await的组合,完成了条件判断和通知等待线程,这和 wait()、notify()、notifyAll() 是相同的,但是不一样的是后者只有在 synchronized 实现的管程里才能使用。
ReentrantReadWriteLock
可重入读写锁,lock的另外一种实现方式,同样支持公平与非公平,与ReentrantLock这种互斥类型的锁不同的是,读写锁允许多个线程同时读共享变量但是写操作互斥。应用场景就是适合读多写少,比如缓存。这样根据不同场景使用不同的锁,可以提升性能。
缓存代码示例如下:
public class CacheDemo<K, V> {
final Map<K, V> m = new HashMap<>();
final ReadWriteLock rwl = new ReentrantReadWriteLock();
// 读锁
final Lock r = rwl.readLock();
// 写锁
final Lock w = rwl.writeLock();
// 读缓存
V get(K key) {
r.lock();
try { return m.get(key); }
finally { r.unlock(); }
}
// 写缓存
V put(K key, V v) {
w.lock();
try {
return m.put(key, v);
} finally {
w.unlock();
}
}
}
这里读缓存存在一个问题,有可能缓存不存在那么需要从数据库重新读取,所以修改读缓存如下:
V get(K key){
// 读缓存
r.lock();
try {
V v = m.get(key);
//如果缓存不存在需要从数据库读取
if (v == null) {
//此时需要写锁,因为有更新缓存的操作
w.lock();
try {
//查询数据库
//更新并返回缓存
} finally{
w.unlock();
}
}
} finally{
r.unlock();
}
}
这样看上去好像是没有问题的,先是获取读锁,然后再升级为写锁,对此还有个专业的名字,叫 “锁的升级”。然而读写锁并不支持这种升级操作,如果这里读锁没释放就获取写锁,会导致写锁一直等待下去,造成线程阻塞。
不过,虽然锁的升级是不允许的,但是锁的降级却是允许的,既支持写锁降级为读锁。
StampedLock
读写锁虽然比ReentrantLock的粒度似乎细一些,但由于较大的开销性能仍然不高。所以,JDK在后期引入了StampedLock,它的性能更优,支持三种模式:写锁、悲观读锁和乐观锁。其语义和 读写锁的语义非常类似,允许多个线程同时获取悲观读锁,但是只允许一个线程获取写锁,写锁和悲观读锁是互斥的。
StampedLock不支持重入,也不支持中断,如果需要支持中断功能,一定使用可中断的悲观读锁 readLockInterruptibly() 和写锁 writeLockInterruptibly()。
StampedLock 的性能之所以比 ReadWriteLock 还要好,其关键是 StampedLock 支持乐观读的方式,而乐观读是无锁的。乐观读的实现原理:假设大多数情况下读操作并不会和写操作冲突,其逻辑是先试着修改,然后通过validate方法确认是否进入了写模式,如果没有进入,就成功避免了开销;如果进入,则尝试获取读锁。 它的写锁和悲观读锁加锁成功之后,都会返回一个 stamp,然后解锁的时候,需要传入这个 stamp。
关于乐观读的伪代码如下列代码所示:
private final StampedLock sl = new StampedLock();
void mutate() {
long samp = sl.writeLock();
try {
//写数据
write();
} finally {
sl.unlockWrite(samp);
}
}
Object access() {
long samp = sl.tryOptimisticRead();
//读数据
Data data = read();
//校验samp,检查是否持有写锁
if (!sl.validate(samp)) {
//如果持有写锁就升级为悲观读锁
samp = sl.readLock();
try {
//重新读数据
data = read();
} finally {
sl.unlockRead(samp);
}
}
//如果没有持有写锁就直接返回
return data;
}