提到JAVA加锁,我们通常会想到synchronized关键字或者是Java Concurrent Util(后面简称JCU)包下面的Lock,今天就来扒一扒Lock是如何实现的,比如我们可以先提出一些问题:当我们通过实例化一个ReentrantLock并且调用它的lock或unlock的时候,这其中发生了什么?如果多个线程同时对同一个锁实例进行lock或unlcok操作,这其中又发生了什么?
什么是可重入锁?
ReentrantLock是可重入锁,什么是可重入锁呢?可重入锁就是当前持有该锁的线程能够多次获取该锁,无需等待。可重入锁是如何实现的呢?这要从ReentrantLock的一个内部类Sync的父类说起,Sync的父类是AbstractQueuedSynchronizer(后面简称AQS)。
什么是AQS?
AQS是JDK1.5提供的一个基于FIFO等待队列实现的一个用于实现同步器的基础框架,这个基础框架的重要性可以这么说,JCU包里面几乎所有的有关锁、多线程并发以及线程同步器等重要组件的实现都是基于AQS这个框架。AQS的核心思想是基于volatile int state这样的一个属性同时配合Unsafe工具对其原子性的操作来实现对当前锁的状态进行修改。当state的值为0的时候,标识改Lock不被任何线程所占有。
ReentrantLock 和 AQS 的关系
首先我们以你最受的方式带你进入这个核武器库,Java 并发包下的 ReentrantLock大家肯定很熟悉了。
基本上学过Java 的都知道ReentrantLock,下面我就不多说了直接上一段代码。
ReentrantLock lock = new ReentrantLock(); try { lock.lock(); // 加锁 // 业务逻辑代码 } finally { lock.unlock(); // 释放锁 }
这段代码大家应该很熟悉了,无非就是获取一把锁,加锁和释放锁的过程。
有同学就问了这和AQS有毛关系呀!别着急,告诉你关系大着去了。在Java并发包中很多锁都是通过AQS来实现加锁和释放锁的过程的,AQS就是并发包基础。
例如:ReentrantLock、ReentrantReadWriteLock 底层都是通过AQS来实现的。
那么AQS到底为何物尼?别急,我们一步一来揭开其神秘的面纱。
AQS 的全称 AbstractQueuedSynchronizers抽象队列同步器,给大家画三张图来说明其在Java 并发包的地位、 长啥样、和ReentrantLock 的关系。
通过此类图可以彰显出了AQS的地位、上层锁实现基本都是通过其底层来实现的。
有没有被忽悠的感觉?你没看错AQS就长这个鸟样。说白了其内部就是包含了三个组件
-
-
state 资源状态
-
exclusiveOwnerThread 持有资源的线程
-
CLH 同步等待队列。
-
在看这张图现在明白ReentrantLock 和 AQS 的关系了吧!大白话说就是ReentrantLock其内部包含一个AQS对象(内部类),AQS就是ReentrantLock可以获取和释放锁实现的核心部件。
三、ReentrantLock 加锁和释放锁底层原理实现
好了! 经过上面的介绍估计大家已经对AQS混了个脸熟,下面我们就来说说这一段代码。
ReentrantLock lock = new ReentrantLock(); try { lock.lock(); // 加锁 // 业务逻辑代码 } finally { lock.unlock(); // 释放锁 }
这段代码加锁和释放锁到底会发生什么故事尼?
很简单在AQS 内部有一个核心变量 (volatile)state 变量其代表了加锁的状态,初始值为0。
另外一个重要的关键 OwnerThread 持有锁的线程,默认值为null 在回顾下这张图。
接着线程1过来通过lock.lock()方式获取锁,获取锁的过程就是通过CAS操作volatile 变量state 将其值从0变为1。
如果之前没有人获取锁,那么state的值肯定为0,此时线程1加锁成功将state = 1。
线程1加锁成功后还有一步重要的操作,就是将OwnerThread 设置成为自己。如下图线程1加锁过程。
其实到这大家应该对AQS有个大概认识了,说白了就是并发包下面的一个核心组件,其内部维持state变量、线程变量等核型的东西,来实现加锁和释放锁的过程。
大家有没有不管是ReentrantLock还是ReentrantReadWriteLock 等为什么都是Reentrant 开头尼?
从单词本身意思也能看出,Reentrant 可重入的意思 ,也就说其是一个可重入锁。
可重入锁?
就是你可以对一个 ReentrantLock 进行多次的lock() 和 unlock() 操作,也就是可以对一个锁加多次,叫做可重入锁。 来一段代码直观感受下。
ReentrantLock lock = new ReentrantLock(); try { lock.lock(); // 加锁1 // 业务逻辑代码 lock.lock() // 加锁2 // 业务逻辑代码 lock.lock() // 加锁3 } finally { lock.unlock(); // 释放锁3 lock.unlock(); // 释放锁2 lock.unlock(); // 释放锁1 }
注意:释放锁是由内到外依次释放的,不可缺少。
问题又来了?ReentrantLock 内部又是如何来实现的尼?
说白了!还是我们AQS这个核心组件帮我实现的,很 easy~ 上述两个核心变量 state 和 OwnerThread 还记得吧!
重入就是判断当前锁是不是自己加上的,如果是就代表自己可以在次上锁,每重入一次就是将state值加1。就是这么简单啦!!!
说完了可重入我们再来看看锁的互斥又是如何实现的尼?
此时线程2也跑过来想加锁,CAS操作尝试将 state 从0 变成 1, 哎呀!糟糕state已经不是0了,说明此锁已经被别人拿到了。
接着线程2想??? 这个锁是不是我以前加上的,瞅瞅 OwnerThread=线程1 哎! 明显不是自己上的 ,悲催加锁失败了~~~。来张图记录下线程2的悲苦经历。
可是线程2加锁失败将何去何从尼?
线程2:想,要是有个地方让我休息下,等线程1释放锁后通知我下再来从新尝试上锁就好了。
这时我们的核心部件AQS又登场了!
AQS: OK! 好吧!那我就给你提供一个落脚地吧(CLH)进去待着吧!一会让线程1叫你。
线程2: 屁颠屁颠的就去等待区小憩一会去了。同样来张图记录下线程2高兴样。
此时线程1业务执行完了,开始释放锁
-
将state值改为0
-
将OwnerThread 设为null
-
通知线程2锁我已经用完了,该你登场了
线程2一听,乐坏了!立马开始尝试获取取锁,CAS 尝试将 state 值设为 1 ,如果成功将OwnerThread设为自己 线程2。
此时线程2成功获取到了锁,再来张图瞅瞅。
四、总结
Ok !到这借着Reentrantkock 的加锁和释放锁的过程给大家讲解了一下AQS工作原理。
用一句话总结下:AQS就是Java并发包下的一个基础组件,用来实现各种锁和同步组件的,其核心分为三个组件。
-
Volatile state 变量
-
OwnerThread 加锁线程
-
CLH 同步等待队列
转自:https://mp.weixin.qq.com/s/N9FXv8ga_DFXahr_hDusnQ
公平锁与非公平锁
上一篇提到重入锁 ReentrantLock 支持两种锁,公平锁与非公平锁。那么这篇文章就来介绍一下公平锁与非公平锁。
- 为什么需要公平锁?
- ReentrantLock 如何是实现公平锁和非公平锁的?
- 公平锁和非公平锁又都有什么优缺点呢?
1. 为什么需要公平锁
饥饿
我们知道 CPU 会根据不同的调度算法进行线程调度,将时间片分派给线程,那么就可能存在一个问题:某个线程可能一直得不到 CPU 分配的时间片,也就不能执行。
一个线程因为得不到 CPU 运行时间,就会处于饥饿状态。如果该线程一直得不到 CPU 运行时间的机会,最终会被“饥饿致死”。
1.1 导致线程饥饿的原因
- 高优先级线程吞噬所有的低优先级线程的 CPU 时间。
每个线程都有独自的线程优先级,优先级越高的线程获得的 CPU 时间越多,如果并发状态下的线程包括一个低优先级的线程和多个高优先级的线程,那么这个低优先级的线程就有可能因为得不到 CPU 时间而饥饿。
- 线程被永久堵塞在一个等待进入同步块的状态。
当同步锁被占用,线程处在 BLOCKED 状态等锁。当锁被释放,处在 BLOCKED 状态的线程都会去抢锁,抢到锁的线程可以执行,未抢到锁的线程继续在 BLOCKED 状态阻塞。问题在于这个抢锁过程中,到底哪个线程能抢到锁是没有任何保障的,这就意味着理论上是会有一个线程会一直抢不到锁,那么它将会永远阻塞下去的,导致饥饿。
- 线程在一个对象上等待,但一直没有未被唤醒。
当一个线程调用 Object.wait()之后会被阻塞,直到被 Object.notify()唤醒。而 Object.notify()是随机选取一个线程唤醒的,不能保证哪一个线程会获得唤醒。因此如果多个线程都在一个对象的 wait()上阻塞,在没有调用足够多的 Object.notify()时,理论上是会有一个线程因为一直得不到唤醒而处于 WAITING 状态的,从而导致饥饿。
1.2 解决饥饿
解决饥饿的方案被称之为公平性,即所有线程能公平地获得运行机会。
公平性针对获取锁而言的,如果一个锁是公平的,那么锁的获取顺序就应该符合请求上的绝对时间顺序,满足 FIFO。
2. 公平锁和非公平锁的实现
温馨提示:在理解了上一篇 AQS 实现 ReentrantLock 的原理之后,学习公平锁和非公平锁的实现会很容易。
ReentrantLock 的类结构:
public class ReentrantLock implements Lock, java.io.Serializable { private final Sync sync; abstract static class Sync extends AbstractQueuedSynchronizer {} static final class FairSync extends Sync {} static final class NonfairSync extends Sync {} }
ReentrantLock 锁是由 sync 来管理的,而 Sync 是抽象类,所以 sync 只能是 NonfairSync(非公平锁)和 FairSync(公平锁)中的一种,也就是说重入锁 ReentrantLock 要么是非公平锁,要么是公平锁。
ReentrantLock 在构造时,就已经选择好是公平锁还是非公平锁了,默认是非公平锁。源码如下:
public ReentrantLock() { sync = new NonfairSync(); } public ReentrantLock(boolean fair) { sync = fair ? new FairSync() : new NonfairSync(); }
上一篇讲解了重入锁实现同步过程:
- 线程 1 调用 lock()加锁,判断 state=0,所以直接获取到锁,设置 state=1 exclusiveOwnerThread=线程 1。
- 线程 2 调用 lock()加锁,判断 state=1 exclusiveOwnerThread=线程 1,锁已经被线程 1 持有,线程 2 被封装成结点 Node 加入同步队列中排队等锁。此时线程 1 执行同步代码,线程 2 阻塞等锁。
- 线程 1 调用 unlock()解锁,判断 exclusiveOwnerThread=线程 1,可以解锁。设置 state 减 1,exclusiveOwnerThread=null。state 变为 0 时,唤醒 AQS 同步队列中 head 的后继结点,这里是线程 2。
- 线程 2 被唤醒,再次去抢锁,成功之后执行同步代码。
获取锁的方法调用栈:lock()--> acquire()--> tryAcquire()--> acquire()
acquire()是父类 AQS 的方法,公平锁与非公平锁都一样,不同之处在于 lock()和 tryAcquire()。
lock()方法源码:
// 公平锁FairSync final void lock() { acquire(1); } // 非公平锁NonfairSync final void lock() { // 在调用acquire()方法获取锁之前,先CAS抢锁 if (compareAndSetState(0, 1)) // state=0时,CAS设置state=1 setExclusiveOwnerThread(Thread.currentThread()); else acquire(1); }
可以看到,非公平锁在调用 acquire()方法获取锁之前,先利用 CAS 将 state 修改为 1,如果成功就将 exclusiveOwnerThread 设置为当前线程。
state 是锁的标志,利用 CAS 将 state 从 0 修改为 1 就代表获取到了该锁。
所以非公平锁和公平锁的不同之处在于lock()之后,公平锁直接调用 acquire()方法,而非公平锁先利用 CAS 抢锁,如果 CAS 获取锁失败再调用 acquire()方法。
那么,非公平锁先利用 CAS 抢锁到底有什么作用呢?
回忆一下释放锁的过程 AQS.release()方法:
- state 改为 0,exclusiveOwnerThread 设置为 null
- 唤醒 AQS 队列中 head 的后继结点线程去获取锁
如果在线程 2 在线程 1 释放锁的过程中调用 lock()方法获取锁,
对于公平锁:线程 2 只能先加入同步队列的队尾,等队列中在它之前的线程获取、释放锁之后才有机会去抢锁。这也就保证了公平,先到先得。
对于非公平锁:线程 1 释放锁过程执行到一半,“①state 改为 0,exclusiveOwnerThread 设置为 null”已经完成,此时线程 2 调用 lock(),那么 CAS 就抢锁成功。这种情况下线程 2 是可以先获取非公平锁而不需要进入队列中排队的,也就不公平了。
tryAcquire()方法源码:
// 公平锁 protected final boolean tryAcquire(int acquires) { final Thread current = Thread.currentThread(); int c = getState(); if (c == 0) {// state==0表示没有线程占用锁 if (!hasQueuedPredecessors() && // AQS队列中没有结点时,再去获取锁 compareAndSetState(0, acquires)) { // CAS获取锁 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; } // 非公平锁 protected final boolean tryAcquire(int acquires) { return nonfairTryAcquire(acquires); } final boolean nonfairTryAcquire(int acquires) { final Thread current = Thread.currentThread(); int c = getState(); if (c == 0) {// state==0表示没有线程占用锁 if (compareAndSetState(0, acquires)) {// CAS获取锁 setExclusiveOwnerThread(current); return true; } } else if (current == getExclusiveOwnerThread()) {// 重入 int nextc = c + acquires; if (nextc < 0) // overflow throw new Error("Maximum lock count exceeded"); setState(nextc); return true; } return false; }
两个 tryAcquire()方法只有一行代码不同,公平锁多了一行!hasQueuedPredecessors()。hasQueuedPredecessors()方法是判断 AQS 队列中是否还有结点,如果队列中没有结点返回 false。
公平锁的 tryAcquire():如果 AQS 同步队列中仍然有线程在排队,即使这个时刻没有线程占用锁时,当前线程也是不能去抢锁的,这样可以保证先来等锁的线程先有机会获取锁。
非公平锁的 tryAcquire():**只要当前时刻没有线程占用锁,不管同步队列中是什么情况,当前线程都可以去抢锁。**如果当前线程抢到了锁,对于那些早早在队列中排队等锁的线程就是不公平的了。
分析总结:
非公平锁和公平锁只有两处不同:
- lock()方法:
公平锁直接调用 acquire(),当前线程到同步队列中排队等锁。
非公平锁会先利用 CAS 抢锁,抢不到锁才会调用 acquire()。- tryAcquire()方法:
公平锁在同步队列还有线程等锁时,即使锁没有被占用,也不能获取锁。非公平锁不管同步队列中是什么情况,直接去抢锁。
3. 公平锁 VS 非公平锁
非公平锁有可能导致线程永远无法获取到锁,造成饥饿现象。而公平锁保证线程获取锁的顺序符合请求上的时间顺序,满足 FIFO,可以解决饥饿问题。
公平锁为了保证时间上的绝对顺序,需要频繁的上下文切换,性能开销较大。而非公平锁会降低一定的上下文切换,有更好的性能,可以保证更大的吞吐量,这也是 ReentrantLock 默认选择的是非公平锁的原因。
总结
一个线程因为得不到 CPU 运行时间,就会处于饥饿状态。公平锁是为了解决饥饿问题。
公平锁要求线程获取锁的顺序符合请求上的时间顺序,满足 FIFO。
在获取公平锁时,要先看同步队列中是否有线程在等锁,如果有线程已经在等锁了,就只能将当前线程加到队尾。只有没有线程等锁时才能获取锁。而在获取非公平锁时,不管同步队列中是什么情况,只要有机会就尝试抢锁。
非公平锁有更好的性能,可以保证更大的吞吐量。