引言
本文继续讲解 Java 并发编程实践的基础篇,今天来说说并发编程中锁的概念。不同领域,对锁的分类也不同,比如数据库的表锁、行锁等,它们因底层细节的差异,而有了各自的名字。扩展到整个 IT 技术领域,衍生出的那些名目繁多的锁,大抵也都是这样产生的。
Java 语言中,最顶层锁的实现方式,只有内置锁和显式锁两种。但是,这两种锁在实现过程中,遭遇到了各种处理方式的选择,不同的处理方式,也对应着一种锁,比如:
- 已经被某个线程持有的锁,是否允许其他线程线程同时持有呢?【独占/共享】
- 多个线程阻塞在同一个条件队列上时,先唤醒谁呢?已经有线程排队等待某个锁时,又有新的线程请求该锁,而恰好该锁被释放了,是否允许新线程插队获取锁呢?【公平/非公平】
- 已经持有锁的线程,还想继续请求同一把锁,是否允许呢?【可重入】
- 线程在请求锁而不得的等待期间,是否允许外部调用
inerterupt
中断该线程呢?【可中断锁】
对于开发人员而言,内置锁和显式锁的实现,是一个白盒,我们只需要知道哪种 API 可以触发某种锁的处理分支就可以了,没有必要去纠结它们之间的具体区别。况且,这些锁的概念,有些是分属不同维度的,貌似也没有可比性。
一起来跟它们过过招吧!
锁的独占与共享
锁的独占与共享,是排他性的两种表现。指已经被某个线程持有的锁,是否允许其他线程线程同时持有?内置锁和显式锁在解决这个问题时,具体是怎么做的呢?这就是独占锁和共享锁产生的背景了,它们的处理差异为:
- 独占锁,每次只能有一个线程能持有锁【霸道独享】
- 共享锁,则允许多个线程同时获取锁,并发访问共享资源【和谐共享】
内置锁和显式锁的排他性
先来看由 synchronized
代表的监视器层面的内置锁 ,它是以独占方式实现的,只允许一个线程持有某个锁对象,锁未释放,其他线程只能等待。
而以 Lock
为代表的显式锁,它提供了两种锁实现模式,独占和共享。比如,ReentrantLock
是独占锁,ReadWriteLock
的读锁是共享锁,写锁是独占锁。很显然,独占锁是一种悲观保守的加锁策略,它避免了读/读冲突,如果某个只读线程获取锁,则其他读线程都只能等待,这种情况下就限制了不必要的并发性,因为读操作并不会影响数据的一致性。
共享锁则是一种乐观锁,它放宽了加锁策略,允许多个执行读操作的线程同时访问共享资源。 Java 的 ReadWriteLock
,读-写锁,它允许一个资源同时被多个读线程访问,或只能被一个写线程访问,但两者不能同时进行,共享也仅限于读操作。如果是写线程获取了锁控制权,那么此时的锁就被降级成独占锁了,即由共享锁变成了独占锁。
事实上,在 “读多-写少” 的并发场景下,乐观锁它允许多个读线程同时访问资源,极大地提高了并发效率,但是在 “写多-读少” 的场景下,效果跟悲观锁就一样的。
AQS 的模板方法
AQS ,全称是 「 AbstractQueuedSynchronizer 」,它是显式锁的底层抽象类,定义了独占锁和共享锁必须实现的方法。而独占和共享,分别对应着 AQS 的内部类 Node
的两个常量 SHARED
和 EXCLUSIVE
,标识 AQS 队列中等待线程的锁获取模式。
独占锁的子类,必须实现 tryAcquire
、tryRelease
、isHeldExclusively
等方法;共享锁的子类,必须实现 tryAcquireShared
和 tryReleaseShared
等方法,带有 Shared
后缀的方法是支持共享锁语义的。JUC
中,Semaphore
是一种共享锁,ReentrantLock
则是一种独占锁。
独占锁获取锁时,需要设置等待线程的节点模式为 Node.EXCLUSIVE
,源码如下:
public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
而共享锁,节点模式则为 Node.SHARED
,是添加到等待队列的线程的锁模式:
private void doAcquireShared(int arg) {
final Node node = addWaiter(Node.SHARED);
boolean failed = true;
.....
}
共享锁应用案例
来看一个读共享的例子,笔者曾在问答频道回答过 这样一个问题 ,一个使用 ReadWriteLock
的例子中,写线程锁释放锁之后,读线程获取锁的机会还是很少,读线程的并发数并不高,这是为什么呢?
首先,使用读写锁提供一个具有 plus
写操作和 get
读操作的类 ReadWriteFac
:
class ReadWriteFac {
ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
private volatile int i = 0;
Lock r = lock.readLock();
Lock w = lock.writeLock();
public void plus() {
w.lock();
System.out.println(Thread.currentThread().getName() + "---获取了写锁");
try {
i++;
System.out.println(Thread.currentThread().getName() + "---将i修改为" + i);
r.lock();
} finally {
w.unlock();//释放写锁 因为上面读锁未被释放 其他写线程无法进入但读线程可以继续
System.out.println(Thread.currentThread().getName() + "---释放了写锁\r\n\r\n");
try{
TimeUnit.SECONDS.sleep(3);
}catch(Exception e){
}
r.unlock();//释放读锁
System.out.println(Thread.currentThread().getName() + "---释放了读锁");
}
}
public int get() {
r.lock();
try {
if(i!=10){
System.out.println(Thread.currentThread().getName() + "获取到了" + i);
}
return i;
}finally {
r.unlock();
}
}
}
接着,定义一个测试类,创建 10 个写线程, 5 个读线程,读线程循环读取数据,直到 30 秒后程序结束:
public class ReadWriteTest {
private static boolean isRun = true;
public static void main(String[] args) {
ReadWriteFac fac = new ReadWriteFac();
Thread thread = new Thread();
for (int i = 0; i < 5; i++) {
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
while (isRun) {
fac.get();
}
}
}, "读线程" + i);
t1.setPriority(10);
t1.start();
}
for (int i = 0; i < 10; i++) {
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
fac.plus();
}
}, "写线程" + i);
t1.setPriority(1);
t1.start();
}
try{
TimeUnit.SECONDS.sleep(30);
isRun = false;
}catch(Exception e){
}
}
}
示例中,读线程使用 while(true)
循环读取最新数据;plus()
方法中,写锁释放之后,线程休眠了 3 秒。理论上,这 3 秒休眠期间,读线程应该有机会获取读锁、继续执行读操作的,但是运行后发现跟预期效果不一致,为什么呢?
笔者推测,根源是写线程过多,当写锁释放的时候,写锁又立即被其他写线程获取了,导致一直看到是写线程占据写锁,而读线程在这休眠的三秒内,获得读锁的机会很少。
读写锁适用读多写少的场景,而这个测试案例恰好相反,写多读少,导致写锁一释放,就被其他的写线程给抢占了,所以读线程依旧没有机会获取读锁。调小写线程个数,打印时间,就能看到写线程释放写锁休眠期间读线程获取读锁的过程了。
锁的公平与非公平
来看第二个问题, 多个线程阻塞在同一个条件队列上时,先唤醒谁呢?已经有线程排队等待某个锁时,又有新的线程请求该锁,而此时恰好该锁被释放了,是否允许新线程插队获取锁呢?
这两个问题都属于锁的公平与非公平概念,它是指线程请求获取锁的过程中,是否允许插队。公平锁实现的方式是,JVM 将按线程发出请求的顺序来获得锁;而非公平锁则允许在线程发出请求后立即尝试获取锁,如果可用则可直接获取锁,尝试失败才进行排队等待。
插队的诱惑
插队请求锁,带来的实际效益是什么呢?我们来看看排队唤醒的过程:
- 第一步,将当前请求锁的线程加入队尾
- 第二步,从队头移除一个等待最久的线程
- 第三步,把锁分配给线程
插队比按规矩排队更高效,因为时机刚好,只需要执行第三步,当然更简单啦。
内置锁和显式锁的公平性
内置锁和显式锁在公平性方面的表现是,内置锁是非公平锁。多个线程阻塞在同一个条件队列上时,随机唤醒;已经有线程排队等待某个锁时,又有新的线程请求该锁,而此时恰好该锁被释放了,则直接给它。在公平性和排他性方面,内置锁是非公平、排他的,这是底层决定的,没有办法干预。
相比之下,显式锁灵活多了,它允许开发者选择。比如,ReentrantLock
类维护了一个成员变量
private final Sync sync;
,它代表了锁获取方式,这个抽象类有两种实现 FairSyn
和 NofairSync
,从源码中看,类的层级关系为:
ReentrantLock
默认是非公平的,它的有参构造函数可以传入一个标识改变锁的类型,源码相当简洁:
/**
* Creates an instance of {@code ReentrantLock}.
* This is equivalent to using {@code ReentrantLock(false)}.
*/
public ReentrantLock() {
sync = new NonfairSync();
}
/**
* Creates an instance of {@code ReentrantLock} with the
* given fairness policy.
*
* @param fair {@code true} if this lock should use a fair ordering policy
*/
public ReentrantLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
}
启示录
笔者最初看源码时并没有了解过这些锁的概念,所以看得很困惑。理解了用法之后,意识到,对于开发者来说,上层始终只有内置锁和显式锁这一个概念,其他都是为它的实现服务的。
弄清了内置锁和显式锁的各个行为表现,这些概念就不攻自破了:
锁实现 | 排他性 | 公平性 | 是否可重入 | 是否可中断 |
---|---|---|---|---|
内置锁 | 独占锁 | 非公平 | 可重入 | 不可中断 |
ReentrantLock | 独占锁 | 默认非公平,可更改为公平 | 可重入 | 可中断 |
ReadWriteLock | 共享锁 | 默认非公平,可更改为公平 | 可重入 | 可中断 |