
Java 在java.util.concurrent.locks
包下,提供了几个关于锁的类和接口。
1 synchronized 的不足之处
我们先来看看synchronized
有什么不足之处。
- 如果临界区是只读操作,其实可以多线程一起执行,但使用 synchronized 的话,同一时间只能有一个线程执行。
- synchronized 无法知道线程有没有成功获取到锁
- 使用 synchronized,如果临界区因为 IO 或者 sleep 方法等原因阻塞了,而当前线程又没有释放锁,就会导致所有线程等待。
而这些都是 locks 包下的锁可以解决的。
2 锁的几种分类
锁可以根据以下几种方式来进行分类,下面我们逐一介绍。
2.1 可重入锁和非可重入锁
所谓重入锁,顾名思义。就是支持重新进入的锁,也就是说这个锁支持一个线程对资源重复加锁。
synchronized 关键字就是使用的重入锁。比如说,你在一个 synchronized 实例方法里面调用另一个本实例的 synchronized 实例方法,它可以重新进入这个锁,不会出现任何异常。
如果我们自己在继承 AQS 实现同步器的时候,没有考虑到占有锁的线程再次获取锁的场景,可能就会导致线程阻塞,那这个就是一个 “非可重入锁”。
ReentrantLock
的中文意思就是可重入锁。也是本文后续要介绍的重点类。
2.2 公平锁与非公平锁
这里的 “公平”,其实通俗意义来说就是 “先来后到”,也就是 FIFO。如果对一个锁来说,先对锁获取请求的线程一定会先被满足,后对锁获取请求的线程后被满足,那这个锁就是公平的。反之,那就是不公平的。
一般情况下,非公平锁能提升一定的效率。但是非公平锁可能会发生线程饥饿(有一些线程长时间得不到锁)的情况。所以要根据实际的需求来选择非公平锁和公平锁。
ReentrantLock 支持非公平锁和公平锁两种。
2.3 读写锁和排它锁
我们前面讲到的 synchronized 用的锁和 ReentrantLock,其实都是 “排它锁”。也就是说,这些锁在同一时刻只允许一个线程进行访问。
而读写锁可以在同一时刻允许多个读线程访问。Java 提供了 ReentrantReadWriteLock 类作为读写锁的默认实现,内部维护了两个锁:一个读锁,一个写锁。通过分离读锁和写锁,使得在 “读多写少” 的环境下,大大地提高了性能。
注意,即使用读写锁,在写线程访问时,所有的读线程和其它写线程均被阻塞。
可见,只是 synchronized 是远远不能满足多样化的业务对锁的要求的。接下来我们介绍一下 JDK 中有关锁的一些接口和类。
3 JDK 中有关锁的一些接口和类
众所周知,JDK 中关于并发的类大多都在java.util.concurrent
(以下简称 juc)包下。而 juc.locks 包看名字就知道,是提供了一些并发锁的工具类的。前面我们介绍的 AQS(AbstractQueuedSynchronizer)就是在这个包下。下面分别介绍一下这个包下的类和接口以及它们之间的关系。
3.1 抽象类 AQS/AQLS/AOS
这三个抽象类有一定的关系,所以这里放到一起讲。
首先我们看 AQS(AbstractQueuedSynchronizer),之前专门有章节介绍这个类,它是在 JDK 1.5 发布的,提供了一个 “队列同步器” 的基本功能实现。而 AQS 里面的 “资源” 是用一个int
类型的数据来表示的,有时候我们的业务需求资源的数量超出了int
的范围,所以在 JDK 1.6 中,多了一个 AQLS(AbstractQueuedLongSynchronizer)。它的代码跟 AQS 几乎一样,只是把资源的类型变成了long
类型。
AQS 和 AQLS 都继承了一个类叫 AOS(AbstractOwnableSynchronizer)。这个类也是在 JDK 1.6 中出现的。这个类只有几行简单的代码。从源码类上的注释可以知道,它是用于表示锁与持有者之间的关系(独占模式)。可以看一下它的主要方法:
private transient Thread exclusiveOwnerThread;
protected final void setExclusiveOwnerThread(Thread t) {
exclusiveOwnerThread = t;
}
protected final Thread getExclusiveOwnerThread() {
return exclusiveOwnerThread;
}
复制代码
3.2 接口 Condition/Lock/ReadWriteLock
juc.locks 包下共有三个接口:Condition
、Lock
、ReadWriteLock
。其中,Lock 和 ReadWriteLock 从名字就可以看得出来,分别是锁和读写锁的意思。Lock 接口里面有一些获取锁和释放锁的方法声明,而 ReadWriteLock 里面只有两个方法,分别返回 “读锁” 和“写锁”:
public interface ReadWriteLock {
Lock readLock();
Lock writeLock();
}
复制代码
Lock 接口中有一个方法是可以获得一个Condition
:
Condition newCondition();
复制代码
之前我们提到了每个对象都可以用继承自Object
的 wait/notify 方法来实现等待 / 通知机制。而 Condition 接口也提供了类似 Object 监视器的方法,通过与 Lock 配合来实现等待 / 通知模式。
那为什么既然有 Object 的监视器方法了,还要用 Condition 呢?这里有一个二者简单的对比:
对比项 | Object 监视器 | Condition |
---|---|---|
前置条件 | 获取对象的锁 | 调用 Lock.lock 获取锁,调用 Lock.newCondition 获取 Condition 对象 |
调用方式 | 直接调用,比如 object.notify() | 直接调用,比如 condition.await() |
等待队列的个数 | 一个 | 多个 |
当前线程释放锁进入等待状态 | 支持 | 支持 |
当前线程释放锁进入等待状态,在等待状态中不中断 | 不支持 | 支持 |
当前线程释放锁并进入超时等待状态 | 支持 | 支持 |
当前线程释放锁并进入等待状态直到将来的某个时间 | 不支持 | 支持 |
唤醒等待队列中的一个线程 | 支持 | 支持 |
唤醒等待队列中的全部线程 | 支持 | 支持 |
Condition 和 Object 的 wait/notify 基本相似。其中,Condition 的 await 方法对应的是 Object 的 wait 方法,而 Condition 的 signal/signalAll 方法则对应 Object 的 notify/notifyAll()。但 Condition 类似于 Object 的等待 / 通知机制的加强版。我们来看看主要的方法:
方法名称 | 描述 |
---|---|
await() | 当前线程进入等待状态直到被通知(signal)或者中断;当前线程进入运行状态并从 await() 方法返回的场景包括:(1)其他线程调用相同 Condition 对象的 signal/signalAll 方法,并且当前线程被唤醒;(2)其他线程调用 interrupt 方法中断当前线程; |
awaitUninterruptibly() | 当前线程进入等待状态直到被通知,在此过程中对中断信号不敏感,不支持中断当前线程 |
awaitNanos(long) | 当前线程进入等待状态,直到被通知、中断或者超时。如果返回值小于等于 0,可以认定就是超时了 |
awaitUntil(Date) | 当前线程进入等待状态,直到被通知、中断或者超时。如果没到指定时间被通知,则返回 true,否则返回 false |
signal() | 唤醒一个等待在 Condition 上的线程,被唤醒的线程在方法返回前必须获得与 Condition 对象关联的锁 |
signalAll() | 唤醒所有等待在 Condition 上的线程,能够从 await() 等方法返回的线程必须先获得与 Condition 对象关联的锁 |
3.3 ReentrantLock
ReentrantLock 是一个非抽象类,它是 Lock 接口的 JDK 默认实现,实现了锁的基本功能。从名字上看,它是一个” 可重入 “锁,从源码上看,它内部有一个抽象类Sync
,是继承了 AQS,自己实现的一个同步器。同时,ReentrantLock 内部有两个非抽象类NonfairSync
和FairSync
,它们都继承了 Sync。从名字上看得出,分别是”非公平同步器 “和” 公平同步器 “的意思。这意味着 ReentrantLock 可以支持” 公平锁 “和” 非公平锁“。
通过看这两个同步器的源码可以发现,它们的实现都是” 独占 “的。都调用了 AOS 的setExclusiveOwnerThread
方法,所以 ReentrantLock 的锁是”独占 “的,也就是说,它的锁都是” 排他锁“,不能共享。
在 ReentrantLock 的构造方法里,可以传入一个boolean
类型的参数,来指定它是否是一个公平锁,默认情况下是非公平的。这个参数一旦实例化后就不能修改,只能通过isFair()
方法来查看。
3.4 ReentrantReadWriteLock
这个类也是一个非抽象类,它是 ReadWriteLock 接口的 JDK 默认实现。它与 ReentrantLock 的功能类似,同样是可重入的,支持非公平锁和公平锁。不同的是,它还支持” 读写锁 “。
ReentrantReadWriteLock 内部的结构大概是这样:
private final ReentrantReadWriteLock.ReadLock readerLock;
private final ReentrantReadWriteLock.WriteLock writerLock;
final Sync sync;
abstract static class Sync extends AbstractQueuedSynchronizer {
}
static final class NonfairSync extends Sync {
}
static final class FairSync extends Sync {
}
public static class ReadLock implements Lock, java.io.Serializable {
private final Sync sync;
protected ReadLock(ReentrantReadWriteLock lock) {
sync = lock.sync;
}
}
public static class WriteLock implements Lock, java.io.Serializable {
private final Sync sync;
protected WriteLock(ReentrantReadWriteLock lock) {
sync = lock.sync;
}
}
public ReentrantReadWriteLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
readerLock = new ReadLock(this);
writerLock = new WriteLock(this);
}
public ReentrantReadWriteLock.WriteLock writeLock() { return writerLock; }
public ReentrantReadWriteLock.ReadLock readLock() { return readerLock; }
复制代码
可以看到,它同样是内部维护了两个同步器。且维护了两个 Lock 的实现类 ReadLock 和 WriteLock。从源码可以发现,这两个内部类用的是外部类的同步器。
ReentrantReadWriteLock 实现了读写锁,但它有一个小弊端,就是在 “写” 操作的时候,其它线程不能写也不能读。我们称这种现象为“写饥饿”,将在后文的 StampedLock 类继续讨论这个问题。
3.5 StampedLock
StampedLock
类是在 Java 8 才发布的,也是 Doug Lea 大神所写,有人号称它为锁的性能之王。它没有实现 Lock 接口和 ReadWriteLock 接口,但它其实是实现了 “读写锁” 的功能,并且性能比 ReentrantReadWriteLock 更高。StampedLock 还把读锁分为了 “乐观读锁” 和“悲观读锁”两种。
前面提到了 ReentrantReadWriteLock 会发生 “写饥饿” 的现象,但 StampedLock 不会。它是怎么做到的呢?它的核心思想在于,在读的时候如果发生了写,应该通过重试的方式来获取新的值,而不应该阻塞写操作。这种模式也就是典型的无锁编程思想,和 CAS 自旋的思想一样。这种操作方式决定了 StampedLock 在读线程非常多而写线程非常少的场景下非常适用,同时还避免了写饥饿情况的发生。
这里篇幅有限,就不介绍 StampedLock 的源码了,只是分析一下官方提供的用法(在 JDK 源码类声明的上方或 Javadoc 里可以找到)。
class Point {
private double x, y;
private final StampedLock sl = new StampedLock();
void move(double deltaX, double deltaY) {
long stamp = sl.writeLock();
try {
x += deltaX;
y += deltaY;
} finally {
sl.unlockWrite(stamp);
}
}
double distanceFromOrigin() {
long stamp = sl.tryOptimisticRead();
double currentX = x, currentY = y;
if (!sl.validate(stamp)) {
stamp = sl.readLock();
try {
currentX = x;
currentY = y;
} finally {
sl.unlockRead(stamp);
}
}
return Math.sqrt(currentX * currentX + currentY * currentY);
}
void moveIfAtOrigin(double newX, double newY) {
long stamp = sl.readLock();
try {
while (x == 0.0 && y == 0.0) {
long ws = sl.tryConvertToWriteLock(stamp);
if (ws != 0L) {
stamp = ws;
x = newX;
y = newY;
break;
}
else {
sl.unlockRead(stamp);
stamp = sl.writeLock();
}
}
} finally {
sl.unlock(stamp);
}
}
}
复制代码
乐观读锁的意思就是先假定在这个锁获取期间,共享变量不会被改变,既然假定不会被改变,那就不需要上锁。在获取乐观读锁之后进行了一些操作,然后又调用了 validate 方法,这个方法就是用来验证 tryOptimisticRead 之后,是否有写操作执行过,如果有,则获取一个悲观读锁,这里的悲观读锁和 ReentrantReadWriteLock 中的读锁类似,也是个共享锁。
可以看到,StampedLock 获取锁会返回一个long
类型的变量,释放锁的时候再把这个变量传进去。简单看看源码:
private static final int LG_READERS = 7;
private static final long RUNIT = 1L;
private static final long WBIT = 1L << LG_READERS;
private static final long RBITS = WBIT - 1L;
private static final long RFULL = RBITS - 1L;
private static final long ABITS = RBITS | WBIT;
private static final long SBITS = ~RBITS;
private static final long ORIGIN = WBIT << 1;
private transient volatile long state;
private transient int readerOverflow;
复制代码
StampedLock 用这个 long 类型的变量的前 7 位(LG_READERS)来表示读锁,每获取一个悲观读锁,就加 1(RUNIT),每释放一个悲观读锁,就减 1。而悲观读锁最多只能装 128 个(7 位限制),很容易溢出,所以用一个 int 类型的变量来存储溢出的悲观读锁。
写锁用 state 变量剩下的位来表示,每次获取一个写锁,就加 0000 1000 0000(WBIT)。需要注意的是,写锁在释放的时候,并不是减 WBIT,而是再加 WBIT。这是为了让每次写锁都留下痕迹,解决 CAS 中的 ABA 问题,也为乐观锁检查变化 validate 方法提供基础。
乐观读锁就比较简单了,并没有真正改变 state 的值,而是在获取锁的时候记录 state 的写状态,在操作完成后去检查 state 的写状态部分是否发生变化,上文提到了,每次写锁都会留下痕迹,也是为了这里乐观锁检查变化提供方便。
总的来说,StampedLock 的性能是非常优异的,基本上可以取代 ReentrantReadWriteLock 的作用。
原创文章,作者:睿达君,如若转载,请注明出处:https://zrrd.net.cn/2182.html