Java并发——别再问 ReentrantLock 的原理了

说到并发,我们会马上想到锁,使用锁来保证各线程之间能够安全访问临界区以及非线程安全的数据。
那为啥 Java 要提供另一种机制呢?难道 synchronized 关键字不香吗?嗯,它确实在有些场景
不是那么香,从而迫切需要提供一种更灵活,更易于控制的锁机制。那在去了解 Doug Lea 大佬写的
锁机制原理之前,我们自己先想想应该怎么去实现。

自实现思考

1、需要一个标志是否可以访问共享资源,如果直接使用 boolean 变量来做标记,可以控制。但是,
在如果在可以并发性访问某个共享资源,那么就不好做了,所以考虑可以使用 int 变量来表示资源数
2、当线程需要阻塞时,怎么去阻塞它?另一个线程释放锁时,怎么去通知阻塞中的线程?

伪代码

class MyLock {
    int resources = 0;
    
    public void lock() {
        while (!compareAndSet(resources, 0, 1)) {
           sleep(10);
        }
            
        // 未被其它线程占有
        setOwnerThread(Thread.currentThread);
    }
    
    public void unlock() {
        resouces = 0;
    }
}

伪代码很简单,只是简单描述了思路,具体的东西都没有展现出来,这些我们就看大神是怎么思考以及实现的。

ReentrantLock

类图

ReentrantLock 类图

ReentrantLock 类定义了三个内部类,可以说 ReentrantLock 类的逻辑就是由这三个内部类来完成的。Sync 内部类是 FairSync 和 NonfairSync 类的父类。

非公平独占锁

在了解 ReentrantLock 类的结构后,我们先看看它的加锁逻辑。

public void lock() {
    // 直接调用内部类 Sync 的 lock() 方法
    sync.lock();
}

// Sync 类的 lock() 方法时抽象方法,这样定义主要是更快速实现非公平锁
final void lock() {
    // 非公平锁会立即就去抢占锁,而公平锁没有这一步
    // compareAndSetState() 使用 CAS 机制来更新变量值
    if (compareAndSetState(0, 1)) 
        // 此方法不需要任何同步操作
        setExclusiveOwnerThread(Thread.currentThread());
    else
        // 说明已经有其他线程获取到锁,去尝试获取锁
        // 核心
        acquire(1);
}

其流程图如图:


lock

AbstractQueuedSynchronizer

用于实现阻塞式锁和相关同步器(信号量和事件等)的通用组件,它底层依赖FIFO
的等待队列。

在上述过程中如果没有抢到锁,就会进入核心 acquire() 方法,我们看看这里的逻辑:

