博客记录-day036-ConcurrentLinkedQueue+死锁、悲观锁、乐观锁

78 阅读32分钟

一、沉默王二-并发编程

1、ConcurrentLinkedQueue

ConcurrentLinkedQueue 是 java.util.concurrent(JUC) 包下的一个线程安全的队列实现。基于非阻塞算法(Michael-Scott 非阻塞算法的一种变体),这意味着 ConcurrentLinkedQueue 不再使用传统的锁机制来保护数据安全,而是依靠底层原子的操作(如 CAS)来实现。

ConcurrentLinkedQueue 是一种先进先出(FIFO,First-In-First-Out)的队列,它是一个基于链接节点的无界线程安全队列。该队列的元素遵循先进先出的原则。头是最先加入的,尾是最近加入的。该队列不允许 null 元素。

ConcurrentLinkedQueue 采用了 HOPS 的设计,即 head 和 tail 是延迟更新的,这种设计的主要目的是减小多线程环境下的争用,并提高性能。

ConcurrentLinkedQueue 的 offer 方法用于在队列尾部插入一个元素。如果成功添加元素,则返回 true。

ConcurrentLinkedQueue 的 poll 方法用于检索并删除队列的头部元素。如果队列为空,则返回 null。

ConcurrentLinkedQueue 的 isEmpty 方法用于检索队列是否为空。

ConcurrentLinkedQueue 的 size 方法用于返回队列的大小。

1.1 节点类Node

先从它的节点类 Node 看起,好明白 ConcurrentLinkedQueue 的底层数据结构。Node 类的源码如下:

private static class Node<E> {
        volatile E item;
        volatile Node<E> next;
		.......
}

Node 节点包含了两个字段:

  • 一个是数据域 item
  • 另一个是 next 指针,用于指向下一个节点从而构成链式队列。

另外,ConcurrentLinkedQueue 还有这样两个成员变量:

private transient volatile Node<E> head;
private transient volatile Node<E> tail;

说明 ConcurrentLinkedQueue 通过持有头尾两个引用来进行队列管理。当我们调用无参构造方法时,其源码如下:

public ConcurrentLinkedQueue() {
    head = tail = new Node<E>(null);
}

head 和 tail 会指向同一个节点,此时 ConcurrentLinkedQueue 的状态如下图所示:

ConcurrentLinkedQueue初始化状态

head 和 tail 指向同一个节点 Node0,该节点的 item 字段为 null,next 字段也为 null。

在队列进行出队入队的时候,免不了要对节点进行操作,在多线程环境下就很容易出现线程安全问题。ConcurrentLinkedQueue 选择使用 CAS 来保证线程安全

//更改Node中的数据域item
boolean casItem(E cmp, E val) {
    return UNSAFE.compareAndSwapObject(this, itemOffset, cmp, val);
}
//更改Node中的指针域next
void lazySetNext(Node<E> val) {
    UNSAFE.putOrderedObject(this, nextOffset, val);
}
//更改Node中的指针域next
boolean casNext(Node<E> cmp, Node<E> val) {
    return UNSAFE.compareAndSwapObject(this, nextOffset, cmp, val);
}

可以看出,这些方法实际上调用的是 UNSAFE 的方法

1.2 offer方法

ConcurrentLinkedQueue 是一种先进先出(FIFO)的队列,offer 方法用于在队列尾部插入一个元素。如果成功添加元素,则返回 true。下面是这个方法的一般定义:

public boolean offer(E e)

来看这么一段代码:

ConcurrentLinkedQueue<Integer> queue = new ConcurrentLinkedQueue<>();
queue.offer(1);
queue.offer(2);

我们创建一个 ConcurrentLinkedQueue 对象 queue,先 offer 1,再 offer 2。

public boolean offer(E e) {
1.    checkNotNull(e);
2.    final Node<E> newNode = new Node<E>(e);
3.    for (Node<E> t = tail, p = t;;) {
4.        Node<E> q = p.next;
5.        if (q == null) {
6.            // p is last node
7.            if (p.casNext(null, newNode)) {
8.                if (p != t)
9.                    casTail(t, newNode);
10.                return true;
            }
        }
11.        else if (p == q)
12.            p = (t != (t = tail)) ? t : head;
           else
13.            p = (p != t && t != (t = tail)) ? t : q;
    }
}

1、参数检查:checkNotNull(e) 确保传递的元素不是 null。

2、新节点创建:final Node<E> newNode = new Node<E>(e) 创建一个新的节点来保存要添加的元素。

