网上很多答案都说老版本
Synchronized是重量级锁,而重量级锁产生的原因是线程阻塞和唤醒需要操作系统经历内核态到用户态的相互切换。那么问题来了,ReentrantLock的lock()方法同样会使得线程进行休眠阻塞,那么为什么ReentrantLock的效率还会更高呢?
今天逛知乎收到了这个问题引发了我分析ReentrantLock源码的想法,主体还是分析分析AQS怎么做到的可重入锁方案,还有探讨为什么它比较快(相较于重量级锁)
在分析源码前,我大概了解Lock主要用的还是cas方式实现的方法,其内部首先使用了cas做预判,如果预判发现上锁成功则直接就不上锁了,使用RLock还可以有很多比如tryLock和公平锁非公平锁等等功能
好,自己之前浅薄的了解,可能存在错误,现在进入源码的世界分析分析
类族(了解,没啥用就先看看字段和类的继承情况)
上面是字段、属性和内部类的的类图, 这个类的内部结构相对比较简单,难的在这里
由于这里有很多细节,我把这张图分开分享出来
首先看下这个类Node
可以发现这是一个存放数据的类,其中waitStatus存放的是节点的状态,prev和next存放的是前后双向链表,nextWaiter存放的是下一个等待状态的节点, 这个类的主要目的是保存各个节点的状态是什么
然后再看下ConditionObject类
这是个单向链表,主要存放等待状态的节点
简单的看看他也是一个单向链表和状态
[1]在分析Lock源码的 lock -- acquire -- tryAcquire 函数中用作Lock上锁是否成功的门票,在ReentrantLock中state只有两个值, 1 or 0
[1] 做这个标记的位置,在本博客下搜索下你会发现有三个,表示几个节点知识相同的意思
起步分析下构造方法
public ReentrantLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
}
这里构造方法最全,fair == true 就是公平锁,fair == false 则为非公平锁
我们这里以公平锁分析,和非公平锁之间的区别就是检测下队列中是否已经存在旧的
Node
现在从怎么使用ReentrantLock开始分析
public void lock() {
sync.acquire(1);
}
// arg = 1
public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
分析 tryAcquire 函数
protected final boolean tryAcquire(int acquires) {
final Thread current = Thread.currentThread();
// 获取 `state` 状态
int c = getState();
// 如果为空,表示初次上锁
if (c == 0) {
// 判断是否有旧的等待时间更长的线程在队列中;
// 修改 `state` 状态,初次修改 `state` 状态,把值修改为 `acquires == 1`(因为前面已经传递了 1 );
// 设置独占拥有的线程
// 下面这个 `if` 语句中可以表示:
// ① 如果前面的队列为空,找不到旧的Node, 这里就是公平锁判断方案的方法
// ② 修改 `state` 的值,初次修改为 0 修改为 1
if (!hasQueuedPredecessors() &&
compareAndSetState(0, acquires)) {
// `if` 语句能够进来,可以设置独占线程拥有锁对象
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;
}
上面的 tryAcquire 这个方法表示如果是初次上锁,则设置锁对象独占线程,并且把重入数量增加到 1 ,如果不是初次上锁,判断是是否是原本独占的线程,如果是则锁重入数量 +1
现在分析下公平锁函数 hasQueuedPredecessors
分析方法前需要注意这几个 waitStatus 的状态
/** waitStatus值表示线程已经取消 */
static final int CANCELLED = 1;
/** waitStatus的值,用于指示继承者的线程需要unparking。 */
static final int SIGNAL = -1;
/** waitStatus值表示线程正在等待条件. */
static final int CONDITION = -2;
/** waitStatus值,表示下一个acquireShared应该无条件地传播。 */
static final int PROPAGATE = -3;
public final boolean hasQueuedPredecessors() {
Node h, s;
if ((h = head) != null) {
// 获取了头节点的下一个节点 == null 或者 `waitStatus` 为取消状态
if ((s = h.next) == null || s.waitStatus > 0) {
s = null; // traverse in case of concurrent cancellation
// 查找出刚刚创建、`CONDITION` 和 `PROPAGATE` 的状态,给 s 变量
for (Node p = tail; p != h && p != null; p = p.prev) {
if (p.waitStatus <= 0)
s = p;
}
}
// 如果找到的 Node 线程和当前线程相同的话,则表示存在以前的节点, 说明队列前面存在可用可执行的 Node
if (s != null && s.thread != Thread.currentThread())
return true;
}
return false;
}
既上锁不成功(说明竞争失败),又不是 Owner 的那个线程无法进行锁重入,那么只有
private Node addWaiter(Node mode) {
// 获取新的 `Node`
Node node = new Node(mode);
for (;;) {
Node oldTail = tail;
if (oldTail != null) {
// 往 `node` 的 `prev` 里添加 `tail`
node.setPrevRelaxed(oldTail);
// 修改 `tail` 的地址 ,这句话相当于这样
/**
* `if (oldTail == 内存实际值 tail) { 内存实际值 tail = node }`
*/
if (compareAndSetTail(oldTail, node)) {
// 这句话相当于 `tail.next = node`
oldTail.next = node;
return node;
}
} else {
// 使用 cas 搞了个头节点
initializeSyncQueue();
}
}
}
- 上面的代码就是
node.prev = tail;然后再tail = node;之后oldTail.next = node; // 这里的oldTail是之前tail地址
说白了就类似双向链表添加了个新的 node 的方法
记住了, 这里其实是
AQS中的那个链表,主要记录wait node到aqs中
// `node` 添加到 `wait` 单向链表最后的那个节点, `arg` 重入次数, 这里 `arg == 1`
final boolean acquireQueued(final Node node, int arg) {
boolean interrupted = false;
try {
for (;;) {
// 获取到前一个节点
final Node p = node.predecessor();
// 再次尝试获取独占状态,但在我的流程中,它将会是 `false` 不满足条件,但是 `head` 的地址给了 `p` 对象
if (p == head && tryAcquire(arg)) {
setHead(node);
p.next = null; // help GC
return interrupted;
}
// 如果 `p` 和 `node` 之间存在某种关系导致返回值为 `true` 则直接阻塞线程,我们知道,阻塞线程是因为这个锁正在被别人使用,而当前线程想要或者锁,却失败了,片面的了解了下,我们再考虑考虑这里的参数变化情况,`p` 这个对象不断通过 `node` 获取 `node` 前面的节点,然后放入下面这个函数进行某种算法,导致返回了不同的结果,这里 `node` 和 `node.prve` 在这里并没有看到被修改过。我们估计它将在下面的函数中被修改掉
// 现在我们正式分析这个函数, 分析过程在下面的代码块中
if (shouldParkAfterFailedAcquire(p, node))
// `park` 阻塞了,还把当前是否为中断导致的状态丢给 `interrupted` 变量
interrupted |= parkAndCheckInterrupt();
}
} catch (Throwable t) {
cancelAcquire(node);
if (interrupted)
selfInterrupt();
throw t;
}
}
函数 shouldParkAfterFailedAcquire 的分析
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
int ws = pred.waitStatus;
if (ws == Node.SIGNAL)
/*
* 在这个分支中发现如果 `Node.SINGAL` 则直接返回了 `true` ,
* 直接进入了阻塞,只要是 `SINGAL` 状态的 `Node` 只要在这里返回了 `true` 阻塞了,
* 那么一般只能等待 `unparking` 了
*/
return true;
if (ws > 0) {
/*
* 大于零的情况下,我们发现 pred 已经被取消了,所以需要跳过,再找下一个,直到找不到已经取消的 Node 节点为止
*/
do {
node.prev = pred = pred.prev;
} while (pred.waitStatus > 0);
// 找到了不是取消状态的节点后,将这个节点的 `next` 字段设置成 `node` 的引用地址
pred.next = node;
} else {
/*
* `waitStatus` 必须为0或 `PROPAGATE` 。 表示我们需要一个 `signal` ,但还不能 `park` 。 调用者需要重试,以确保不能 `acquire` 才 `parking`
*/
pred.compareAndSetWaitStatus(ws, Node.SIGNAL);
}
return false;
}
这个函数主要的目的还是找 signal 状态的节点,如果不是 signal 状态则去找到没有被取消掉的 Node 等到下次进入该函数再次判断 waitStatus 的状态, 如果该状态为 初创 或者是 PROPAGATE 状态时需要使用 cas 修改成 Signal 状态等待下次再次进入这个函数
老版本的 Synchronized 为什么慢?
其实就是 ReentrantLock 不会直接上锁,而是首先使用的 cas 操作然后再使用的重量级锁,并且还支持优先级队列方案,但是老版本的 Synchronized 直接就是重量级锁了,所以慢了
新版本的 Synchronized 都做了什么?
新版本的 Synchronized 优化了很多功能,主要的优化有:
新版本不允许直接使用上重量级锁,它有两道门坎拦着,第一道是 偏向锁 ,第二道是 轻量级锁
在介绍这两道门坎之前,我们需要首先了解下 对象头 结构
对象头
如上图,对象头由三个部分组成, 其中最为重要的部分是 Mark Word
Mark Word
偏向锁
第一道是偏向锁(其实也可以叫自旋锁, 而后面的轻量级锁也是自旋锁的一部分)
偏向锁的使用前提是如果只有一个线程的话,或者两个或者多个线程交替执行,那么就会使用偏向锁,偏向锁的优势就是只做一次cas操作,如果锁对象即将进入偏向锁时,会有这几个步骤:
- 修改标志位为 01
- 修改偏向模式为 1
- 使用 cas 方式将执行上偏向锁的线程id记录在 mark word 的 线程ID 上
经过这三个步骤,偏向锁上锁完毕
这样在线程只有一个的情况下, 不会再出现任何同步操作(比如加锁,解锁和修改Mark Word等操作)。
但是现在呢,如果突然出现了另一个线程怎么办???
这种情况分两种
线程A运行同步代码块退出后另一个线程B也进入了,这个时候偏向锁将会被重偏向,但是这种 重偏向 也是存在问题,如果重偏向过多的话(貌似是20还是40次),那么就会将偏向锁升级为轻量级锁线程A正在运行同步代码块,还没退出,线程B运行来了,发现同步代码块中存在另一个线程(线程A),那么此时线程A将被挂起,将偏向锁升级为轻量级锁
锁升级的过程在下图:
刚刚开始的时候
① 标志位 = 01 | 可偏向标志位 = 1 | 线程ID 位置为 空
② 初始锁定,现在准备使用 cas 方式将 thread id 设置到 mark word 的 Thread ID 位置,此时偏向锁上锁完毕
③ 重偏向, 如果一个线程执行完毕同步代码块之后,另一个线程也进入同步代码块,此时偏向锁将被释放掉,置空 thread Id 位置
④ 如果 hashCode 已经存在,则偏向锁是无法使用的,那么此时上锁只能上轻量级锁,轻量锁的上锁方式其实也很简单,主要还是在 mark word 上空出存放指针地址的内存,然后修改下 所标志位 为 00, 而空出存放指针的位置给线程存放当前线程的栈帧中建立的名为锁记录(Lock Record)的空间地址
⑤ 如果轻量级出现竞争状况,此时将要自旋 10 次,如果 10 次之后将会膨胀,将锁标志位修改为 10 状态,然后原本记录锁记录空间的地址修改为 重量级锁指针 地址
轻量级锁
轻量级锁也可以叫 自旋锁(前面的偏向锁也可以叫自旋锁),所谓自旋就是 while 空转,防止线程直接进入 重量级锁 中(其实这里就是防止系统调用【中断】,用户态转内核态再转用户态),而 while 空转的次数一般是 10 次
现在我们来聊聊,偏向锁或者无法偏向锁的情况升级到轻量级锁的详细过程
在 锁标志位 为 01 的情况下,线程会创建 锁记录 空间,现在呢开始正式进入轻量级上锁时间
① 将 mark word 的内容 copy 一份到锁记录空间 lock record 中,然后将 mark word 的空间空出来,做成指针指向刚刚的锁记录空间地址
② 将 mark word 修改为 ptr 指针方式,现在这个指针指向了 线程的 lock record 的地址, 这代表着 锁对象 拥有了 线程锁记录 地址, 然后把 锁标志位 修改位 00
好了轻量级上锁成功
解锁的过程其实很简单
现在 锁对象 存在这么一个指向 lock record 的指针,现在解锁方法是直接把 lock record 中的内容 copy 到 mark word 中(使用 cas ),如果替换成功,说明解锁成功
轻量级锁膨胀到重量级锁的过程
如果在轻量级锁上锁的过程中存在另一个线程B也在抢占这个锁,现在的情况是线程B发现这个锁对象正在上锁阶段,此时线程B选择自旋,自旋的次数一般为 10 次,如果 10 次自旋后还是不能够抢到锁,现在开始膨胀为重量级锁了
① mark word 空间变成一个重量级指针,现在将重量级指针指向重量级锁的Monitor指针,并且修改 mark word 中的锁状态为 10