// 入参 arg = 1
public final void acquire(int arg) {
    // ① tryAcquire(arg)
    // ② 获取锁失败,先调用 addWaiter(Node.EXCLUSIVE) 方法,然后调用 acquireQueued() 方法加入同步队列去排队
    if (!tryAcquire(arg) &&
        acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
        // 中断自己
        selfInterrupt();
}

AQS 中的 tryAcquire() 方法是个空实现,直接抛 UnsupportedOperationException,所以这个方法是留给子类去实现的,是不是想起了设计模式中的模板模式。既然 AQS 没有提供默认实现,那我们进入 ReentrantLock 类的内部类 NonfairSync 类看看。

// 入参 acquires = 1
protected final boolean tryAcquire(int acquires) {
    // 调用其父类 Sync 的 nonfairTryAcquire() 方法
    return nonfairTryAcquire(acquires);
}

Sync 类的 nonfairTryAcquire() 方法:

final boolean nonfairTryAcquire(int acquires) {
    final Thread current = Thread.currentThread();
    // 获取资源状态变量
    int c = getState();
    if (c == 0) { // 没有被线程占有,要去占有它
        // CAS 设置 state 变量值,防止在更新 state 变量值时有其他
        // 线程也在更新,从而避免使用 JVM 同步机制
        if (compareAndSetState(0, acquires)) {
            // 更新成功,成功获取锁
            setExclusiveOwnerThread(current);
            return true;
        }
    }
    // 判断获取锁的线程是否是当前线程,可重入
    else if (current == getExclusiveOwnerThread()) {
        int nextc = c + acquires;
        if (nextc < 0) // overflow
            throw new Error("Maximum lock count exceeded");
        // 这里不需要使用 CAS,也不需要 JVM 同步机制,因为此时肯定没有
        // 其他线程能更改此变量
        setState(nextc);
        return true;
    }
    
    return false;
}

这里逻辑不复杂,就两步:
① 锁是否有被其他线程占用
② 锁是否被同一线程所占有

如果上述操作没有获取到锁,则会执行 AQS#addWaiter() 方法。在说 addWaiter() 方法逻辑之前,我们先瞧瞧 AQS 的类结构。


AQS

我们在分别看看 AQS 和 Node 的数据结构。

  • AQS


    AQS

    从结构上看,AQS 是一个队列,head 和 tail 分别指向队列的头尾节点,后缀为 Offset 的变量是内存地址,
    CAS 操作需要用到。

  • Node


    Node

    它作为队列中的节点对象,从 Node 数据结构可以看出 AQS 是一个双端队列。其中核心字段是 waitStatus,它可以具有的值:

    • CANCELLED = 1
      超时或中断,节点(线程)被删除
    • SIGNAL = -1
      当前节点的后继节点处于(或即将处于)阻塞状态,因此当前在释放锁或被删除时需要唤醒它的后继节点
    • CONDITION = -2
      当前节点处于条件队列中,而不是同步队列。当它被转移到同步队列时,waitStatus = 0
    • PROPAGATE = -3
      在共享模式下,传播

    waitStatue 的值非负数意味着它不需要被唤醒。它的值变化主要在于前继节点

我们回归正题,继续看 addWaiter() 方法的逻辑。

// 入参 mode = Node.EXCLUSIVE
private Node addWaiter(Node mode) {
    // 当前线程封装为 Node 节点
    Node node = new Node(Thread.currentThread(), mode);
    // 判断pred是否为空,其实就是判断对尾是否有节点,其实只要队列被初始化了队尾肯定不为空
    // 假设队列里面只有一个元素,那么对尾和对首都是这个元素
    // 
    Node pred = tail;
    if (pred != null) {
        node.prev = pred;
        if (compareAndSetTail(pred, node)) {
            pred.next = node;
            return node;
        }
    }
    
    // 同步队列没有被初始化
    enq(node);
    return node;
}

private Node enq(final Node node) {
    for (;;) { // 循环入队
        // 进入这个方法时,同步队列还没有被初始化
        // 故而第一次循环t==null(因为是死循环,因此强调第一次,后面可能还有第二次、第三次,每次t的情况肯定不同)
        Node t = tail;
        if (t == null) { // 初始化,队列有哨兵头节点
            if (compareAndSetHead(new Node())) // CAS 设置头节点
                tail = head;
        } else {
            // 这里,表明同步已经被初始化
            node.prev = t;
            if (compareAndSetTail(t, node)) { // CAS 设置尾节点
                t.next = node;
                return t;
            }
        }
    }
}

上面操作只是把当前线程作为节点入队,逻辑不难,有一点可以稍加注意的是快速入队操作。现在我们看看核心逻辑 acquireQueued() 方法。

// 非中断状态下
final boolean acquireQueued(final Node node, int arg) {
    // 标记
    boolean failed = true;
    try {
        boolean interrupted = false;
        // 两种情况:1、上一个节点为头部;2上一个节点不为头部
        for (;;) { // 死循环,直至遇到退出条件
            final Node p = node.predecessor(); // 插入节点的前继节点
            // ①
            if (p == head && tryAcquire(arg)) {
                setHead(node);
                p.next = null; // help GC
                failed = false;
                return interrupted;
            }
            // ②
            if (shouldParkAfterFailedAcquire(p, node) &&
                parkAndCheckInterrupt())
                interrupted = true;
        }
    } finally {
        if (failed)
            cancelAcquire(node);
    }
}

// 为获取锁失败的节点检查且更新其状态,如果线程需要阻塞,则方法返回 true
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
    int ws = pred.waitStatus;
    if (ws == Node.SIGNAL) // 前继节点的 waitStatus = -1
        // 节点早已告知其前继节点,我需要你 signal 我,因此节点可以被 阻塞
        return true;
    if (ws > 0) { // 前继节点的 waitStatus = 1, 被删除
        // 前继节点被删除,所以可以忽略,从新找出它的前继节点
        do {
            node.prev = pred = pred.prev;
        } while (pred.waitStatus > 0);
        pred.next = node;
    } else {
        // 前继节点的 waitStatus 肯定为 0 或 PROPAGATE,此时需要 signal 信号,但还无需阻塞
        compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
    }
    return false;
}