3、尾部节点循环:该循环用于找到队列的尾部节点,并将新节点安全地链接到尾部。

  • a. 读取下一个节点:Node<E> q = p.next 读取当前节点的下一个节点。
  • b. 尾部节点检查:如果 q 是 null,这意味着当前节点 p 是尾部节点。
  • c. CAS操作添加新节点:p.casNext(null, newNode) 使用 CAS 操作将新节点链接到当前的尾部节点。如果成功,则更新尾部引用,并返回 true。
  • d. 双跳尾部更新:casTail(t, newNode) 尝试更新尾部引用,使其指向新的尾部节点。这有助于其他线程更快地找到尾部。
  • e. 掉出列表检查:如果 p == q,这意味着当前线程从列表上掉了下来。此时,代码尝试跳转到头部或新的尾部。
  • f. 进一步检查:否则,代码进行进一步的检查并更新 p 的值,可能是当前的尾部或下一个节点。
1.2.1 单线程执行角度分析

我们再从单线程的角度分析 offer 1 的过程。

第 1 行代码检查元素 e 是否为 null,为 null 就直接抛出空指针异常。

第 2 行代码将 e 包装成一个 Node 对象。

第 3 行为 for 循环,只有初始化条件没有循环结束条件,这很符合 CAS的“套路”,在循环体内,如果 CAS 操作成功会直接 return 返回,如果 CAS 操作失败就在 for 循环中不断重试直至成功。这里实例变量 t 被初始化为 tail,p 被初始化为 t 即 tail。

p 被认为是队列真正的尾节点,tail 不一定是真正的尾节点,因为在 ConcurrentLinkedQueue 中 tail 延迟更新的

代码走到第 3 行的时候,t 和 p 分别指向初始化时创建的 item(null),next 字段也为 null,即 Node0。

第 4 行变量 q 被赋值为 null。

第 5 行 if 判断结果为 true。

第 7 行使用 casNext 将插入的 Node 设置为当前队列尾节点 p 的 next 节点,如果 CAS 操作失败,此次循环结束,下次循环进行重试。

CAS 操作成功走到第 8 行,此时 p==t,if 判断为 false,直接 return true 返回。如果成功插入 1 的话,此时 ConcurrentLinkedQueue 的状态如下图所示:

offer 1后队列的状态

offer 1后队列的状态

此时队列的尾节点应该是 Node1,而 tail 指向的节点依然是 Node0,因此可以说明 tail 是延迟更新的。

那么我们继续看 offer 2,很显然此时第 4 行 q 指向的节点不为 null 了,而是指向 Node1,第 5 行 if 判断为 false,第 11 行 if 判断为 false,代码会走到第 13 行。

好了,再插入节点的时候我们来问自己这样一个问题:tail 并不是真正的尾节点,那么在插入节点的时候,我们是不是应该先找到当前的尾节点才能插入?

第 13 行代码就是找出队列真正的尾节点

p = (p != t && t != (t = tail)) ? t : q;

这段代码在单线程环境执行时,由于 p==t,此时 p 会被赋值为 q,而 q 等于Node<E> q = p.next,即 Node1。

在第一次循环中,p 指向了队列真正的尾节点 Node1,那么在下一次循环中,第 4 行 q 指向的节点为 null,那么第 5 行 if 判断则为 true,第 7 行依然通过 casNext 设置 p 节点的 next 为当前新增的 Node,接下来走到第 8 行,这个时候 p!=t,第 8 行 if 判断为 true,会通过casTail(t, newNode)将当前节点 Node 设置为队列的尾节点,此时的队列的状态示意图如下图所示:

队列offer 2后的状态

tail 指向的节点由 Node0 变为 Node2,这里的 casTail 是不需要重试的,原因是,offer 主要是通过 p 的 next 节点 q(Node<E> q = p.next)决定后面的逻辑走向,casTail 失败时状态示意图如下:

队列进行入队操作后casTail失败后的状态图

如果 casTail 更新 tail 失败,即 tail 还是指向 Node0 节点,无非就是多循环几次,通过第 13 行代码定位到尾节点

通过单线程执行角度的分析,我们可以了解到 offer 的执行逻辑为:

  1. 如果 tail 节点的下一个节点(next 字段)为 null 的话,说明 tail 节点即为队列真正的尾节点,因此可以通过 casNext 插入当前待插入的节点,但此时 tail 并未变化
  2. 如果 tail 节点的下一个节点(next 字段)不为 null 的话,说明 tail 节点不是队列的真正尾节点。通过q(Node<E> q = p.next)往前找到尾节点,然后通过 casNext 插入当前待插入的节点,并通过 casTail 方式更新 tail

