必须掌握 Lock 锁系列!
1、引言
针对并发编程,Doug Lea大师已经为我们提供了大量实用、高性能的工具类,针对这些代码进行研究会让我们对并发编程的掌握更加透彻也会大大提升我们对并发编程技术的热爱。这些代码在java.util.concurrent包下,如下图:
其中包含了两个子包:atomic、locks,另外在concurrent下的阻塞队列以及executors,这些都是concurrent包中的精华。这些类的实现主要是依赖于CAS及AQS
,从整体上来看concurrent包的整体实现图如下图所示:
2、Lock接口
锁是用来控制多个线程访问共享资源的手段,一般来说,一个锁能够防止多个线程同时访问共享资源。 在Lock接口出现之前,Java程序主要是靠synchronized
关键字实现锁功能,而java SE5之后,并发包中增加了Lock
接口,它提供了与synchronized一样的锁功能。虽然它失去了像synchronize关键字隐式加锁解锁的便捷性,但是却拥有了锁获取和释放的可操作性、可中断的获取锁以及超时获取锁等多种synchronized关键字所不具备的同步特性。 通常我们都是显示地使用lock接口,如下:
Lock lock = new ReentrantLock();
lock.lock();
try{
.......
}finally{
lock.unlock();
}
需要注意的是synchronized同步块执行完成或者遇到异常是锁会自动释放,而lock必须手动调用unlock()方法释放锁,因此在finally块中释放锁。
我们现在就来看看Lock中接口定义了哪些方法:
void lock(); //获取锁
void lockInterruptibly() throws InterruptedException;//获取锁的过程能够响应中断
boolean tryLock();//非阻塞式响应中断能立即返回,获取锁放回true反之返回fasle
boolean tryLock(long time, TimeUnit unit) throws InterruptedException;//超时获取锁,在超时内或者未中断的情况下能够获取锁
Condition newCondition();//获取与lock绑定的等待通知组件,当前线程必须获得了锁才能进行等待,进行等待时会先释放锁,当再次获取锁时才能从等待中返回
上面是Lock接口中定义的5个方法,那么在locks包下有哪些类实现了该接口了?
先从最熟悉的ReentrantLock
说起:
public class ReentrantLock implements Lock, java.io.Serializable
很显然ReentrantLock
实现了Lock接口,当你查看源码时你会惊讶的发现ReentrantLock并没有多少代码,另外有一个很明显的特点是:基本上所有的方法的实现实际上都是调用了其静态内存类Sync
中的方法,而Sync类继承了AbstractQueuedSynchronizer(AQS)
。
可以看出要想理解ReentrantLock
关键核心在于对队列同步器AQS
的理解,关于AQS
的原理已在上一篇文章中有介绍:CAS & AQS,这里不再敖述!
3、可重入锁ReentrantLock
3.1 ReentrantLock的介绍
可重入锁ReentrantLock,是Lock接口的一个实现类,也是在实际编程中使用频率很高的一个锁。
支持可重入性,表示能够对共享资源重复加锁,即当前线程获取该锁再次获取不会被阻塞。
Java关键字synchronized隐式的支持可重入性,synchronized通过获取自增、释放自减的方式实现重入性。
与此同时,ReentrantLock还支持公平锁和非公平锁两种方式。
那么,要想完完全全的弄懂ReentrantLock,主要也就是理解ReentrantLock两个同步语义:1. 可重入性的实现原理;2. 公平锁和非公平锁。
3.2 可重入性的实现原理
要想支持重入性,就要解决两个问题:
1. 在线程获取锁的时候,如果已经获取锁的线程是当前线程的话,则直接再次获取成功
2. 由于锁被获取了n次,那么只有这把锁同样被释放n次之后,该锁才算是完全释放成功
通过上篇文章CAS & AQS,我们知道,同步组件主要是通过重写AQS的几个protected方法来表达自己的同步语义。
针对第1个问题,我们来看看ReentrantLock是怎样实现的,以非公平锁为例,判断当前线程能否获得锁,核心方法为nonfairTryAcquire
:
final boolean nonfairTryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
//1. 如果该锁未被任何线程占有,该锁能被当前线程获取
if (c == 0) {
if (compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
//2.若被占有,检查占有线程是否是当前线程
else if (current == getExclusiveOwnerThread()) {
// 3. 再次获取,计数加一
int nextc = c + acquires;
if (nextc < 0) // overflow
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}
这段代码的逻辑也很简单,具体请看注释。
为了支持重入性,在第二步增加了处理逻辑,如果该锁已经被线程所占有,会继续检查占有线程是否为当前线程,如果是的话,同步状态加1返回true,表示可以再次获取成功,即每次重新获取都会对同步状态进行加1的操作!
针对第2个问题,即那么释放的时候处理思路是怎样的呢?依然还是以非公平锁为例,核心方法为tryRelease
:
protected final boolean tryRelease(int releases) {
//1. 同步状态减1
int c = getState() - releases;
if (Thread.currentThread() != getExclusiveOwnerThread())
throw new IllegalMonitorStateException();
boolean free = false;
if (c == 0) {
//2. 只有当同步状态为0时,锁成功被释放,返回true
free = true;
setExclusiveOwnerThread(null);
}
// 3. 锁未被完全释放,返回false
setState(c);
return free;
}
代码的逻辑请看注释,需要注意的是,重入锁的释放必须得等到同步状态为0时锁才算成功释放,否则锁仍未释放。
如果锁被获取n次,释放了n-1次,该锁未完全释放返回false,只有被释放n次才算成功释放,返回true。
到现在我们可以理清ReentrantLock重入性的实现了,也就是理解了同步语义的第1条:可重入性的实现原理。
3.3 公平锁与非公平锁
ReentrantLock支持两种锁:公平锁和非公平锁。
何谓公平性,是针对获取锁而言的,如果一个锁是公平的,那么锁的获取顺序就应该符合请求上的绝对时间顺序,满足FIFO。
ReentrantLock的构造方法是无参时,则构造的是非公平锁,源码为:
public ReentrantLock() {
sync = new NonfairSync();
}
另外还提供了一种方式,可传入一个boolean值,true时为公平锁,false时为非公平锁,源码为:
public ReentrantLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
}
在上面非公平锁获取方法nonfairTryAcquire
中,只是简单的获取了一下当前状态并做了一些逻辑处理,并没有考虑到当前同步队列中线程等待的情况。
我们来看看公平锁
的处理逻辑是怎样的,核心方法为:
protected final boolean tryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
if (!hasQueuedPredecessors() &&
compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires;
if (nextc < 0)
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}
}
这段代码的逻辑与nonfairTryAcquire
基本上一致,唯一的不同在于增加了hasQueuedPredecessors
的逻辑判断!
由方法名就可知道该方法用来判断当前节点在同步队列中是否有前驱节点的判断:
如果有前驱节点则说明有线程比当前线程更早的请求资源,根据公平性,当前线程请求资源失败
如果当前节点没有前驱节点的话,才有做后面的逻辑判断的必要性
公平锁 VS 非公平锁:
1.公平锁每次获取到锁为同步队列中的第一个节点,保证请求资源时间上的绝对顺序;而非公平锁有可能刚释放锁的线程下次继续获取到锁,则有可能导致其他线程永远无法获取到锁,造成“饥饿”现象。
2.公平锁为了保证时间上的绝对顺序,需要频繁的上下文切换;而非公平锁会降低一定的上下文切换,降低性能开销。因此,ReentrantLock默认选择的是非公平锁,则是为了减少一部分上下文切换,保证了系统更大的吞吐量。
4、可重入读写锁ReentrantReadWriteLock
4.1 读写锁的介绍
在并发场景中用于解决线程安全的问题,我们几乎会高频率的使用到独占式锁
,通常使用Java提供的关键字synchronized或者concurrents包中实现了Lock接口的ReentrantLock。它们都是独占式获取锁,也就是在同一时刻只有一个线程能够获取锁。
而在一些业务场景中,大部分只是读数据,写数据很少,如果仅仅是读数据的话并不会影响数据正确性(出现脏读),而如果在这种业务场景下,依然使用独占锁的话,很显然这将是出现性能瓶颈的地方。
针对这种读多写少的情况,Java还提供了另外一个实现Lock接口的ReentrantReadWriteLock(可重入读写锁)。
读写锁允许同一时刻被多个读线程访问,但是在写线程访问时,所有的读线程和其他的写线程都会被阻塞。
值得注意的是,ReentrantReadWriteLock
实现了读写锁,但它有一个小弊端,就是在“写”操作的时候,其它线程不能写也不能读。我们称这种现象为“写饥饿”
。
关于ReentrantReadWriteLock
的特性,这里首先做一个归纳总结:
- 公平性选择:支持非公平锁(默认)和公平锁两种获取方式,吞吐量还是非公平优于公平
- 可重入性:支持重入,读锁获取后能再次获取,写锁获取之后能够再次获取写锁,同时也能够获取读锁
- 锁降级:遵循获取写锁,获取读锁再释放写锁的次序,写锁能够降级成为读锁
要想彻底理解读写锁必须能够理解这样几个问题:
- 读写锁是怎样实现分别记录读写状态的?
- 写锁是怎样获取和释放的?
- 读锁是怎样获取和释放的?
我们带着这样的三个问题,再去了解下读写锁。
4.2 写锁详解
①写锁的获取
同步组件的实现聚合了同步器(AQS),并通过重写同步器(AQS)中的方法实现同步组件的同步语义。因此,写锁的实现依然也是采用这种方式。
在同一时刻写锁是不能被多个线程所获取,**很显然写锁是独占式锁,**而实现写锁的同步语义是通过重写AQS中的tryAcquire方法实现的。源码:
protected final boolean tryAcquire(int acquires) {
/*
* Walkthrough:
* 1. If read count nonzero or write count nonzero
* and owner is a different thread, fail.
* 2. If count would saturate, fail. (This can only
* happen if count is already nonzero.)
* 3. Otherwise, this thread is eligible for lock if
* it is either a reentrant acquire or
* queue policy allows it. If so, update state
* and set owner.
*/
Thread current = Thread.currentThread();
// 1. 获取写锁当前的同步状态
int c = getState();
// 2. 获取写锁获取的次数
int w = exclusiveCount(c);
if (c != 0) {
// (Note: if c != 0 and w == 0 then shared count != 0)
// 3.1 当读锁已被读线程获取或者当前线程不是已经获取写锁的线程的话
// 当前线程获取写锁失败
if (w == 0 || current != getExclusiveOwnerThread())
return false;
if (w + exclusiveCount(acquires) > MAX_COUNT)
throw new Error("Maximum lock count exceeded");
// Reentrant acquire
// 3.2 当前线程获取写锁,支持可重复加锁
setState(c + acquires);
return true;
}
// 3.3 写锁未被任何线程获取,当前线程可获取写锁
if (writerShouldBlock() ||
!compareAndSetState(c, c + acquires))
return false;
setExclusiveOwnerThread(current);
return true;
}
此方法用于获取写锁。
首先会获取state,判断是否为0:
若为0,表示此时没有读锁线程,再判断写线程是否应该被阻塞,而在非公平策略下总是不会被阻塞,在公平策略下会进行判断(判断同步队列中是否有等待时间更长的线程,若存在,则需要被阻塞,否则,无需阻塞),之后在设置状态state,然后返回true。
若state不为0,则表示此时存在读锁或写锁线程,若写锁线程数量为0或者当前线程为独占锁线程,则返回false,表示不成功,否则,判断写锁线程的重入次数是否大于了最大值,若是,则抛出异常,否则,设置状态state,返回true,表示成功。
其大致流程图如下:
②写锁的释放
写锁释放通过重写AQS的tryRelease方法,源码为:
protected final boolean tryRelease(int releases) {
if (!isHeldExclusively())
throw new IllegalMonitorStateException();
//1. 同步状态减去写状态
int nextc = getState() - releases;
//2. 当前写状态是否为0,为0则释放写锁
boolean free = exclusiveCount(nextc) == 0;
if (free)
setExclusiveOwnerThread(null);
//3. 不为0则更新同步状态
setState(nextc);
return free;
}
此方法用于释放写锁资源。
首先会判断该线程是否为独占线程:
若不为独占线程,则抛出异常
否则,计算释放资源后的写锁的数量
- 若为0,表示成功释放,资源不将被占用
- 否则,表示资源还被占用。
其大致流程图如下:
4.3 读锁详解
①读锁的获取
看完了写锁,现在来看看读锁,读锁不是独占式锁,即同一时刻该锁可以被多个读线程获取也就是一种共享锁!
按照之前对AQS介绍,实现共享式同步组件的同步语义需要通过重写AQS的tryAcquireShared方法和tryReleaseShared方法
。
读锁的获取源码:
protected final int tryAcquireShared(int unused) {
/*
* Walkthrough:
* 1. If write lock held by another thread, fail.
* 2. Otherwise, this thread is eligible for
* lock wrt state, so ask if it should block
* because of queue policy. If not, try
* to grant by CASing state and updating count.
* Note that step does not check for reentrant
* acquires, which is postponed to full version
* to avoid having to check hold count in
* the more typical non-reentrant case.
* 3. If step 2 fails either because thread
* apparently not eligible or CAS fails or count
* saturated, chain to version with full retry loop.
*/
Thread current = Thread.currentThread();
int c = getState();
//1. 如果写锁已经被获取并且获取写锁的线程不是当前线程的话,当前
// 线程获取读锁失败返回-1
if (exclusiveCount(c) != 0 &&
getExclusiveOwnerThread() != current)
return -1;
int r = sharedCount(c);
if (!readerShouldBlock() &&
r < MAX_COUNT &&
//2. 当前线程获取读锁
compareAndSetState(c, c + SHARED_UNIT)) {
//3. 下面的代码主要是新增的一些功能,比如getReadHoldCount()方法
//返回当前获取读锁的次数
if (r == 0) {
firstReader = current;
firstReaderHoldCount = 1;
} else if (firstReader == current) {
firstReaderHoldCount++;
} else {
HoldCounter rh = cachedHoldCounter;
if (rh == null || rh.tid != getThreadId(current))
cachedHoldCounter = rh = readHolds.get();
else if (rh.count == 0)
readHolds.set(rh);
rh.count++;
}
return 1;
}
//4. 处理在第二步中CAS操作失败的自旋已经实现重入性
return fullTryAcquireShared(current);
}
此方法表示读锁线程获取读锁。
1、首先判断写锁是否为0并且当前线程不占有独占锁,直接返回;
2、然后,判断读线程是否需要被阻塞并且读锁数量是否小于最大值并且比较设置状态成功:
若当前没有读锁,则设置第一个读线程firstReader和firstReaderHoldCount
若当前线程线程为第一个读线程,则增加firstReaderHoldCount;
否则,将设置当前线程对应的HoldCounter对象的值
3、最后,如果注释2中,CAS失败或者已经获取读锁的线程再次获取读锁时,是靠fullTryAcquireShared方法实现的,这段代码就不展开说了,有兴趣可以看看。
其大致流程图如下:
②读锁的释放
读锁释放的实现主要通过方法tryReleaseShared
,源码如下:
protected final boolean tryReleaseShared(int unused) {
Thread current = Thread.currentThread();
// 前面还是为了实现getReadHoldCount等新功能
if (firstReader == current) {
// assert firstReaderHoldCount > 0;
if (firstReaderHoldCount == 1)
firstReader = null;
else
firstReaderHoldCount--;
} else {
HoldCounter rh = cachedHoldCounter;
if (rh == null || rh.tid != getThreadId(current))
rh = readHolds.get();
int count = rh.count;
if (count <= 1) {
readHolds.remove();
if (count <= 0)
throw unmatchedUnlockException();
}
--rh.count;
}
for (;;) {
int c = getState();
// 读锁释放 将同步状态减去读状态即可
int nextc = c - SHARED_UNIT;
if (compareAndSetState(c, nextc))
// Releasing the read lock has no effect on readers,
// but it may allow waiting writers to proceed if
// both read and write locks are now free.
return nextc == 0;
}
}
此方法表示读锁线程释放锁。
首先判断当前线程是否为第一个读线程firstReader,若是,则判断第一个读线程占有的资源数firstReaderHoldCount是否为1,若是,则设置第一个读线程firstReader为空,否则,将第一个读线程占有的资源数firstReaderHoldCount减1;
若当前线程不是第一个读线程,那么首先会获取缓存计数器(上一个读锁线程对应的计数器 ),若计数器为空或者tid不等于当前线程的tid值,则获取当前线程的计数器,如果计数器的计数count小于等于1,则移除当前线程对应的计数器,如果计数器的计数count小于等于0,则抛出异常,之后再减少计数即可。
无论何种情况,都会进入无限循环,该循环可以确保成功设置状态state。
其大致流程图如下:
4.4 更深入理解:什么是锁降级?
锁降级指的是写锁降级成为读锁! 如果当前线程拥有写锁,然后将其释放,最后再获取读锁,这种分段完成的过程不能称之为锁降级。锁降级是指把持住(当前拥有的)写锁,再获取到读锁,随后释放(先前拥有的)写锁的过程。
接下来看一个锁降级的示例。因为数据不常变化,所以多个线程可以并发地进行数据处理,当数据变更后,如果当前线程感知到数据变化,则进行数据的准备工作,同时其他处理线程被阻塞,直到当前线程完成数据的准备工作。演示代码:
public void processData() {
readLock.lock();
if (!update) {
// 必须先释放读锁
readLock.unlock();
// 锁降级从写锁获取到开始
writeLock.lock();
try {
if (!update) {
// 准备数据的流程(略)
update = true;
}
readLock.lock();
} finally {
writeLock.unlock();
}
// 锁降级完成,写锁降级为读锁
}
try {
// 使用数据的流程(略)
} finally {
readLock.unlock();
}
}
上述示例中,当数据发生变更后,update变量(布尔类型且volatile修饰)被设置为false,此时所有访问processData()
方法的线程都能够感知到变化,但只有一个线程能够获取到写锁,其他线程会被阻塞在读锁和写锁的lock()方法上。当前线程获取写锁完成数据准备之后,再获取读锁,随后释放写锁,完成锁降级。
锁降级中读锁的获取是否必要呢? 答案是必要的。 主要是为了保证数据的可见性,如果当前线程不获取读锁而是直接释放写锁,假设此刻另一个线程(记作线程T)获取了写锁并修改了数据,那么当前线程无法感知线程T的数据更新。如果当前线程获取读锁,即遵循锁降级的步骤,则线程T将会被阻塞,直到当前线程使用数据并释放读锁之后,线程T才能获取写锁进行数据更新。
RentrantReadWriteLock不支持锁升级(把持读锁、获取写锁,最后释放读锁的过程),目的也是保证数据可见性! 如果读锁已被多个线程获取,其中任意线程成功获取了写锁并更新了数据,则其更新对其他获取到读锁的线程是不可见的!
5、Lock与synchronized的区别
5.1 Lock与synchronized的区别
1. 加锁范围
synchronized 可以给类、方法、代码块加锁;而 Lock 只能给代码块加锁。
2. 获取、释放锁的方式
synchronized 不需要手动获取锁和释放锁,使用简单,发生异常会自动释放锁,不会造成死锁;而 Lock 需要自己加锁和释放锁,如果使用不当没有 unLock()去释放锁就会造成死锁。
3. 锁通知
通过 Lock 可以知道有没有成功获取锁,而 synchronized 却无法办到。
5.2 ReentrantLock与synchronized的区别
1. 两者都是可重入锁
可重入锁:重入锁,也叫做递归锁,可重入锁指的是在一个线程中可以多次获取同一把锁。比如: 一个线程在执行一个带锁的方法,该方法中又调用了另一个需要相同锁的方法,则该线程可以直接执行调用的方法,而无需重新获得锁, 两者都是同一个线程每进入一次,锁的计数器都自增1,所以要等到锁的计数器下降为0时才能释放锁。
2. synchronized 依赖于 JVM 而 ReentrantLock 依赖于 API
- synchronized 是依赖于 JVM 实现的,前面我们也讲到了JVM团队在 JDK1.6 为 synchronized 关键字进行了很多优化,但是这些优化都是在虚拟机层面实现的
- ReentrantLock 是 JDK 层面实现的(也就是 API 层面,需要 lock() 和 unlock() 方法配合 try/finally 语句块来完成)
3. ReentrantLock 比 synchronized 增加了一些高级功能
相比synchronized,ReentrantLock增加了一些高级功能,主要来说主要有三点:①等待可中断;②可实现公平锁;③可实现选择性通知(锁可以绑定多个条件)
- 等待可中断,通过lock.lockInterruptibly()来实现这个机制,也就是说正在等待的线程可以选择放弃等待,改为处理其他事情。
- ReentrantLock支持公平锁和非公平锁,而synchronized只能是非公平锁。所谓的公平锁就是先等待的线程先获得锁, ReentrantLock默认情况是非公平的,可以通过 ReentrantLock类的ReentrantLock(boolean fair)构造方法来制定是否是公平的。
- ReentrantLock类线程对象可以注册在指定的Condition中,从而可以有选择性的进行线程通知,在调度线程上更加灵活。 在使用notify()/notifyAll()方法进行通知时,被通知的线程是由 JVM 选择,用ReentrantLock类结合Condition实例可以实现“选择性通知”。
4. 使用上的选择:建议优先使用synchronized
- 除非需要使用 ReentrantLock 的高级功能,否则优先使用 synchronized
- synchronized 是 JVM 实现的一种锁机制,JVM 原生地支持它,而 ReentrantLock 不是所有的 JDK 版本都支持;并且使用 synchronized 不用担心没有释放锁而导致死锁问题,因为 JVM 会确保锁的释放
6、总结
6.1 锁的用途
锁是用来控制多个线程访问共享资源的手段,一般来说,一个锁能够防止多个线程同时访问共享资源。
6.2 ReentrantLock的特性
ReentrantLock的可重入性,表示能够对共享资源重复加锁,即当前线程获取该锁再次获取不会被阻塞;
此外,ReentrantLock还支持公平锁和非公平锁。
6.3 ReentrantReadWriteLock的特性
ReentrantReadWriteLock读写锁即允许同一时刻被多个读线程访问,但是在写线程访问时,所有的读线程和其他的写线程都会被阻塞;
特别适合读多写少的场景;
它具备3个特性:可重入性、公平锁与非公平锁、锁降级(遵循获取写锁,获取读锁再释放写锁的次序,写锁能够降级成为读锁)。
6.4.Lock与synchronized的区别
- 两者都是可重入锁:可重入锁指的是在一个线程中可以多次获取同一把锁
- synchronized 依赖于 JVM 而 ReentrantLock 依赖于 API
- ReentrantLock 比 synchronized 增加了一些高级功能:①等待可中断;②可实现公平锁;③可实现选择性通知
- 使用上的选择:建议优先使用synchronized,除非需要使用 ReentrantLock 的高级功能,否则优先使用 synchronized