假设当前 A 线程已获取到锁,没有释放,而此时 B 线程来获取锁资源,那此时同步队列是这个样子的:

初始同步队列

所以注释 ① 下的代码就明白为什么这样做了。虽然当时 B 线程没有获取到锁,并且入了同步队列,但可能 A 线程之后释放了锁,而现在 B 线程节点的前继节点是 head 节点,所以 B 线程节点可以再尝试获取锁,如果获取到锁,那么把当前节点变成 head 节点,之前的 head 节点被垃圾回收。 如果 A 线程没有释放锁,则会进入注释 ②
的代码,用于判断需不需要马上阻塞 B 线程以及更新其节点状态。从代码可知,节点不会马上进入阻塞,而是再下一轮
确定后再进入阻塞。

假设现在 B 线程节点没有获取到锁,那么此时队列是这样子的:


初始队列

现在假设 C 线程来获取锁,那么此时队列是这样子的:


多个节点队列

整个简易流程如图:
简单lock流程图

非公平释放锁

释放锁逻辑:

// 如果当前线程占用锁资源,那么它持有的数量减 1,持有数量为 0,则释放锁。
// 如果当前线程没有占用锁资源,那么抛出异常 IllegalMonitorStateException
public void unlock() {
    sync.release(1);
}

释放锁逻辑直接委派给其内部类 Sync,而 Sync 类没有重写 release() 方法,因此直接调用其父类 AQS 的 release() 方法。

// arg = 1
public final boolean release(int arg) {
    // 尝试释放锁,只有当没有任何线程占有锁的时候,tryRelease() 方法才会返回 true
    if (tryRelease(arg)) {
        // 表示同步队列其他线程可以竞争锁
        Node h = head;
        if (h != null && h.waitStatus != 0)
            // 核心,唤醒后继节点
            unparkSuccessor(h);
        return true;
    }

    return false;
}

tryRelease() 方法由 Sync 类实现:

// 尝试释放锁,只有当没有任何线程占有锁的时候,tryRelease() 方法才会返回 true
protected final boolean tryRelease(int releases) {
    // 更新 state
    int c = getState() - releases;
    if (Thread.currentThread() != getExclusiveOwnerThread())
        throw new IllegalMonitorStateException();
    boolean free = false;
    if (c == 0) {
        free = true;
        setExclusiveOwnerThread(null);
    }
    setState(c);
    return free;
}

尝试释放锁资源的逻辑还是比较简单,然后我们再看看在释放锁成功后,唤醒后继节点的逻辑。

// node = head
private void unparkSuccessor(Node node) {
    int ws = node.waitStatus;
    if (ws < 0)
        compareAndSetWaitStatus(node, ws, 0);
    
    Node s = node.next;
    // 后继节点是否存在或后继节点是否被删除
    if (s == null || s.waitStatus > 0) {
        // 从后向前遍历,找到其第一个真正意义上的后继节点
        s = null;
        for (Node t = tail; t != null && t != node; t = t.prev)
            if (t.waitStatus <= 0)
                s = t;
    }

    if (s != null)
        // 唤醒
        LockSupport.unpark(s.thread);
}

公平独占锁

公平独占锁核心逻辑差不都,只是在尝试获取锁时逻辑有点不一样,即 tryAcquire() 方法。

// 可以跟 NonfairSync 类的 tryAcquire() 方法的逻辑比较下
protected final boolean tryAcquire(int acquires) {
    final Thread current = Thread.currentThread();
    int c = getState();
    if (c == 0) {
        // 前面增加了一个判断条件 hasQueuedPredecessors() 方法
        // 检查同步队列是否有其他线程等待,有,不去获取锁,入队
        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;
}

独占锁公平模式或不公平模式的区别就在于 lock() 方法,一个上来直接去获取锁,一个上来先判断同步队列中有没有其他线程在等待。之后后面的逻辑是一样的。在分析逻辑时要注意两点:

  • head 节点的线程已经拿到锁
  • 不管是公平还是不公平,一朝入同步队列,就只能乖乖的等待
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 219,753评论 6 508
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 93,668评论 3 396
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 166,090评论 0 356
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 59,010评论 1 295
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 68,054评论 6 395
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,806评论 1 308
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,484评论 3 420
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,380评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,873评论 1 319
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 38,021评论 3 338
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 40,158评论 1 352
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,838评论 5 346
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,499评论 3 331
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 32,044评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 33,159评论 1 272
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,449评论 3 374
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 45,136评论 2 356

推荐阅读更多精彩内容