在单线程环境下,p = (p != t && t != (t = tail)) ? t : q;这行代码永远不会将 p 赋值为 t,我们试着在多线程的环境下继续分析。

1.2.2 多线程执行角度分析

多线程环境下,p = (p != t && t != (t = tail)) ? t : q; 这行代码就有意思了。

由于 t != (t = tail) 这个操作并非一个原子操作,所以就有这样一种情况:

线程A和线程B有可能的执行时序

假设线程 A 此时读取了变量 t,线程 B 刚好在这个时候 offer 一个 Node,此时会修改 tail,那么线程 A 再次执行 t=tail 时,t 会指向另外一个节点,很显然线程 A 前后两次读取的变量 t 指向的节点不同,即t != (t = tail)为 true,并且由于 t 节点的变化,p != t也为 true,此时该行代码的执行结果是:p 和 t 都指向了同一个节点,并且 t 也是队列真正的尾节点。也就是说,现在已经定位到队列真正的尾节点,可以执行 offer 操作了。

到此为止,还剩下第 11 行的代码没有分析,大家应该可以猜到这种情况:一部分线程 offer,一部分线程 poll(下面会讲,用于检索并删除队列的头部元素,和 offer 是相对的)。

if (p == q)为 true 时,说明 p 节点的 next 也指向它自己,这种节点称之为哨兵节点这种节点在队列中存在的价值不大,一般表示要删除的节点或者空节点。为了能够更好地理解这种情况,我们先看看 poll 方法的执行过程,再回过头来看,总之这是一个很有意思的事情。

1.3 poll方法

poll 方法的源码如下

/**
 * 从队列的头部移除元素。
 * 如果队列为空,则返回null。
 * 
 * @return 队列头部的元素,如果队列为空则返回null。
 */
public E poll() {
    // 标记,用于在需要时重新从头部开始
    restartFromHead:
    for (;;) {
        // 初始化头节点引用和当前节点引用
        for (Node<E> h = head, p = h, q;;) {
            // 读取当前节点的元素
            E item = p.item;

            // 检查当前项是否不为null,并尝试通过CAS移除它
            if (item != null && p.casItem(item, null)) {
                // 成功的CAS操作是移除元素的线性化点
                if (p != h) // 如果当前节点不是头节点,则跳过两个节点
                    updateHead(h, ((q = p.next) != null) ? q : p);
                return item; // 返回被移除的元素
            }
            // 如果下一个节点是null,表示到达队尾
            else if ((q = p.next) == null) {
                updateHead(h, p); // 更新头节点
                return null; // 返回null表示队列为空
            }
            // 如果当前节点等于下一个节点,可能存在并发修改,重新开始
            else if (p == q)
                continue restartFromHead;
            else
                p = q; // 移动到下一个节点
        }
    }
}

1、无限循环:外部的无限循环是为了确保在高并发环境中能够正确地从队列的头部移除元素。

2、初始化引用:对于当前头节点h和节点p(开始时与头节点相同)的初始化。

3、读取当前节点的项:E item = p.item 读取当前节点的元素。

4、检查当前项是否不为null:

  • 如果是,并且CAS操作成功将该项设置为null(即p.casItem(item, null)),则表示元素已成功移除。
  • 如果当前节点不是头节点(p != h),则更新头引用以"跳过"两个节点。
  • 返回被移除的元素。

5、检查是否到达队尾:

  • 如果q = p.next是null,则表示已到达队列的尾部。更新头引用,并返回null表示队列为空。
  • 如果p == q,则表示可能有并发修改造成的异常情况,通过continue restartFromHead跳回外部循环的开始,重新尝试。

6、移动到下一个节点:将p设置为q,即下一个节点,并继续循环。

1.3.1 单线程执行角度分析
public E poll() {
    restartFromHead:
    1. for (;;) {
    2.    for (Node<E> h = head, p = h, q;;) {
    3.        E item = p.item;
    4.        if (item != null && p.casItem(item, null)) {
    5.            if (p != h) // hop two nodes at a time
    6.                updateHead(h, ((q = p.next) != null) ? q : p);
    7.            return item;
            }
    8.        else if ((q = p.next) == null) {
    9.            updateHead(h, p);
    10.            return null;
            }
    11.        else if (p == q)
    12.            continue restartFromHead;
            else
    13.            p = q;
        }
    }
}

假设 ConcurrentLinkedQueue 初始状态如下图所示:

队列初始状态

参数 offer 时的定义,我们将变量 p 作为要删除的头节点,h(head)并不一定是队列的头节点

