上一次我们介绍了Synchronized的优化,除此之外在JDK1.5之后,也提供了另外一种锁Lock,今天我们就看看这个有什么优势
相比Synchronized,lock更加灵活,他的基本操作是通过乐观锁来实现的,但由于Lock锁也会在阻塞时候挂起,因此他依然属于悲观锁,他们之间的比较如下图

Lock实现原理
Lock锁是基于java实现的锁,Lock是一个接口,常用的类有ReentrantLock,ReentrantReadWriteLock(RRW),他们都是依赖AQS实现
AQS类结构包含一个基于链表实现的CHL,用于存储阻塞的线程,AQS还有一个state变量,他是用来表示ReentrantLock表示加锁状态.

锁分离优化lock同步锁
我们知道ReentrantLock是独占锁,他是同一时刻只能有一个线程获取到做,但是我们知道,对于同一份数据进行读写,如果一个线程在读数据,一个线程在写数据,会导致数据不一致,如果一个线程在写数据,另外一个线程也在写数据,或导致线程前后看到数据也会不一致,这个时候我们使用互斥锁,但是这样就会导致性能不是很好
在大部分场景,我们的读锁写少的情况特别多,在我们多个读线程操作一份资源,就没有必要去加互斥锁,如果加了互斥锁,反而会导致业务的并发性能,这个时候我们有什么办法优化锁呢
读写锁分离ReentrantReadWriteLock
针对这种读锁写少的场景,RRW允许多个读线程同时访问,但不允许写线程和写线程,读线程和写线程同时访问,读写锁内部维护了两个锁,一个是读锁ReadLock,一个用于写锁WriteLock
RRW也是基于AQS实现的,他的自定义同步器需求在state上维护多个读线程和一个写线程的状态,而这个状态就是实现读写锁的关键,RRW很好的使用了高低位,用一个整型控制两种状态的功能,读写锁变量分成两部分,高16位表示读,低16位表示写,我们现在分别介绍一个获取读锁和写锁的流程
一个线程尝试获取写锁,会先判断同步状态state是否为0,如果state=0,说明没有其他线程获取到锁,如果state!=0,说明有其他线程获取到了锁
此时我们在判断state的低16位w是否是0,如果w=0,则表示有其他线程获取了读锁.此时进入CLH队列进入阻塞等待,如果w!=0,则表示有其他线程获取到了写锁,然后我们在判断是否是本线程,如果不是就进入CLH队列阻塞等待,若是,我们再判断当前线程是否超过了最大次数,如果超多,抛异常,反之更新同步状态。

一个线程尝试获取读锁时候,我们会判断state是否为0,如果是0,说明没有其他线程获取到锁,此时判断是否需要阻塞,如果需要阻塞,则进入CLH队列进入阻塞等待,如果不需要阻塞,则CAS更新同步状态为读状态
如果state!=0,则判断同步状态低16位,w是否等于0,如果w!=0.表示存在写锁,则获取读锁失败,进入CLH阻塞队列,反之,判断当前线程是否应该被阻塞,如果不应该则尝试CAS同步状态,获取成功更新同步锁为读状态

我们可以看看下面的例子感受一下RRW锁
public class TestRTTLock {
private double x, y;
private ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
// 读锁
private Lock readLock = lock.readLock();
// 写锁
private Lock writeLock = lock.writeLock();
public double read() {
//获取读锁
readLock.lock();
try {
return Math.sqrt(x * x + y * y);
} finally {
//释放读锁
readLock.unlock();
}
}
public void move(double deltaX, double deltaY) {
//获取写锁
writeLock.lock();
try {
x += deltaX;
y += deltaY;
} finally {
//释放写锁
writeLock.unlock();
}
}
}读写锁再优化StampedLock
我们只得到在读多写少的并发场景中,我们使用RRW可能会遇到写操作遭遇饥饿问题,也就是写请求一直获取不到锁一直等待
而在JDK1.8中,java提供了StampedLock类解决了这个问题,StampedLock不是基于AQS实现的,但是实现的原理基本一样,都是基于队列和锁状态实现,与RRW不一样的是,StampedLock控制锁的三种模式,写,悲观锁,和乐观锁,且在stampedLock获取到锁的时候会返回一个票据stamp,获取到stamp的时候除了释放锁的时候校验,在乐观锁读模式下,stamp还会所谓读取共享资源后二次校验。如下面例子
public class Point {
private double x, y;
private final StampedLock s1 = new StampedLock();
void move(double deltaX, double deltaY) {
//获取写锁
long stamp = s1.writeLock();
try {
x += deltaX;
y += deltaY;
} finally {
//释放写锁
s1.unlockWrite(stamp);
}
}
double distanceFormOrigin() {
//乐观读操作
long stamp = s1.tryOptimisticRead();
//拷贝变量
double currentX = x, currentY = y;
//判断读期间是否有写操作
if (!s1.validate(stamp)) {
//升级为悲观读
stamp = s1.readLock();
try {
currentX = x;
currentY = y;
} finally {
s1.unlockRead(stamp);
}
}
return Math.sqrt(currentX * currentX + currentY * currentY);
}
}我们看到,一个写线程获取到写锁的过程中,首先返回一个stamp一个票据,writelock是一个独占锁,同时只有一个线程可以获取该锁,当一个线程获取到该锁后,其他请求就必须等待,只有没有线程持有读锁或写锁的时候,才会获取到此锁,成功之后返回的票据stamp,这个变量表示版本,用于释放锁的时候校验。
然后当一个读线程获取锁的过程中,首先是用乐观锁获取票据stamp,如果没有线程持有写锁,则返回一个非0的stamp版本信息,线程获取到stamp后,将会拷贝一份共享资源到方法栈,在这之前的操作都是基于方法栈的拷贝数据.
之后还有方法调用validate,验证之前使用乐观锁获取的票据是否有其他线程持有了写锁,如果是,那么validate会返回0,升级悲观锁,否则就可以使用这个票据stamp版本的锁对数据进行操作。
相比RRW,stampedLock获取读锁只是使用与或操作进行校验,不涉及CAS操作,即使第一次乐观锁失败,也就会升级到悲观锁,这样就可以避免一直进行CAS操作带来的CPU占用的性能问题,因此StampedLock的效率更高.