先来看 poll 出 Node1 时的情况,由于p=h=head,很显然此时 p 指向的 Node1 的数据不为 null,第 4 行代码item!=null 的判断为 true,接下来通过casItem将 Node1 的数据设置为 null。

如果 CAS 失败则此次循环结束,等待下一次循环进行重试。

若第 4 行执行成功进入到第 5 行代码,此时 p 和 h 都指向 Node1,第 5 行 if 判断为 false,然后直接到第 7 行 return 回 Node1 的数据域 1,方法结束,此时的队列状态如下图所示。

队列出队操作后的状态

继续从队列中 poll,很显然当前 h 和 p 指向的 Node1 的数据为 null,那么第一件事就是要定位准备删除的头节点(找到数据不为 null 的节点)

继续看,第三行代码 item 为 null,第 4 行代码 if 判断为 false,走到第 8 行代码(q = p.next),if 也为 false,由于 q 指向了 Node2,第 11 行的 if 判断也为 false,因此代码走到了第 13 行,这个时候 p 和 q 共同指向了 Node2,也就找到了要删除的真正的头节点。

定位待删除的头节点的过程为:如果当前节点的数据为 null,很显然该节点不是待删除的节点,就用当前节点的下一个节点去试探。经过第一次循环后,此时状态图为下图所示:

经过一次循环后的状态

经过一次循环后的状态

进行下一次循环,第 4 行的操作同上所述,假设第 4 行中 casItem 设置成功,由于 p 已经指向了 Node2,而 h 依旧指向 Node1,此时第 5 行的 if 判断为 true,然后执行updateHead(h, ((q = p.next) != null) ? q : p),此时 q 指向 Node3,updateHead 方法的源码如下:

final void updateHead(Node<E> h, Node<E> p) {
    if (h != p && casHead(h, p))
        h.lazySetNext(h);
}

该方法主要通过casHead将队列的 head 指向 Node3,并且通过 h.lazySetNext将 Node1 的 next 指向它自己。最后在第 7 行代码返回 Node2 的值。此时队列的状态如下图所示:

Node2从队列中出队后的状态

Node1 的 next 指向它自己,head 指向了 Node3。

如果队列为空的话,就会执行到第 8 行(q = p.next) == null,if 判断为 true,因此在第 10 行中直接返回 null。

来做个总结:

  1. 如果当前 head、h 和 p 指向的节点 Item 不为 null,说明该节点为真正的头节点(待删除节点),只需要通过 casItem 方法将 item 设置为 null,然后将原来的 item 返回即可。
  2. 如果当前 head、h 和 p 指向的节点 item 为 null 的话,说明该节点不是真正待删除的节点,那么应该继续寻找 item 不为 null 的节点。通过让 q 指向 p 的下一个节点(q = p.next)进行试探,若找到则通过 updateHead 方法更新 head 节点以及构造哨兵节点(通过updateHead方法的h.lazySetNext(h)
1.3.2 多线程执行情况分析

现在回过头来看 poll 方法的源码,有这样一部分:

else if (p == q)
    continue restartFromHead;

这部分就是用来处理多个线程 poll 的,q = p.next,也就是说 q 永远指向的是 p 的下一个节点,那什么情况下 p 和 q 会指向同一个节点呢?

根据前面的分析,只有 p 指向的节点在 poll 的时候变成了哨兵节点(通过 updateHead 方法中的 h.lazySetNext)。

当线程 A 在判断p==q时,线程 B 已经执行完 poll 方法,将 p 节点转换为哨兵节点,并且 head 节点已经发生了改变,所以就需要从 restartFromHead 处执行,保证用到的是最新的 head。

试想,还有这样一种情况。如果当前队列为空队列,线程 A 进行 poll 操作,同时线程 B 执行 offer,然后线程 A 再执行 poll,那么此时线程 A 返回的是 null 还是线程 B 刚插入的那个节点呢?我们来写一段 demo:

public static void main(String[] args) {
    Thread thread1 = new Thread(() -> {
        Integer value = queue.poll();
        System.out.println(Thread.currentThread().getName() + " poll 的值为:" + value);
        System.out.println("queue当前是否为空队列:" + queue.isEmpty());
    });
    thread1.start();
    Thread thread2 = new Thread(() -> {
        queue.offer(1);
    });
    thread2.start();
}

输出结果为:

Thread-0 poll 的值为:null
queue当前是否为空队列:false

thread1 先执行到第 8 行代码if ((q = p.next) == null),由于队列为空 if 判断为 true,进入 if 块,此时让 thread1 暂停,然后 thread2 进行 offer 插入值为 1 节点,thread2 执行结束。再让 thread1 执行,这时thread1 并没有进行重试,而是继续往下走,返回 null,尽管此时队列由于 thread2 已经插入了值为 1 的新节点。

输出结果为 thread0 poll 的为 null,并且队列不为空。

因此,在判断队列是否为空的时候,不能通过 poll 返回 null 进行判断,要通过 isEmpty 进行判断

1.4 部分线程 offer 部分线程 poll

在分析 offer 方法的时候我们留了一个问题,即对 offer 方法中第 11 行代码的理解。

offer->poll->offer

前面我们提到,offer 方法的第 11 行代码 if (p == q),能够让 if 条件为 true 的情况只有 p 节点为哨兵节点,什么时候会有哨兵节点呢?

在 poll 方法的分析中,我们找到了答案,即当 head 节点的 item 字段为 null 时会寻找真正的头节点,等到待插入的节点插入之后,会更新 head,并且将原 head 节点设置为哨兵节点。  假设队列初始状态如下图所示:

offer和poll相互影响分析时队列初始状态.png

因此在线程 A 执行 offer 时,线程 B 执行 poll 会存在如下一种情况:

线程A和线程B可能存在的执行时序

线程 A 的 tail 节点存在 next 节点 Node1,因此会通过 q 往前寻找队列真正的尾节点,当执行到 if (p == q) 时,线程 B 执行 poll 操作,对线程 B 来说,head 和 p 指向 Node0,由于 Node0 的 item 字段为 null,同样会往前找队列的真正头节点 Node1,在线程 B 执行完 poll 后,Node0 就会转换为哨兵节点,也就意味着队列的 head 发生了改变,此时队列状态为下图所示。

线程B进行poll后队列的状态图

线程 A 执行判断 if (p == q) 为 true,继续执行 p = (t != (t = tail)) ? t : head;,由于 tail 没有发生改变,所以 p 被赋值为 head,重新从 head 开始完成插入操作。

1.5 延迟更新策略

通过上面对 offer 和 poll 方法的分析,我们发现 tail 和 head 是延迟更新的,两者更新的触发时机为:

tail 更新的触发时机:当 tail 节点的下一个节点不为 null 的时候,会执行定位队列真正尾节点的操作,找到尾节点后完成插入,之后才会通过 casTail 进行 tail 更新;当 tail 节点的下一个节点为 null 的时候,只插入节点不更新 tail。

head 更新的触发时机:当 head 节点的 item 为 null 的时候,会执行定位队列真正头节点的操作,找到头节点后完成删除,之后才会通过 updateHead 进行 head 更新;当 head 节点的 item 不为 null 的时候,只删除节点不更新 head。

注意,源码中有这样一段注释:hop two nodes at a time

所以这种延迟更新的策略叫做 HOPS,大概原因是这个(猜的),从上面更新时的状态图可以看出,head 和 tail 的更新是“跳着的”,即中间总是隔了一个。这样设计的意图是什么呢?

如果让 tail 永远作为尾节点,实现的代码量会更少,而且逻辑更易懂。

但是,这样做有一个缺点,如果有大量的入队操作,每次都要执行 CAS 进行 tail 的更新,汇总起来对性能也是非常大的损耗。如果能减少 CAS 更新操作,就可以大大提升入队的操作效率,所以 doug lea 大师每间隔 1 次(tail 和队尾节点的距离为 1)才利用 CAS 更新 tail。

对 head 的更新也是同样的道理,虽然这样设计会多出在循环中定位尾节点的操作,但总体来说,读的操作效率要远远高于写的效率,因此,多出来的定位尾节点的性能损耗相对就很小了。

二、小林-图解系统

1、怎么避免死锁?

1.1 死锁的概念

在多线程编程中,我们为了防止多线程竞争共享资源而导致数据错乱,都会在操作共享资源之前加上互斥锁,只有成功获得到锁的线程,才能操作共享资源,获取不到锁的线程就只能等待,直到锁被释放。

那么,当两个线程为了保护两个不同的共享资源而使用了两个互斥锁,那么这两个互斥锁应用不当的时候,可能会造成两个线程都在等待对方释放锁,在没有外力的作用下,这些线程会一直相互等待,就没办法继续运行,这种情况就是发生了死锁

举个例子,小林拿了小美房间的钥匙,而小林在自己的房间里,小美拿了小林房间的钥匙,而小美也在自己的房间里。如果小林要从自己的房间里出去,必须拿到小美手中的钥匙,但是小美要出去,又必须拿到小林手中的钥匙,这就形成了死锁。

死锁只有同时满足以下四个条件才会发生:

  • 互斥条件
  • 持有并等待条件
  • 不可剥夺条件
  • 环路等待条件
1.1.1 互斥条件

互斥条件是指多个线程不能同时使用同一个资源

比如下图,如果线程 A 已经持有的资源,不能再同时被线程 B 持有,如果线程 B 请求获取线程 A 已经占用的资源,那线程 B 只能等待,直到线程 A 释放了资源。

1.1.2 持有并等待条件

持有并等待条件是指,当线程 A 已经持有了资源 1,又想申请资源 2,而资源 2 已经被线程 C 持有了,所以线程 A 就会处于等待状态,但是线程 A 在等待资源 2 的同时并不会释放自己已经持有的资源 1

1.1.3 不可剥夺条件

不可剥夺条件是指,当线程已经持有了资源 ,在自己使用完之前不能被其他线程获取,线程 B 如果也想使用此资源,则只能在线程 A 使用完并释放后才能获取。

1.1.4 环路等待条件

环路等待条件指的是,在死锁发生的时候,两个线程获取资源的顺序构成了环形链

比如,线程 A 已经持有资源 2,而想请求资源 1, 线程 B 已经获取了资源 1,而想请求资源 2,这就形成资源请求等待的环形图。

1.2 避免死锁问题的发生

前面我们提到,产生死锁的四个必要条件是:互斥条件、持有并等待条件、不可剥夺条件、环路等待条件。

那么避免死锁问题就只需要破环其中一个条件就可以,最常见的并且可行的就是使用资源有序分配法,来破环环路等待条件

那什么是资源有序分配法呢?

线程 A 和 线程 B 获取资源的顺序要一样,当线程 A 是先尝试获取资源 A,然后尝试获取资源 B 的时候,线程 B 同样也是先尝试获取资源 A,然后尝试获取资源 B。也就是说,线程 A 和 线程 B 总是以相同的顺序申请自己想要的资源。

我们使用资源有序分配法的方式来修改前面发生死锁的代码,我们可以不改动线程 A 的代码。

我们先要清楚线程 A 获取资源的顺序,它是先获取互斥锁 A,然后获取互斥锁 B。

所以我们只需将线程 B 改成以相同顺序的获取资源,就可以打破死锁了。

2、什么是悲观锁、乐观锁

开发过程中,最常见的就是互斥锁的了,互斥锁加锁失败时,会用「线程切换」来应对,当加锁失败的线程再次加锁成功后的这一过程,会有两次线程上下文切换的成本,性能损耗比较大。

如果我们明确知道被锁住的代码的执行时间很短,那我们应该选择开销比较小的自旋锁,因为自旋锁加锁失败时,并不会主动产生线程切换,而是一直忙等待,直到获取到锁,那么如果被锁住的代码执行时间很短,那这个忙等待的时间相对应也很短。

如果能区分读操作和写操作的场景,那读写锁就更合适了,它允许多个读线程可以同时持有读锁,提高了读的并发性。根据偏袒读方还是写方,可以分为读优先锁和写优先锁,读优先锁并发性很强,但是写线程会被饿死,而写优先锁会优先服务写线程,读线程也可能会被饿死,那为了避免饥饿的问题,于是就有了公平读写锁,它是用队列把请求锁的线程排队,并保证先入先出的原则来对线程加锁,这样便保证了某种线程不会被饿死,通用性也更好点。

互斥锁和自旋锁都是最基本的锁,读写锁可以根据场景来选择这两种锁其中的一个进行实现。

另外,互斥锁、自旋锁、读写锁都属于悲观锁,悲观锁认为并发访问共享资源时,冲突概率可能非常高,所以在访问共享资源前,都需要先加锁。

相反的,如果并发访问共享资源时,冲突概率非常低的话,就可以使用乐观锁,它的工作方式是,在访问共享资源时,不用先加锁,修改完共享资源后,再验证这段时间内有没有发生冲突,如果没有其他线程在修改资源,那么操作完成,如果发现有其他线程已经修改过这个资源,就放弃本次操作。

但是,一旦冲突概率上升,就不适合使用乐观锁了,因为它解决冲突的重试成本非常高。

不管使用的哪种锁,我们的加锁的代码范围应该尽可能的小,也就是加锁的粒度要小,这样执行速度会比较快。再来,使用上了合适的锁,就会快上加快了。

接下来,就谈一谈常见的这几种锁:

多线程访问共享资源的时候,避免不了资源竞争而导致数据错乱的问题,所以我们通常为了解决这一问题,都会在访问共享资源之前加锁。

最常用的就是互斥锁,当然还有很多种不同的锁,比如自旋锁、读写锁、乐观锁等,不同种类的锁自然适用于不同的场景。

如果选择了错误的锁,那么在一些高并发的场景下,可能会降低系统的性能,这样用户体验就会非常差了。

所以,为了选择合适的锁,我们不仅需要清楚知道加锁的成本开销有多大,还需要分析业务场景中访问的共享资源的方式,再来还要考虑并发访问共享资源时的冲突概率。

对症下药,才能减少锁对高并发性能的影响。

那接下来,针对不同的应用场景,谈一谈「互斥锁、自旋锁、读写锁、乐观锁、悲观锁」的选择和使用。

2.1 互斥锁与自旋锁

最底层的两种就是「互斥锁和自旋锁」,有很多高级的锁都是基于它们实现的,你可以认为它们是各种锁的地基,所以我们必须清楚它俩之间的区别和应用。

加锁的目的就是保证共享资源在任意时间里,只有一个线程访问,这样就可以避免多线程导致共享数据错乱的问题。

当已经有一个线程加锁后,其他线程加锁则就会失败,互斥锁和自旋锁对于加锁失败后的处理方式是不一样的:

  • 互斥锁加锁失败后,线程会释放 CPU ,给其他线程;
  • 自旋锁加锁失败后,线程会忙等待,直到它拿到锁;

互斥锁是一种「独占锁」,比如当线程 A 加锁成功后,此时互斥锁已经被线程 A 独占了,只要线程 A 没有释放手中的锁,线程 B 加锁就会失败,于是就会释放 CPU 让给其他线程,既然线程 B 释放掉了 CPU,自然线程 B 加锁的代码就会被阻塞

对于互斥锁加锁失败而阻塞的现象,是由操作系统内核实现的。当加锁失败时,内核会将线程置为「睡眠」状态,等到锁被释放后,内核会在合适的时机唤醒线程,当这个线程成功获取到锁后,于是就可以继续执行。如下图:

所以,互斥锁加锁失败时,会从用户态陷入到内核态,让内核帮我们切换线程,虽然简化了使用锁的难度,但是存在一定的性能开销成本。

那这个开销成本是什么呢?会有两次线程上下文切换的成本

  • 当线程加锁失败时,内核会把线程的状态从「运行」状态设置为「睡眠」状态,然后把 CPU 切换给其他线程运行;
  • 接着,当锁被释放时,之前「睡眠」状态的线程会变为「就绪」状态,然后内核会在合适的时间,把 CPU 切换给该线程运行。

线程的上下文切换的是什么?当两个线程是属于同一个进程,因为虚拟内存是共享的,所以在切换时,虚拟内存这些资源就保持不动,只需要切换线程的私有数据、寄存器等不共享的数据。

所以,如果你能确定被锁住的代码执行时间很短,就不应该用互斥锁,而应该选用自旋锁,否则使用互斥锁。

自旋锁是通过 CPU 提供的 CAS 函数(Compare And Swap),在「用户态」完成加锁和解锁操作,不会主动产生线程上下文切换,所以相比互斥锁来说,会快一些,开销也小一些。

一般加锁的过程,包含两个步骤:

  • 第一步,查看锁的状态,如果锁是空闲的,则执行第二步;
  • 第二步,将锁设置为当前线程持有;

CAS 函数就把这两个步骤合并成一条硬件级指令,形成原子指令,这样就保证了这两个步骤是不可分割的,要么一次性执行完两个步骤,要么两个步骤都不执行。

比如,设锁为变量 lock,整数 0 表示锁是空闲状态,整数 pid 表示线程 ID,那么 CAS(lock, 0, pid) 就表示自旋锁的加锁操作,CAS(lock, pid, 0) 则表示解锁操作。

使用自旋锁的时候,当发生多线程竞争锁的情况,加锁失败的线程会「忙等待」,直到它拿到锁。这里的「忙等待」可以用 while 循环等待实现,不过最好是使用 CPU 提供的 PAUSE 指令来实现「忙等待」,因为可以减少循环等待时的耗电量。

自旋锁是最比较简单的一种锁,一直自旋,利用 CPU 周期,直到锁可用。需要注意,在单核 CPU 上,需要抢占式的调度器(即不断通过时钟中断一个线程,运行其他线程)。否则,自旋锁在单 CPU 上无法使用,因为一个自旋的线程永远不会放弃 CPU。

自旋锁开销少,在多核系统下一般不会主动产生线程切换,适合异步、协程等在用户态切换请求的编程方式,但如果被锁住的代码执行时间过长,自旋的线程会长时间占用 CPU 资源,所以自旋的时间和被锁住的代码执行的时间是成「正比」的关系,我们需要清楚的知道这一点。

自旋锁与互斥锁使用层面比较相似,但实现层面上完全不同:当加锁失败时,互斥锁用「线程切换」来应对,自旋锁则用「忙等待」来应对

它俩是锁的最基本处理方式,更高级的锁都会选择其中一个来实现,比如读写锁既可以选择互斥锁实现,也可以基于自旋锁实现。

2.2 读写锁

读写锁从字面意思我们也可以知道,它由「读锁」和「写锁」两部分构成,如果只读取共享资源用「读锁」加锁,如果要修改共享资源则用「写锁」加锁。

所以,读写锁适用于能明确区分读操作和写操作的场景

读写锁的工作原理是:

  • 当「写锁」没有被线程持有时,多个线程能够并发地持有读锁,这大大提高了共享资源的访问效率,因为「读锁」是用于读取共享资源的场景,所以多个线程同时持有读锁也不会破坏共享资源的数据。
  • 但是,一旦「写锁」被线程持有后,读线程的获取读锁的操作会被阻塞,而且其他写线程的获取写锁的操作也会被阻塞。

所以说,写锁是独占锁,因为任何时刻只能有一个线程持有写锁,类似互斥锁和自旋锁,而读锁是共享锁,因为读锁可以被多个线程同时持有。

知道了读写锁的工作原理后,我们可以发现,读写锁在读多写少的场景,能发挥出优势

另外,根据实现的不同,读写锁可以分为「读优先锁」和「写优先锁」。

读优先锁期望的是,读锁能被更多的线程持有,以便提高读线程的并发性,它的工作方式是:当读线程 A 先持有了读锁,写线程 B 在获取写锁的时候,会被阻塞,并且在阻塞过程中,后续来的读线程 C 仍然可以成功获取读锁,最后直到读线程 A 和 C 释放读锁后,写线程 B 才可以成功获取写锁。如下图:

而「写优先锁」是优先服务写线程,其工作方式是:当读线程 A 先持有了读锁,写线程 B 在获取写锁的时候,会被阻塞,并且在阻塞过程中,后续来的读线程 C 获取读锁时会失败,于是读线程 C 将被阻塞在获取读锁的操作,这样只要读线程 A 释放读锁后,写线程 B 就可以成功获取写锁。如下图:

读优先锁对于读线程并发性更好,但也不是没有问题。我们试想一下,如果一直有读线程获取读锁,那么写线程将永远获取不到写锁,这就造成了写线程「饥饿」的现象。

写优先锁可以保证写线程不会饿死,但是如果一直有写线程获取写锁,读线程也会被「饿死」。

既然不管优先读锁还是写锁,对方可能会出现饿死问题,那么我们就不偏袒任何一方,搞个「公平读写锁」。

公平读写锁比较简单的一种方式是:用队列把获取锁的线程排队,不管是写线程还是读线程都按照先进先出的原则加锁即可,这样读线程仍然可以并发,也不会出现「饥饿」的现象。

互斥锁和自旋锁都是最基本的锁,读写锁可以根据场景来选择这两种锁其中的一个进行实现。

2.3 乐观锁与悲观锁

前面提到的互斥锁、自旋锁、读写锁,都是属于悲观锁。

悲观锁做事比较悲观,它认为多线程同时修改共享资源的概率比较高,于是很容易出现冲突,所以访问共享资源前,先要上锁

那相反的,如果多线程同时修改共享资源的概率比较低,就可以采用乐观锁。

乐观锁做事比较乐观,它假定冲突的概率很低,它的工作方式是:先修改完共享资源,再验证这段时间内有没有发生冲突,如果没有其他线程在修改资源,那么操作完成,如果发现有其他线程已经修改过这个资源,就放弃本次操作

放弃后如何重试,这跟业务场景息息相关,虽然重试的成本很高,但是冲突的概率足够低的话,还是可以接受的。

可见,乐观锁的心态是,不管三七二十一,先改了资源再说。另外,你会发现乐观锁全程并没有加锁,所以它也叫无锁编程

乐观锁虽然去除了加锁解锁的操作,但是一旦发生冲突,重试的成本非常高,所以只有在冲突概率非常低,且加锁成本非常高的场景时,才考虑使用乐观锁。